├── .gitignore ├── .scripts ├── docs-examples.sh ├── docs-index.sh └── docs-inject-ga.sh ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── Makefile ├── README.md ├── docs-src ├── ga.html.snippet ├── index.html.begin ├── index.html.end └── index.md ├── docs ├── .gitignore ├── examples │ ├── mortgage │ │ ├── bundle.js │ │ └── index.html │ ├── pizza │ │ ├── bundle.js │ │ ├── index.html │ │ └── styles.css │ └── spreadsheet │ │ ├── bundle.js │ │ └── index.html ├── index.html ├── spreadsheet.png └── style.css ├── examples ├── mortgage │ ├── index.html │ ├── package.json │ ├── src │ │ ├── index.js │ │ ├── mortgage.js │ │ └── utils.js │ └── webpack.config.js ├── pizza │ ├── index.html │ ├── package.json │ ├── src │ │ ├── index.js │ │ └── style.css │ └── webpack.config.js └── spreadsheet │ ├── .babelrc │ ├── .vscode │ └── settings.json │ ├── index.html │ ├── package.json │ ├── src │ ├── cell-component.js │ ├── compile-formula.js │ ├── formula.js │ ├── formula.test.js │ ├── functions.js │ ├── index.js │ ├── sheet.js │ └── spreadsheet.js │ └── webpack.config.js ├── lerna.json ├── package.json ├── packages ├── xcell-inspect │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── src │ │ ├── cell-to-dot-node.ts │ │ ├── create-dot.ts │ │ ├── deferred.ts │ │ ├── dot-layout.test.ts │ │ ├── dot-layout.ts │ │ ├── index-umd.ts │ │ ├── index.ts │ │ ├── index.worker.ts │ │ ├── inspect.ts │ │ ├── render-dot-graph.ts │ │ ├── render-root.ts │ │ ├── style.css │ │ └── worker-functions.ts │ ├── tsconfig.json │ └── webpack.config.js └── xcell │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── jest.debug.json │ ├── package.json │ ├── src │ ├── cell.test.ts │ ├── cell.ts │ ├── index-umd.ts │ └── index.ts │ ├── tsconfig.debug.json │ ├── tsconfig.json │ └── webpack.config.js ├── tsconfig.common.json ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn-error.log 4 | -------------------------------------------------------------------------------- /.scripts/docs-examples.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | mkdir -p docs/examples 4 | 5 | for i in `ls examples` 6 | do 7 | pushd examples/$i 8 | npm run build& 9 | popd 10 | done 11 | 12 | wait 13 | 14 | for i in `ls examples` 15 | do 16 | mkdir -p docs/examples/$i 17 | cp -r examples/$i/dist/* docs/examples/$i 18 | mv docs/examples/$i/index.html docs/examples/$i/~index.html 19 | .scripts/docs-inject-ga.sh < docs/examples/$i/~index.html > docs/examples/$i/index.html 20 | rm docs/examples/$i/~index.html 21 | done 22 | 23 | -------------------------------------------------------------------------------- /.scripts/docs-index.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | REMARK=./node_modules/.bin/remark 6 | REMARK_CMD="${REMARK} \ 7 | -q \ 8 | --use html \ 9 | --use highlight.js \ 10 | --use preset-lint-markdown-style-guide" 11 | 12 | cat docs-src/index.html.begin \ 13 | <(${REMARK_CMD} docs-src/index.md) \ 14 | docs-src/index.html.end 15 | -------------------------------------------------------------------------------- /.scripts/docs-inject-ga.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | sed '/<\/head>/ { 6 | h 7 | r docs-src/ga.html.snippet 8 | g 9 | N 10 | }' 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "preLaunchTask": "xcell-build-for-debug", 11 | "name": "debug xcell", 12 | "program": "${workspaceRoot}/node_modules/jest/bin/jest.js", 13 | "args": [ 14 | "--config", 15 | "${workspaceFolder}/packages/xcell/jest.debug.json", 16 | "--runInBand" 17 | ], 18 | "sourceMaps": true, 19 | "trace": true, 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "cSpell.words": [ 4 | "debounced", 5 | "isequal", 6 | "lightgrey", 7 | "morhpdom", 8 | "readonly", 9 | "shallowequal", 10 | "xcell" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "xcell-build-for-debug", 8 | "type": "npm", 9 | "script": "xcell:build:for-debug", 10 | "problemMatcher": [] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all build docs docs-examples 2 | 3 | all: build docs 4 | 5 | build: 6 | npm run build 7 | 8 | docs: docs/index.html docs-examples 9 | 10 | docs/index.html: docs-src/index.md 11 | .scripts/docs-index.sh | .scripts/docs-inject-ga.sh > docs/index.html 12 | 13 | docs-examples: 14 | .scripts/docs-examples.sh 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xcell monorepo 2 | 3 | A a tiny library for building reactive spreadsheet-like calculations in JavaScript 4 | 5 | Packages: 6 | * [xcell](packages/xcell) 7 | * [xcell-inspect](packages/xcell-inspect) 8 | 9 | Check the [`examples`](examples) folder to see how it can be used. 10 | 11 | The examples are hosted here: https://tomazy.github.io/xcell/ 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs-src/ga.html.snippet: -------------------------------------------------------------------------------- 1 | 2 | 8 | -------------------------------------------------------------------------------- /docs-src/index.html.begin: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | xcell 7 | 8 | 9 | 10 |
11 | -------------------------------------------------------------------------------- /docs-src/index.html.end: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | -------------------------------------------------------------------------------- /docs-src/index.md: -------------------------------------------------------------------------------- 1 | # xcell 2 | 3 | [xcell](https://github.com/tomazy/xcell) is a tiny, open source (MIT) 4 | library for building reactive, spreadsheet-like calculations in JavaScript. 5 | 6 | Spreadsheets are cool—if we say that **A1** is the sum of **B1** and **C1**, 7 | the spreadsheet will automatically update whenever we change the dependent 8 | cells. 9 | 10 | ![spreadsheet](spreadsheet.png) 11 | 12 | This usually doesn't happen in our programs. 13 | 14 | For example in JavaScript: 15 | 16 | ```javascript 17 | var b = 1, c = 2 18 | 19 | var a = b + c // a is 3 now 20 | 21 | b = 42 22 | 23 | alert("a is now: " + a) // it is still 3 :( 24 | ``` 25 | 26 | our variable **a** does not automatically change if we 27 | change **b**. It will be equal to **3** until we *imperatively* 28 | change it something else. 29 | 30 | **xcell** allows us to write programs that work like spreadsheets. 31 | 32 | Here is how: 33 | 34 | ```javascript 35 | 36 | function add(x, y) { 37 | return x + y 38 | } 39 | 40 | var b = xcell(1), c = xcell(2) 41 | 42 | var a = xcell([b, c], add) 43 | 44 | alert(a.value) // a is 3 45 | 46 | b.value = 42 47 | 48 | alert(a.value) // a is 44 \o/ 49 | ``` 50 | 51 | `xcell` is a function that returns an object that holds always 52 | updated value, just like a spreadsheet cell. 53 | 54 | When we create our "cells" we tell them to either be independent: 55 | 56 | ```javascript 57 | var b = xcell(1) 58 | ``` 59 | 60 | or to depend on other cells and update its value when necessary using 61 | a provided function: 62 | 63 | ```javascript 64 | var a = xcell([b, c], add) 65 | ``` 66 | 67 | The cells emit `change` event whenever they change, so we can observe 68 | them and update our UI: 69 | 70 | ```javascript 71 | a.on('change', function handleChange(sender) { 72 | document.getElementById("my-cell").value = sender.value 73 | }) 74 | ``` 75 | 76 | Here are a few examples of how **xcell** can be used: 77 | 78 | - [the real price of a pizza](examples/pizza) 79 | - [mortgage calculator](examples/mortgage) 80 | - [spreadsheet demo](examples/spreadsheet) 81 | 82 | The source code is on [github](https://github.com/tomazy/xcell). 83 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | *.map 2 | -------------------------------------------------------------------------------- /docs/examples/mortgage/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | xcell: mortgage 7 | 71 | 72 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /docs/examples/pizza/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | xcell: pizza 7 | 8 | 14 | 15 | 16 |
17 |
receipt
18 | 19 |
20 | 21 |
22 |
$
23 | 24 |
25 |
26 | 27 |
28 | 29 |
30 | 31 |
%
32 |
33 |
34 | 35 |
36 | 37 |
38 | 39 |
%
40 |
41 |
42 | 43 |
 
44 | 45 |
46 |
Tax
47 |
$
48 |
49 | 50 |
 
51 | 52 |
53 |
Gross
54 |
$
55 |
56 | 57 |
58 |
Tip
59 |
$
60 |
61 | 62 |
 
63 | 64 |
65 |
Total
66 |
$
67 |
68 |
69 | 70 |
71 | source code 72 |
73 | 74 | 75 | -------------------------------------------------------------------------------- /docs/examples/pizza/styles.css: -------------------------------------------------------------------------------- 1 | /*! TACHYONS v4.11.1 | http://tachyons.io */ 2 | /*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}[hidden],template{display:none}.border-box,a,article,aside,blockquote,body,code,dd,div,dl,dt,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,html,input[type=email],input[type=number],input[type=password],input[type=tel],input[type=text],input[type=url],legend,li,main,nav,ol,p,pre,section,table,td,textarea,th,tr,ul{box-sizing:border-box}.aspect-ratio{height:0;position:relative}.aspect-ratio--16x9{padding-bottom:56.25%}.aspect-ratio--9x16{padding-bottom:177.77%}.aspect-ratio--4x3{padding-bottom:75%}.aspect-ratio--3x4{padding-bottom:133.33%}.aspect-ratio--6x4{padding-bottom:66.6%}.aspect-ratio--4x6{padding-bottom:150%}.aspect-ratio--8x5{padding-bottom:62.5%}.aspect-ratio--5x8{padding-bottom:160%}.aspect-ratio--7x5{padding-bottom:71.42%}.aspect-ratio--5x7{padding-bottom:140%}.aspect-ratio--1x1{padding-bottom:100%}.aspect-ratio--object{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}img{max-width:100%}.cover{background-size:cover!important}.contain{background-size:contain!important}.bg-center{background-position:50%}.bg-center,.bg-top{background-repeat:no-repeat}.bg-top{background-position:top}.bg-right{background-position:100%}.bg-bottom,.bg-right{background-repeat:no-repeat}.bg-bottom{background-position:bottom}.bg-left{background-repeat:no-repeat;background-position:0}.outline{outline:1px solid}.outline-transparent{outline:1px solid transparent}.outline-0{outline:0}.ba{border-style:solid;border-width:1px}.bt{border-top-style:solid;border-top-width:1px}.br{border-right-style:solid;border-right-width:1px}.bb{border-bottom-style:solid;border-bottom-width:1px}.bl{border-left-style:solid;border-left-width:1px}.bn{border-style:none;border-width:0}.b--black{border-color:#000}.b--near-black{border-color:#111}.b--dark-gray{border-color:#333}.b--mid-gray{border-color:#555}.b--gray{border-color:#777}.b--silver{border-color:#999}.b--light-silver{border-color:#aaa}.b--moon-gray{border-color:#ccc}.b--light-gray{border-color:#eee}.b--near-white{border-color:#f4f4f4}.b--white{border-color:#fff}.b--white-90{border-color:hsla(0,0%,100%,.9)}.b--white-80{border-color:hsla(0,0%,100%,.8)}.b--white-70{border-color:hsla(0,0%,100%,.7)}.b--white-60{border-color:hsla(0,0%,100%,.6)}.b--white-50{border-color:hsla(0,0%,100%,.5)}.b--white-40{border-color:hsla(0,0%,100%,.4)}.b--white-30{border-color:hsla(0,0%,100%,.3)}.b--white-20{border-color:hsla(0,0%,100%,.2)}.b--white-10{border-color:hsla(0,0%,100%,.1)}.b--white-05{border-color:hsla(0,0%,100%,.05)}.b--white-025{border-color:hsla(0,0%,100%,.025)}.b--white-0125{border-color:hsla(0,0%,100%,.0125)}.b--black-90{border-color:rgba(0,0,0,.9)}.b--black-80{border-color:rgba(0,0,0,.8)}.b--black-70{border-color:rgba(0,0,0,.7)}.b--black-60{border-color:rgba(0,0,0,.6)}.b--black-50{border-color:rgba(0,0,0,.5)}.b--black-40{border-color:rgba(0,0,0,.4)}.b--black-30{border-color:rgba(0,0,0,.3)}.b--black-20{border-color:rgba(0,0,0,.2)}.b--black-10{border-color:rgba(0,0,0,.1)}.b--black-05{border-color:rgba(0,0,0,.05)}.b--black-025{border-color:rgba(0,0,0,.025)}.b--black-0125{border-color:rgba(0,0,0,.0125)}.b--dark-red{border-color:#e7040f}.b--red{border-color:#ff4136}.b--light-red{border-color:#ff725c}.b--orange{border-color:#ff6300}.b--gold{border-color:#ffb700}.b--yellow{border-color:gold}.b--light-yellow{border-color:#fbf1a9}.b--purple{border-color:#5e2ca5}.b--light-purple{border-color:#a463f2}.b--dark-pink{border-color:#d5008f}.b--hot-pink{border-color:#ff41b4}.b--pink{border-color:#ff80cc}.b--light-pink{border-color:#ffa3d7}.b--dark-green{border-color:#137752}.b--green{border-color:#19a974}.b--light-green{border-color:#9eebcf}.b--navy{border-color:#001b44}.b--dark-blue{border-color:#00449e}.b--blue{border-color:#357edd}.b--light-blue{border-color:#96ccff}.b--lightest-blue{border-color:#cdecff}.b--washed-blue{border-color:#f6fffe}.b--washed-green{border-color:#e8fdf5}.b--washed-yellow{border-color:#fffceb}.b--washed-red{border-color:#ffdfdf}.b--transparent{border-color:transparent}.b--inherit{border-color:inherit}.br0{border-radius:0}.br1{border-radius:.125rem}.br2{border-radius:.25rem}.br3{border-radius:.5rem}.br4{border-radius:1rem}.br-100{border-radius:100%}.br-pill{border-radius:9999px}.br--bottom{border-top-left-radius:0;border-top-right-radius:0}.br--top{border-bottom-right-radius:0}.br--right,.br--top{border-bottom-left-radius:0}.br--right{border-top-left-radius:0}.br--left{border-top-right-radius:0;border-bottom-right-radius:0}.b--dotted{border-style:dotted}.b--dashed{border-style:dashed}.b--solid{border-style:solid}.b--none{border-style:none}.bw0{border-width:0}.bw1{border-width:.125rem}.bw2{border-width:.25rem}.bw3{border-width:.5rem}.bw4{border-width:1rem}.bw5{border-width:2rem}.bt-0{border-top-width:0}.br-0{border-right-width:0}.bb-0{border-bottom-width:0}.bl-0{border-left-width:0}.shadow-1{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.pre{overflow-x:auto;overflow-y:hidden;overflow:scroll}.top-0{top:0}.right-0{right:0}.bottom-0{bottom:0}.left-0{left:0}.top-1{top:1rem}.right-1{right:1rem}.bottom-1{bottom:1rem}.left-1{left:1rem}.top-2{top:2rem}.right-2{right:2rem}.bottom-2{bottom:2rem}.left-2{left:2rem}.top--1{top:-1rem}.right--1{right:-1rem}.bottom--1{bottom:-1rem}.left--1{left:-1rem}.top--2{top:-2rem}.right--2{right:-2rem}.bottom--2{bottom:-2rem}.left--2{left:-2rem}.absolute--fill{top:0;right:0;bottom:0;left:0}.cf:after,.cf:before{content:" ";display:table}.cf:after{clear:both}.cf{*zoom:1}.cl{clear:left}.cr{clear:right}.cb{clear:both}.cn{clear:none}.dn{display:none}.di{display:inline}.db{display:block}.dib{display:inline-block}.dit{display:inline-table}.dt{display:table}.dtc{display:table-cell}.dt-row{display:table-row}.dt-row-group{display:table-row-group}.dt-column{display:table-column}.dt-column-group{display:table-column-group}.dt--fixed{table-layout:fixed;width:100%}.flex{display:flex}.inline-flex{display:inline-flex}.flex-auto{flex:1 1 auto;min-width:0;min-height:0}.flex-none{flex:none}.flex-column{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.flex-wrap-reverse{flex-wrap:wrap-reverse}.flex-column-reverse{flex-direction:column-reverse}.flex-row-reverse{flex-direction:row-reverse}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.items-stretch{align-items:stretch}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.self-center{align-self:center}.self-baseline{align-self:baseline}.self-stretch{align-self:stretch}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.content-start{align-content:flex-start}.content-end{align-content:flex-end}.content-center{align-content:center}.content-between{align-content:space-between}.content-around{align-content:space-around}.content-stretch{align-content:stretch}.order-0{order:0}.order-1{order:1}.order-2{order:2}.order-3{order:3}.order-4{order:4}.order-5{order:5}.order-6{order:6}.order-7{order:7}.order-8{order:8}.order-last{order:99999}.flex-grow-0{flex-grow:0}.flex-grow-1{flex-grow:1}.flex-shrink-0{flex-shrink:0}.flex-shrink-1{flex-shrink:1}.fl{float:left}.fl,.fr{_display:inline}.fr{float:right}.fn{float:none}.sans-serif{font-family:-apple-system,BlinkMacSystemFont,avenir next,avenir,helvetica neue,helvetica,ubuntu,roboto,noto,segoe ui,arial,sans-serif}.serif{font-family:georgia,times,serif}.system-sans-serif{font-family:sans-serif}.system-serif{font-family:serif}.code,code{font-family:Consolas,monaco,monospace}.courier{font-family:Courier Next,courier,monospace}.helvetica{font-family:helvetica neue,helvetica,sans-serif}.avenir{font-family:avenir next,avenir,sans-serif}.athelas{font-family:athelas,georgia,serif}.georgia{font-family:georgia,serif}.times{font-family:times,serif}.bodoni{font-family:Bodoni MT,serif}.calisto{font-family:Calisto MT,serif}.garamond{font-family:garamond,serif}.baskerville{font-family:baskerville,serif}.i{font-style:italic}.fs-normal{font-style:normal}.normal{font-weight:400}.b{font-weight:700}.fw1{font-weight:100}.fw2{font-weight:200}.fw3{font-weight:300}.fw4{font-weight:400}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fw9{font-weight:900}.input-reset{-webkit-appearance:none;-moz-appearance:none}.button-reset::-moz-focus-inner,.input-reset::-moz-focus-inner{border:0;padding:0}.h1{height:1rem}.h2{height:2rem}.h3{height:4rem}.h4{height:8rem}.h5{height:16rem}.h-25{height:25%}.h-50{height:50%}.h-75{height:75%}.h-100{height:100%}.min-h-100{min-height:100%}.vh-25{height:25vh}.vh-50{height:50vh}.vh-75{height:75vh}.vh-100{height:100vh}.min-vh-100{min-height:100vh}.h-auto{height:auto}.h-inherit{height:inherit}.tracked{letter-spacing:.1em}.tracked-tight{letter-spacing:-.05em}.tracked-mega{letter-spacing:.25em}.lh-solid{line-height:1}.lh-title{line-height:1.25}.lh-copy{line-height:1.5}.link{text-decoration:none}.link,.link:active,.link:focus,.link:hover,.link:link,.link:visited{transition:color .15s ease-in}.link:focus{outline:1px dotted currentColor}.list{list-style-type:none}.mw-100{max-width:100%}.mw1{max-width:1rem}.mw2{max-width:2rem}.mw3{max-width:4rem}.mw4{max-width:8rem}.mw5{max-width:16rem}.mw6{max-width:32rem}.mw7{max-width:48rem}.mw8{max-width:64rem}.mw9{max-width:96rem}.mw-none{max-width:none}.w1{width:1rem}.w2{width:2rem}.w3{width:4rem}.w4{width:8rem}.w5{width:16rem}.w-10{width:10%}.w-20{width:20%}.w-25{width:25%}.w-30{width:30%}.w-33{width:33%}.w-34{width:34%}.w-40{width:40%}.w-50{width:50%}.w-60{width:60%}.w-70{width:70%}.w-75{width:75%}.w-80{width:80%}.w-90{width:90%}.w-100{width:100%}.w-third{width:33.33333%}.w-two-thirds{width:66.66667%}.w-auto{width:auto}.overflow-visible{overflow:visible}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.overflow-auto{overflow:auto}.overflow-x-visible{overflow-x:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-x-scroll{overflow-x:scroll}.overflow-x-auto{overflow-x:auto}.overflow-y-visible{overflow-y:visible}.overflow-y-hidden{overflow-y:hidden}.overflow-y-scroll{overflow-y:scroll}.overflow-y-auto{overflow-y:auto}.static{position:static}.relative{position:relative}.absolute{position:absolute}.fixed{position:fixed}.o-100{opacity:1}.o-90{opacity:.9}.o-80{opacity:.8}.o-70{opacity:.7}.o-60{opacity:.6}.o-50{opacity:.5}.o-40{opacity:.4}.o-30{opacity:.3}.o-20{opacity:.2}.o-10{opacity:.1}.o-05{opacity:.05}.o-025{opacity:.025}.o-0{opacity:0}.rotate-45{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.black-90{color:rgba(0,0,0,.9)}.black-80{color:rgba(0,0,0,.8)}.black-70{color:rgba(0,0,0,.7)}.black-60{color:rgba(0,0,0,.6)}.black-50{color:rgba(0,0,0,.5)}.black-40{color:rgba(0,0,0,.4)}.black-30{color:rgba(0,0,0,.3)}.black-20{color:rgba(0,0,0,.2)}.black-10{color:rgba(0,0,0,.1)}.black-05{color:rgba(0,0,0,.05)}.white-90{color:hsla(0,0%,100%,.9)}.white-80{color:hsla(0,0%,100%,.8)}.white-70{color:hsla(0,0%,100%,.7)}.white-60{color:hsla(0,0%,100%,.6)}.white-50{color:hsla(0,0%,100%,.5)}.white-40{color:hsla(0,0%,100%,.4)}.white-30{color:hsla(0,0%,100%,.3)}.white-20{color:hsla(0,0%,100%,.2)}.white-10{color:hsla(0,0%,100%,.1)}.black{color:#000}.near-black{color:#111}.dark-gray{color:#333}.mid-gray{color:#555}.gray{color:#777}.silver{color:#999}.light-silver{color:#aaa}.moon-gray{color:#ccc}.light-gray{color:#eee}.near-white{color:#f4f4f4}.white{color:#fff}.dark-red{color:#e7040f}.red{color:#ff4136}.light-red{color:#ff725c}.orange{color:#ff6300}.gold{color:#ffb700}.yellow{color:gold}.light-yellow{color:#fbf1a9}.purple{color:#5e2ca5}.light-purple{color:#a463f2}.dark-pink{color:#d5008f}.hot-pink{color:#ff41b4}.pink{color:#ff80cc}.light-pink{color:#ffa3d7}.dark-green{color:#137752}.green{color:#19a974}.light-green{color:#9eebcf}.navy{color:#001b44}.dark-blue{color:#00449e}.blue{color:#357edd}.light-blue{color:#96ccff}.lightest-blue{color:#cdecff}.washed-blue{color:#f6fffe}.washed-green{color:#e8fdf5}.washed-yellow{color:#fffceb}.washed-red{color:#ffdfdf}.color-inherit{color:inherit}.bg-black-90{background-color:rgba(0,0,0,.9)}.bg-black-80{background-color:rgba(0,0,0,.8)}.bg-black-70{background-color:rgba(0,0,0,.7)}.bg-black-60{background-color:rgba(0,0,0,.6)}.bg-black-50{background-color:rgba(0,0,0,.5)}.bg-black-40{background-color:rgba(0,0,0,.4)}.bg-black-30{background-color:rgba(0,0,0,.3)}.bg-black-20{background-color:rgba(0,0,0,.2)}.bg-black-10{background-color:rgba(0,0,0,.1)}.bg-black-05{background-color:rgba(0,0,0,.05)}.bg-white-90{background-color:hsla(0,0%,100%,.9)}.bg-white-80{background-color:hsla(0,0%,100%,.8)}.bg-white-70{background-color:hsla(0,0%,100%,.7)}.bg-white-60{background-color:hsla(0,0%,100%,.6)}.bg-white-50{background-color:hsla(0,0%,100%,.5)}.bg-white-40{background-color:hsla(0,0%,100%,.4)}.bg-white-30{background-color:hsla(0,0%,100%,.3)}.bg-white-20{background-color:hsla(0,0%,100%,.2)}.bg-white-10{background-color:hsla(0,0%,100%,.1)}.bg-black{background-color:#000}.bg-near-black{background-color:#111}.bg-dark-gray{background-color:#333}.bg-mid-gray{background-color:#555}.bg-gray{background-color:#777}.bg-silver{background-color:#999}.bg-light-silver{background-color:#aaa}.bg-moon-gray{background-color:#ccc}.bg-light-gray{background-color:#eee}.bg-near-white{background-color:#f4f4f4}.bg-white{background-color:#fff}.bg-transparent{background-color:transparent}.bg-dark-red{background-color:#e7040f}.bg-red{background-color:#ff4136}.bg-light-red{background-color:#ff725c}.bg-orange{background-color:#ff6300}.bg-gold{background-color:#ffb700}.bg-yellow{background-color:gold}.bg-light-yellow{background-color:#fbf1a9}.bg-purple{background-color:#5e2ca5}.bg-light-purple{background-color:#a463f2}.bg-dark-pink{background-color:#d5008f}.bg-hot-pink{background-color:#ff41b4}.bg-pink{background-color:#ff80cc}.bg-light-pink{background-color:#ffa3d7}.bg-dark-green{background-color:#137752}.bg-green{background-color:#19a974}.bg-light-green{background-color:#9eebcf}.bg-navy{background-color:#001b44}.bg-dark-blue{background-color:#00449e}.bg-blue{background-color:#357edd}.bg-light-blue{background-color:#96ccff}.bg-lightest-blue{background-color:#cdecff}.bg-washed-blue{background-color:#f6fffe}.bg-washed-green{background-color:#e8fdf5}.bg-washed-yellow{background-color:#fffceb}.bg-washed-red{background-color:#ffdfdf}.bg-inherit{background-color:inherit}.hover-black:focus,.hover-black:hover{color:#000}.hover-near-black:focus,.hover-near-black:hover{color:#111}.hover-dark-gray:focus,.hover-dark-gray:hover{color:#333}.hover-mid-gray:focus,.hover-mid-gray:hover{color:#555}.hover-gray:focus,.hover-gray:hover{color:#777}.hover-silver:focus,.hover-silver:hover{color:#999}.hover-light-silver:focus,.hover-light-silver:hover{color:#aaa}.hover-moon-gray:focus,.hover-moon-gray:hover{color:#ccc}.hover-light-gray:focus,.hover-light-gray:hover{color:#eee}.hover-near-white:focus,.hover-near-white:hover{color:#f4f4f4}.hover-white:focus,.hover-white:hover{color:#fff}.hover-black-90:focus,.hover-black-90:hover{color:rgba(0,0,0,.9)}.hover-black-80:focus,.hover-black-80:hover{color:rgba(0,0,0,.8)}.hover-black-70:focus,.hover-black-70:hover{color:rgba(0,0,0,.7)}.hover-black-60:focus,.hover-black-60:hover{color:rgba(0,0,0,.6)}.hover-black-50:focus,.hover-black-50:hover{color:rgba(0,0,0,.5)}.hover-black-40:focus,.hover-black-40:hover{color:rgba(0,0,0,.4)}.hover-black-30:focus,.hover-black-30:hover{color:rgba(0,0,0,.3)}.hover-black-20:focus,.hover-black-20:hover{color:rgba(0,0,0,.2)}.hover-black-10:focus,.hover-black-10:hover{color:rgba(0,0,0,.1)}.hover-white-90:focus,.hover-white-90:hover{color:hsla(0,0%,100%,.9)}.hover-white-80:focus,.hover-white-80:hover{color:hsla(0,0%,100%,.8)}.hover-white-70:focus,.hover-white-70:hover{color:hsla(0,0%,100%,.7)}.hover-white-60:focus,.hover-white-60:hover{color:hsla(0,0%,100%,.6)}.hover-white-50:focus,.hover-white-50:hover{color:hsla(0,0%,100%,.5)}.hover-white-40:focus,.hover-white-40:hover{color:hsla(0,0%,100%,.4)}.hover-white-30:focus,.hover-white-30:hover{color:hsla(0,0%,100%,.3)}.hover-white-20:focus,.hover-white-20:hover{color:hsla(0,0%,100%,.2)}.hover-white-10:focus,.hover-white-10:hover{color:hsla(0,0%,100%,.1)}.hover-inherit:focus,.hover-inherit:hover{color:inherit}.hover-bg-black:focus,.hover-bg-black:hover{background-color:#000}.hover-bg-near-black:focus,.hover-bg-near-black:hover{background-color:#111}.hover-bg-dark-gray:focus,.hover-bg-dark-gray:hover{background-color:#333}.hover-bg-mid-gray:focus,.hover-bg-mid-gray:hover{background-color:#555}.hover-bg-gray:focus,.hover-bg-gray:hover{background-color:#777}.hover-bg-silver:focus,.hover-bg-silver:hover{background-color:#999}.hover-bg-light-silver:focus,.hover-bg-light-silver:hover{background-color:#aaa}.hover-bg-moon-gray:focus,.hover-bg-moon-gray:hover{background-color:#ccc}.hover-bg-light-gray:focus,.hover-bg-light-gray:hover{background-color:#eee}.hover-bg-near-white:focus,.hover-bg-near-white:hover{background-color:#f4f4f4}.hover-bg-white:focus,.hover-bg-white:hover{background-color:#fff}.hover-bg-transparent:focus,.hover-bg-transparent:hover{background-color:transparent}.hover-bg-black-90:focus,.hover-bg-black-90:hover{background-color:rgba(0,0,0,.9)}.hover-bg-black-80:focus,.hover-bg-black-80:hover{background-color:rgba(0,0,0,.8)}.hover-bg-black-70:focus,.hover-bg-black-70:hover{background-color:rgba(0,0,0,.7)}.hover-bg-black-60:focus,.hover-bg-black-60:hover{background-color:rgba(0,0,0,.6)}.hover-bg-black-50:focus,.hover-bg-black-50:hover{background-color:rgba(0,0,0,.5)}.hover-bg-black-40:focus,.hover-bg-black-40:hover{background-color:rgba(0,0,0,.4)}.hover-bg-black-30:focus,.hover-bg-black-30:hover{background-color:rgba(0,0,0,.3)}.hover-bg-black-20:focus,.hover-bg-black-20:hover{background-color:rgba(0,0,0,.2)}.hover-bg-black-10:focus,.hover-bg-black-10:hover{background-color:rgba(0,0,0,.1)}.hover-bg-white-90:focus,.hover-bg-white-90:hover{background-color:hsla(0,0%,100%,.9)}.hover-bg-white-80:focus,.hover-bg-white-80:hover{background-color:hsla(0,0%,100%,.8)}.hover-bg-white-70:focus,.hover-bg-white-70:hover{background-color:hsla(0,0%,100%,.7)}.hover-bg-white-60:focus,.hover-bg-white-60:hover{background-color:hsla(0,0%,100%,.6)}.hover-bg-white-50:focus,.hover-bg-white-50:hover{background-color:hsla(0,0%,100%,.5)}.hover-bg-white-40:focus,.hover-bg-white-40:hover{background-color:hsla(0,0%,100%,.4)}.hover-bg-white-30:focus,.hover-bg-white-30:hover{background-color:hsla(0,0%,100%,.3)}.hover-bg-white-20:focus,.hover-bg-white-20:hover{background-color:hsla(0,0%,100%,.2)}.hover-bg-white-10:focus,.hover-bg-white-10:hover{background-color:hsla(0,0%,100%,.1)}.hover-dark-red:focus,.hover-dark-red:hover{color:#e7040f}.hover-red:focus,.hover-red:hover{color:#ff4136}.hover-light-red:focus,.hover-light-red:hover{color:#ff725c}.hover-orange:focus,.hover-orange:hover{color:#ff6300}.hover-gold:focus,.hover-gold:hover{color:#ffb700}.hover-yellow:focus,.hover-yellow:hover{color:gold}.hover-light-yellow:focus,.hover-light-yellow:hover{color:#fbf1a9}.hover-purple:focus,.hover-purple:hover{color:#5e2ca5}.hover-light-purple:focus,.hover-light-purple:hover{color:#a463f2}.hover-dark-pink:focus,.hover-dark-pink:hover{color:#d5008f}.hover-hot-pink:focus,.hover-hot-pink:hover{color:#ff41b4}.hover-pink:focus,.hover-pink:hover{color:#ff80cc}.hover-light-pink:focus,.hover-light-pink:hover{color:#ffa3d7}.hover-dark-green:focus,.hover-dark-green:hover{color:#137752}.hover-green:focus,.hover-green:hover{color:#19a974}.hover-light-green:focus,.hover-light-green:hover{color:#9eebcf}.hover-navy:focus,.hover-navy:hover{color:#001b44}.hover-dark-blue:focus,.hover-dark-blue:hover{color:#00449e}.hover-blue:focus,.hover-blue:hover{color:#357edd}.hover-light-blue:focus,.hover-light-blue:hover{color:#96ccff}.hover-lightest-blue:focus,.hover-lightest-blue:hover{color:#cdecff}.hover-washed-blue:focus,.hover-washed-blue:hover{color:#f6fffe}.hover-washed-green:focus,.hover-washed-green:hover{color:#e8fdf5}.hover-washed-yellow:focus,.hover-washed-yellow:hover{color:#fffceb}.hover-washed-red:focus,.hover-washed-red:hover{color:#ffdfdf}.hover-bg-dark-red:focus,.hover-bg-dark-red:hover{background-color:#e7040f}.hover-bg-red:focus,.hover-bg-red:hover{background-color:#ff4136}.hover-bg-light-red:focus,.hover-bg-light-red:hover{background-color:#ff725c}.hover-bg-orange:focus,.hover-bg-orange:hover{background-color:#ff6300}.hover-bg-gold:focus,.hover-bg-gold:hover{background-color:#ffb700}.hover-bg-yellow:focus,.hover-bg-yellow:hover{background-color:gold}.hover-bg-light-yellow:focus,.hover-bg-light-yellow:hover{background-color:#fbf1a9}.hover-bg-purple:focus,.hover-bg-purple:hover{background-color:#5e2ca5}.hover-bg-light-purple:focus,.hover-bg-light-purple:hover{background-color:#a463f2}.hover-bg-dark-pink:focus,.hover-bg-dark-pink:hover{background-color:#d5008f}.hover-bg-hot-pink:focus,.hover-bg-hot-pink:hover{background-color:#ff41b4}.hover-bg-pink:focus,.hover-bg-pink:hover{background-color:#ff80cc}.hover-bg-light-pink:focus,.hover-bg-light-pink:hover{background-color:#ffa3d7}.hover-bg-dark-green:focus,.hover-bg-dark-green:hover{background-color:#137752}.hover-bg-green:focus,.hover-bg-green:hover{background-color:#19a974}.hover-bg-light-green:focus,.hover-bg-light-green:hover{background-color:#9eebcf}.hover-bg-navy:focus,.hover-bg-navy:hover{background-color:#001b44}.hover-bg-dark-blue:focus,.hover-bg-dark-blue:hover{background-color:#00449e}.hover-bg-blue:focus,.hover-bg-blue:hover{background-color:#357edd}.hover-bg-light-blue:focus,.hover-bg-light-blue:hover{background-color:#96ccff}.hover-bg-lightest-blue:focus,.hover-bg-lightest-blue:hover{background-color:#cdecff}.hover-bg-washed-blue:focus,.hover-bg-washed-blue:hover{background-color:#f6fffe}.hover-bg-washed-green:focus,.hover-bg-washed-green:hover{background-color:#e8fdf5}.hover-bg-washed-yellow:focus,.hover-bg-washed-yellow:hover{background-color:#fffceb}.hover-bg-washed-red:focus,.hover-bg-washed-red:hover{background-color:#ffdfdf}.hover-bg-inherit:focus,.hover-bg-inherit:hover{background-color:inherit}.pa0{padding:0}.pa1{padding:.25rem}.pa2{padding:.5rem}.pa3{padding:1rem}.pa4{padding:2rem}.pa5{padding:4rem}.pa6{padding:8rem}.pa7{padding:16rem}.pl0{padding-left:0}.pl1{padding-left:.25rem}.pl2{padding-left:.5rem}.pl3{padding-left:1rem}.pl4{padding-left:2rem}.pl5{padding-left:4rem}.pl6{padding-left:8rem}.pl7{padding-left:16rem}.pr0{padding-right:0}.pr1{padding-right:.25rem}.pr2{padding-right:.5rem}.pr3{padding-right:1rem}.pr4{padding-right:2rem}.pr5{padding-right:4rem}.pr6{padding-right:8rem}.pr7{padding-right:16rem}.pb0{padding-bottom:0}.pb1{padding-bottom:.25rem}.pb2{padding-bottom:.5rem}.pb3{padding-bottom:1rem}.pb4{padding-bottom:2rem}.pb5{padding-bottom:4rem}.pb6{padding-bottom:8rem}.pb7{padding-bottom:16rem}.pt0{padding-top:0}.pt1{padding-top:.25rem}.pt2{padding-top:.5rem}.pt3{padding-top:1rem}.pt4{padding-top:2rem}.pt5{padding-top:4rem}.pt6{padding-top:8rem}.pt7{padding-top:16rem}.pv0{padding-top:0;padding-bottom:0}.pv1{padding-top:.25rem;padding-bottom:.25rem}.pv2{padding-top:.5rem;padding-bottom:.5rem}.pv3{padding-top:1rem;padding-bottom:1rem}.pv4{padding-top:2rem;padding-bottom:2rem}.pv5{padding-top:4rem;padding-bottom:4rem}.pv6{padding-top:8rem;padding-bottom:8rem}.pv7{padding-top:16rem;padding-bottom:16rem}.ph0{padding-left:0;padding-right:0}.ph1{padding-left:.25rem;padding-right:.25rem}.ph2{padding-left:.5rem;padding-right:.5rem}.ph3{padding-left:1rem;padding-right:1rem}.ph4{padding-left:2rem;padding-right:2rem}.ph5{padding-left:4rem;padding-right:4rem}.ph6{padding-left:8rem;padding-right:8rem}.ph7{padding-left:16rem;padding-right:16rem}.ma0{margin:0}.ma1{margin:.25rem}.ma2{margin:.5rem}.ma3{margin:1rem}.ma4{margin:2rem}.ma5{margin:4rem}.ma6{margin:8rem}.ma7{margin:16rem}.ml0{margin-left:0}.ml1{margin-left:.25rem}.ml2{margin-left:.5rem}.ml3{margin-left:1rem}.ml4{margin-left:2rem}.ml5{margin-left:4rem}.ml6{margin-left:8rem}.ml7{margin-left:16rem}.mr0{margin-right:0}.mr1{margin-right:.25rem}.mr2{margin-right:.5rem}.mr3{margin-right:1rem}.mr4{margin-right:2rem}.mr5{margin-right:4rem}.mr6{margin-right:8rem}.mr7{margin-right:16rem}.mb0{margin-bottom:0}.mb1{margin-bottom:.25rem}.mb2{margin-bottom:.5rem}.mb3{margin-bottom:1rem}.mb4{margin-bottom:2rem}.mb5{margin-bottom:4rem}.mb6{margin-bottom:8rem}.mb7{margin-bottom:16rem}.mt0{margin-top:0}.mt1{margin-top:.25rem}.mt2{margin-top:.5rem}.mt3{margin-top:1rem}.mt4{margin-top:2rem}.mt5{margin-top:4rem}.mt6{margin-top:8rem}.mt7{margin-top:16rem}.mv0{margin-top:0;margin-bottom:0}.mv1{margin-top:.25rem;margin-bottom:.25rem}.mv2{margin-top:.5rem;margin-bottom:.5rem}.mv3{margin-top:1rem;margin-bottom:1rem}.mv4{margin-top:2rem;margin-bottom:2rem}.mv5{margin-top:4rem;margin-bottom:4rem}.mv6{margin-top:8rem;margin-bottom:8rem}.mv7{margin-top:16rem;margin-bottom:16rem}.mh0{margin-left:0;margin-right:0}.mh1{margin-left:.25rem;margin-right:.25rem}.mh2{margin-left:.5rem;margin-right:.5rem}.mh3{margin-left:1rem;margin-right:1rem}.mh4{margin-left:2rem;margin-right:2rem}.mh5{margin-left:4rem;margin-right:4rem}.mh6{margin-left:8rem;margin-right:8rem}.mh7{margin-left:16rem;margin-right:16rem}.na1{margin:-.25rem}.na2{margin:-.5rem}.na3{margin:-1rem}.na4{margin:-2rem}.na5{margin:-4rem}.na6{margin:-8rem}.na7{margin:-16rem}.nl1{margin-left:-.25rem}.nl2{margin-left:-.5rem}.nl3{margin-left:-1rem}.nl4{margin-left:-2rem}.nl5{margin-left:-4rem}.nl6{margin-left:-8rem}.nl7{margin-left:-16rem}.nr1{margin-right:-.25rem}.nr2{margin-right:-.5rem}.nr3{margin-right:-1rem}.nr4{margin-right:-2rem}.nr5{margin-right:-4rem}.nr6{margin-right:-8rem}.nr7{margin-right:-16rem}.nb1{margin-bottom:-.25rem}.nb2{margin-bottom:-.5rem}.nb3{margin-bottom:-1rem}.nb4{margin-bottom:-2rem}.nb5{margin-bottom:-4rem}.nb6{margin-bottom:-8rem}.nb7{margin-bottom:-16rem}.nt1{margin-top:-.25rem}.nt2{margin-top:-.5rem}.nt3{margin-top:-1rem}.nt4{margin-top:-2rem}.nt5{margin-top:-4rem}.nt6{margin-top:-8rem}.nt7{margin-top:-16rem}.collapse{border-collapse:collapse;border-spacing:0}.striped--light-silver:nth-child(odd){background-color:#aaa}.striped--moon-gray:nth-child(odd){background-color:#ccc}.striped--light-gray:nth-child(odd){background-color:#eee}.striped--near-white:nth-child(odd){background-color:#f4f4f4}.stripe-light:nth-child(odd){background-color:hsla(0,0%,100%,.1)}.stripe-dark:nth-child(odd){background-color:rgba(0,0,0,.1)}.strike{text-decoration:line-through}.underline{text-decoration:underline}.no-underline{text-decoration:none}.tl{text-align:left}.tr{text-align:right}.tc{text-align:center}.tj{text-align:justify}.ttc{text-transform:capitalize}.ttl{text-transform:lowercase}.ttu{text-transform:uppercase}.ttn{text-transform:none}.f-6,.f-headline{font-size:6rem}.f-5,.f-subheadline{font-size:5rem}.f1{font-size:3rem}.f2{font-size:2.25rem}.f3{font-size:1.5rem}.f4{font-size:1.25rem}.f5{font-size:1rem}.f6{font-size:.875rem}.f7{font-size:.75rem}.measure{max-width:30em}.measure-wide{max-width:34em}.measure-narrow{max-width:20em}.indent{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps{font-variant:small-caps}.truncate{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.overflow-container{overflow-y:scroll}.center{margin-left:auto}.center,.mr-auto{margin-right:auto}.ml-auto{margin-left:auto}.clip{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal{white-space:normal}.nowrap{white-space:nowrap}.pre{white-space:pre}.v-base{vertical-align:baseline}.v-mid{vertical-align:middle}.v-top{vertical-align:top}.v-btm{vertical-align:bottom}.dim{opacity:1}.dim,.dim:focus,.dim:hover{transition:opacity .15s ease-in}.dim:focus,.dim:hover{opacity:.5}.dim:active{opacity:.8;transition:opacity .15s ease-out}.glow,.glow:focus,.glow:hover{transition:opacity .15s ease-in}.glow:focus,.glow:hover{opacity:1}.hide-child .child{opacity:0;transition:opacity .15s ease-in}.hide-child:active .child,.hide-child:focus .child,.hide-child:hover .child{opacity:1;transition:opacity .15s ease-in}.underline-hover:focus,.underline-hover:hover{text-decoration:underline}.grow{-moz-osx-font-smoothing:grayscale;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform:translateZ(0);transform:translateZ(0);transition:-webkit-transform .25s ease-out;transition:transform .25s ease-out;transition:transform .25s ease-out,-webkit-transform .25s ease-out}.grow:focus,.grow:hover{-webkit-transform:scale(1.05);transform:scale(1.05)}.grow:active{-webkit-transform:scale(.9);transform:scale(.9)}.grow-large{-moz-osx-font-smoothing:grayscale;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform:translateZ(0);transform:translateZ(0);transition:-webkit-transform .25s ease-in-out;transition:transform .25s ease-in-out;transition:transform .25s ease-in-out,-webkit-transform .25s ease-in-out}.grow-large:focus,.grow-large:hover{-webkit-transform:scale(1.2);transform:scale(1.2)}.grow-large:active{-webkit-transform:scale(.95);transform:scale(.95)}.pointer:hover,.shadow-hover{cursor:pointer}.shadow-hover{position:relative;transition:all .5s cubic-bezier(.165,.84,.44,1)}.shadow-hover:after{content:"";box-shadow:0 0 16px 2px rgba(0,0,0,.2);border-radius:inherit;opacity:0;position:absolute;top:0;left:0;width:100%;height:100%;z-index:-1;transition:opacity .5s cubic-bezier(.165,.84,.44,1)}.shadow-hover:focus:after,.shadow-hover:hover:after{opacity:1}.bg-animate,.bg-animate:focus,.bg-animate:hover{transition:background-color .15s ease-in-out}.z-0{z-index:0}.z-1{z-index:1}.z-2{z-index:2}.z-3{z-index:3}.z-4{z-index:4}.z-5{z-index:5}.z-999{z-index:999}.z-9999{z-index:9999}.z-max{z-index:2147483647}.z-inherit{z-index:inherit}.z-initial{z-index:auto}.z-unset{z-index:unset}.nested-copy-line-height ol,.nested-copy-line-height p,.nested-copy-line-height ul{line-height:1.5}.nested-headline-line-height h1,.nested-headline-line-height h2,.nested-headline-line-height h3,.nested-headline-line-height h4,.nested-headline-line-height h5,.nested-headline-line-height h6{line-height:1.25}.nested-list-reset ol,.nested-list-reset ul{padding-left:0;margin-left:0;list-style-type:none}.nested-copy-indent p+p{text-indent:1em;margin-top:0;margin-bottom:0}.nested-copy-separator p+p{margin-top:1.5em}.nested-img img{width:100%;max-width:100%;display:block}.nested-links a{color:#357edd;transition:color .15s ease-in}.nested-links a:focus,.nested-links a:hover{color:#96ccff;transition:color .15s ease-in}.debug *{outline:1px solid gold}.debug-white *{outline:1px solid #fff}.debug-black *{outline:1px solid #000}.debug-grid{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4AWPAC97/9x0eCsAEPgwAVLshdpENIxcAAAAASUVORK5CYII=) repeat 0 0}.debug-grid-16{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMklEQVR4AWOgCLz/b0epAa6UGuBOqQHOQHLUgFEDnAbcBZ4UGwDOkiCnkIhdgNgNxAYAiYlD+8sEuo8AAAAASUVORK5CYII=) repeat 0 0}.debug-grid-8-solid{background:#fff url(data:image/gif;base64,R0lGODdhCAAIAPEAAADw/wDx/////wAAACwAAAAACAAIAAACDZQvgaeb/lxbAIKA8y0AOw==) repeat 0 0}.debug-grid-16-solid{background:#fff url(data:image/gif;base64,R0lGODdhEAAQAPEAAADw/wDx/xXy/////ywAAAAAEAAQAAACIZyPKckYDQFsb6ZqD85jZ2+BkwiRFKehhqQCQgDHcgwEBQA7) repeat 0 0}@media screen and (min-width:30em){.aspect-ratio-ns{height:0;position:relative}.aspect-ratio--16x9-ns{padding-bottom:56.25%}.aspect-ratio--9x16-ns{padding-bottom:177.77%}.aspect-ratio--4x3-ns{padding-bottom:75%}.aspect-ratio--3x4-ns{padding-bottom:133.33%}.aspect-ratio--6x4-ns{padding-bottom:66.6%}.aspect-ratio--4x6-ns{padding-bottom:150%}.aspect-ratio--8x5-ns{padding-bottom:62.5%}.aspect-ratio--5x8-ns{padding-bottom:160%}.aspect-ratio--7x5-ns{padding-bottom:71.42%}.aspect-ratio--5x7-ns{padding-bottom:140%}.aspect-ratio--1x1-ns{padding-bottom:100%}.aspect-ratio--object-ns{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}.cover-ns{background-size:cover!important}.contain-ns{background-size:contain!important}.bg-center-ns{background-position:50%}.bg-center-ns,.bg-top-ns{background-repeat:no-repeat}.bg-top-ns{background-position:top}.bg-right-ns{background-position:100%}.bg-bottom-ns,.bg-right-ns{background-repeat:no-repeat}.bg-bottom-ns{background-position:bottom}.bg-left-ns{background-repeat:no-repeat;background-position:0}.outline-ns{outline:1px solid}.outline-transparent-ns{outline:1px solid transparent}.outline-0-ns{outline:0}.ba-ns{border-style:solid;border-width:1px}.bt-ns{border-top-style:solid;border-top-width:1px}.br-ns{border-right-style:solid;border-right-width:1px}.bb-ns{border-bottom-style:solid;border-bottom-width:1px}.bl-ns{border-left-style:solid;border-left-width:1px}.bn-ns{border-style:none;border-width:0}.br0-ns{border-radius:0}.br1-ns{border-radius:.125rem}.br2-ns{border-radius:.25rem}.br3-ns{border-radius:.5rem}.br4-ns{border-radius:1rem}.br-100-ns{border-radius:100%}.br-pill-ns{border-radius:9999px}.br--bottom-ns{border-top-left-radius:0;border-top-right-radius:0}.br--top-ns{border-bottom-right-radius:0}.br--right-ns,.br--top-ns{border-bottom-left-radius:0}.br--right-ns{border-top-left-radius:0}.br--left-ns{border-top-right-radius:0;border-bottom-right-radius:0}.b--dotted-ns{border-style:dotted}.b--dashed-ns{border-style:dashed}.b--solid-ns{border-style:solid}.b--none-ns{border-style:none}.bw0-ns{border-width:0}.bw1-ns{border-width:.125rem}.bw2-ns{border-width:.25rem}.bw3-ns{border-width:.5rem}.bw4-ns{border-width:1rem}.bw5-ns{border-width:2rem}.bt-0-ns{border-top-width:0}.br-0-ns{border-right-width:0}.bb-0-ns{border-bottom-width:0}.bl-0-ns{border-left-width:0}.shadow-1-ns{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-ns{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-ns{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-ns{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-ns{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.top-0-ns{top:0}.left-0-ns{left:0}.right-0-ns{right:0}.bottom-0-ns{bottom:0}.top-1-ns{top:1rem}.left-1-ns{left:1rem}.right-1-ns{right:1rem}.bottom-1-ns{bottom:1rem}.top-2-ns{top:2rem}.left-2-ns{left:2rem}.right-2-ns{right:2rem}.bottom-2-ns{bottom:2rem}.top--1-ns{top:-1rem}.right--1-ns{right:-1rem}.bottom--1-ns{bottom:-1rem}.left--1-ns{left:-1rem}.top--2-ns{top:-2rem}.right--2-ns{right:-2rem}.bottom--2-ns{bottom:-2rem}.left--2-ns{left:-2rem}.absolute--fill-ns{top:0;right:0;bottom:0;left:0}.cl-ns{clear:left}.cr-ns{clear:right}.cb-ns{clear:both}.cn-ns{clear:none}.dn-ns{display:none}.di-ns{display:inline}.db-ns{display:block}.dib-ns{display:inline-block}.dit-ns{display:inline-table}.dt-ns{display:table}.dtc-ns{display:table-cell}.dt-row-ns{display:table-row}.dt-row-group-ns{display:table-row-group}.dt-column-ns{display:table-column}.dt-column-group-ns{display:table-column-group}.dt--fixed-ns{table-layout:fixed;width:100%}.flex-ns{display:flex}.inline-flex-ns{display:inline-flex}.flex-auto-ns{flex:1 1 auto;min-width:0;min-height:0}.flex-none-ns{flex:none}.flex-column-ns{flex-direction:column}.flex-row-ns{flex-direction:row}.flex-wrap-ns{flex-wrap:wrap}.flex-nowrap-ns{flex-wrap:nowrap}.flex-wrap-reverse-ns{flex-wrap:wrap-reverse}.flex-column-reverse-ns{flex-direction:column-reverse}.flex-row-reverse-ns{flex-direction:row-reverse}.items-start-ns{align-items:flex-start}.items-end-ns{align-items:flex-end}.items-center-ns{align-items:center}.items-baseline-ns{align-items:baseline}.items-stretch-ns{align-items:stretch}.self-start-ns{align-self:flex-start}.self-end-ns{align-self:flex-end}.self-center-ns{align-self:center}.self-baseline-ns{align-self:baseline}.self-stretch-ns{align-self:stretch}.justify-start-ns{justify-content:flex-start}.justify-end-ns{justify-content:flex-end}.justify-center-ns{justify-content:center}.justify-between-ns{justify-content:space-between}.justify-around-ns{justify-content:space-around}.content-start-ns{align-content:flex-start}.content-end-ns{align-content:flex-end}.content-center-ns{align-content:center}.content-between-ns{align-content:space-between}.content-around-ns{align-content:space-around}.content-stretch-ns{align-content:stretch}.order-0-ns{order:0}.order-1-ns{order:1}.order-2-ns{order:2}.order-3-ns{order:3}.order-4-ns{order:4}.order-5-ns{order:5}.order-6-ns{order:6}.order-7-ns{order:7}.order-8-ns{order:8}.order-last-ns{order:99999}.flex-grow-0-ns{flex-grow:0}.flex-grow-1-ns{flex-grow:1}.flex-shrink-0-ns{flex-shrink:0}.flex-shrink-1-ns{flex-shrink:1}.fl-ns{float:left}.fl-ns,.fr-ns{_display:inline}.fr-ns{float:right}.fn-ns{float:none}.i-ns{font-style:italic}.fs-normal-ns{font-style:normal}.normal-ns{font-weight:400}.b-ns{font-weight:700}.fw1-ns{font-weight:100}.fw2-ns{font-weight:200}.fw3-ns{font-weight:300}.fw4-ns{font-weight:400}.fw5-ns{font-weight:500}.fw6-ns{font-weight:600}.fw7-ns{font-weight:700}.fw8-ns{font-weight:800}.fw9-ns{font-weight:900}.h1-ns{height:1rem}.h2-ns{height:2rem}.h3-ns{height:4rem}.h4-ns{height:8rem}.h5-ns{height:16rem}.h-25-ns{height:25%}.h-50-ns{height:50%}.h-75-ns{height:75%}.h-100-ns{height:100%}.min-h-100-ns{min-height:100%}.vh-25-ns{height:25vh}.vh-50-ns{height:50vh}.vh-75-ns{height:75vh}.vh-100-ns{height:100vh}.min-vh-100-ns{min-height:100vh}.h-auto-ns{height:auto}.h-inherit-ns{height:inherit}.tracked-ns{letter-spacing:.1em}.tracked-tight-ns{letter-spacing:-.05em}.tracked-mega-ns{letter-spacing:.25em}.lh-solid-ns{line-height:1}.lh-title-ns{line-height:1.25}.lh-copy-ns{line-height:1.5}.mw-100-ns{max-width:100%}.mw1-ns{max-width:1rem}.mw2-ns{max-width:2rem}.mw3-ns{max-width:4rem}.mw4-ns{max-width:8rem}.mw5-ns{max-width:16rem}.mw6-ns{max-width:32rem}.mw7-ns{max-width:48rem}.mw8-ns{max-width:64rem}.mw9-ns{max-width:96rem}.mw-none-ns{max-width:none}.w1-ns{width:1rem}.w2-ns{width:2rem}.w3-ns{width:4rem}.w4-ns{width:8rem}.w5-ns{width:16rem}.w-10-ns{width:10%}.w-20-ns{width:20%}.w-25-ns{width:25%}.w-30-ns{width:30%}.w-33-ns{width:33%}.w-34-ns{width:34%}.w-40-ns{width:40%}.w-50-ns{width:50%}.w-60-ns{width:60%}.w-70-ns{width:70%}.w-75-ns{width:75%}.w-80-ns{width:80%}.w-90-ns{width:90%}.w-100-ns{width:100%}.w-third-ns{width:33.33333%}.w-two-thirds-ns{width:66.66667%}.w-auto-ns{width:auto}.overflow-visible-ns{overflow:visible}.overflow-hidden-ns{overflow:hidden}.overflow-scroll-ns{overflow:scroll}.overflow-auto-ns{overflow:auto}.overflow-x-visible-ns{overflow-x:visible}.overflow-x-hidden-ns{overflow-x:hidden}.overflow-x-scroll-ns{overflow-x:scroll}.overflow-x-auto-ns{overflow-x:auto}.overflow-y-visible-ns{overflow-y:visible}.overflow-y-hidden-ns{overflow-y:hidden}.overflow-y-scroll-ns{overflow-y:scroll}.overflow-y-auto-ns{overflow-y:auto}.static-ns{position:static}.relative-ns{position:relative}.absolute-ns{position:absolute}.fixed-ns{position:fixed}.rotate-45-ns{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90-ns{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135-ns{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180-ns{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225-ns{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270-ns{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315-ns{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.pa0-ns{padding:0}.pa1-ns{padding:.25rem}.pa2-ns{padding:.5rem}.pa3-ns{padding:1rem}.pa4-ns{padding:2rem}.pa5-ns{padding:4rem}.pa6-ns{padding:8rem}.pa7-ns{padding:16rem}.pl0-ns{padding-left:0}.pl1-ns{padding-left:.25rem}.pl2-ns{padding-left:.5rem}.pl3-ns{padding-left:1rem}.pl4-ns{padding-left:2rem}.pl5-ns{padding-left:4rem}.pl6-ns{padding-left:8rem}.pl7-ns{padding-left:16rem}.pr0-ns{padding-right:0}.pr1-ns{padding-right:.25rem}.pr2-ns{padding-right:.5rem}.pr3-ns{padding-right:1rem}.pr4-ns{padding-right:2rem}.pr5-ns{padding-right:4rem}.pr6-ns{padding-right:8rem}.pr7-ns{padding-right:16rem}.pb0-ns{padding-bottom:0}.pb1-ns{padding-bottom:.25rem}.pb2-ns{padding-bottom:.5rem}.pb3-ns{padding-bottom:1rem}.pb4-ns{padding-bottom:2rem}.pb5-ns{padding-bottom:4rem}.pb6-ns{padding-bottom:8rem}.pb7-ns{padding-bottom:16rem}.pt0-ns{padding-top:0}.pt1-ns{padding-top:.25rem}.pt2-ns{padding-top:.5rem}.pt3-ns{padding-top:1rem}.pt4-ns{padding-top:2rem}.pt5-ns{padding-top:4rem}.pt6-ns{padding-top:8rem}.pt7-ns{padding-top:16rem}.pv0-ns{padding-top:0;padding-bottom:0}.pv1-ns{padding-top:.25rem;padding-bottom:.25rem}.pv2-ns{padding-top:.5rem;padding-bottom:.5rem}.pv3-ns{padding-top:1rem;padding-bottom:1rem}.pv4-ns{padding-top:2rem;padding-bottom:2rem}.pv5-ns{padding-top:4rem;padding-bottom:4rem}.pv6-ns{padding-top:8rem;padding-bottom:8rem}.pv7-ns{padding-top:16rem;padding-bottom:16rem}.ph0-ns{padding-left:0;padding-right:0}.ph1-ns{padding-left:.25rem;padding-right:.25rem}.ph2-ns{padding-left:.5rem;padding-right:.5rem}.ph3-ns{padding-left:1rem;padding-right:1rem}.ph4-ns{padding-left:2rem;padding-right:2rem}.ph5-ns{padding-left:4rem;padding-right:4rem}.ph6-ns{padding-left:8rem;padding-right:8rem}.ph7-ns{padding-left:16rem;padding-right:16rem}.ma0-ns{margin:0}.ma1-ns{margin:.25rem}.ma2-ns{margin:.5rem}.ma3-ns{margin:1rem}.ma4-ns{margin:2rem}.ma5-ns{margin:4rem}.ma6-ns{margin:8rem}.ma7-ns{margin:16rem}.ml0-ns{margin-left:0}.ml1-ns{margin-left:.25rem}.ml2-ns{margin-left:.5rem}.ml3-ns{margin-left:1rem}.ml4-ns{margin-left:2rem}.ml5-ns{margin-left:4rem}.ml6-ns{margin-left:8rem}.ml7-ns{margin-left:16rem}.mr0-ns{margin-right:0}.mr1-ns{margin-right:.25rem}.mr2-ns{margin-right:.5rem}.mr3-ns{margin-right:1rem}.mr4-ns{margin-right:2rem}.mr5-ns{margin-right:4rem}.mr6-ns{margin-right:8rem}.mr7-ns{margin-right:16rem}.mb0-ns{margin-bottom:0}.mb1-ns{margin-bottom:.25rem}.mb2-ns{margin-bottom:.5rem}.mb3-ns{margin-bottom:1rem}.mb4-ns{margin-bottom:2rem}.mb5-ns{margin-bottom:4rem}.mb6-ns{margin-bottom:8rem}.mb7-ns{margin-bottom:16rem}.mt0-ns{margin-top:0}.mt1-ns{margin-top:.25rem}.mt2-ns{margin-top:.5rem}.mt3-ns{margin-top:1rem}.mt4-ns{margin-top:2rem}.mt5-ns{margin-top:4rem}.mt6-ns{margin-top:8rem}.mt7-ns{margin-top:16rem}.mv0-ns{margin-top:0;margin-bottom:0}.mv1-ns{margin-top:.25rem;margin-bottom:.25rem}.mv2-ns{margin-top:.5rem;margin-bottom:.5rem}.mv3-ns{margin-top:1rem;margin-bottom:1rem}.mv4-ns{margin-top:2rem;margin-bottom:2rem}.mv5-ns{margin-top:4rem;margin-bottom:4rem}.mv6-ns{margin-top:8rem;margin-bottom:8rem}.mv7-ns{margin-top:16rem;margin-bottom:16rem}.mh0-ns{margin-left:0;margin-right:0}.mh1-ns{margin-left:.25rem;margin-right:.25rem}.mh2-ns{margin-left:.5rem;margin-right:.5rem}.mh3-ns{margin-left:1rem;margin-right:1rem}.mh4-ns{margin-left:2rem;margin-right:2rem}.mh5-ns{margin-left:4rem;margin-right:4rem}.mh6-ns{margin-left:8rem;margin-right:8rem}.mh7-ns{margin-left:16rem;margin-right:16rem}.na1-ns{margin:-.25rem}.na2-ns{margin:-.5rem}.na3-ns{margin:-1rem}.na4-ns{margin:-2rem}.na5-ns{margin:-4rem}.na6-ns{margin:-8rem}.na7-ns{margin:-16rem}.nl1-ns{margin-left:-.25rem}.nl2-ns{margin-left:-.5rem}.nl3-ns{margin-left:-1rem}.nl4-ns{margin-left:-2rem}.nl5-ns{margin-left:-4rem}.nl6-ns{margin-left:-8rem}.nl7-ns{margin-left:-16rem}.nr1-ns{margin-right:-.25rem}.nr2-ns{margin-right:-.5rem}.nr3-ns{margin-right:-1rem}.nr4-ns{margin-right:-2rem}.nr5-ns{margin-right:-4rem}.nr6-ns{margin-right:-8rem}.nr7-ns{margin-right:-16rem}.nb1-ns{margin-bottom:-.25rem}.nb2-ns{margin-bottom:-.5rem}.nb3-ns{margin-bottom:-1rem}.nb4-ns{margin-bottom:-2rem}.nb5-ns{margin-bottom:-4rem}.nb6-ns{margin-bottom:-8rem}.nb7-ns{margin-bottom:-16rem}.nt1-ns{margin-top:-.25rem}.nt2-ns{margin-top:-.5rem}.nt3-ns{margin-top:-1rem}.nt4-ns{margin-top:-2rem}.nt5-ns{margin-top:-4rem}.nt6-ns{margin-top:-8rem}.nt7-ns{margin-top:-16rem}.strike-ns{text-decoration:line-through}.underline-ns{text-decoration:underline}.no-underline-ns{text-decoration:none}.tl-ns{text-align:left}.tr-ns{text-align:right}.tc-ns{text-align:center}.tj-ns{text-align:justify}.ttc-ns{text-transform:capitalize}.ttl-ns{text-transform:lowercase}.ttu-ns{text-transform:uppercase}.ttn-ns{text-transform:none}.f-6-ns,.f-headline-ns{font-size:6rem}.f-5-ns,.f-subheadline-ns{font-size:5rem}.f1-ns{font-size:3rem}.f2-ns{font-size:2.25rem}.f3-ns{font-size:1.5rem}.f4-ns{font-size:1.25rem}.f5-ns{font-size:1rem}.f6-ns{font-size:.875rem}.f7-ns{font-size:.75rem}.measure-ns{max-width:30em}.measure-wide-ns{max-width:34em}.measure-narrow-ns{max-width:20em}.indent-ns{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-ns{font-variant:small-caps}.truncate-ns{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.center-ns{margin-left:auto}.center-ns,.mr-auto-ns{margin-right:auto}.ml-auto-ns{margin-left:auto}.clip-ns{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal-ns{white-space:normal}.nowrap-ns{white-space:nowrap}.pre-ns{white-space:pre}.v-base-ns{vertical-align:baseline}.v-mid-ns{vertical-align:middle}.v-top-ns{vertical-align:top}.v-btm-ns{vertical-align:bottom}}@media screen and (min-width:30em) and (max-width:60em){.aspect-ratio-m{height:0;position:relative}.aspect-ratio--16x9-m{padding-bottom:56.25%}.aspect-ratio--9x16-m{padding-bottom:177.77%}.aspect-ratio--4x3-m{padding-bottom:75%}.aspect-ratio--3x4-m{padding-bottom:133.33%}.aspect-ratio--6x4-m{padding-bottom:66.6%}.aspect-ratio--4x6-m{padding-bottom:150%}.aspect-ratio--8x5-m{padding-bottom:62.5%}.aspect-ratio--5x8-m{padding-bottom:160%}.aspect-ratio--7x5-m{padding-bottom:71.42%}.aspect-ratio--5x7-m{padding-bottom:140%}.aspect-ratio--1x1-m{padding-bottom:100%}.aspect-ratio--object-m{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}.cover-m{background-size:cover!important}.contain-m{background-size:contain!important}.bg-center-m{background-position:50%}.bg-center-m,.bg-top-m{background-repeat:no-repeat}.bg-top-m{background-position:top}.bg-right-m{background-position:100%}.bg-bottom-m,.bg-right-m{background-repeat:no-repeat}.bg-bottom-m{background-position:bottom}.bg-left-m{background-repeat:no-repeat;background-position:0}.outline-m{outline:1px solid}.outline-transparent-m{outline:1px solid transparent}.outline-0-m{outline:0}.ba-m{border-style:solid;border-width:1px}.bt-m{border-top-style:solid;border-top-width:1px}.br-m{border-right-style:solid;border-right-width:1px}.bb-m{border-bottom-style:solid;border-bottom-width:1px}.bl-m{border-left-style:solid;border-left-width:1px}.bn-m{border-style:none;border-width:0}.br0-m{border-radius:0}.br1-m{border-radius:.125rem}.br2-m{border-radius:.25rem}.br3-m{border-radius:.5rem}.br4-m{border-radius:1rem}.br-100-m{border-radius:100%}.br-pill-m{border-radius:9999px}.br--bottom-m{border-top-left-radius:0;border-top-right-radius:0}.br--top-m{border-bottom-right-radius:0}.br--right-m,.br--top-m{border-bottom-left-radius:0}.br--right-m{border-top-left-radius:0}.br--left-m{border-top-right-radius:0;border-bottom-right-radius:0}.b--dotted-m{border-style:dotted}.b--dashed-m{border-style:dashed}.b--solid-m{border-style:solid}.b--none-m{border-style:none}.bw0-m{border-width:0}.bw1-m{border-width:.125rem}.bw2-m{border-width:.25rem}.bw3-m{border-width:.5rem}.bw4-m{border-width:1rem}.bw5-m{border-width:2rem}.bt-0-m{border-top-width:0}.br-0-m{border-right-width:0}.bb-0-m{border-bottom-width:0}.bl-0-m{border-left-width:0}.shadow-1-m{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-m{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-m{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-m{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-m{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.top-0-m{top:0}.left-0-m{left:0}.right-0-m{right:0}.bottom-0-m{bottom:0}.top-1-m{top:1rem}.left-1-m{left:1rem}.right-1-m{right:1rem}.bottom-1-m{bottom:1rem}.top-2-m{top:2rem}.left-2-m{left:2rem}.right-2-m{right:2rem}.bottom-2-m{bottom:2rem}.top--1-m{top:-1rem}.right--1-m{right:-1rem}.bottom--1-m{bottom:-1rem}.left--1-m{left:-1rem}.top--2-m{top:-2rem}.right--2-m{right:-2rem}.bottom--2-m{bottom:-2rem}.left--2-m{left:-2rem}.absolute--fill-m{top:0;right:0;bottom:0;left:0}.cl-m{clear:left}.cr-m{clear:right}.cb-m{clear:both}.cn-m{clear:none}.dn-m{display:none}.di-m{display:inline}.db-m{display:block}.dib-m{display:inline-block}.dit-m{display:inline-table}.dt-m{display:table}.dtc-m{display:table-cell}.dt-row-m{display:table-row}.dt-row-group-m{display:table-row-group}.dt-column-m{display:table-column}.dt-column-group-m{display:table-column-group}.dt--fixed-m{table-layout:fixed;width:100%}.flex-m{display:flex}.inline-flex-m{display:inline-flex}.flex-auto-m{flex:1 1 auto;min-width:0;min-height:0}.flex-none-m{flex:none}.flex-column-m{flex-direction:column}.flex-row-m{flex-direction:row}.flex-wrap-m{flex-wrap:wrap}.flex-nowrap-m{flex-wrap:nowrap}.flex-wrap-reverse-m{flex-wrap:wrap-reverse}.flex-column-reverse-m{flex-direction:column-reverse}.flex-row-reverse-m{flex-direction:row-reverse}.items-start-m{align-items:flex-start}.items-end-m{align-items:flex-end}.items-center-m{align-items:center}.items-baseline-m{align-items:baseline}.items-stretch-m{align-items:stretch}.self-start-m{align-self:flex-start}.self-end-m{align-self:flex-end}.self-center-m{align-self:center}.self-baseline-m{align-self:baseline}.self-stretch-m{align-self:stretch}.justify-start-m{justify-content:flex-start}.justify-end-m{justify-content:flex-end}.justify-center-m{justify-content:center}.justify-between-m{justify-content:space-between}.justify-around-m{justify-content:space-around}.content-start-m{align-content:flex-start}.content-end-m{align-content:flex-end}.content-center-m{align-content:center}.content-between-m{align-content:space-between}.content-around-m{align-content:space-around}.content-stretch-m{align-content:stretch}.order-0-m{order:0}.order-1-m{order:1}.order-2-m{order:2}.order-3-m{order:3}.order-4-m{order:4}.order-5-m{order:5}.order-6-m{order:6}.order-7-m{order:7}.order-8-m{order:8}.order-last-m{order:99999}.flex-grow-0-m{flex-grow:0}.flex-grow-1-m{flex-grow:1}.flex-shrink-0-m{flex-shrink:0}.flex-shrink-1-m{flex-shrink:1}.fl-m{float:left}.fl-m,.fr-m{_display:inline}.fr-m{float:right}.fn-m{float:none}.i-m{font-style:italic}.fs-normal-m{font-style:normal}.normal-m{font-weight:400}.b-m{font-weight:700}.fw1-m{font-weight:100}.fw2-m{font-weight:200}.fw3-m{font-weight:300}.fw4-m{font-weight:400}.fw5-m{font-weight:500}.fw6-m{font-weight:600}.fw7-m{font-weight:700}.fw8-m{font-weight:800}.fw9-m{font-weight:900}.h1-m{height:1rem}.h2-m{height:2rem}.h3-m{height:4rem}.h4-m{height:8rem}.h5-m{height:16rem}.h-25-m{height:25%}.h-50-m{height:50%}.h-75-m{height:75%}.h-100-m{height:100%}.min-h-100-m{min-height:100%}.vh-25-m{height:25vh}.vh-50-m{height:50vh}.vh-75-m{height:75vh}.vh-100-m{height:100vh}.min-vh-100-m{min-height:100vh}.h-auto-m{height:auto}.h-inherit-m{height:inherit}.tracked-m{letter-spacing:.1em}.tracked-tight-m{letter-spacing:-.05em}.tracked-mega-m{letter-spacing:.25em}.lh-solid-m{line-height:1}.lh-title-m{line-height:1.25}.lh-copy-m{line-height:1.5}.mw-100-m{max-width:100%}.mw1-m{max-width:1rem}.mw2-m{max-width:2rem}.mw3-m{max-width:4rem}.mw4-m{max-width:8rem}.mw5-m{max-width:16rem}.mw6-m{max-width:32rem}.mw7-m{max-width:48rem}.mw8-m{max-width:64rem}.mw9-m{max-width:96rem}.mw-none-m{max-width:none}.w1-m{width:1rem}.w2-m{width:2rem}.w3-m{width:4rem}.w4-m{width:8rem}.w5-m{width:16rem}.w-10-m{width:10%}.w-20-m{width:20%}.w-25-m{width:25%}.w-30-m{width:30%}.w-33-m{width:33%}.w-34-m{width:34%}.w-40-m{width:40%}.w-50-m{width:50%}.w-60-m{width:60%}.w-70-m{width:70%}.w-75-m{width:75%}.w-80-m{width:80%}.w-90-m{width:90%}.w-100-m{width:100%}.w-third-m{width:33.33333%}.w-two-thirds-m{width:66.66667%}.w-auto-m{width:auto}.overflow-visible-m{overflow:visible}.overflow-hidden-m{overflow:hidden}.overflow-scroll-m{overflow:scroll}.overflow-auto-m{overflow:auto}.overflow-x-visible-m{overflow-x:visible}.overflow-x-hidden-m{overflow-x:hidden}.overflow-x-scroll-m{overflow-x:scroll}.overflow-x-auto-m{overflow-x:auto}.overflow-y-visible-m{overflow-y:visible}.overflow-y-hidden-m{overflow-y:hidden}.overflow-y-scroll-m{overflow-y:scroll}.overflow-y-auto-m{overflow-y:auto}.static-m{position:static}.relative-m{position:relative}.absolute-m{position:absolute}.fixed-m{position:fixed}.rotate-45-m{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90-m{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135-m{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180-m{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225-m{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270-m{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315-m{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.pa0-m{padding:0}.pa1-m{padding:.25rem}.pa2-m{padding:.5rem}.pa3-m{padding:1rem}.pa4-m{padding:2rem}.pa5-m{padding:4rem}.pa6-m{padding:8rem}.pa7-m{padding:16rem}.pl0-m{padding-left:0}.pl1-m{padding-left:.25rem}.pl2-m{padding-left:.5rem}.pl3-m{padding-left:1rem}.pl4-m{padding-left:2rem}.pl5-m{padding-left:4rem}.pl6-m{padding-left:8rem}.pl7-m{padding-left:16rem}.pr0-m{padding-right:0}.pr1-m{padding-right:.25rem}.pr2-m{padding-right:.5rem}.pr3-m{padding-right:1rem}.pr4-m{padding-right:2rem}.pr5-m{padding-right:4rem}.pr6-m{padding-right:8rem}.pr7-m{padding-right:16rem}.pb0-m{padding-bottom:0}.pb1-m{padding-bottom:.25rem}.pb2-m{padding-bottom:.5rem}.pb3-m{padding-bottom:1rem}.pb4-m{padding-bottom:2rem}.pb5-m{padding-bottom:4rem}.pb6-m{padding-bottom:8rem}.pb7-m{padding-bottom:16rem}.pt0-m{padding-top:0}.pt1-m{padding-top:.25rem}.pt2-m{padding-top:.5rem}.pt3-m{padding-top:1rem}.pt4-m{padding-top:2rem}.pt5-m{padding-top:4rem}.pt6-m{padding-top:8rem}.pt7-m{padding-top:16rem}.pv0-m{padding-top:0;padding-bottom:0}.pv1-m{padding-top:.25rem;padding-bottom:.25rem}.pv2-m{padding-top:.5rem;padding-bottom:.5rem}.pv3-m{padding-top:1rem;padding-bottom:1rem}.pv4-m{padding-top:2rem;padding-bottom:2rem}.pv5-m{padding-top:4rem;padding-bottom:4rem}.pv6-m{padding-top:8rem;padding-bottom:8rem}.pv7-m{padding-top:16rem;padding-bottom:16rem}.ph0-m{padding-left:0;padding-right:0}.ph1-m{padding-left:.25rem;padding-right:.25rem}.ph2-m{padding-left:.5rem;padding-right:.5rem}.ph3-m{padding-left:1rem;padding-right:1rem}.ph4-m{padding-left:2rem;padding-right:2rem}.ph5-m{padding-left:4rem;padding-right:4rem}.ph6-m{padding-left:8rem;padding-right:8rem}.ph7-m{padding-left:16rem;padding-right:16rem}.ma0-m{margin:0}.ma1-m{margin:.25rem}.ma2-m{margin:.5rem}.ma3-m{margin:1rem}.ma4-m{margin:2rem}.ma5-m{margin:4rem}.ma6-m{margin:8rem}.ma7-m{margin:16rem}.ml0-m{margin-left:0}.ml1-m{margin-left:.25rem}.ml2-m{margin-left:.5rem}.ml3-m{margin-left:1rem}.ml4-m{margin-left:2rem}.ml5-m{margin-left:4rem}.ml6-m{margin-left:8rem}.ml7-m{margin-left:16rem}.mr0-m{margin-right:0}.mr1-m{margin-right:.25rem}.mr2-m{margin-right:.5rem}.mr3-m{margin-right:1rem}.mr4-m{margin-right:2rem}.mr5-m{margin-right:4rem}.mr6-m{margin-right:8rem}.mr7-m{margin-right:16rem}.mb0-m{margin-bottom:0}.mb1-m{margin-bottom:.25rem}.mb2-m{margin-bottom:.5rem}.mb3-m{margin-bottom:1rem}.mb4-m{margin-bottom:2rem}.mb5-m{margin-bottom:4rem}.mb6-m{margin-bottom:8rem}.mb7-m{margin-bottom:16rem}.mt0-m{margin-top:0}.mt1-m{margin-top:.25rem}.mt2-m{margin-top:.5rem}.mt3-m{margin-top:1rem}.mt4-m{margin-top:2rem}.mt5-m{margin-top:4rem}.mt6-m{margin-top:8rem}.mt7-m{margin-top:16rem}.mv0-m{margin-top:0;margin-bottom:0}.mv1-m{margin-top:.25rem;margin-bottom:.25rem}.mv2-m{margin-top:.5rem;margin-bottom:.5rem}.mv3-m{margin-top:1rem;margin-bottom:1rem}.mv4-m{margin-top:2rem;margin-bottom:2rem}.mv5-m{margin-top:4rem;margin-bottom:4rem}.mv6-m{margin-top:8rem;margin-bottom:8rem}.mv7-m{margin-top:16rem;margin-bottom:16rem}.mh0-m{margin-left:0;margin-right:0}.mh1-m{margin-left:.25rem;margin-right:.25rem}.mh2-m{margin-left:.5rem;margin-right:.5rem}.mh3-m{margin-left:1rem;margin-right:1rem}.mh4-m{margin-left:2rem;margin-right:2rem}.mh5-m{margin-left:4rem;margin-right:4rem}.mh6-m{margin-left:8rem;margin-right:8rem}.mh7-m{margin-left:16rem;margin-right:16rem}.na1-m{margin:-.25rem}.na2-m{margin:-.5rem}.na3-m{margin:-1rem}.na4-m{margin:-2rem}.na5-m{margin:-4rem}.na6-m{margin:-8rem}.na7-m{margin:-16rem}.nl1-m{margin-left:-.25rem}.nl2-m{margin-left:-.5rem}.nl3-m{margin-left:-1rem}.nl4-m{margin-left:-2rem}.nl5-m{margin-left:-4rem}.nl6-m{margin-left:-8rem}.nl7-m{margin-left:-16rem}.nr1-m{margin-right:-.25rem}.nr2-m{margin-right:-.5rem}.nr3-m{margin-right:-1rem}.nr4-m{margin-right:-2rem}.nr5-m{margin-right:-4rem}.nr6-m{margin-right:-8rem}.nr7-m{margin-right:-16rem}.nb1-m{margin-bottom:-.25rem}.nb2-m{margin-bottom:-.5rem}.nb3-m{margin-bottom:-1rem}.nb4-m{margin-bottom:-2rem}.nb5-m{margin-bottom:-4rem}.nb6-m{margin-bottom:-8rem}.nb7-m{margin-bottom:-16rem}.nt1-m{margin-top:-.25rem}.nt2-m{margin-top:-.5rem}.nt3-m{margin-top:-1rem}.nt4-m{margin-top:-2rem}.nt5-m{margin-top:-4rem}.nt6-m{margin-top:-8rem}.nt7-m{margin-top:-16rem}.strike-m{text-decoration:line-through}.underline-m{text-decoration:underline}.no-underline-m{text-decoration:none}.tl-m{text-align:left}.tr-m{text-align:right}.tc-m{text-align:center}.tj-m{text-align:justify}.ttc-m{text-transform:capitalize}.ttl-m{text-transform:lowercase}.ttu-m{text-transform:uppercase}.ttn-m{text-transform:none}.f-6-m,.f-headline-m{font-size:6rem}.f-5-m,.f-subheadline-m{font-size:5rem}.f1-m{font-size:3rem}.f2-m{font-size:2.25rem}.f3-m{font-size:1.5rem}.f4-m{font-size:1.25rem}.f5-m{font-size:1rem}.f6-m{font-size:.875rem}.f7-m{font-size:.75rem}.measure-m{max-width:30em}.measure-wide-m{max-width:34em}.measure-narrow-m{max-width:20em}.indent-m{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-m{font-variant:small-caps}.truncate-m{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.center-m{margin-left:auto}.center-m,.mr-auto-m{margin-right:auto}.ml-auto-m{margin-left:auto}.clip-m{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal-m{white-space:normal}.nowrap-m{white-space:nowrap}.pre-m{white-space:pre}.v-base-m{vertical-align:baseline}.v-mid-m{vertical-align:middle}.v-top-m{vertical-align:top}.v-btm-m{vertical-align:bottom}}@media screen and (min-width:60em){.aspect-ratio-l{height:0;position:relative}.aspect-ratio--16x9-l{padding-bottom:56.25%}.aspect-ratio--9x16-l{padding-bottom:177.77%}.aspect-ratio--4x3-l{padding-bottom:75%}.aspect-ratio--3x4-l{padding-bottom:133.33%}.aspect-ratio--6x4-l{padding-bottom:66.6%}.aspect-ratio--4x6-l{padding-bottom:150%}.aspect-ratio--8x5-l{padding-bottom:62.5%}.aspect-ratio--5x8-l{padding-bottom:160%}.aspect-ratio--7x5-l{padding-bottom:71.42%}.aspect-ratio--5x7-l{padding-bottom:140%}.aspect-ratio--1x1-l{padding-bottom:100%}.aspect-ratio--object-l{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}.cover-l{background-size:cover!important}.contain-l{background-size:contain!important}.bg-center-l{background-position:50%}.bg-center-l,.bg-top-l{background-repeat:no-repeat}.bg-top-l{background-position:top}.bg-right-l{background-position:100%}.bg-bottom-l,.bg-right-l{background-repeat:no-repeat}.bg-bottom-l{background-position:bottom}.bg-left-l{background-repeat:no-repeat;background-position:0}.outline-l{outline:1px solid}.outline-transparent-l{outline:1px solid transparent}.outline-0-l{outline:0}.ba-l{border-style:solid;border-width:1px}.bt-l{border-top-style:solid;border-top-width:1px}.br-l{border-right-style:solid;border-right-width:1px}.bb-l{border-bottom-style:solid;border-bottom-width:1px}.bl-l{border-left-style:solid;border-left-width:1px}.bn-l{border-style:none;border-width:0}.br0-l{border-radius:0}.br1-l{border-radius:.125rem}.br2-l{border-radius:.25rem}.br3-l{border-radius:.5rem}.br4-l{border-radius:1rem}.br-100-l{border-radius:100%}.br-pill-l{border-radius:9999px}.br--bottom-l{border-top-left-radius:0;border-top-right-radius:0}.br--top-l{border-bottom-right-radius:0}.br--right-l,.br--top-l{border-bottom-left-radius:0}.br--right-l{border-top-left-radius:0}.br--left-l{border-top-right-radius:0;border-bottom-right-radius:0}.b--dotted-l{border-style:dotted}.b--dashed-l{border-style:dashed}.b--solid-l{border-style:solid}.b--none-l{border-style:none}.bw0-l{border-width:0}.bw1-l{border-width:.125rem}.bw2-l{border-width:.25rem}.bw3-l{border-width:.5rem}.bw4-l{border-width:1rem}.bw5-l{border-width:2rem}.bt-0-l{border-top-width:0}.br-0-l{border-right-width:0}.bb-0-l{border-bottom-width:0}.bl-0-l{border-left-width:0}.shadow-1-l{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-l{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-l{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-l{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-l{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.top-0-l{top:0}.left-0-l{left:0}.right-0-l{right:0}.bottom-0-l{bottom:0}.top-1-l{top:1rem}.left-1-l{left:1rem}.right-1-l{right:1rem}.bottom-1-l{bottom:1rem}.top-2-l{top:2rem}.left-2-l{left:2rem}.right-2-l{right:2rem}.bottom-2-l{bottom:2rem}.top--1-l{top:-1rem}.right--1-l{right:-1rem}.bottom--1-l{bottom:-1rem}.left--1-l{left:-1rem}.top--2-l{top:-2rem}.right--2-l{right:-2rem}.bottom--2-l{bottom:-2rem}.left--2-l{left:-2rem}.absolute--fill-l{top:0;right:0;bottom:0;left:0}.cl-l{clear:left}.cr-l{clear:right}.cb-l{clear:both}.cn-l{clear:none}.dn-l{display:none}.di-l{display:inline}.db-l{display:block}.dib-l{display:inline-block}.dit-l{display:inline-table}.dt-l{display:table}.dtc-l{display:table-cell}.dt-row-l{display:table-row}.dt-row-group-l{display:table-row-group}.dt-column-l{display:table-column}.dt-column-group-l{display:table-column-group}.dt--fixed-l{table-layout:fixed;width:100%}.flex-l{display:flex}.inline-flex-l{display:inline-flex}.flex-auto-l{flex:1 1 auto;min-width:0;min-height:0}.flex-none-l{flex:none}.flex-column-l{flex-direction:column}.flex-row-l{flex-direction:row}.flex-wrap-l{flex-wrap:wrap}.flex-nowrap-l{flex-wrap:nowrap}.flex-wrap-reverse-l{flex-wrap:wrap-reverse}.flex-column-reverse-l{flex-direction:column-reverse}.flex-row-reverse-l{flex-direction:row-reverse}.items-start-l{align-items:flex-start}.items-end-l{align-items:flex-end}.items-center-l{align-items:center}.items-baseline-l{align-items:baseline}.items-stretch-l{align-items:stretch}.self-start-l{align-self:flex-start}.self-end-l{align-self:flex-end}.self-center-l{align-self:center}.self-baseline-l{align-self:baseline}.self-stretch-l{align-self:stretch}.justify-start-l{justify-content:flex-start}.justify-end-l{justify-content:flex-end}.justify-center-l{justify-content:center}.justify-between-l{justify-content:space-between}.justify-around-l{justify-content:space-around}.content-start-l{align-content:flex-start}.content-end-l{align-content:flex-end}.content-center-l{align-content:center}.content-between-l{align-content:space-between}.content-around-l{align-content:space-around}.content-stretch-l{align-content:stretch}.order-0-l{order:0}.order-1-l{order:1}.order-2-l{order:2}.order-3-l{order:3}.order-4-l{order:4}.order-5-l{order:5}.order-6-l{order:6}.order-7-l{order:7}.order-8-l{order:8}.order-last-l{order:99999}.flex-grow-0-l{flex-grow:0}.flex-grow-1-l{flex-grow:1}.flex-shrink-0-l{flex-shrink:0}.flex-shrink-1-l{flex-shrink:1}.fl-l{float:left}.fl-l,.fr-l{_display:inline}.fr-l{float:right}.fn-l{float:none}.i-l{font-style:italic}.fs-normal-l{font-style:normal}.normal-l{font-weight:400}.b-l{font-weight:700}.fw1-l{font-weight:100}.fw2-l{font-weight:200}.fw3-l{font-weight:300}.fw4-l{font-weight:400}.fw5-l{font-weight:500}.fw6-l{font-weight:600}.fw7-l{font-weight:700}.fw8-l{font-weight:800}.fw9-l{font-weight:900}.h1-l{height:1rem}.h2-l{height:2rem}.h3-l{height:4rem}.h4-l{height:8rem}.h5-l{height:16rem}.h-25-l{height:25%}.h-50-l{height:50%}.h-75-l{height:75%}.h-100-l{height:100%}.min-h-100-l{min-height:100%}.vh-25-l{height:25vh}.vh-50-l{height:50vh}.vh-75-l{height:75vh}.vh-100-l{height:100vh}.min-vh-100-l{min-height:100vh}.h-auto-l{height:auto}.h-inherit-l{height:inherit}.tracked-l{letter-spacing:.1em}.tracked-tight-l{letter-spacing:-.05em}.tracked-mega-l{letter-spacing:.25em}.lh-solid-l{line-height:1}.lh-title-l{line-height:1.25}.lh-copy-l{line-height:1.5}.mw-100-l{max-width:100%}.mw1-l{max-width:1rem}.mw2-l{max-width:2rem}.mw3-l{max-width:4rem}.mw4-l{max-width:8rem}.mw5-l{max-width:16rem}.mw6-l{max-width:32rem}.mw7-l{max-width:48rem}.mw8-l{max-width:64rem}.mw9-l{max-width:96rem}.mw-none-l{max-width:none}.w1-l{width:1rem}.w2-l{width:2rem}.w3-l{width:4rem}.w4-l{width:8rem}.w5-l{width:16rem}.w-10-l{width:10%}.w-20-l{width:20%}.w-25-l{width:25%}.w-30-l{width:30%}.w-33-l{width:33%}.w-34-l{width:34%}.w-40-l{width:40%}.w-50-l{width:50%}.w-60-l{width:60%}.w-70-l{width:70%}.w-75-l{width:75%}.w-80-l{width:80%}.w-90-l{width:90%}.w-100-l{width:100%}.w-third-l{width:33.33333%}.w-two-thirds-l{width:66.66667%}.w-auto-l{width:auto}.overflow-visible-l{overflow:visible}.overflow-hidden-l{overflow:hidden}.overflow-scroll-l{overflow:scroll}.overflow-auto-l{overflow:auto}.overflow-x-visible-l{overflow-x:visible}.overflow-x-hidden-l{overflow-x:hidden}.overflow-x-scroll-l{overflow-x:scroll}.overflow-x-auto-l{overflow-x:auto}.overflow-y-visible-l{overflow-y:visible}.overflow-y-hidden-l{overflow-y:hidden}.overflow-y-scroll-l{overflow-y:scroll}.overflow-y-auto-l{overflow-y:auto}.static-l{position:static}.relative-l{position:relative}.absolute-l{position:absolute}.fixed-l{position:fixed}.rotate-45-l{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90-l{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135-l{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180-l{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225-l{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270-l{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315-l{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.pa0-l{padding:0}.pa1-l{padding:.25rem}.pa2-l{padding:.5rem}.pa3-l{padding:1rem}.pa4-l{padding:2rem}.pa5-l{padding:4rem}.pa6-l{padding:8rem}.pa7-l{padding:16rem}.pl0-l{padding-left:0}.pl1-l{padding-left:.25rem}.pl2-l{padding-left:.5rem}.pl3-l{padding-left:1rem}.pl4-l{padding-left:2rem}.pl5-l{padding-left:4rem}.pl6-l{padding-left:8rem}.pl7-l{padding-left:16rem}.pr0-l{padding-right:0}.pr1-l{padding-right:.25rem}.pr2-l{padding-right:.5rem}.pr3-l{padding-right:1rem}.pr4-l{padding-right:2rem}.pr5-l{padding-right:4rem}.pr6-l{padding-right:8rem}.pr7-l{padding-right:16rem}.pb0-l{padding-bottom:0}.pb1-l{padding-bottom:.25rem}.pb2-l{padding-bottom:.5rem}.pb3-l{padding-bottom:1rem}.pb4-l{padding-bottom:2rem}.pb5-l{padding-bottom:4rem}.pb6-l{padding-bottom:8rem}.pb7-l{padding-bottom:16rem}.pt0-l{padding-top:0}.pt1-l{padding-top:.25rem}.pt2-l{padding-top:.5rem}.pt3-l{padding-top:1rem}.pt4-l{padding-top:2rem}.pt5-l{padding-top:4rem}.pt6-l{padding-top:8rem}.pt7-l{padding-top:16rem}.pv0-l{padding-top:0;padding-bottom:0}.pv1-l{padding-top:.25rem;padding-bottom:.25rem}.pv2-l{padding-top:.5rem;padding-bottom:.5rem}.pv3-l{padding-top:1rem;padding-bottom:1rem}.pv4-l{padding-top:2rem;padding-bottom:2rem}.pv5-l{padding-top:4rem;padding-bottom:4rem}.pv6-l{padding-top:8rem;padding-bottom:8rem}.pv7-l{padding-top:16rem;padding-bottom:16rem}.ph0-l{padding-left:0;padding-right:0}.ph1-l{padding-left:.25rem;padding-right:.25rem}.ph2-l{padding-left:.5rem;padding-right:.5rem}.ph3-l{padding-left:1rem;padding-right:1rem}.ph4-l{padding-left:2rem;padding-right:2rem}.ph5-l{padding-left:4rem;padding-right:4rem}.ph6-l{padding-left:8rem;padding-right:8rem}.ph7-l{padding-left:16rem;padding-right:16rem}.ma0-l{margin:0}.ma1-l{margin:.25rem}.ma2-l{margin:.5rem}.ma3-l{margin:1rem}.ma4-l{margin:2rem}.ma5-l{margin:4rem}.ma6-l{margin:8rem}.ma7-l{margin:16rem}.ml0-l{margin-left:0}.ml1-l{margin-left:.25rem}.ml2-l{margin-left:.5rem}.ml3-l{margin-left:1rem}.ml4-l{margin-left:2rem}.ml5-l{margin-left:4rem}.ml6-l{margin-left:8rem}.ml7-l{margin-left:16rem}.mr0-l{margin-right:0}.mr1-l{margin-right:.25rem}.mr2-l{margin-right:.5rem}.mr3-l{margin-right:1rem}.mr4-l{margin-right:2rem}.mr5-l{margin-right:4rem}.mr6-l{margin-right:8rem}.mr7-l{margin-right:16rem}.mb0-l{margin-bottom:0}.mb1-l{margin-bottom:.25rem}.mb2-l{margin-bottom:.5rem}.mb3-l{margin-bottom:1rem}.mb4-l{margin-bottom:2rem}.mb5-l{margin-bottom:4rem}.mb6-l{margin-bottom:8rem}.mb7-l{margin-bottom:16rem}.mt0-l{margin-top:0}.mt1-l{margin-top:.25rem}.mt2-l{margin-top:.5rem}.mt3-l{margin-top:1rem}.mt4-l{margin-top:2rem}.mt5-l{margin-top:4rem}.mt6-l{margin-top:8rem}.mt7-l{margin-top:16rem}.mv0-l{margin-top:0;margin-bottom:0}.mv1-l{margin-top:.25rem;margin-bottom:.25rem}.mv2-l{margin-top:.5rem;margin-bottom:.5rem}.mv3-l{margin-top:1rem;margin-bottom:1rem}.mv4-l{margin-top:2rem;margin-bottom:2rem}.mv5-l{margin-top:4rem;margin-bottom:4rem}.mv6-l{margin-top:8rem;margin-bottom:8rem}.mv7-l{margin-top:16rem;margin-bottom:16rem}.mh0-l{margin-left:0;margin-right:0}.mh1-l{margin-left:.25rem;margin-right:.25rem}.mh2-l{margin-left:.5rem;margin-right:.5rem}.mh3-l{margin-left:1rem;margin-right:1rem}.mh4-l{margin-left:2rem;margin-right:2rem}.mh5-l{margin-left:4rem;margin-right:4rem}.mh6-l{margin-left:8rem;margin-right:8rem}.mh7-l{margin-left:16rem;margin-right:16rem}.na1-l{margin:-.25rem}.na2-l{margin:-.5rem}.na3-l{margin:-1rem}.na4-l{margin:-2rem}.na5-l{margin:-4rem}.na6-l{margin:-8rem}.na7-l{margin:-16rem}.nl1-l{margin-left:-.25rem}.nl2-l{margin-left:-.5rem}.nl3-l{margin-left:-1rem}.nl4-l{margin-left:-2rem}.nl5-l{margin-left:-4rem}.nl6-l{margin-left:-8rem}.nl7-l{margin-left:-16rem}.nr1-l{margin-right:-.25rem}.nr2-l{margin-right:-.5rem}.nr3-l{margin-right:-1rem}.nr4-l{margin-right:-2rem}.nr5-l{margin-right:-4rem}.nr6-l{margin-right:-8rem}.nr7-l{margin-right:-16rem}.nb1-l{margin-bottom:-.25rem}.nb2-l{margin-bottom:-.5rem}.nb3-l{margin-bottom:-1rem}.nb4-l{margin-bottom:-2rem}.nb5-l{margin-bottom:-4rem}.nb6-l{margin-bottom:-8rem}.nb7-l{margin-bottom:-16rem}.nt1-l{margin-top:-.25rem}.nt2-l{margin-top:-.5rem}.nt3-l{margin-top:-1rem}.nt4-l{margin-top:-2rem}.nt5-l{margin-top:-4rem}.nt6-l{margin-top:-8rem}.nt7-l{margin-top:-16rem}.strike-l{text-decoration:line-through}.underline-l{text-decoration:underline}.no-underline-l{text-decoration:none}.tl-l{text-align:left}.tr-l{text-align:right}.tc-l{text-align:center}.tj-l{text-align:justify}.ttc-l{text-transform:capitalize}.ttl-l{text-transform:lowercase}.ttu-l{text-transform:uppercase}.ttn-l{text-transform:none}.f-6-l,.f-headline-l{font-size:6rem}.f-5-l,.f-subheadline-l{font-size:5rem}.f1-l{font-size:3rem}.f2-l{font-size:2.25rem}.f3-l{font-size:1.5rem}.f4-l{font-size:1.25rem}.f5-l{font-size:1rem}.f6-l{font-size:.875rem}.f7-l{font-size:.75rem}.measure-l{max-width:30em}.measure-wide-l{max-width:34em}.measure-narrow-l{max-width:20em}.indent-l{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-l{font-variant:small-caps}.truncate-l{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.center-l{margin-left:auto}.center-l,.mr-auto-l{margin-right:auto}.ml-auto-l{margin-left:auto}.clip-l{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal-l{white-space:normal}.nowrap-l{white-space:nowrap}.pre-l{white-space:pre}.v-base-l{vertical-align:baseline}.v-mid-l{vertical-align:middle}.v-top-l{vertical-align:top}.v-btm-l{vertical-align:bottom}} 3 | 4 | :root { 5 | --light-gray: rgba(0, 0, 0, .1); 6 | /* --focused-input-bg-color: #FBF1A9; */ 7 | } 8 | 9 | input[type="number"] { 10 | outline: 0; 11 | padding: .25rem; 12 | font-size: 1em; 13 | border: none; 14 | border: 1px solid var(--light-gray); 15 | text-align: right; 16 | transition: all .3s cubic-bezier(.645,.045,.355, 1); 17 | } 18 | 19 | input[type="number"]:focus { 20 | background-color: var(--focused-input-bg-color); 21 | } 22 | 23 | .input-group { 24 | display: flex; 25 | } 26 | 27 | .input-group input { 28 | flex: 1 1 auto; 29 | width: 100%; 30 | } 31 | 32 | .input-group-prepend, .input-group-append { 33 | display: flex; 34 | flex-direction: row; 35 | align-items: center; 36 | padding-left: 4px; 37 | padding-right: 4px; 38 | border: 1px solid var(--light-gray); 39 | } 40 | 41 | .input-group-prepend { 42 | margin-right: -1px; 43 | } 44 | 45 | .input-group-append { 46 | margin-left: -1px; 47 | } 48 | 49 | /*# sourceMappingURL=styles.css.map*/ -------------------------------------------------------------------------------- /docs/examples/spreadsheet/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | xcell: spreadsheet 7 | 54 | 55 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | xcell 7 | 8 | 9 | 15 | 16 | 17 |
18 |

xcell

19 |

xcell is a tiny, open source (MIT) 20 | library for building reactive, spreadsheet-like calculations in JavaScript.

21 |

Spreadsheets are cool—if we say that A1 is the sum of B1 and C1, 22 | the spreadsheet will automatically update whenever we change the dependent 23 | cells.

24 |

spreadsheet

25 |

This usually doesn't happen in our programs.

26 |

For example in JavaScript:

27 |
var b = 1, c = 2
28 | 
29 | var a = b + c // a is 3 now
30 | 
31 | b = 42
32 | 
33 | alert("a is now:  " + a) // it is still 3 :(
34 |

our variable a does not automatically change if we 35 | change b. It will be equal to 3 until we imperatively 36 | change it something else.

37 |

xcell allows us to write programs that work like spreadsheets.

38 |

Here is how:

39 |
function add(x, y) {
40 |   return x + y
41 | }
42 | 
43 | var b = xcell(1), c = xcell(2)
44 | 
45 | var a = xcell([b, c], add)
46 | 
47 | alert(a.value) // a is 3
48 | 
49 | b.value = 42
50 | 
51 | alert(a.value) // a is 44 \o/
52 |

xcell is a function that returns an object that holds always 53 | updated value, just like a spreadsheet cell.

54 |

When we create our "cells" we tell them to either be independent:

55 |
var b = xcell(1)
56 |

or to depend on other cells and update its value when necessary using 57 | a provided function:

58 |
var a = xcell([b, c], add)
59 |

The cells emit change event whenever they change, so we can observe 60 | them and update our UI:

61 |
a.on('change', function handleChange(sender) {
62 |   document.getElementById("my-cell").value = sender.value
63 | })
64 |

Here are a few examples of how xcell can be used:

65 | 70 |

The source code is on github.

71 |
72 | 73 | 74 | -------------------------------------------------------------------------------- /docs/spreadsheet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomazy/xcell/f1793c6b268882d0f915ef0c5b204b380eb83e56/docs/spreadsheet.png -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | padding: 1em; 4 | color: rgba(0, 0, 0, .9); 5 | } 6 | 7 | main { 8 | font-size: 1rem; 9 | margin: auto; 10 | max-width: 34em; 11 | } 12 | 13 | main h1 { 14 | font-size: 3rem; 15 | } 16 | 17 | main p { 18 | line-height: 1.5; 19 | } 20 | 21 | main ul { 22 | line-height: 1.5; 23 | } 24 | 25 | main img { 26 | max-width: 100%; 27 | } 28 | 29 | /* 30 | Visual Studio-like style based on original C# coloring by Jason Diamond 31 | */ 32 | .hljs { 33 | display: block; 34 | overflow-x: auto; 35 | padding: 0.5em; 36 | background: white; 37 | color: black; 38 | } 39 | 40 | .hljs-comment, 41 | .hljs-quote, 42 | .hljs-variable { 43 | color: #008000; 44 | } 45 | 46 | .hljs-keyword, 47 | .hljs-selector-tag, 48 | .hljs-built_in, 49 | .hljs-name, 50 | .hljs-tag { 51 | color: #00f; 52 | } 53 | 54 | .hljs-string, 55 | .hljs-title, 56 | .hljs-section, 57 | .hljs-attribute, 58 | .hljs-literal, 59 | .hljs-template-tag, 60 | .hljs-template-variable, 61 | .hljs-type, 62 | .hljs-addition { 63 | color: #a31515; 64 | } 65 | 66 | .hljs-deletion, 67 | .hljs-selector-attr, 68 | .hljs-selector-pseudo, 69 | .hljs-meta { 70 | color: #2b91af; 71 | } 72 | 73 | .hljs-doctag { 74 | color: #808080; 75 | } 76 | 77 | .hljs-attr { 78 | color: #f00; 79 | } 80 | 81 | .hljs-symbol, 82 | .hljs-bullet, 83 | .hljs-link { 84 | color: #00b0e8; 85 | } 86 | 87 | 88 | .hljs-emphasis { 89 | font-style: italic; 90 | } 91 | 92 | .hljs-strong { 93 | font-weight: bold; 94 | } 95 | -------------------------------------------------------------------------------- /examples/mortgage/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | xcell: mortgage 7 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /examples/mortgage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mortgage", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "dependencies": { 7 | "date-fns": "^1.29.0", 8 | "xcell": "*", 9 | "xcell-inspect": "*", 10 | "yo-yo": "^1.4.1" 11 | }, 12 | "devDependencies": { 13 | "babel-loader": "^7.1.2", 14 | "html-webpack-plugin": "^2.30.1", 15 | "webpack": "^3.10.0", 16 | "webpack-dev-server": "^2.11.1" 17 | }, 18 | "scripts": { 19 | "start": "webpack-dev-server", 20 | "build": "webpack -p" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/mortgage/src/index.js: -------------------------------------------------------------------------------- 1 | import { createStore } from './mortgage'; 2 | import yo from 'yo-yo'; 3 | import formatDate from 'date-fns/format' 4 | import parseDate from 'date-fns/parse' 5 | import inspect from 'xcell-inspect'; 6 | import { Cell } from 'xcell'; 7 | 8 | const store = createStore({ 9 | loanAmount: 500000, 10 | rate: 1.2, 11 | loanDate: new Date(), 12 | loanTermYears: 30, 13 | }) 14 | 15 | function input(cell, attrs = {}) { 16 | return yo` 17 | 18 | ` 19 | 20 | function oninput(e) { 21 | cell.value = parse(e.target.value) 22 | } 23 | 24 | function parse(v) { 25 | switch (attrs.type) { 26 | case 'date': 27 | return /^\d{4}-\d{2}-\d{2}$/.test(v) 28 | ? parseDate(v) 29 | : null 30 | 31 | case 'number': 32 | return Number(v) 33 | 34 | default: 35 | return '' + v 36 | } 37 | } 38 | 39 | function format(v) { 40 | switch (attrs.type) { 41 | case 'date': 42 | return Boolean(v) 43 | ? defaultFormatDate(v) 44 | : '' 45 | 46 | case 'number': 47 | return String(v) 48 | 49 | default: 50 | return '' + v 51 | } 52 | } 53 | } 54 | 55 | function output(cell, format) { 56 | cell.on('change', cellChanged); 57 | 58 | return yo`${format(cell.value)}`; 59 | 60 | function cellChanged({id, value}) { 61 | const el = document.querySelector(`[data-cell-id="${id}"]`) 62 | el.textContent = format(value) 63 | } 64 | } 65 | 66 | function form() { 67 | return yo` 68 |
69 | 70 | 71 | 72 | 75 | 76 | 77 | 78 | 81 | 82 | 83 | 84 | 87 | 88 | 89 | 90 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 |
Loan amount: 73 | ${input(store.$loanAmount, { type: 'number', min: 1, max: 9000000 })}$ 74 |
Interest rate: 79 | ${input(store.$rate, { type: 'number', step: 0.1, min: 0, max: 100 })}% 80 |
Loan term: 85 | ${input(store.$loanTermYears, { type: 'number', step: 1, min: 1, max: 40 })} years 86 |
Loan date: 91 | ${input(store.$loanDate, { type: 'date', placeholder: 'YYYY-MM-DD' })} 92 |
Total interest to pay:${output(store.$interestSum, formatMoney)}
Total to pay:${output(store.$amountSum, formatMoney)}
103 |
104 | ` 105 | } 106 | 107 | function installments($installments) { 108 | const element = renderInstallments($installments.value); 109 | 110 | $installments.on('change', ({ value }) => { 111 | yo.update(element, renderInstallments(value)) 112 | }) 113 | 114 | return element; 115 | 116 | function renderInstallments(items) { 117 | return yo` 118 |
119 | ${ 120 | items.length 121 | ? yo` 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | ${items.map(ii => ( 135 | yo` 136 | 137 | 140 | 143 | 146 | 149 | 152 | 153 | ` 154 | ))} 155 | 156 |
Monthly payments
#DateInterestPrincipalAmount
138 | ${ii.idx + 1} 139 | 141 | ${output(ii.$date, defaultFormatDate)} 142 | 144 | ${output(ii.$interest, formatMoney)} 145 | 147 | ${output(ii.$principal, formatMoney)} 148 | 150 | ${output(ii.$amount, formatMoney)} 151 |
` 157 | : null 158 | } 159 |
160 | ` 161 | } 162 | } 163 | 164 | function app() { 165 | return yo` 166 |
167 |

168 | mortgage calculator 169 | source code 170 |

171 |
172 |
173 | ${form()} 174 |
175 | ${installments(store.$installments)} 176 |
`; 177 | } 178 | 179 | document.body.appendChild(app()) 180 | 181 | function formatMoney(v) { 182 | return Number.isFinite(v) 183 | ? `$${v.toFixed(2)}` 184 | : '?' 185 | } 186 | 187 | function defaultFormatDate(date) { 188 | return formatDate(date, 'YYYY-MM-DD') 189 | } 190 | 191 | function autoNameCellsForGraph(obj, prefix = '') { 192 | for (const key in obj) { 193 | if (obj[key] instanceof Cell) { 194 | obj[key].name = prefix + key 195 | } 196 | } 197 | } 198 | 199 | function autoNameInstallmentsCellsForGraph({ value }) { 200 | value.forEach((ii, idx) => { 201 | autoNameCellsForGraph(ii, `I.[${idx}].`) 202 | }) 203 | } 204 | 205 | function extractCellsFromStore(store) { 206 | const result = []; 207 | for (const key in store) { 208 | if (store[key] instanceof Cell) { 209 | result.push(store[key]) 210 | } 211 | } 212 | return result; 213 | } 214 | 215 | // connect the debug graph 216 | autoNameCellsForGraph(store); 217 | autoNameInstallmentsCellsForGraph(store.$installments) 218 | store.$installments.on('change', autoNameInstallmentsCellsForGraph); 219 | document.body.appendChild( 220 | inspect( 221 | extractCellsFromStore(store), 222 | { 223 | renderGraph: false, 224 | renderDOT: true, 225 | hidden: (window.innerWidth < 900) || (window.innerHeight < 700) 226 | } 227 | ).element 228 | ) 229 | -------------------------------------------------------------------------------- /examples/mortgage/src/mortgage.js: -------------------------------------------------------------------------------- 1 | import differenceInDays from 'date-fns/difference_in_days' 2 | import xcell, { Cell } from 'xcell' 3 | 4 | import { 5 | getInstallmentDate, 6 | getInstallmentInterest, 7 | getMonthlyPayment, 8 | minus, 9 | plus, 10 | sum, 11 | identity, 12 | } from "./utils"; 13 | 14 | class Installment { 15 | dispose() { 16 | for (let key in this) { 17 | if (this[key] instanceof Cell) { 18 | this[key].dispose(); 19 | } 20 | } 21 | } 22 | } 23 | 24 | export function createStore({ loanAmount, rate, loanDate, loanTermYears = 30 }) { 25 | const $rate = xcell(rate) 26 | const $loanAmount = xcell(loanAmount) 27 | const $loanTermYears = xcell(loanTermYears) 28 | const $loanTermMonths = xcell([$loanTermYears], x => x * 12) 29 | const $loanDate = xcell(loanDate) 30 | 31 | const $installments = xcell( 32 | [$loanTermMonths, $loanDate, $loanAmount], 33 | ( loanTermMonths, loanDate, loanAmount) => { 34 | 35 | /** 36 | * @type Installment[] 37 | */ 38 | const result = [] 39 | 40 | if (!loanDate || !(loanAmount > 0)) { 41 | return result; 42 | } 43 | 44 | let prev; 45 | 46 | for (let i = 0; i < loanTermMonths; i++) { 47 | void function makeInstallment(idx) { 48 | const $date = xcell([$loanDate], d => getInstallmentDate(d, idx)) 49 | const $paid = prev 50 | ? xcell([prev.$paid, prev.$principal], plus) 51 | : xcell(0) 52 | const $debt = xcell([$loanAmount, $paid], minus) 53 | const $interestDays = xcell([ 54 | $date, 55 | prev ? prev.$date : $loanDate 56 | ], differenceInDays) 57 | const $interest = xcell([$debt, $rate, $interestDays], getInstallmentInterest) 58 | 59 | let $principal, $amount; 60 | if (i === loanTermMonths - 1) { 61 | $principal = xcell([$debt], identity); 62 | $amount = xcell([$principal, $interest], plus); 63 | } else { 64 | const loanTerms = loanTermMonths - idx; 65 | $amount = xcell([$debt, $rate], (debt, rate) => getMonthlyPayment(debt, loanTerms, rate)) 66 | $principal = xcell([$amount, $interest], minus) 67 | } 68 | 69 | const installment = new Installment() 70 | Object.assign(installment, { 71 | idx, 72 | $date, 73 | $interestDays, 74 | $debt, 75 | $paid, 76 | $interest, 77 | $principal, 78 | $amount, 79 | }) 80 | 81 | result.push(installment) 82 | prev = installment; 83 | }(i) 84 | } 85 | 86 | return result; 87 | } 88 | ) 89 | 90 | // cleanup 91 | $installments.on('change', (_, previous) => { 92 | for (let installment of previous) { 93 | installment.dispose(); 94 | } 95 | }) 96 | 97 | // dynamic ranges 98 | const $interestRange = xcell([$installments], installments => 99 | installments.map(i => i.$interest) 100 | ) 101 | const $interestSum = xcell($interestRange.value, sum) 102 | $interestRange.on('change', ({ value }) => { 103 | $interestSum.dependencies = value 104 | }) 105 | 106 | const $amountRange = xcell([$installments], installments => 107 | installments.map(i => i.$amount) 108 | ) 109 | const $amountSum = xcell($amountRange.value, sum) 110 | $amountRange.on('change', ({ value }) => { 111 | $amountSum.dependencies = value 112 | }) 113 | 114 | return { 115 | $rate, 116 | $loanAmount, 117 | $loanTermYears, 118 | $loanTermMonths, 119 | $loanDate, 120 | $installments, 121 | $interestSum, 122 | $amountSum, 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /examples/mortgage/src/utils.js: -------------------------------------------------------------------------------- 1 | import getDate from 'date-fns/get_date' 2 | import getMonth from 'date-fns/get_month' 3 | import getYear from 'date-fns/get_year' 4 | import lastDayOfMonth from 'date-fns/last_day_of_month' 5 | 6 | function capOnLastDayOfTheMonth(year, month, day) { 7 | const lastDay = getDate(lastDayOfMonth(new Date(year, month, 1))) 8 | return new Date(year, month, Math.min(lastDay, day)) 9 | } 10 | 11 | export function getInstallmentDate(payoutDate, installmentIndex) { 12 | const year = getYear(payoutDate) + Math.floor(installmentIndex / 12) 13 | const month = getMonth(payoutDate) + installmentIndex % 12 + 1 14 | const day = getDate(payoutDate) 15 | 16 | return capOnLastDayOfTheMonth(year, month, day) 17 | } 18 | 19 | export function getInstallmentInterest(loan, interestRate, interestDays) { 20 | return loan * (interestRate / 100) * interestDays / 365 21 | } 22 | 23 | export function getMonthlyPayment(S, n, rate) { 24 | // I = S * q^n * (q-1)/(q^n-1) 25 | // I: monthly payment 26 | // S: loan amount 27 | // n: loan terms 28 | // r: rate 29 | // q: 1 + (r / 12) 30 | const r = rate / 100 31 | const q = 1 + (r / 12) 32 | const q_pow_n = Math.pow(q, n) 33 | return S * q_pow_n * (q - 1) / (q_pow_n - 1) 34 | } 35 | 36 | export function minus(a, b) { 37 | return a - b 38 | } 39 | 40 | export function plus(a, b) { 41 | return a + b 42 | } 43 | 44 | export function sum(...args) { 45 | return args.reduce((acc, e) => acc + e, 0) 46 | } 47 | 48 | export function identity(x) { 49 | return x; 50 | } 51 | -------------------------------------------------------------------------------- /examples/mortgage/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 4 | 5 | module.exports = { 6 | entry: './src/index.js', 7 | output: { 8 | filename: 'bundle.js', 9 | path: __dirname + '/dist', 10 | }, 11 | 12 | devtool: 'source-maps', 13 | 14 | resolve: { 15 | extensions: ['.ts', '.tsx', '.js', '.json'] 16 | }, 17 | 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.js$/, 22 | exclude: /node_modules/, 23 | use: { 24 | loader: 'babel-loader', 25 | options: { presets: ['es2015'] } 26 | }, 27 | }, 28 | ] 29 | }, 30 | 31 | devServer: { 32 | contentBase: './dist' 33 | }, 34 | 35 | node: { 36 | fs: 'empty', 37 | }, 38 | 39 | plugins: [ 40 | new CleanWebpackPlugin(['dist']), 41 | new HtmlWebpackPlugin({ 42 | template: __dirname + '/index.html' 43 | }) 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /examples/pizza/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | xcell: pizza 7 | 8 | 9 |
10 |
receipt
11 | 12 |
13 | 14 |
15 |
$
16 | 17 |
18 |
19 | 20 |
21 | 22 |
23 | 24 |
%
25 |
26 |
27 | 28 |
29 | 30 |
31 | 32 |
%
33 |
34 |
35 | 36 |
 
37 | 38 |
39 |
Tax
40 |
$
41 |
42 | 43 |
 
44 | 45 |
46 |
Gross
47 |
$
48 |
49 | 50 |
51 |
Tip
52 |
$
53 |
54 | 55 |
 
56 | 57 |
58 |
Total
59 |
$
60 |
61 |
62 | 63 |
64 | source code 65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /examples/pizza/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pizza", 3 | "license": "MIT", 4 | "version": "0.0.0", 5 | "private": true, 6 | "devDependencies": { 7 | "babel-loader": "^7.1.2", 8 | "clean-webpack-plugin": "^0.1.17", 9 | "css-loader": "^1.0.0", 10 | "extract-text-webpack-plugin": "^3.0.2", 11 | "html-webpack-plugin": "^2.30.1", 12 | "style-loader": "^0.21.0", 13 | "webpack": "^3.10.0", 14 | "webpack-dev-server": "^2.11.0" 15 | }, 16 | "scripts": { 17 | "start": "webpack-dev-server", 18 | "build": "webpack -p" 19 | }, 20 | "dependencies": { 21 | "tachyons": "^4.11.1", 22 | "xcell": "*", 23 | "xcell-inspect": "*" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/pizza/src/index.js: -------------------------------------------------------------------------------- 1 | import xcell, { Cell } from 'xcell' 2 | import inspect from 'xcell-inspect' 3 | 4 | //#region -- css -- 5 | import 'tachyons/css/tachyons.min.css' 6 | import './style.css' 7 | //#endregion 8 | 9 | const cells = createCells({ menuPrice: 15, taxPercent: 13, tipPercent: 15 }); 10 | 11 | connectInput(MENU_PRICE, cells.$menuPrice) 12 | connectInput(TAX_PERCENT, cells.$taxPercent) 13 | connectInput(TIP_PERCENT, cells.$tipPercent) 14 | 15 | connectOutput(TAX, cells.$tax) 16 | connectOutput(TIP, cells.$tip) 17 | connectOutput(GROSS, cells.$gross) 18 | connectOutput(TOTAL, cells.$total) 19 | 20 | /** 21 | * Creates directional graph of the data with provided defaults. 22 | * @param {Object} defaults 23 | * @param {number} defaults.menuPrice 24 | * @param {number} defaults.taxPercent 25 | * @param {number} defaults.tipPercent 26 | */ 27 | function createCells(defaults) { 28 | const $menuPrice = xcell(defaults.menuPrice) 29 | const $taxPercent = xcell(defaults.taxPercent) 30 | const $tipPercent = xcell(defaults.tipPercent) 31 | 32 | const $tax = xcell( 33 | [$menuPrice, $taxPercent], 34 | ( menuPrice, taxPercent) => menuPrice * taxPercent / 100, 35 | ) 36 | 37 | const $gross = xcell([$menuPrice, $tax], add) 38 | 39 | const $tip = xcell( 40 | [$gross, $tipPercent], 41 | ( gross, tipPercent) => gross * tipPercent / 100, 42 | ) 43 | 44 | const $total = xcell([$gross, $tip], add) 45 | 46 | return { 47 | $menuPrice, 48 | $taxPercent, 49 | $tipPercent, 50 | $tax, 51 | $tip, 52 | $gross, 53 | $total, 54 | } 55 | } 56 | 57 | //#region --- helpers --- 58 | /** 59 | * @param {Element} input 60 | * @param {Cell} cell 61 | * @param {(s: string) => any} parse 62 | */ 63 | function connectInput(input, cell, parse = Number) { 64 | input.value = cell.value 65 | input.addEventListener('change', ev => { 66 | cell.value = parse(ev.target.value) 67 | }) 68 | } 69 | 70 | /** 71 | * @param {Element} output 72 | * @param {Cell} cell 73 | * @param {(v: any) => string} format 74 | */ 75 | function connectOutput(output, cell, format = formatMoney) { 76 | output.textContent = format(cell.value) 77 | cell.on('change', ({ value }) => { 78 | output.textContent = format(value) 79 | }) 80 | } 81 | 82 | /** 83 | * @param {number} x 84 | * @param {number} y 85 | */ 86 | function add(x, y) { 87 | return x + y 88 | } 89 | 90 | /** 91 | * @param {number} value 92 | */ 93 | function formatMoney(value) { 94 | return value.toFixed(2) 95 | } 96 | //#endregion 97 | 98 | //#region --- inspector --- 99 | function autoNameCellsForGraph(store) { 100 | for (let key in store) { 101 | if (store[key] instanceof Cell) { 102 | store[key].name = key 103 | } 104 | } 105 | } 106 | 107 | function extractCellsFromStore(store) { 108 | const result = []; 109 | for (let key in store) { 110 | if (store[key] instanceof Cell) { 111 | result.push(store[key]) 112 | } 113 | } 114 | return result; 115 | } 116 | // connect the debug inspector 117 | autoNameCellsForGraph(cells); 118 | const inspector = inspect(extractCellsFromStore(cells), { 119 | renderGraph: true, 120 | renderDOT: false, 121 | hidden: (window.innerWidth < 900) || (window.innerHeight < 700) 122 | }) 123 | document.body.appendChild(inspector.element) 124 | //#endregion 125 | -------------------------------------------------------------------------------- /examples/pizza/src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-gray: rgba(0, 0, 0, .1); 3 | /* --focused-input-bg-color: #FBF1A9; */ 4 | } 5 | 6 | input[type="number"] { 7 | outline: 0; 8 | padding: .25rem; 9 | font-size: 1em; 10 | border: none; 11 | border: 1px solid var(--light-gray); 12 | text-align: right; 13 | transition: all .3s cubic-bezier(.645,.045,.355, 1); 14 | } 15 | 16 | input[type="number"]:focus { 17 | background-color: var(--focused-input-bg-color); 18 | } 19 | 20 | .input-group { 21 | display: flex; 22 | } 23 | 24 | .input-group input { 25 | flex: 1 1 auto; 26 | width: 100%; 27 | } 28 | 29 | .input-group-prepend, .input-group-append { 30 | display: flex; 31 | flex-direction: row; 32 | align-items: center; 33 | padding-left: 4px; 34 | padding-right: 4px; 35 | border: 1px solid var(--light-gray); 36 | } 37 | 38 | .input-group-prepend { 39 | margin-right: -1px; 40 | } 41 | 42 | .input-group-append { 43 | margin-left: -1px; 44 | } 45 | -------------------------------------------------------------------------------- /examples/pizza/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 5 | 6 | module.exports = { 7 | entry: './src/index.js', 8 | output: { 9 | filename: 'bundle.js', 10 | path: __dirname + '/dist', 11 | }, 12 | 13 | devtool: 'source-maps', 14 | 15 | resolve: { 16 | extensions: ['.ts', '.tsx', '.js', '.json'] 17 | }, 18 | 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.js$/, 23 | exclude: /node_modules/, 24 | use: { 25 | loader: 'babel-loader', 26 | options: { presets: ['es2015'] } 27 | }, 28 | }, { 29 | test: /\.css$/, 30 | use: ExtractTextPlugin.extract({ 31 | fallback: 'style-loader', 32 | use: 'css-loader' 33 | }) 34 | } 35 | ] 36 | }, 37 | 38 | devServer: { 39 | contentBase: './dist' 40 | }, 41 | 42 | node: { 43 | fs: 'empty', 44 | }, 45 | 46 | plugins: [ 47 | new CleanWebpackPlugin(['dist']), 48 | new HtmlWebpackPlugin({ 49 | template: __dirname + '/index.html' 50 | }), 51 | new ExtractTextPlugin('styles.css'), 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /examples/spreadsheet/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/spreadsheet/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": false, 3 | "cSpell.words": [ 4 | "shallowequal", 5 | "xcell" 6 | ] 7 | } -------------------------------------------------------------------------------- /examples/spreadsheet/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | xcell: spreadsheet 7 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /examples/spreadsheet/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spreadsheet", 3 | "license": "MIT", 4 | "version": "0.0.0", 5 | "private": true, 6 | "devDependencies": { 7 | "babel-core": "^6.26.0", 8 | "babel-jest": "^22.1.0", 9 | "babel-loader": "^7.1.2", 10 | "babel-plugin-transform-es2015-parameters": "^6.24.1", 11 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 12 | "babel-plugin-yo-yoify": "^1.0.2", 13 | "babel-preset-env": "^1.6.1", 14 | "clean-webpack-plugin": "^0.1.17", 15 | "html-webpack-plugin": "^2.30.1", 16 | "jest": "^22.1.4", 17 | "standard": "^10.0.3", 18 | "webpack": "^3.10.0", 19 | "webpack-dev-server": "^2.11.0", 20 | "yo-yoify": "^4.3.0" 21 | }, 22 | "dependencies": { 23 | "debug": "^3.1.0", 24 | "formula-ast": "^2.0.3", 25 | "shallowequal": "^1.0.2", 26 | "xcell": "*", 27 | "xcell-inspect": "*", 28 | "yo-yo": "^1.4.1" 29 | }, 30 | "jest": { 31 | "testPathIgnorePatterns": [ 32 | "node_modules" 33 | ] 34 | }, 35 | "scripts": { 36 | "start": "webpack-dev-server", 37 | "build": "webpack -p", 38 | "test": "jest" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/spreadsheet/src/cell-component.js: -------------------------------------------------------------------------------- 1 | import yo from 'yo-yo' 2 | 3 | const debug = require('debug')('xcell:ex:cell-component') 4 | 5 | export class CellComponent { 6 | constructor (sheet, id, onEnterPressed) { 7 | this._id = id 8 | this._sheet = sheet 9 | this._focused = false 10 | this._dirty = false 11 | this._onEnterPressed = onEnterPressed 12 | this._cellChanged = this._cellChanged.bind(this) 13 | this._setCell(sheet.getCell(id)) 14 | } 15 | 16 | _updateCell (value) { 17 | this._setCell(this._sheet.setCell(this._id, String(value))) 18 | } 19 | 20 | _setCell (cell) { 21 | if (this._cell) { 22 | this._cell.removeListener('change', this._cellChanged) 23 | } 24 | 25 | this._cell = cell 26 | 27 | if (this._cell) { 28 | this._cell.addListener('change', this._cellChanged) 29 | } 30 | } 31 | 32 | _cellChanged ({ value }) { 33 | debug(`cell changed! ${this._id}`, value) 34 | this._update() 35 | } 36 | 37 | _createElement () { 38 | const oninput = (e) => { 39 | this._dirty = true 40 | } 41 | 42 | const onblur = (e) => { 43 | if (this._dirty) { 44 | this._updateCell(e.target.value) 45 | } 46 | this._focused = false 47 | this._dirty = false 48 | this._update() 49 | } 50 | 51 | const onfocus = (e) => { 52 | this._focused = true 53 | this._update() 54 | } 55 | 56 | const onkeypress = (e) => { 57 | if (e.key === 'Enter') { 58 | if (this._dirty) { 59 | this._updateCell(e.target.value) 60 | } 61 | this._onEnterPressed(this._id, e.shiftKey) 62 | } 63 | } 64 | 65 | const value = this._focused 66 | ? this._sheet.formulas[this._id] || '' 67 | : (this._cell 68 | ? String(this._cell.value) 69 | : '') 70 | 71 | return yo` 72 | 80 | ` 81 | } 82 | 83 | _update () { 84 | yo.update(this.element, this._createElement()) 85 | } 86 | 87 | get element () { 88 | return this.el || (this.el = this._createElement()) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /examples/spreadsheet/src/compile-formula.js: -------------------------------------------------------------------------------- 1 | import { compile } from './formula' 2 | import { functions } from './functions' 3 | 4 | /** 5 | * @param {string} formula 6 | */ 7 | export function compileFormula (formula) { 8 | const result = compile(formula) 9 | if (result.type === 'value') { 10 | return result.code 11 | } else { 12 | const fn = new Function(...result.refs, `return ${result.code};`) // eslint-disable-line 13 | fn.refs = result.refs 14 | fn.ranges = result.ranges 15 | Object.assign(fn, functions) 16 | return fn 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/spreadsheet/src/formula.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import * as formulaAst from 'formula-ast' 3 | 4 | export const parse = formulaAst.parse.bind(formulaAst) 5 | 6 | const INFIX_SYMBOL = { 7 | 'infix-add': '+', 8 | 'infix-subtract': '-', 9 | 'infix-multiply': '*', 10 | 'infix-divide': '/', 11 | 'infix-gt': '>', 12 | 'infix-gte': '>=', 13 | 'infix-lt': '<', 14 | 'infix-lte': '<=', 15 | 'infix-eq': '==' 16 | } 17 | 18 | export class CompilerContext { 19 | constructor () { 20 | this.refs = [] 21 | this.ranges = [] 22 | } 23 | 24 | ref (cell) { 25 | this.refs.push(cell) 26 | } 27 | 28 | range (tl, br) { 29 | this.ranges.push({ tl, br }) 30 | } 31 | } 32 | 33 | export function compile (formula) { 34 | const ast = parse(formula) 35 | const ctx = new CompilerContext() 36 | const code = compileAst(ctx, ast) 37 | return { 38 | type: ast.type === 'value' ? 'value' : 'function', 39 | code, 40 | refs: ctx.refs, 41 | ranges: ctx.ranges 42 | } 43 | } 44 | 45 | function compileAst (ctx, node) { 46 | let children 47 | let op 48 | switch (node.type) { 49 | case 'operator': 50 | switch (node.subtype) { 51 | case 'infix-add': 52 | case 'infix-subtract': 53 | case 'infix-multiply': 54 | case 'infix-divide': 55 | case 'infix-gt': 56 | case 'infix-gte': 57 | case 'infix-lt': 58 | case 'infix-lte': 59 | case 'infix-eq': 60 | children = node.operands.map(n => compileAst(ctx, n)) 61 | op = INFIX_SYMBOL[node.subtype] 62 | return '(' + children.join(op) + ')' 63 | case 'prefix-minus': 64 | return `-(${compileAst(ctx, node.operands[0])})` 65 | default: 66 | throw new Error('Unhandled operator: ' + node.subtype) 67 | } 68 | 69 | case 'cell': 70 | const addr = node.addr.toUpperCase() 71 | ctx.ref(addr) 72 | return addr 73 | 74 | case 'range': 75 | const tl = node.topLeft 76 | const br = node.bottomRight 77 | assert(tl.type === 'cell', 'expected "cell" type') 78 | assert(br.type === 'cell', 'expected "cell" type') 79 | 80 | const tlAddr = tl.addr.toUpperCase() 81 | const brAddr = br.addr.toUpperCase() 82 | 83 | const ref = `${tlAddr}_${brAddr}` 84 | 85 | ctx.range(tlAddr, brAddr) 86 | ctx.ref(ref) 87 | return ref 88 | 89 | case 'value': 90 | switch (node.subtype) { 91 | case 'array': 92 | return node.items.map(i => compileAst(ctx, i)) 93 | case 'string': 94 | return JSON.stringify(node.value) 95 | default: 96 | return node.value 97 | } 98 | 99 | case 'group': 100 | return compileAst(ctx, node.exp) 101 | 102 | case 'function': 103 | const fnName = node.name.toUpperCase() 104 | const args = node.args.map(n => compileAst(ctx, n)) 105 | return `arguments.callee.${fnName}(${args.join(',')})` 106 | 107 | default: 108 | throw new Error('Unhandled type: ' + node.type) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /examples/spreadsheet/src/formula.test.js: -------------------------------------------------------------------------------- 1 | /* global test,expect */ 2 | /* eslint-disable no-new-func */ 3 | import { compile } from './formula' 4 | 5 | /* 6 | 7 | ........................................ 8 | : : A : B : C : D : E : 9 | :...:...:...:........:.............:...: 10 | : 1 : : : : : : 11 | : 2 : 1 : 2 : =A2*B2 : : : 12 | : 3 : 3 : 4 : =A3*B3 : : : 13 | : 4 : 5 : 6 : =A4*B4 : =SUM(C1:C5) : : 14 | : 5 : : : : : : 15 | :...:...:...:........:.............:...: 16 | 17 | */ 18 | 19 | test('+', () => { 20 | const expr = 'A1 + A2 + A3' 21 | const result = compile(expr) 22 | expect(result.refs).toEqual(['A1', 'A2', 'A3']) 23 | expect(result.code).toEqual('((A1+A2)+A3)') 24 | }) 25 | 26 | test('-', () => { 27 | const expr = 'A1 - A2 - A3' 28 | const result = compile(expr) 29 | expect(result.refs).toEqual(['A1', 'A2', 'A3']) 30 | expect(result.code).toEqual('((A1-A2)-A3)') 31 | }) 32 | 33 | test('*', () => { 34 | const expr = 'A1 * A2 * A3' 35 | const result = compile(expr) 36 | expect(result.refs).toEqual(['A1', 'A2', 'A3']) 37 | expect(result.code).toEqual('((A1*A2)*A3)') 38 | }) 39 | 40 | test('/', () => { 41 | const expr = 'A1 / A2 / A3' 42 | const result = compile(expr) 43 | expect(result.refs).toEqual(['A1', 'A2', 'A3']) 44 | expect(result.code).toEqual('((A1/A2)/A3)') 45 | }) 46 | 47 | test('>', () => { 48 | const expr = 'A1 > A2' 49 | const result = compile(expr) 50 | expect(result.refs).toEqual(['A1', 'A2']) 51 | expect(result.code).toEqual('(A1>A2)') 52 | }) 53 | 54 | test('>=', () => { 55 | const expr = 'A1 >= A2' 56 | const result = compile(expr) 57 | expect(result.refs).toEqual(['A1', 'A2']) 58 | expect(result.code).toEqual('(A1>=A2)') 59 | }) 60 | 61 | test('<', () => { 62 | const expr = 'A1 < A2' 63 | const result = compile(expr) 64 | expect(result.refs).toEqual(['A1', 'A2']) 65 | expect(result.code).toEqual('(A1 { 83 | const expr = 'A1 * A2 + A3' 84 | const result = compile(expr) 85 | expect(result.refs).toEqual(['A1', 'A2', 'A3']) 86 | expect(result.code).toEqual('((A1*A2)+A3)') 87 | }) 88 | 89 | test('lowercase', () => { 90 | const expr = 'a1 * A2 + a3' 91 | const result = compile(expr) 92 | expect(result.refs).toEqual(['A1', 'A2', 'A3']) 93 | expect(result.code).toEqual('((A1*A2)+A3)') 94 | }) 95 | 96 | test('SUM', () => { 97 | const expr = '=SUM(A1:a10)' 98 | const result = compile(expr) 99 | expect(result.ranges).toEqual([{ tl: 'A1', br: 'A10' }]) 100 | expect(result.refs).toEqual(['A1_A10']) 101 | expect(result.code).toEqual('arguments.callee.SUM(A1_A10)') 102 | }) 103 | 104 | test('IF', () => { 105 | const expr = '=IF(A1, A2, A3)' 106 | const result = compile(expr) 107 | expect(result.ranges).toEqual([]) 108 | expect(result.refs).toEqual(['A1', 'A2', 'A3']) 109 | expect(result.code).toEqual('arguments.callee.IF(A1,A2,A3)') 110 | }) 111 | 112 | test('IF (;)', () => { 113 | const expr = '=IF(A1; A2; A3)' 114 | const result = compile(expr) 115 | expect(result.ranges).toEqual([]) 116 | expect(result.refs).toEqual(['A1', 'A2', 'A3']) 117 | expect(result.code).toEqual('arguments.callee.IF(A1,A2,A3)') 118 | }) 119 | 120 | test('cell + a value', () => { 121 | const expr = 'A1 + 100' 122 | const result = compile(expr) 123 | expect(result.refs).toEqual(['A1']) 124 | expect(result.ranges).toEqual([]) 125 | expect(result.code).toEqual('(A1+100)') 126 | }) 127 | 128 | test('()', () => { 129 | expect(compile('(3+4)*5').code).toEqual('((3+4)*5)') 130 | }) 131 | -------------------------------------------------------------------------------- /examples/spreadsheet/src/functions.js: -------------------------------------------------------------------------------- 1 | function IF (condition, trueValue, elseValue) { 2 | return condition ? trueValue : elseValue 3 | } 4 | 5 | function ROUND (number, precision = 0) { 6 | const factor = Math.pow(10, precision) 7 | return Math.round(number * factor) / factor 8 | } 9 | 10 | function SUM (arr) { 11 | return ( 12 | arr 13 | .filter(validNumber) 14 | .reduce((a, b) => a + b, 0) 15 | ) 16 | } 17 | 18 | function AVERAGE (arr) { 19 | const sumCount = arr 20 | .filter(validNumber) 21 | .reduce((acc, e) => ({sum: acc.sum + e, count: acc.count + 1}), { sum: 0, count: 0 }) 22 | return (sumCount.count === 0) 23 | ? '#DIV/0!' 24 | : sumCount.sum / sumCount.count 25 | } 26 | 27 | function MIN (...args) { 28 | const arr = (args.length > 1) 29 | ? args 30 | : args[0] 31 | return ( 32 | arr.length > 0 33 | ? arr 34 | .filter(validNumber) 35 | .reduce((acc, e) => e < acc ? e : acc) 36 | : 0 37 | ) 38 | } 39 | 40 | function MAX (...args) { 41 | const arr = (args.length > 1) 42 | ? args 43 | : args[0] 44 | return ( 45 | arr.length > 0 46 | ? arr 47 | .filter(validNumber) 48 | .reduce((acc, e) => e > acc ? e : acc) 49 | : 0 50 | ) 51 | } 52 | 53 | function COUNT (arr) { 54 | return ( 55 | arr 56 | .filter(validNumber) 57 | .length 58 | ) 59 | } 60 | 61 | export const functions = { 62 | AVERAGE, 63 | COUNT, 64 | IF, 65 | MAX, 66 | MIN, 67 | ROUND, 68 | SUM 69 | } 70 | 71 | function validNumber (n) { 72 | return (typeof n === 'number') && Number.isFinite(n) 73 | } 74 | -------------------------------------------------------------------------------- /examples/spreadsheet/src/index.js: -------------------------------------------------------------------------------- 1 | import yo from 'yo-yo' 2 | import inspect from 'xcell-inspect' 3 | import { spreadsheet } from './spreadsheet' 4 | import { Sheet } from './sheet' 5 | import { functions } from './functions' 6 | 7 | const sheet = new Sheet() 8 | sheet.setCell('A1', '1') 9 | sheet.setCell('A2', '2') 10 | sheet.setCell('A3', '-1') 11 | sheet.setCell('A4', '4') 12 | sheet.setCell('A5', '5.2') 13 | 14 | const samples = [ 15 | 'SUM(A1:A7)', 16 | 'AVERAGE(A1:A7)', 17 | 'ROUND(C2;2)', 18 | 'IF(C3 > C2; "More!"; "Less!")', 19 | 'MIN(A1:A7)', 20 | 'MAX(A1:A7)', 21 | 'COUNT(A1:A7)', 22 | 'A1 + A2 - A3 / A4', 23 | ] 24 | 25 | for (let i = 0; i < samples.length; i++) { 26 | sheet.setCell(`B${i+1}`, `${samples[i]} —>`) 27 | sheet.setCell(`C${i+1}`, `=${samples[i]}`) 28 | } 29 | 30 | const root = spreadsheet(6, samples.length + 2, sheet) 31 | 32 | const app = yo` 33 |
34 |

spreadsheet demo

35 | ${root} 36 |
37 |

Navigation: Tab/[Shift] Tab, Enter/[Shift] Enter

38 |

39 | Supported functions: 40 |

    41 | ${Object.keys(functions).sort().map(n => yo` 42 |
  • ${n}
  • 43 | `)} 44 |
45 |

46 |
47 |
48 | source code 49 |
50 |
51 | ` 52 | document.body.appendChild(app) 53 | 54 | const inspector = inspect(sheet.getCells(), { 55 | renderDOT: false, 56 | renderGraph: true, 57 | hidden: (window.innerWidth < 900) || (window.innerHeight < 700) 58 | }) 59 | document.body.appendChild(inspector.element) 60 | 61 | sheet.on('update', () => { 62 | inspector.update(sheet.getCells()) 63 | }) 64 | -------------------------------------------------------------------------------- /examples/spreadsheet/src/sheet.js: -------------------------------------------------------------------------------- 1 | import { Cell } from 'xcell' 2 | import shallowEqual from 'shallowequal' 3 | import assert from 'assert' 4 | import { EventEmitter } from 'events' 5 | import { compileFormula } from './compile-formula' 6 | 7 | const debug = require('debug')('xcell:ex:sheet') 8 | 9 | const RANGE_ADDRESS_RE = /^([A-Z]+\d+)_([A-Z]+\d+)$/ 10 | const CELL_ADDRESS_RE = /^[A-Z]\d+$/ 11 | 12 | export class Sheet extends EventEmitter { 13 | constructor () { 14 | super() 15 | 16 | this.$cells = new Cell({ 17 | value: {}, 18 | equalFunction: shallowEqual, 19 | name: 'ALL' 20 | }) 21 | 22 | this.ranges = {} 23 | this.formulas = {} 24 | } 25 | 26 | /** 27 | * @param {string} id 28 | * @returns {Cell} 29 | */ 30 | getCell (id) { 31 | return this.cells[id] 32 | } 33 | 34 | /** 35 | * @returns {Cell[]} 36 | */ 37 | getCells () { 38 | return Object.keys(this.cells) 39 | .map(id => this.getCell(id)) 40 | .filter(cell => !!cell) 41 | } 42 | 43 | /** 44 | * @private 45 | * @param {string} id 46 | * @param {string} formula 47 | * @returns {Cell} 48 | */ 49 | createCellWithFormula (id, formula) { 50 | debug(`createCellWithFormula(${id}, ${formula})`) 51 | assert.equal(formula[0], '=', 'Formula must start with "="') 52 | 53 | const func = compileFormula(formula) 54 | let deps = [] 55 | if (func.refs) { 56 | deps = func.refs.map(ref => ( 57 | isRangeAddress(ref) 58 | ? this.ensureRange(ref) 59 | : this.ensureCellNode(ref) 60 | )) 61 | } 62 | return new Cell({ 63 | name: id, 64 | deps, 65 | formula: func 66 | }) 67 | } 68 | 69 | /** 70 | * @private 71 | * @param {string} begin 72 | * @param {string} end 73 | * @returns {Cell} 74 | */ 75 | createRange (begin, end) { 76 | const inRange = isInRange.bind(undefined, begin, end) 77 | 78 | // $rangeDeps depends on $cells and 79 | // generates array of all the cells 80 | // in the given range. 81 | const $rangeDeps = new Cell({ 82 | name: `${begin}:${end} - deps`, 83 | deps: [this.$cells], 84 | formula: (cells) => ( 85 | Object.keys(cells) 86 | .filter(inRange) 87 | .map(key => cells[key]) 88 | .filter(n => !!n) 89 | ) 90 | }) 91 | 92 | // $range depends on the cells generated above 93 | const $range = new Cell({ 94 | equalFunction: shallowEqual, 95 | name: `${begin}:${end}`, 96 | deps: $rangeDeps.value, 97 | formula: (...args) => args 98 | }) 99 | $range.on('change', ({ name, value }) => { 100 | debug(`range "${name}" changed`, value) 101 | }) 102 | 103 | // whenever $rangeDeps changes, we update the dependencies 104 | // of the $range 105 | $rangeDeps.on('change', ({ name, value }) => { 106 | debug(`rangeHelper "${name}" changed`) 107 | $range.dependencies = value 108 | }) 109 | 110 | $range.$rangeDeps = $rangeDeps 111 | 112 | return $range 113 | } 114 | 115 | /** 116 | * @param {string} id 117 | * @param {string} content 118 | * @returns {Cell} 119 | */ 120 | setCell (id, content) { 121 | debug(`setValue(${id}, ${content})`) 122 | content = content.trim() 123 | 124 | const previous = this.getCell(id) 125 | 126 | if ((this.formulas[id] === content) || (content === '' && this.formulas[id] === undefined)) { 127 | return previous 128 | } 129 | 130 | let current 131 | 132 | if (content === '') { 133 | delete this.formulas[id] 134 | 135 | if (previous && previous.dependents.length > 0) { 136 | current = new Cell({ 137 | value: '', 138 | name: id 139 | }) 140 | } else { 141 | current = undefined 142 | } 143 | } else if (content[0] === '=') { 144 | this.formulas[id] = content 145 | current = this.createCellWithFormula(id, content) 146 | } else { 147 | this.formulas[id] = content 148 | const floatValue = parseFloat(content) 149 | const value = String(floatValue) === content ? floatValue : content 150 | if (previous && !previous.formula) { 151 | previous.value = value 152 | current = previous 153 | } else { 154 | current = new Cell({ 155 | name: id, 156 | value 157 | }) 158 | } 159 | } 160 | 161 | if (current !== previous) { 162 | this.cells = Object.assign({}, this.cells, { [id]: current }) 163 | if (previous) { 164 | if (current) { 165 | const dependents = previous.dependents.slice() 166 | // replace dependency 167 | for (const dep of dependents) { 168 | dep.dependencies = dep.dependencies.map( 169 | d => d === previous ? current : d 170 | ) 171 | } 172 | } 173 | 174 | previous.dispose() 175 | } 176 | this.gcCells() 177 | this.gcRanges() 178 | } 179 | 180 | this.emit('update') 181 | 182 | return current 183 | } 184 | 185 | /** 186 | * @private 187 | */ 188 | get cells () { 189 | return this.$cells.value 190 | } 191 | 192 | /** 193 | * @private 194 | */ 195 | set cells (value) { 196 | this.$cells.value = value 197 | } 198 | 199 | /** 200 | * @private 201 | */ 202 | ensureCellNode (id) { 203 | const result = this.getCell(id) 204 | if (result) { return result } 205 | 206 | const cell = new Cell({ name: id, value: '' }) 207 | this.cells = Object.assign({}, this.cells, { [id]: cell }) 208 | return cell 209 | } 210 | 211 | /** 212 | * @private 213 | */ 214 | ensureRange (address) { 215 | const result = this.ranges[address] 216 | if (result) { 217 | return result 218 | } 219 | const [, rangeBegin, rangeEnd] = RANGE_ADDRESS_RE.exec(address) 220 | return (this.ranges[address] = this.createRange(rangeBegin, rangeEnd)) 221 | } 222 | 223 | /** 224 | * @private 225 | */ 226 | gcCells () { 227 | const toDispose = [] 228 | const keysToDelete = [] 229 | 230 | Object.keys(this.cells).forEach(addr => { 231 | const cell = this.cells[addr] 232 | if (!cell) { 233 | keysToDelete.push(addr) 234 | } else if (cell.dependents.length === 0) { 235 | if (cell.value === '' && typeof cell.formula !== 'function') { 236 | toDispose.push(cell) 237 | keysToDelete.push(addr) 238 | } 239 | } 240 | }) 241 | 242 | if (keysToDelete.length > 0) { 243 | const cells = Object.assign({}, this.cells) 244 | keysToDelete.forEach(key => delete cells[key]) 245 | this.cells = cells 246 | } 247 | 248 | toDispose.forEach(cell => cell.dispose()) 249 | } 250 | 251 | /** 252 | * @private 253 | */ 254 | gcRanges () { 255 | Object.keys(this.ranges).forEach(addr => { 256 | const cell = this.ranges[addr] 257 | if (cell.dependents.length === 0) { 258 | cell.$rangeDeps.dispose() 259 | cell.$rangeDeps = null 260 | cell.dispose() 261 | delete this.ranges[addr] 262 | } 263 | }) 264 | } 265 | } 266 | 267 | function isRangeAddress (address) { 268 | return RANGE_ADDRESS_RE.test(address) 269 | } 270 | 271 | const LETTERS = [ 272 | 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 273 | 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 274 | 'U', 'V', 'W', 'X', 'Y', 'Z'].join('') 275 | 276 | export function cellAddressToCoords (addr) { 277 | return { 278 | col: LETTERS.indexOf(addr[0]) + 1, 279 | row: parseInt(addr.substr(1), 10) 280 | } 281 | } 282 | 283 | export function coordsToCellAddress (coords) { 284 | return `${LETTERS[coords.col - 1]}${coords.row}` 285 | } 286 | 287 | function isCellAddress (address) { 288 | return CELL_ADDRESS_RE.test(address) 289 | } 290 | 291 | function isInRange (begin, end, value) { 292 | if (!isCellAddress(value)) { 293 | return false 294 | } 295 | const b = cellAddressToCoords(begin) 296 | const e = cellAddressToCoords(end) 297 | const v = cellAddressToCoords(value) 298 | return (v.col >= b.col) && (v.col <= e.col) && 299 | (v.row >= b.row) && (v.row <= e.row) 300 | } 301 | -------------------------------------------------------------------------------- /examples/spreadsheet/src/spreadsheet.js: -------------------------------------------------------------------------------- 1 | import yo from 'yo-yo' 2 | import { cellAddressToCoords, coordsToCellAddress } from './sheet' 3 | import { CellComponent } from './cell-component' 4 | 5 | /** 6 | * Creates spreadsheet view 7 | * @param {number} cols: number of columns 8 | * @param {number} rows: number of rows 9 | * @param {Sheet} sheet: sheet 10 | * @returns {HTMLElement} 11 | */ 12 | export function spreadsheet (cols, rows, sheet) { 13 | const headers = fill(cols).map((_, i) => String.fromCharCode(0x41 + i)) 14 | const cellComponents = {} 15 | 16 | for (let c = 1; c <= cols; c++) { 17 | for (let r = 1; r <= rows; r++) { 18 | const id = getId(c, r) 19 | cellComponents[id] = new CellComponent(sheet, id, handleEnterPressed) 20 | } 21 | } 22 | 23 | return yo` 24 | 25 | 26 | 27 | ${headers.map(h => yo` 28 | 29 | `)} 30 | 31 | 32 | ${fill(rows).map((_, ri) => yo` 33 | 34 | 35 | ${headers.map((col, ci) => yo` 36 | 37 | `)} 38 | 39 | `)} 40 | 41 |
${' '}${h}
${ri + 1}${cellComp(ci + 1, ri + 1)}
42 | ` 43 | 44 | function cellComp (col, row) { 45 | return cellComponents[getId(col, row)].element 46 | } 47 | 48 | function getId (c, r) { 49 | return `${headers[c - 1]}${r}` 50 | } 51 | 52 | function handleEnterPressed (address, shiftKey) { 53 | const coords = cellAddressToCoords(address) 54 | let nextAddress 55 | if (shiftKey) { 56 | if (coords.row > 1) { 57 | nextAddress = coordsToCellAddress({ ...coords, row: coords.row - 1 }) 58 | } 59 | } else { 60 | if (coords.row < rows) { 61 | nextAddress = coordsToCellAddress({ ...coords, row: coords.row + 1 }) 62 | } 63 | } 64 | if (nextAddress) { 65 | (window)[nextAddress].focus() 66 | } 67 | } 68 | } 69 | 70 | function fill (num) { 71 | return new Array(num).fill(void 0) 72 | } 73 | -------------------------------------------------------------------------------- /examples/spreadsheet/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | const CleanWebpackPlugin = require('clean-webpack-plugin') 4 | 5 | module.exports = { 6 | entry: './src/index.js', 7 | output: { 8 | filename: 'bundle.js', 9 | path: path.join(__dirname, 'dist') 10 | }, 11 | 12 | devtool: 'source-maps', 13 | 14 | resolve: { 15 | extensions: ['.js', '.json'] 16 | }, 17 | 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.js$/, 22 | exclude: /node_modules/, 23 | use: { 24 | loader: 'babel-loader', 25 | options: { 26 | presets: ['env'], 27 | plugins: [ 28 | 'babel-plugin-yo-yoify', 29 | 'transform-object-rest-spread' 30 | ] 31 | } 32 | } 33 | } 34 | ] 35 | }, 36 | 37 | devServer: { 38 | contentBase: './dist' 39 | }, 40 | 41 | node: { 42 | fs: 'empty' 43 | }, 44 | 45 | plugins: [ 46 | new CleanWebpackPlugin(['dist']), 47 | new HtmlWebpackPlugin({ 48 | template: path.join(__dirname, 'index.html') 49 | }) 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "lerna": "2.8.0", 3 | "packages": [ 4 | "packages/*" 5 | ], 6 | "version": "0.0.10", 7 | "npmClient": "yarn" 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "private": true, 4 | "devDependencies": { 5 | "lerna": "^2.8.0", 6 | "remark-cli": "5.0.0", 7 | "remark-highlight.js": "5.0.0", 8 | "remark-html": "7.0.0", 9 | "remark-preset-lint-markdown-style-guide": "2.1.1" 10 | }, 11 | "workspaces": [ 12 | "packages/*", 13 | "docs/*", 14 | "examples/*" 15 | ], 16 | "scripts": { 17 | "xcell:build:for-debug": "cd packages/xcell && npm run build:for-debug", 18 | "publish": "lerna publish", 19 | "build": "lerna run build", 20 | "make-docs": "make docs" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/xcell-inspect/.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | webpack.config.js 3 | -------------------------------------------------------------------------------- /packages/xcell-inspect/README.md: -------------------------------------------------------------------------------- 1 | # xcell-inspect 2 | 3 | Inspector for [xcell](https://github.com/tomazy/xcell) 4 | 5 | *Browser only* 6 | 7 | ## Installation 8 | 9 | ```bash 10 | npm install xcell-inspect 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```html 16 | 17 | 18 | 19 | + = 20 | 21 | 43 | ``` 44 | 45 | See it live on JS Bin: [demo](https://jsbin.com/humeqab/edit?output) 46 | 47 | -------------------------------------------------------------------------------- /packages/xcell-inspect/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xcell-inspect", 3 | "version": "0.0.10", 4 | "license": "MIT", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "unpkg": "dist/xcell-inspect-umd.js", 8 | "repository": "https://github.com/tomazy/xcell/tree/master/packages/xcell-inspect", 9 | "author": { 10 | "email": "tomazy@go2.pl", 11 | "name": "Tomek Maszkowski" 12 | }, 13 | "description": "xcell inspector", 14 | "keywords": [ 15 | "graph", 16 | "dot" 17 | ], 18 | "dependencies": { 19 | "debug": "^3.1.0", 20 | "lodash.debounce": "^4.0.8", 21 | "lodash.isequal": "^4.5.0", 22 | "viz.js": "^1.8.0", 23 | "xcell": "^0.0.10", 24 | "yo-yo": "^1.4.1" 25 | }, 26 | "devDependencies": { 27 | "@types/core-js": "^0.9.46", 28 | "@types/jasmine": "^2.8.4", 29 | "@types/jest": "^22.0.1", 30 | "@types/node": "^9.3.0", 31 | "jest": "^22.1.4", 32 | "npm-run-all": "^4.1.2", 33 | "raw-loader": "^0.5.1", 34 | "ts-jest": "^22.0.1", 35 | "ts-loader": "^3.2.0", 36 | "typescript": "^2.6.2", 37 | "webpack": "^3.10.0", 38 | "worker-loader": "^1.1.0" 39 | }, 40 | "jest": { 41 | "moduleFileExtensions": [ 42 | "ts", 43 | "js" 44 | ], 45 | "transform": { 46 | ".(ts|tsx)": "../../node_modules/ts-jest/preprocessor.js" 47 | }, 48 | "testMatch": [ 49 | "**/*.test.ts" 50 | ] 51 | }, 52 | "scripts": { 53 | "clean": "rm -fr dist/*", 54 | "prebuild": "npm run clean", 55 | "build": "webpack -p --progress", 56 | "build:debug": "webpack -d --progress", 57 | "test": "jest", 58 | "test:watch": "npm run test -- --watch" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/xcell-inspect/src/cell-to-dot-node.ts: -------------------------------------------------------------------------------- 1 | import { Cell } from 'xcell'; 2 | import { Node } from './create-dot'; 3 | 4 | export default function cell2node(cell: Cell): Node { 5 | const { id, name, value, dependents } = cell; 6 | const label = JSON.stringify(`${name || '#' + id}: ${valueToString(value)}`); 7 | return { 8 | id: String(id), 9 | label, 10 | dependents: dependents.map(d => String(d.id)), 11 | }; 12 | } 13 | 14 | function valueToString(value: any): string { 15 | if (value === undefined) { 16 | return '??'; 17 | } else if (typeof value === 'string') { 18 | if (value.length > 25) { 19 | return JSON.stringify(`${value.slice(0, 25)}...`); 20 | } 21 | return JSON.stringify(value); 22 | } else if (typeof value === 'number') { 23 | if (isInt(value)) { 24 | return String(value); 25 | } else { 26 | return value.toPrecision(8).replace(/0+$/, ''); 27 | } 28 | } else if (typeof value === 'function') { 29 | return `ƒ ${value.name || 'anonymous'}()`; 30 | } else if (Array.isArray(value)) { 31 | return `Array(${value.length})`; 32 | } else { 33 | return value.toString().substring(0, 25); 34 | } 35 | } 36 | 37 | function isInt(value: number) { 38 | if (isNaN(value)) { 39 | return false; 40 | } 41 | // tslint:disable-next-line:no-bitwise 42 | return (value | 0) === value; 43 | } 44 | -------------------------------------------------------------------------------- /packages/xcell-inspect/src/create-dot.ts: -------------------------------------------------------------------------------- 1 | export type ID = string; 2 | export interface Node { 3 | id: ID; 4 | label: string; 5 | dependents: ID[]; 6 | } 7 | 8 | function visitNode(node: Node, nodeDefs: string[], edgeDefs: string[]): void { 9 | const { id, label } = node; 10 | nodeDefs.push(` ${id}[label=${label}]`); 11 | 12 | if (node.dependents.length === 0) return; 13 | 14 | const ids = node.dependents.join(','); 15 | edgeDefs.push(` ${node.id} -> ${ids};`); 16 | } 17 | 18 | export default function createDOT(nodes: Node[]): string { 19 | const nodeDefs: string[] = []; 20 | const edgeDefs: string[] = []; 21 | const seen = {}; 22 | 23 | for (const node of nodes) { 24 | if (seen[node.id]) continue; 25 | 26 | seen[node.id] = true; 27 | visitNode(node, nodeDefs, edgeDefs); 28 | } 29 | 30 | const content = [...nodeDefs, ...edgeDefs].join('\n'); 31 | 32 | return [ 33 | 'digraph {', 34 | content, 35 | '}', 36 | ].join('\n'); 37 | } 38 | -------------------------------------------------------------------------------- /packages/xcell-inspect/src/deferred.ts: -------------------------------------------------------------------------------- 1 | export class Deferred { 2 | public promise: Promise; 3 | public resolve: (...args: any[]) => any; 4 | public reject: (...args: any[]) => any; 5 | 6 | constructor() { 7 | this.promise = new Promise((resolve, reject) => { 8 | this.resolve = resolve; 9 | this.reject = reject; 10 | }); 11 | } 12 | } 13 | 14 | export function defer() { 15 | return new Deferred(); 16 | } 17 | -------------------------------------------------------------------------------- /packages/xcell-inspect/src/dot-layout.test.ts: -------------------------------------------------------------------------------- 1 | import { parsePlain } from './dot-layout'; 2 | 3 | test('graph', () => { 4 | const plain = ` 5 | graph 1 3.614 4.5 6 | stop 7 | `.trim(); 8 | 9 | const graph = parsePlain(plain); 10 | 11 | expect(graph.scale).toEqual(1); 12 | expect(graph.width).toEqual(3.614); 13 | expect(graph.height).toEqual(4.5); 14 | }); 15 | 16 | test('node - simple', () => { 17 | const plain = ` 18 | graph 1 10 10 19 | node 123 1 2 3 4 ABC solid box red yellow 20 | stop 21 | `.trim(); 22 | 23 | const parsed = parsePlain(plain); 24 | expect(parsed.nodes.length).toEqual(1); 25 | 26 | const [node] = parsed.nodes; 27 | 28 | expect(node.x).toEqual(1); 29 | expect(node.y).toEqual(10 - 2); 30 | expect(node.width).toEqual(3); 31 | expect(node.height).toEqual(4); 32 | expect(node.id).toEqual('123'); 33 | expect(node.label).toEqual('ABC'); 34 | expect(node.style).toEqual('solid'); 35 | expect(node.shape).toEqual('box'); 36 | expect(node.color).toEqual('red'); 37 | expect(node.fillColor).toEqual('yellow'); 38 | }); 39 | 40 | test('node - quotes', () => { 41 | const plain = ` 42 | graph 1 3.614 4.5 43 | node 123 1.2181 4.25 1.6474 0.5 "tax rate: 0.13" solid ellipse black lightgrey 44 | stop 45 | `.trim(); 46 | 47 | const parsed = parsePlain(plain); 48 | const [node] = parsed.nodes; 49 | 50 | expect(node.id).toEqual('123'); 51 | expect(node.label).toEqual('tax rate: 0.13'); 52 | expect(node.style).toEqual('solid'); 53 | expect(node.shape).toEqual('ellipse'); 54 | expect(node.color).toEqual('black'); 55 | expect(node.fillColor).toEqual('lightgrey'); 56 | }); 57 | 58 | test('node - quotes in quotes', () => { 59 | const plain = ` 60 | graph 1 3.614 4.5 61 | node 123 1.2181 4.25 1.6474 0.5 "tax rate: \\"0.13\\"" solid ellipse black lightgrey 62 | stop 63 | `.trim(); 64 | 65 | const parsed = parsePlain(plain); 66 | const [node] = parsed.nodes; 67 | 68 | expect(node.id).toEqual('123'); 69 | expect(node.label).toEqual('tax rate: "0.13"'); 70 | expect(node.style).toEqual('solid'); 71 | expect(node.shape).toEqual('ellipse'); 72 | expect(node.color).toEqual('black'); 73 | expect(node.fillColor).toEqual('lightgrey'); 74 | }); 75 | 76 | test('node - fix coords', () => { 77 | const graphH = 4.5; 78 | const plain = ` 79 | graph 1 3.614 ${graphH} 80 | node 123 1.2181 4.25 1.6474 0.5 "tax rate: 0.13" solid ellipse black lightgrey 81 | stop 82 | `.trim(); 83 | 84 | const parsed = parsePlain(plain); 85 | const [node] = parsed.nodes; 86 | 87 | expect(node.x).toEqual(1.2181); 88 | expect(node.y).toEqual(graphH - 4.25); 89 | expect(node.width).toEqual(1.6474); 90 | expect(node.height).toEqual(.5); 91 | }); 92 | 93 | test('edge - fix coords', () => { 94 | const graphH = 4.5; 95 | const plain = ` 96 | graph 1 3.614 ${graphH} 97 | edge A B 7 2.9014 3.9961 2.8487 3.7434 2.765 3.3447 2.6903 3 2.6649 2.8827 2.6367 2.7551 2.6109 2.6391 solid black 98 | stop 99 | `.trim(); 100 | 101 | const parsed = parsePlain(plain); 102 | expect(parsed.edges.length).toEqual(1); 103 | const [edge] = parsed.edges; 104 | 105 | expect(edge.head).toEqual('B'); 106 | expect(edge.tail).toEqual('A'); 107 | expect(edge.points.length).toEqual(7); 108 | expect(edge.style).toEqual('solid'); 109 | expect(edge.color).toEqual('black'); 110 | }); 111 | -------------------------------------------------------------------------------- /packages/xcell-inspect/src/dot-layout.ts: -------------------------------------------------------------------------------- 1 | export type ID = string; 2 | 3 | export interface DotGraph { 4 | scale: number; 5 | width: number; 6 | height: number; 7 | nodes: DotNode[]; 8 | edges: DotEdge[]; 9 | } 10 | 11 | export interface DotNode { 12 | id: ID; 13 | x: number; 14 | y: number; 15 | width: number; 16 | height: number; 17 | label: string; 18 | style: string; 19 | shape: string; 20 | color: string; 21 | fillColor: string; 22 | } 23 | 24 | export interface DotPoint { 25 | x: number; 26 | y: number; 27 | } 28 | 29 | export interface DotEdge { 30 | tail: string; 31 | head: string; 32 | points: DotPoint[]; 33 | label?: string; 34 | labelPosition?: DotPoint; 35 | style: string; 36 | color: string; 37 | } 38 | 39 | export interface DotDiff { 40 | added: ID[]; 41 | changed: ID[]; 42 | } 43 | 44 | function unquote(s: string): string { 45 | if (s[0] === '"' && s[s.length - 1] === '"') { 46 | return JSON.parse(s); 47 | } else { 48 | return s; 49 | } 50 | } 51 | 52 | function tokenize(line: string): string[] { 53 | return (line.match(/"(\\.|[^"])*"|\S+/g) || []) 54 | .map(unquote); 55 | } 56 | 57 | export function parsePlain(input: string): DotGraph { 58 | const lines = input.split('\n'); 59 | 60 | let result: DotGraph = { 61 | edges: [], 62 | nodes: [], 63 | height: 0, 64 | width: 0, 65 | scale: 1, 66 | }; 67 | 68 | function translatePosition(p: DotPoint): DotPoint { 69 | return { 70 | x: p.x, 71 | y: result.height - p.y, 72 | }; 73 | } 74 | 75 | function parseGraphLine(line: string) { 76 | const [, scale, width, height] = tokenize(line); 77 | result = { 78 | ...result, 79 | scale: parseFloat(scale), 80 | width: parseFloat(width), 81 | height: parseFloat(height), 82 | }; 83 | } 84 | 85 | function parseNodeLine(line: string) { 86 | // node a 1.375 2.25 0.75 0.5 a solid ellipse black lightgrey 87 | // 0 1 2 3 4 5 6 7 8 9 10 88 | const [, id, x, y, w, h, label, style, shape, color, fillColor] = tokenize(line); 89 | const p = translatePosition({ x: parseFloat(x), y: parseFloat(y)}); 90 | const node: DotNode = { 91 | ...p, 92 | id, 93 | width: parseFloat(w), 94 | height: parseFloat(h), 95 | label, 96 | style, 97 | shape, 98 | color, 99 | fillColor, 100 | }; 101 | result.nodes.push(node); 102 | } 103 | 104 | function parseEdgeLine(line: string) { 105 | const terms = tokenize(line); 106 | const [, tail, head, n] = terms; 107 | const points: DotPoint[] = []; 108 | 109 | let index = 4; 110 | let pointsCount = Number(n); 111 | 112 | while (pointsCount--) { 113 | const x = Number(terms[index++]); 114 | const y = Number(terms[index++]); 115 | points.push( translatePosition({ x, y })); 116 | } 117 | 118 | const style = terms[index++]; 119 | const color = terms[index++]; 120 | 121 | result.edges.push({ 122 | head, 123 | tail, 124 | points, 125 | style, 126 | color, 127 | }); 128 | } 129 | 130 | function parseLine(line: string) { 131 | if (line === 'stop') { 132 | return; 133 | } else if (startsWith(line, 'graph')) { 134 | parseGraphLine(line); 135 | } else if (startsWith(line, 'node')) { 136 | parseNodeLine(line); 137 | } else if (startsWith(line, 'edge')) { 138 | parseEdgeLine(line); 139 | } 140 | } 141 | 142 | for (let i = 0, l = lines.length; i < l; i++) { 143 | parseLine(lines[i]); 144 | } 145 | 146 | return result; 147 | } 148 | 149 | export function diffNodes(a: DotGraph, b: DotGraph): DotDiff { 150 | const added: string[] = []; 151 | const changed: string[] = []; 152 | 153 | if (b && a) { 154 | b.nodes.forEach(bn => { 155 | let an: DotNode | undefined; 156 | if (an = a.nodes.find(n => n.id === bn.id)) { 157 | if (an.label !== bn.label) { 158 | changed.push(bn.id); 159 | } 160 | } else { 161 | added.push(bn.id); 162 | } 163 | }); 164 | } 165 | 166 | return { 167 | added, 168 | changed, 169 | }; 170 | } 171 | 172 | function startsWith(hay: string, needle: string) { 173 | return hay.substr(0, needle.length) === needle; 174 | } 175 | -------------------------------------------------------------------------------- /packages/xcell-inspect/src/index-umd.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from './inspect'; 2 | 3 | export = inspect; 4 | -------------------------------------------------------------------------------- /packages/xcell-inspect/src/index.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from './inspect'; 2 | 3 | export default inspect; 4 | -------------------------------------------------------------------------------- /packages/xcell-inspect/src/index.worker.ts: -------------------------------------------------------------------------------- 1 | import * as vizLite from 'viz.js/viz-lite'; 2 | import { parsePlain } from './dot-layout'; 3 | 4 | const ctx: Worker = self as any; 5 | 6 | const fns = { 7 | vizLite, 8 | generateDotGraph, 9 | }; 10 | 11 | function generateDotGraph(digraph: string) { 12 | const plain: string = vizLite(digraph, { 13 | format: 'plain', 14 | totalMemory: 16777216 * 4, // 64MB 15 | }); 16 | 17 | return parsePlain(plain); 18 | } 19 | 20 | ctx.addEventListener('message', ({ data }) => { 21 | const { fn, args, id } = data; 22 | 23 | try { 24 | const result = fns[fn](...args); 25 | ctx.postMessage({ 26 | id, 27 | result, 28 | }); 29 | } catch (error) { 30 | ctx.postMessage({ 31 | id, 32 | error, 33 | }); 34 | } 35 | }); 36 | 37 | ctx.postMessage({ message: 'Hello from worker!' }); 38 | -------------------------------------------------------------------------------- /packages/xcell-inspect/src/inspect.ts: -------------------------------------------------------------------------------- 1 | import debounce = require('lodash.debounce'); 2 | import deepEqual = require('lodash.isequal'); 3 | import xcell, { Cell } from 'xcell'; 4 | import * as yo from 'yo-yo'; 5 | 6 | import cell2node from './cell-to-dot-node'; 7 | import createDOT from './create-dot'; 8 | import { diffNodes, DotGraph } from './dot-layout'; 9 | import { renderDotGraph } from './render-dot-graph'; 10 | import { renderRoot } from './render-root'; 11 | import { workerFunctions } from './worker-functions'; 12 | 13 | // tslint:disable-next-line:no-var-requires 14 | const debug = require('debug')('xcell-inspect'); 15 | 16 | const workerFns = workerFunctions(); 17 | 18 | export interface Options { 19 | renderGraph: boolean; 20 | renderDOT: boolean; 21 | hidden: boolean; 22 | zoom: number; 23 | } 24 | 25 | const defaults: Options = { 26 | renderGraph: true, 27 | renderDOT: true, 28 | hidden: false, 29 | zoom: 1.0, 30 | }; 31 | 32 | interface GraphInTime { 33 | graph: DotGraph | null; 34 | time: number; 35 | } 36 | export function inspect(cells: Cell[], options: Options = defaults) { 37 | const debouncedRefreshCurrentCells = debounce(refreshCurrentCells, 100, { maxWait: 1000 }); 38 | 39 | const $zoom = xcell(options.zoom || 1.0); 40 | const $loading = xcell(false); 41 | const $hidden = xcell(Boolean(options.hidden)); 42 | 43 | $hidden.on('change', ({ value }) => { 44 | if (!value) refreshCurrentCells(); 45 | }); 46 | 47 | const $lastErrorInTime = xcell({ error: null, time: 0 }); 48 | 49 | const $currentCells = xcell(discoverAllCells(cells)); 50 | 51 | addChangeListener($currentCells.value, cellChanged); 52 | 53 | $currentCells.on('change', ({ value }, previous: Cell[]) => { 54 | debug('$currentCells changed. Count: %d', value.length); 55 | 56 | removeChangeListener(previous, cellChanged); 57 | addChangeListener(value, cellChanged); 58 | }); 59 | 60 | const $cellCount = xcell([$currentCells], cc => cc.length); 61 | 62 | const $dotNodes = xcell([$currentCells], currentCells => currentCells.map(cell2node)); 63 | 64 | const $digraph = xcell([$dotNodes], createDOT); 65 | $digraph.on('change', ({ value }) => { 66 | debug('digraph', value); 67 | }); 68 | 69 | const $graphInTime = xcell({ graph: null, time: 0 }); 70 | 71 | const $graph = xcell([$graphInTime], ({ graph }) => graph); 72 | 73 | const $lastTwoGraphsInTime = xcell([$graphInTime], function(graphInTime) { 74 | const [, previous] = this.value || [null, { graph: null, time: null }]; 75 | return [previous, graphInTime]; 76 | }); 77 | 78 | const $diff = new Cell({ 79 | equalFunction: deepEqual, 80 | deps: [$lastTwoGraphsInTime], 81 | formula: ([a, b]) => diffNodes(a.graph, b.graph), 82 | }); 83 | 84 | const $error = xcell( 85 | [$lastErrorInTime, $graphInTime], 86 | ( lastErrorInTime, graphInTime) => { 87 | if (lastErrorInTime.time > graphInTime.time) { 88 | return lastErrorInTime.error; 89 | } else { 90 | return null; 91 | } 92 | }); 93 | 94 | const graphInTimeTimer = new Timer(graph => { 95 | $graphInTime.value = { graph, time: Date.now() }; 96 | }, 200); 97 | 98 | xcell([$digraph, $hidden], async (digraph, hidden) => { 99 | graphInTimeTimer.stop(); 100 | 101 | if (hidden) return; 102 | if (!options.renderGraph) return; 103 | 104 | const loading = smartLoading(v => $loading.value = v); 105 | 106 | try { 107 | const graph = await workerFns.generateDotGraph(digraph); 108 | $graphInTime.value = { graph, time: Date.now() }; 109 | graphInTimeTimer.start(graph); 110 | } catch (error) { 111 | $lastErrorInTime.value = { 112 | error, 113 | time: Date.now(), 114 | }; 115 | } finally { 116 | loading.done(); 117 | } 118 | }); 119 | 120 | const $graphHtml = xcell(null); 121 | xcell( 122 | [$graph, $zoom, $hidden, $diff], 123 | ( graph, zoom, hidden, diff) => { 124 | 125 | if (!graph) return; 126 | if (hidden) return; 127 | 128 | const svg = renderDotGraph({ 129 | graph, 130 | zoom, 131 | ...diff, 132 | }); 133 | 134 | $graphHtml.value = svg; 135 | }); 136 | 137 | const $rootHtml = xcell( 138 | [$graphHtml, $error, $digraph, $cellCount, $loading, $hidden, $zoom], 139 | ( graphHtml, error, digraph, cellCount, loading, hidden, zoom) => ( 140 | renderRoot({ 141 | hidden, 142 | loading, 143 | zoom, 144 | cellCount, 145 | // yo-yo (morhpdom) mutates provided children nodes so we have to give it a clone here :( 146 | graph: graphHtml && graphHtml.cloneNode(true), 147 | error, 148 | dot: options.renderDOT ? digraph : null, 149 | }, 150 | send, 151 | ) 152 | )); 153 | 154 | const root: HTMLElement = $rootHtml.value; 155 | 156 | $rootHtml.on('change', ({ value }) => { 157 | yo.update(root, value); 158 | }); 159 | 160 | return { 161 | element: root, 162 | update(newCells: Cell[]) { 163 | cells = newCells; 164 | debouncedRefreshCurrentCells(); 165 | }, 166 | }; 167 | 168 | function cellChanged(cell: Cell) { 169 | debug('cellChanged', cell.name || '#' + cell.id); 170 | if ($hidden.value) return; 171 | debouncedRefreshCurrentCells(); 172 | } 173 | 174 | function refreshCurrentCells() { 175 | debug('refreshCurrentCells'); 176 | $currentCells.value = discoverAllCells(cells); 177 | } 178 | 179 | function send(action: string, ...args: any[]) { 180 | switch (action) { 181 | case 'toggleHidden': 182 | $hidden.value = !$hidden.value; 183 | break; 184 | 185 | case 'zoom': 186 | $zoom.value = args[0] as number; 187 | break; 188 | 189 | default: 190 | throw new Error(`Unknown action "${action}"`); 191 | } 192 | } 193 | } 194 | 195 | function smartLoading(cb) { 196 | let isDone = false; 197 | let triggered = false; 198 | 199 | window.setTimeout(() => { 200 | if (!isDone) { 201 | cb(true); 202 | triggered = true; 203 | } 204 | }, 300); 205 | 206 | return { 207 | done() { 208 | isDone = true; 209 | if (triggered) { 210 | cb(false); 211 | } 212 | }, 213 | }; 214 | } 215 | 216 | function discoverAllCells(cells: Cell[]): Cell[] { 217 | const seen = {}; 218 | 219 | function visit(c: Cell) { 220 | if (seen[c.id]) return; 221 | seen[c.id] = c; 222 | for (const d of c.dependents) { 223 | visit(d); 224 | } 225 | for (const d of c.dependencies) { 226 | visit(d); 227 | } 228 | } 229 | 230 | for (const cell of cells) { 231 | visit(cell); 232 | } 233 | 234 | return Object.keys(seen).map(k => seen[k]); 235 | } 236 | 237 | function removeChangeListener(cells: Cell[], listener) { 238 | for (const cell of cells) { 239 | cell.removeListener('change', listener); 240 | } 241 | } 242 | 243 | function addChangeListener(cells: Cell[], listener) { 244 | for (const cell of cells) { 245 | cell.addListener('change', listener); 246 | } 247 | } 248 | 249 | class Timer { 250 | private handle; 251 | 252 | constructor(private fn, private ms: number) {} 253 | 254 | public stop() { 255 | if (this.handle) { 256 | window.clearTimeout(this.handle); 257 | this.handle = null; 258 | } 259 | } 260 | 261 | public start(...args) { 262 | this.stop(); 263 | this.handle = window.setTimeout(() => { 264 | this.handle = null; 265 | this.fn(...args); 266 | }, this.ms); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /packages/xcell-inspect/src/render-dot-graph.ts: -------------------------------------------------------------------------------- 1 | import * as yo from 'yo-yo'; 2 | 3 | import { DotEdge, DotGraph, DotNode, DotPoint } from './dot-layout'; 4 | 5 | // tslint:disable-next-line:no-var-requires 6 | const debug = require('debug')('xcell-inspect:render-dot-graph'); 7 | 8 | export interface Options { 9 | graph: DotGraph; 10 | zoom: number; 11 | added: string[]; 12 | changed: string[]; 13 | } 14 | /** 15 | * 16 | * @param graph: graph 17 | * @param id: all the nodes with IDs set disappear when we hide the graph. Having an ID on the svg fixed that. 18 | */ 19 | export function renderDotGraph(options: Options): HTMLElement { 20 | const { 21 | graph, 22 | zoom, 23 | added, 24 | changed, 25 | } = options; 26 | debug('>>renderDotGraph. nodes: %d, edges: %d, added: %d, changed: %d', 27 | graph.nodes.length, graph.edges.length, added.length, changed.length); 28 | 29 | const margin = 0.05; 30 | const nodes = graph.nodes.map(renderNode); 31 | const edges = graph.edges.map(renderEdge); 32 | return yo` 33 | 39 | 40 | 50 | 51 | 52 | 53 | 54 | ${edges} 55 | 56 | 57 | ${nodes} 58 | 59 | 60 | `; 61 | 62 | function renderEdge(edge: DotEdge) { 63 | const { points } = edge; 64 | const [p0, ...bSpline] = points; 65 | const d = [moveTo(p0), bezierTo(...bSpline)].join(' '); 66 | return yo` 67 | 68 | 72 | 73 | `; 74 | } 75 | 76 | function renderNode(node: DotNode) { 77 | const { x, y, width, height, label, id } = node; 78 | const newNode = isNew(id); 79 | const changedNode = !newNode && isChanged(id); 80 | 81 | const classNames = [ 82 | 'node', 83 | ... newNode ? ['new'] : [], 84 | ... changedNode ? ['changed'] : [], 85 | ].join(' '); 86 | 87 | return yo` 88 | 89 | 90 | 91 | ${label} 92 | 93 | 94 | `; 95 | } 96 | 97 | function bezierTo(...points: DotPoint[]): string { 98 | return 'C' + points.map(p => `${p.x} ${p.y}`).join(','); 99 | } 100 | 101 | function moveTo(p: DotPoint): string { 102 | return 'M' + `${p.x} ${p.y}`; 103 | } 104 | 105 | function isNew(id: string) { 106 | return added.indexOf(id) > -1; 107 | } 108 | 109 | function isChanged(id: string) { 110 | return changed.indexOf(id) > -1; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /packages/xcell-inspect/src/render-root.ts: -------------------------------------------------------------------------------- 1 | import * as yo from 'yo-yo'; 2 | 3 | // tslint:disable-next-line:no-var-requires 4 | export const css = require('raw-loader!./style.css'); 5 | 6 | export interface State { 7 | hidden: boolean; 8 | loading: boolean; 9 | zoom: number; 10 | cellCount: number; 11 | graph?: HTMLElement; 12 | error?: string; 13 | dot?: string; 14 | } 15 | 16 | export type SendFunction = (action: string, ...args: any[]) => void; 17 | 18 | export function renderRoot(state: State, send: SendFunction): HTMLElement { 19 | const { hidden, loading, zoom, cellCount } = state; 20 | 21 | const classNames = [ 22 | 'xcell-inspect', 23 | ... hidden ? ['hide'] : [], 24 | ].join(' '); 25 | 26 | return yo` 27 |
28 | 29 |
30 | ${when(hidden, null, /* else */() => [ 31 | when(loading, yo` 32 | loading graph... 33 | `), 34 | yo` 35 | ${cellCount} cells 36 | `, 37 | ])} 38 | 41 |
42 | ${when(hidden, null, renderContent)} 43 |
44 | `; 45 | 46 | function renderContent() { 47 | const { error, graph, dot } = state; 48 | return yo` 49 |
50 | ${when(error, () => yo` 51 |
${error}
52 | `, 53 | /* else */() => ([ 54 | when(graph, () => yo` 55 |
56 | ${zoomInput()} 57 |
58 | `), 59 | when(graph, () => yo` 60 |
61 | ${graph} 62 |
63 | `), 64 | when(dot, renderDOT), 65 | ]), 66 | )} 67 |
68 | `; 69 | 70 | function renderDOT() { 71 | const MAX = 5000; 72 | const remaining = (dot as string).length - MAX; 73 | 74 | return yo` 75 |
76 |
DOT:
77 |
${(dot as string).slice(0, MAX)}${remaining > 0 ? '...' : ''}
78 | ${when(remaining > 0, () => yo` 79 |
80 |
81 | and ${remaining} characters more. 82 |
83 | 84 |
85 | `)} 86 |
87 | what is dot? 88 |
89 |
90 | `; 91 | 92 | function logToConsole() { 93 | // tslint:disable-next-line:no-console 94 | console.log(dot); 95 | } 96 | } 97 | } 98 | 99 | function zoomInput() { 100 | return yo` 101 | send('zoom', +event.target.value)} 104 | min="0.1" 105 | max="1.0" 106 | step="0.1" 107 | value=${zoom.toFixed(1)} 108 | >`; 109 | } 110 | 111 | function when(condition, trueCase, falseCase?) { 112 | return( 113 | (condition) 114 | ? (typeof trueCase === 'function') 115 | ? trueCase() 116 | : trueCase 117 | : (typeof falseCase === 'function') 118 | ? falseCase() 119 | : falseCase 120 | ); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /packages/xcell-inspect/src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-gray: #ddd; 3 | --node-active-color: rgb(173, 255, 47); 4 | --node-new-color: yellow; 5 | --node-stroke-color: var(--light-gray); 6 | --node-fill-color: white; 7 | --edge-stroke-color: var(--light-gray); 8 | } 9 | 10 | .xcell-inspect { 11 | background-color: rgb(245, 245, 245); 12 | box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 1px 5px 0 rgba(0,0,0,.12), 0 3px 1px -2px rgba(0,0,0,.2); 13 | font-family: sans-serif; 14 | font-size: 13px; 15 | max-height: 720px; 16 | max-width: 640px; 17 | overflow: auto; 18 | position: fixed; 19 | right: 8px; 20 | top: 8px; 21 | } 22 | 23 | .xcell-inspect div { 24 | margin: 0; 25 | padding: 0; 26 | } 27 | 28 | .xcell-inspect .text-right { 29 | text-align: right; 30 | } 31 | 32 | .xcell-inspect .xcell-inspect-menu { 33 | padding: 4px; 34 | font-size: 12px; 35 | } 36 | 37 | .xcell-inspect .xcell-inspect-error { 38 | color: red; 39 | overflow: auto; 40 | padding: 1em; 41 | white-space: pre; 42 | } 43 | 44 | .xcell-inspect .xcell-inspect-wrapper { 45 | max-height: 640px; 46 | overflow: auto; 47 | } 48 | 49 | .xcell-inspect.hide .xcell-inspect-wrapper { 50 | display: none; 51 | } 52 | 53 | .xcell-inspect svg { 54 | background-color: white; 55 | border: 1px solid var(--light-gray); 56 | display: block; 57 | } 58 | 59 | .xcell-inspect .nodes ellipse { 60 | fill: var(--node-fill-color); 61 | stroke-width: .02px; 62 | stroke: var(--node-stroke-color); 63 | transition: fill 2s ease; 64 | } 65 | 66 | .xcell-inspect .node.changed ellipse { 67 | fill: var(--node-active-color); 68 | transition: none; 69 | } 70 | 71 | .xcell-inspect .node.new ellipse { 72 | fill: var(--node-new-color); 73 | transition: none; 74 | } 75 | 76 | .xcell-inspect .nodes rect { 77 | fill: none; 78 | stroke-width: .01px; 79 | stroke: #333; 80 | } 81 | 82 | .xcell-inspect .nodes text { 83 | font-family: monospace; 84 | font-size: .0015in 85 | } 86 | 87 | .xcell-inspect .edges path { 88 | fill: none; 89 | stroke: var(--edge-stroke-color); 90 | stroke-width: .02px; 91 | } 92 | 93 | .xcell-inspect .xcell-inspect-wrapper, 94 | .xcell-inspect .xcell-inspect-cell-count, 95 | .xcell-inspect .xcell-inspect-dot { 96 | padding: 4px; 97 | } 98 | 99 | .xcell-inspect .xcell-inspect-dot pre { 100 | background-color: white; 101 | border: 1px solid var(--light-gray); 102 | margin-bottom: 4px; 103 | margin-top: 4px; 104 | max-height: 300px; 105 | max-width: 100%; 106 | overflow: auto; 107 | } 108 | -------------------------------------------------------------------------------- /packages/xcell-inspect/src/worker-functions.ts: -------------------------------------------------------------------------------- 1 | import { defer, Deferred } from './deferred'; 2 | import { DotGraph } from './dot-layout'; 3 | 4 | // tslint:disable-next-line:no-var-requires 5 | const debug = require('debug')('xcell-inspect:worker-functions'); 6 | 7 | export function workerFunctions() { 8 | const WebpackWorker = require('./index.worker'); 9 | const worker = new WebpackWorker(); 10 | 11 | let nextId = 1; 12 | const deferred = {}; 13 | 14 | worker.addEventListener('message', ({ data }) => { 15 | const { id, result, error } = data; 16 | if (id) { 17 | const d = deferred[id] as Deferred; 18 | const elapsed = Date.now() - (d as any).start; 19 | delete deferred[id]; 20 | debug('Result for id: %d. Waited %dms.', id, elapsed); 21 | 22 | if (error) { 23 | debug('ERROR!', error); 24 | d.reject(error); 25 | } else { 26 | d.resolve(result); 27 | } 28 | } 29 | }); 30 | 31 | function invoke(fn: string, args: any[]) { 32 | const msg = { 33 | id: nextId++, 34 | fn, 35 | args, 36 | }; 37 | 38 | debug('Invoke `%s`. Id: %d', fn, msg.id); 39 | 40 | worker.postMessage(msg); 41 | 42 | const d = deferred[msg.id] = defer(); 43 | (d as any).start = Date.now(); 44 | return d.promise; 45 | } 46 | 47 | function generateDotGraph(digraph: string): Promise { 48 | return invoke('generateDotGraph', [digraph]); 49 | } 50 | 51 | return { 52 | generateDotGraph, 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /packages/xcell-inspect/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.common.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "removeComments": true, 6 | "noImplicitAny": false, 7 | "sourceMap": false, 8 | "target": "es2015", 9 | "types": [ 10 | "node" 11 | ], 12 | "lib": [ 13 | "dom", 14 | "es2015" 15 | ] 16 | }, 17 | "include": [ 18 | "src/**/*.ts" 19 | ], 20 | "exclude": [ 21 | "node_modules", 22 | "src/**/*.test.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /packages/xcell-inspect/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const distDir = path.join(__dirname, 'dist') 4 | 5 | const defaultConfig = (declaration) => ({ 6 | resolve: { 7 | extensions: ['.ts', '.js', '.json'], 8 | }, 9 | 10 | module: { 11 | rules: [{ 12 | test: /\.worker\./, 13 | use: { 14 | loader: 'worker-loader', 15 | options: { 16 | inline: true, 17 | fallback: false 18 | } 19 | } 20 | }, { 21 | test: /\.ts$/, 22 | loader: 'ts-loader', 23 | options: { 24 | compilerOptions: { 25 | declaration, 26 | target: 'es5', 27 | outDir: '', 28 | } 29 | } 30 | }], 31 | }, 32 | 33 | plugins: [] 34 | }); 35 | 36 | module.exports = [ 37 | Object.assign(defaultConfig(false), { 38 | entry: './src/index-umd.ts', 39 | output: { 40 | library: 'xcellInspect', 41 | libraryTarget: 'umd', 42 | filename: 'xcell-inspect-umd.js', 43 | path: distDir, 44 | }, 45 | }), 46 | 47 | Object.assign(defaultConfig(true), { 48 | entry: './src/index.ts', 49 | output: { 50 | filename: 'index.js', 51 | libraryTarget: 'commonjs2', 52 | path: distDir, 53 | }, 54 | }), 55 | ] 56 | -------------------------------------------------------------------------------- /packages/xcell/.gitignore: -------------------------------------------------------------------------------- 1 | debug 2 | -------------------------------------------------------------------------------- /packages/xcell/.npmignore: -------------------------------------------------------------------------------- 1 | webpack.config.js 2 | tsconfig.* 3 | jest.debug.json 4 | -------------------------------------------------------------------------------- /packages/xcell/README.md: -------------------------------------------------------------------------------- 1 | # xcell 2 | 3 | Tiny library for building reactive spreadsheet-like calculations in JavaScript. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm install xcell 9 | ``` 10 | 11 | ## Usage (node) 12 | 13 | ```javascript 14 | const assert = require('assert') 15 | const xcell = require('xcell') 16 | 17 | const $a = xcell(1) 18 | const $b = xcell(2) 19 | const $c = xcell([$a, $b], (a, b) => a + b) 20 | 21 | $a.value = 100 22 | assert.equals($c.value, 102) // 100 + 2 23 | 24 | $b.value = 200 25 | assert.equals($c.value, 300) // 100 + 200 26 | ``` 27 | 28 | A `Cell` is an `EventEmitter`: 29 | 30 | ```javascript 31 | $c.on('change', ({ value }) => console.log(value)) 32 | ``` 33 | 34 | ## Usage (browser) 35 | 36 | ```html 37 | 38 | + = 39 | 53 | ``` 54 | 55 | ## License 56 | 57 | MIT 58 | -------------------------------------------------------------------------------- /packages/xcell/jest.debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "ts-jest": { 4 | "tsConfigFile": "./packages/xcell/tsconfig.debug.json" 5 | } 6 | }, 7 | "moduleFileExtensions": [ 8 | "ts", 9 | "js" 10 | ], 11 | "transform": { 12 | ".(ts|tsx)": "../../node_modules/ts-jest/preprocessor.js" 13 | }, 14 | "testMatch": [ 15 | "**/*.test.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /packages/xcell/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xcell", 3 | "version": "0.0.10", 4 | "license": "MIT", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "unpkg": "dist/xcell-umd.js", 8 | "repository": "https://github.com/tomazy/xcell/tree/master/packages/xcell", 9 | "author": { 10 | "email": "tomazy@go2.pl", 11 | "name": "Tomek Maszkowski" 12 | }, 13 | "description": "reactive cells for spreadsheet-like calculations", 14 | "keywords": [ 15 | "spreadsheet", 16 | "reactive cells", 17 | "reactive programming", 18 | "directed acyclic graph", 19 | "DAG", 20 | "FRP" 21 | ], 22 | "devDependencies": { 23 | "@types/jasmine": "^2.8.4", 24 | "@types/jest": "^22.0.1", 25 | "@types/node": "^9.3.0", 26 | "jest": "^22.1.4", 27 | "npm-run-all": "^4.1.2", 28 | "ts-jest": "^22.0.1", 29 | "ts-loader": "^3.2.0", 30 | "typescript": "^2.6.2", 31 | "webpack": "^3.10.0" 32 | }, 33 | "jest": { 34 | "moduleFileExtensions": [ 35 | "ts", 36 | "js" 37 | ], 38 | "transform": { 39 | ".(ts|tsx)": "../../node_modules/ts-jest/preprocessor.js" 40 | }, 41 | "testMatch": [ 42 | "**/*.test.ts" 43 | ] 44 | }, 45 | "scripts": { 46 | "clean": "rm -fr dist/* debug", 47 | "prebuild": "npm run clean", 48 | "build": "npm-run-all -p build:node build:umd", 49 | "build:node": "tsc", 50 | "build:umd": "webpack -p", 51 | "build:for-debug": "tsc -p ./tsconfig.debug.json --pretty", 52 | "test": "jest", 53 | "test:watch": "npm run test -- --watch" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/xcell/src/cell.test.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-console 2 | import { createCell as xcell } from './cell'; 3 | 4 | test('static cell has a value', () => { 5 | const $a = xcell(123); 6 | expect($a.value).toEqual(123); 7 | }); 8 | 9 | test('cells have unique ids', () => { 10 | const $a = xcell(1); 11 | const $b = xcell(2); 12 | expect($a.id).not.toEqual($b.id); 13 | }); 14 | 15 | test('dynamic cell updates when deps change', () => { 16 | const $b = xcell(1); 17 | const $c = xcell(2); 18 | const $a = xcell([$b, $c], (b, c) => b + c); 19 | 20 | expect($a.value).toEqual(3); 21 | 22 | $b.value = 100; 23 | expect($a.value).toEqual(102); 24 | 25 | $c.value = 200; 26 | expect($a.value).toEqual(300); 27 | }); 28 | 29 | test('dynamic cell without deps', () => { 30 | const $a = xcell([], () => 123); 31 | expect($a.value).toEqual(123); 32 | }); 33 | 34 | test('glitch in diamond', () => { 35 | /* 36 | make sure that `d` is not updated with old value of one of the dependencies 37 | 38 | a 39 | / \ 40 | b c 41 | \ / 42 | d 43 | 44 | */ 45 | const formula = jest.fn(); 46 | 47 | const $a = xcell(1); 48 | const $b = xcell([$a], a => a + 1); 49 | const $c = xcell([$a], a => a + 1); 50 | xcell([$b, $c], formula); 51 | 52 | expect(formula).toHaveBeenCalledTimes(1); 53 | formula.mockReset(); 54 | 55 | $a.value = 5; 56 | expect(formula).toHaveBeenCalledTimes(1); 57 | expect(formula).toHaveBeenCalledWith(6, 6); 58 | }); 59 | 60 | test('static cells emit change events', () => { 61 | const handler = jest.fn(); 62 | const $a = xcell(1); 63 | $a.on('change', handler); 64 | 65 | $a.value = 3; 66 | expect(handler).toHaveBeenCalledTimes(1); 67 | expect(handler).toHaveBeenCalledWith($a, 1); 68 | handler.mockReset(); 69 | 70 | $a.value = 3; 71 | expect(handler).not.toHaveBeenCalled(); 72 | }); 73 | 74 | test('dynamic cell emit change events', () => { 75 | const handler = jest.fn(); 76 | 77 | const $a = xcell(1); 78 | const $b = xcell(2); 79 | const $c = xcell([$a, $b], (a, b) => a + b); 80 | $c.on('change', handler); 81 | 82 | expect(handler).not.toHaveBeenCalled(); 83 | 84 | $a.value++; 85 | expect(handler).toHaveBeenCalledTimes(1); 86 | expect(handler).toHaveBeenCalledWith($c, 3); 87 | }); 88 | 89 | test('dynamic dependencies (range)', () => { 90 | const $a1 = xcell(1); 91 | const $a2 = xcell(1); 92 | 93 | const $b1 = xcell(10); 94 | const $b2 = xcell(10); 95 | 96 | const $all = xcell({ 97 | $a1, 98 | $a2, 99 | $b1, 100 | $b2, 101 | }); 102 | 103 | // select only cells starting with "$a" 104 | const $rangeDeps = xcell([$all], (o) => ( 105 | Object.keys(o) 106 | .filter(k => k.slice(0, 2) === '$a') 107 | .map(k => o[k]) 108 | )); 109 | 110 | const $range = xcell($rangeDeps.value, (...args) => args); 111 | $rangeDeps.on('change', ({ value }) => { 112 | // we have to explicitly update the dependencies of the range 113 | $range.dependencies = value; 114 | }); 115 | 116 | const $sum = xcell([$range], range => sum(...range)); 117 | expect($sum.value).toBe(1 + 1); 118 | 119 | $all.value = {...$all.value, $a3: xcell(1000) }; 120 | expect($sum.value).toBe(1 + 1 + 1000); 121 | }); 122 | 123 | test('replacing dependencies', () => { 124 | const $a = xcell(1); 125 | const $b = xcell(2); 126 | const $c = xcell(3); 127 | const $d = xcell(4); 128 | 129 | const $sum = xcell([$a, $b], sum); 130 | expect($sum.value).toBe(1 + 2); 131 | 132 | const handler = jest.fn(); 133 | $sum.on('change', handler); 134 | 135 | $sum.dependencies = [$c, $d]; 136 | expect($sum.value).toBe(3 + 4); 137 | expect(handler).toHaveBeenCalledTimes(1); 138 | 139 | $c.value = 33; 140 | expect($sum.value).toBe(33 + 4); 141 | }); 142 | 143 | test('disposing cells', () => { 144 | const $a = xcell(123); 145 | const $b = xcell([$a], a => a); 146 | const $c = xcell([$b], b => b); 147 | 148 | const handler = jest.fn(); 149 | $b.on('change', handler); 150 | 151 | $b.dispose(); 152 | expect($b.disposed).toBe(true); 153 | expect($b.dependencies.length).toBe(0); 154 | expect($b.listenerCount('change')).toBe(0); 155 | expect(handler).not.toHaveBeenCalled(); 156 | 157 | // The client is responsible to remove the disposed cell 158 | // from its dependents. So here $b still is a dependency of $c. 159 | expect($c.dependencies.indexOf($b)).toBeGreaterThan(-1); 160 | }); 161 | 162 | function sum(...args: number[]) { 163 | return args.reduce((acc, e) => acc + e, 0); 164 | } 165 | -------------------------------------------------------------------------------- /packages/xcell/src/cell.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | export type Formula = (...args: any[]) => T; 4 | export type EqualFunction = (a: any, b: any) => boolean; 5 | 6 | export interface Options { 7 | value?: any; 8 | deps?: Cell[]; 9 | formula?: Formula; 10 | equalFunction?: EqualFunction; 11 | name?: string; // for inspection 12 | } 13 | 14 | export class Cell extends EventEmitter { 15 | private static nextId = 1; 16 | 17 | public name?: string; 18 | 19 | private _dependencies: Cell[] = []; 20 | private _dependents: Cell[] = []; 21 | private _formula?: Formula; 22 | private _value: T; 23 | private _id: number; 24 | private _updatingWithoutDependents = false; 25 | private _disposing = false; 26 | private _disposed = false; 27 | private _equalFunction?: EqualFunction; 28 | 29 | constructor(options: Options) { 30 | super(); 31 | const { value, formula, deps = [], equalFunction, name } = options; 32 | this._id = Cell.nextId++; 33 | this.name = name; 34 | this._value = value; 35 | this._formula = formula; 36 | this._equalFunction = equalFunction; 37 | this.dependencies = deps; 38 | } 39 | 40 | public dispose() { 41 | this._disposing = true; 42 | this.dependencies = []; 43 | this.removeAllListeners('change'); 44 | this._disposed = true; 45 | } 46 | 47 | public named(name: string) { 48 | this.name = name; 49 | return this; 50 | } 51 | 52 | public get value(): T { 53 | return this._value; 54 | } 55 | 56 | public set value(v: T) { 57 | if (this._equalFunction) { 58 | if (this._equalFunction(this._value, v)) { 59 | return; 60 | } 61 | } else if (this._value === v) { 62 | return; 63 | } 64 | const prev = this._value; 65 | this._value = v; 66 | this.emit('change', this, prev); 67 | if (!this._updatingWithoutDependents) { 68 | this.updateDependents(); 69 | } 70 | } 71 | 72 | public get id() { 73 | return this._id; 74 | } 75 | 76 | public get dependents() { 77 | return [...this._dependents]; 78 | } 79 | 80 | public get dependencies() { 81 | return [...this._dependencies]; 82 | } 83 | 84 | public set dependencies(v) { 85 | for (const d of this._dependencies) { 86 | d.removeDependent(this); 87 | } 88 | 89 | this._dependencies = [...v]; 90 | 91 | for (const d of this._dependencies) { 92 | d.addDependent(this); 93 | } 94 | 95 | this.update(); 96 | } 97 | 98 | public get formula() { 99 | return this._formula; 100 | } 101 | 102 | public get disposed() { 103 | return this._disposed; 104 | } 105 | 106 | private addDependent(cell: Cell) { 107 | this._dependents.push(cell); 108 | } 109 | 110 | private removeDependent(cell: Cell) { 111 | this._dependents = this._dependents.filter(c => c !== cell); 112 | } 113 | 114 | private update() { 115 | if (!this._formula || this._disposing || this._disposed) return; 116 | const args = this._dependencies.map(d => d.value); 117 | this.value = this._formula.apply(this, args); 118 | } 119 | 120 | private updateWithoutDependents() { 121 | this._updatingWithoutDependents = true; 122 | 123 | this.update(); 124 | 125 | this._updatingWithoutDependents = false; 126 | } 127 | 128 | private updateDependents() { 129 | // depth-first search in DAG guarantees topological sort order 130 | const seen = {}; 131 | const processed = {}; 132 | const toUpdate: Cell[] = []; 133 | const stack: Cell[] = [this]; 134 | seen[this.id] = true; 135 | 136 | while (stack.length > 0) { 137 | const c = stack[stack.length - 1]; // peek 138 | if (processed[c.id]) { 139 | const x = stack.pop() as Cell; 140 | if (x !== this) { 141 | toUpdate.push(x); 142 | } 143 | } else { 144 | for (const d of c._dependents) { 145 | if (!seen[d.id]) { 146 | seen[d.id] = true; 147 | stack.push(d); 148 | } 149 | } 150 | processed[c.id] = true; 151 | } 152 | } 153 | 154 | let l = toUpdate.length; 155 | while (l--) { 156 | toUpdate[l].updateWithoutDependents(); 157 | } 158 | } 159 | } 160 | 161 | export function createCell(value: T): Cell; 162 | export function createCell(deps: [Cell], formula: (v1: U1) => T): Cell; 163 | export function createCell(deps: [Cell, Cell], formula: (v1: U1, v2: U2) => T): Cell; 164 | export function createCell(deps: [Cell, Cell, Cell], formula: (v1: U1, v2: U2, v3: U3) => T): Cell; 165 | export function createCell(deps: [Cell, Cell, Cell, Cell], formula: (v1: U1, v2: U2, v3: U3, v4: U4) => T): Cell; 166 | export function createCell(deps: [Cell, Cell, Cell, Cell, Cell], formula: (v1: U1, v2: U2, v3: U3, v4: U4, v5: U5) => T): Cell; 167 | export function createCell(deps: Cell[], formula: Formula): Cell; 168 | export function createCell(...args: any[]): Cell { 169 | let deps, formula, value; 170 | 171 | if (args.length > 1 && typeof args[1] === 'function') { 172 | deps = args[0]; 173 | formula = args[1]; 174 | } else { 175 | value = args[0]; 176 | } 177 | 178 | return new Cell({ deps, formula, value }); 179 | } 180 | -------------------------------------------------------------------------------- /packages/xcell/src/index-umd.ts: -------------------------------------------------------------------------------- 1 | import { Cell, createCell } from './cell'; 2 | 3 | (createCell as any).Cell = Cell; 4 | 5 | export = createCell; 6 | -------------------------------------------------------------------------------- /packages/xcell/src/index.ts: -------------------------------------------------------------------------------- 1 | import { createCell } from './cell'; 2 | export { Cell, Formula } from './cell'; 3 | 4 | export default createCell; 5 | -------------------------------------------------------------------------------- /packages/xcell/tsconfig.debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.common.json", 3 | "compilerOptions": { 4 | "outDir": "debug", 5 | "removeComments": false, 6 | "sourceMap": true, 7 | "sourceRoot": "./src", 8 | "target": "es2016", 9 | "types": [ 10 | "node", 11 | "jest" 12 | ] 13 | }, 14 | "typeRoots": [ 15 | "../../node_modules/@types" 16 | ], 17 | "include": [ 18 | "src/**/*.ts" 19 | ], 20 | "exclude": [ 21 | "node_modules" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /packages/xcell/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.common.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "removeComments": true, 6 | "sourceMap": false, 7 | "target": "es5", 8 | "types": [ 9 | "node" 10 | ] 11 | }, 12 | "include": [ 13 | "src/**/*.ts" 14 | ], 15 | "exclude": [ 16 | "node_modules", 17 | "src/**/*.test.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /packages/xcell/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const distDir = path.join(__dirname, 'dist') 4 | 5 | module.exports = { 6 | entry: './src/index-umd.ts', 7 | output: { 8 | library: 'xcell', 9 | libraryTarget: 'umd', 10 | filename: 'xcell-umd.js', 11 | path: distDir, 12 | }, 13 | 14 | resolve: { 15 | extensions: ['.ts'], 16 | }, 17 | 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.tsx?$/, 22 | loader: 'ts-loader', 23 | options: { 24 | compilerOptions: { 25 | declaration: false, 26 | } 27 | } 28 | } 29 | ] 30 | }, 31 | 32 | plugins: [] 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.common.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "noImplicitAny": true, 7 | "preserveConstEnums": true, 8 | "pretty": true, 9 | "sourceMap": true, 10 | "strictNullChecks": true, 11 | "target": "es5", 12 | "suppressImplicitAnyIndexErrors": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "arrow-parens": false, 9 | "interface-name": [ 10 | true, 11 | "never-prefix" 12 | ], 13 | "quotemark": [ 14 | true, 15 | "single", 16 | "avoid-escape" 17 | ], 18 | "variable-name": [ 19 | "allow-trailing-underscore" 20 | ], 21 | "object-literal-sort-keys": false, 22 | "one-variable-per-declaration": [ 23 | false 24 | ], 25 | "no-conditional-assignment": false, 26 | "curly": false 27 | }, 28 | "rulesDirectory": [] 29 | } 30 | --------------------------------------------------------------------------------