├── demo ├── package.json ├── example.mjs ├── demo.md ├── example.md └── example.html ├── src ├── index.ts ├── utils │ ├── misc.ts │ ├── mdast.ts │ └── tangle.ts ├── index.d.ts └── tanglePlugin.ts ├── docs ├── media │ └── demo.gif └── reference.md ├── rollup.config.js ├── LICENSE ├── package.json ├── .gitignore ├── .eslintrc.json ├── README.md └── tsconfig.json /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./tanglePlugin" -------------------------------------------------------------------------------- /docs/media/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jqhoogland/remark-tangle/HEAD/docs/media/demo.gif -------------------------------------------------------------------------------- /src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | 2 | export function getGuid(): string { 3 | const S4 = function() { 4 | return (((1+Math.random())*0x10000)|0).toString(16).substring(1); 5 | }; 6 | return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4()); 7 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on: https://hackernoon.com/building-and-publishing-a-module-with-typescript-and-rollup-js-faa778c85396 3 | */ 4 | 5 | import typescript from 'rollup-plugin-typescript2' 6 | import pkg from './package.json' 7 | 8 | export default { 9 | input: 'src/index.ts', 10 | output: [ 11 | { 12 | file: pkg.main, 13 | format: 'cjs', 14 | }, 15 | { 16 | file: pkg.module, 17 | format: 'es', 18 | }, 19 | ], 20 | external: [ 21 | ...Object.keys(pkg.dependencies || {}), 22 | ], 23 | plugins: [ 24 | typescript({ 25 | typescript: require('typescript'), 26 | tsconfig: "tsconfig.json", 27 | abortOnError: false 28 | }), 29 | ], 30 | } 31 | -------------------------------------------------------------------------------- /demo/example.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { unified } from 'unified'; 3 | import remarkParse from 'remark-parse'; 4 | import remarkDirective from 'remark-directive'; 5 | import remarkRehype from 'remark-rehype'; 6 | import rehypeFormat from 'rehype-format'; 7 | import rehypeStringify from 'rehype-stringify'; 8 | import remarkGfm from "remark-gfm"; 9 | 10 | import tanglePlugin from '../dist'; 11 | 12 | const buffer = fs.readFileSync('./demo/example.md'); 13 | 14 | unified() 15 | .use(remarkParse) 16 | .use(remarkGfm) 17 | .use(remarkDirective) 18 | .use(tanglePlugin) 19 | .use(remarkRehype) 20 | .use(rehypeFormat) 21 | .use(rehypeStringify) 22 | .process(buffer) 23 | .then((file) => { 24 | // console.error(reporter(file)) 25 | console.log(String(file)) 26 | }); 27 | -------------------------------------------------------------------------------- /demo/demo.md: -------------------------------------------------------------------------------- 1 | ### How many cookies? 2 | 3 | When you eat [3 cookies](cookies=[0..100]), you consume **[150 calories](calories)**. That's [7.5%](daily_percent&margin-right=0.5ch) of your recommended daily calories. 4 | 5 | 6 | | Calculation for daily % | | 7 | | --- | --- | 8 | | [`cookies`](cookies) | = [5 cookies](cookies&margin-left=1ch) | 9 | | [`calories_per_cookie`](calories_per_cookie) | = [50 calories](calories_per_cookie=[10..100;5]&margin-left=1ch) | 10 | | [`calories`](calories) | = [`calories_per_cookie`](calories_per_cookie&margin-left=1ch&margin-right=1ch)*[`cookies`](cookies&margin-left=1ch&margin-right=1ch)
= [150 calories](calories=calories_per_cookie*cookies&margin-left=1ch) | 11 | | [`calories_per_day`](calories_per_day) | = [2000 calories](calories_per_day=[0..10000;100]&margin-left=1ch) | 12 | | [`daily_percent`](daily_percent) | = [`calories`](calories&margin-left=1ch&margin-right=1ch)/[`calories_per_day`](calories_per_day&margin-left=1ch&margin-right=1ch)
= [percent](daily_percent=calories/calories_per_day&margin-left=1ch) | 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 jqhoogland 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remark-tangle", 3 | "version": "0.0.3", 4 | "description": "A remark plugin for making interactive markdown documents with Tangle.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.es.js", 7 | "files": [ 8 | "dist" 9 | ], 10 | "types": "dist/index.d.ts", 11 | "directories": { 12 | "doc": "../docs" 13 | }, 14 | "scripts": { 15 | "build": "rollup -c", 16 | "watch": "rollup -cw" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/jqhoogland/remark-tangle.git" 21 | }, 22 | "author": "Jesse Hoogland", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/jqhoogland/remark-tangle/issues" 26 | }, 27 | "homepage": "https://github.com/jqhoogland/remark-tangle#readme", 28 | "dependencies": { 29 | "hastscript": "^6.0.0", 30 | "mathjs": "^9.5.1", 31 | "mdast-util-to-hast": "^12.0.0", 32 | "rehype-format": "^4.0.0", 33 | "rehype-stringify": "^9.0.2", 34 | "remark": "^14.0.1", 35 | "unified": "^10.1.0", 36 | "unist-util-visit": "^2.0.1" 37 | }, 38 | "devDependencies": { 39 | "@types/mdast": "^3.0.10", 40 | "fs": "^0.0.1-security", 41 | "remark-directive": "^2.0.0", 42 | "remark-gfm": "^3.0.0", 43 | "remark-parse": "^10.0.0", 44 | "remark-rehype": "^10.0.0", 45 | "rollup": "^2.58.0", 46 | "rollup-plugin-typescript2": "^0.30.0", 47 | "ts-loader": "^9.2.6", 48 | "typescript": "^4.4.4", 49 | "vfile-reporter": "^7.0.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # IDEs and editors 33 | .idea 34 | .project 35 | .classpath 36 | .c9/ 37 | *.launch 38 | .settings/ 39 | *.sublime-workspace 40 | 41 | # IDE - VSCode 42 | .vscode/* 43 | !.vscode/settings.json 44 | !.vscode/tasks.json 45 | !.vscode/launch.json 46 | !.vscode/extensions.json 47 | 48 | # misc 49 | .sass-cache 50 | connect.lock 51 | typings 52 | 53 | # Logs 54 | logs 55 | *.log 56 | npm-debug.log* 57 | yarn-debug.log* 58 | yarn-error.log* 59 | 60 | 61 | # Dependency directories 62 | node_modules/ 63 | jspm_packages/ 64 | 65 | # Optional npm cache directory 66 | .npm 67 | 68 | # Optional eslint cache 69 | .eslintcache 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment variables file 81 | .env 82 | 83 | # next.js build output 84 | .next 85 | 86 | # Lerna 87 | lerna-debug.log 88 | 89 | # System Files 90 | .DS_Store 91 | Thumbs.db 92 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import {Parent, Resource, StaticPhrasingContent} from "mdast"; 2 | 3 | 4 | export interface TanglePluginOptions { 5 | start: string; 6 | /** 7 | Whether to accept [display string](variable=[configuration]) 8 | - If `true`, then `remark-directive` is not needed. 9 | - Proceed carefully as this option may break existing links. 10 | */ 11 | allowLinkNotation: boolean; 12 | } 13 | 14 | export interface TangleFieldShorthand extends Resource { 15 | type: "link" | "textDirective" | "leafDirective"; 16 | 17 | // During `processFieldShorthand` 18 | name?: string; 19 | attributes?: Record; 20 | children?: StaticPhrasingContent[]; 21 | } 22 | 23 | export interface TangleField extends Parent { 24 | type: "textDirective" | "leafDirective"; 25 | name: string; 26 | attributes: Record; 27 | children: StaticPhrasingContent[]; 28 | 29 | // After `processFieldDirective` 30 | hName?: string; 31 | hProperties?: Record; 32 | } 33 | 34 | export interface FieldAttributes { 35 | "data-var": string; 36 | "data-type": "reference" | "definition"; 37 | "data-format"?: string; 38 | "data-default"?: (string | number | null); 39 | 40 | "style"?: Record; 41 | "class-name"?: "TKAdjustableNumber" | "TKSwitch" | "TKOutput" | "TKLabel"; // This can't be locally determined for a reference-field 42 | 43 | // Range inputs 44 | "data-min"?: number; 45 | "data-max"?: number; 46 | "data-step"?: number; 47 | 48 | // Select inputs 49 | "data-choices"?: (string|number)[]; 50 | 51 | // Outputs 52 | "id"?: string; // Following guid format. 53 | "data-formula"?: string; 54 | } -------------------------------------------------------------------------------- /src/utils/mdast.ts: -------------------------------------------------------------------------------- 1 | import {Root} from "mdast"; 2 | 3 | export const addScript = (tree: Root, {src, value=""}: {src?: string, value: string }, isMdx=false) => 4 | tree.children.push({ 5 | type: "element", 6 | data: { 7 | hName: "script", 8 | hProperties: { 9 | type: "text/javascript", 10 | ...(src ? {src} : {} ), 11 | } 12 | }, 13 | children: [{type: "text", value}] 14 | }) 15 | 16 | export const addStyleSheet = (tree: Root, href: string, isMdx=false) => 17 | tree.children.push({ 18 | type: "element", 19 | value: isMdx ?``: "", 20 | data: { 21 | hName: "link", 22 | hProperties: { 23 | href, 24 | type: "text/css", 25 | rel: "stylesheet" 26 | } 27 | }, 28 | children: [{type: "text", value: ""}] 29 | }) 30 | 31 | export const addStyleTag = (tree: Root, value: string="", isMdx=false) => 32 | tree.children.push({ 33 | type: "element", 34 | value: "", 35 | data: { 36 | hName: "style", 37 | hProperties: {} 38 | }, 39 | children: [ 40 | {type: "text", value} 41 | ] 42 | }) 43 | 44 | export const addExternalScripts = (tree: Root, scripts: string[], isMdx=false) => 45 | scripts.forEach((src: string) => addScript(tree, {src}, isMdx)) 46 | 47 | export const addStyleSheets = (tree: Root, stylesheets: string[] , isMdx=false) => 48 | stylesheets.forEach((stylesheet: string) => addStyleSheet(tree, stylesheet, isMdx)) 49 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": [], 3 | "root": true, 4 | "extends": [ 5 | "plugin:prettier/recommended", 6 | "prettier" 7 | ], 8 | "rules": { 9 | "prettier/prettier": "error", 10 | "no-underscore-dangle": "off", 11 | "semi": "always" 12 | }, 13 | "settings": { 14 | "jest": { 15 | "version": "detect" 16 | } 17 | }, 18 | "parser": "@typescript-eslint/parser", 19 | "overrides": [ 20 | { 21 | "files": [ 22 | "*.jsx", 23 | "*.js" 24 | ] 25 | }, 26 | { 27 | "files": [ 28 | "**/*.ts", 29 | "**/*.tsx" 30 | ], 31 | "extends": "plugin:@typescript-eslint/recommended", 32 | "rules": { 33 | "@typescript-eslint/explicit-function-return-type": "off", 34 | "@typescript-eslint/explicit-module-boundary-types": "off", 35 | "@typescript-eslint/interface-name-prefix": "off", 36 | "@typescript-eslint/no-explicit-any": "off", 37 | "@typescript-eslint/ban-ts-ignore": "off", 38 | "@typescript-eslint/no-var-requires": "warn" 39 | } 40 | }, 41 | { 42 | "files": [ 43 | "**/*.test.*", 44 | "**/__mocks__/*.*" 45 | ], 46 | "env": { 47 | "jest": true 48 | // now **/*.test.js files' env has both es6 *and* jest 49 | }, 50 | // Can't extend in overrides: https://github.com/eslint/eslint/issues/8813 51 | "extends": [ 52 | "plugin:jest/recommended" 53 | ], 54 | "plugins": [ 55 | "jest" 56 | ], 57 | "rules": { 58 | "jest/no-disabled-tests": "warn", 59 | "jest/no-focused-tests": "error", 60 | "jest/no-identical-title": "error", 61 | "jest/prefer-to-have-length": "warn", 62 | "jest/valid-expect": "error", 63 | "jest/no-export": "warn" 64 | } 65 | } 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/tangle.ts: -------------------------------------------------------------------------------- 1 | export const TANGLE_SCRIPTS = [ 2 | "https://pagecdn.io/lib/mathjs/9.4.4/math.min.js", 3 | "http://worrydream.com/Tangle/Tangle.js", 4 | "http://worrydream.com/Tangle/TangleKit/TangleKit.js", 5 | "http://worrydream.com/Tangle/TangleKit/sprintf.js", 6 | "http://worrydream.com/Tangle/TangleKit/mootools.js", 7 | "http://worrydream.com/Tangle/TangleKit/BVTouchable.js" 8 | ] 9 | 10 | export const TANGLE_STYLESHEETS = [ 11 | "http://worrydream.com/Tangle/TangleKit/TangleKit.css" 12 | ] 13 | 14 | export const TANGLE_STYLING = ` 15 | 16 | .TKOutput { color: #4eabff; border-bottom: 1px dashed #4eabff;} 17 | .TKOutput:hover { background-color: #e3eef3;} 18 | .TKOutput:active { background-color: #4eabff; color: #fff; } 19 | a.TKOutput { text-decoration: none; } 20 | 21 | .TKAdjustableNumberHelp { color: #0dbe04!important } 22 | .TKAdjustableNumber { color: #0dbe04; border-bottom: 1px dashed #0dbe04 } 23 | .TKAdjustableNumber:hover { background-color: #e4ffed } 24 | .TKAdjustableNumber:active { background-color: #66c563; color: #fff; } 25 | 26 | .TKLabel pre { 27 | margin-top: 0; 28 | margin-bottom: 0; 29 | } 30 | .TKLabel { 31 | color: #fff; 32 | display: inline-block; 33 | text-align: center; 34 | line-height: 1.0; 35 | padding-left: 8px; 36 | padding-right: 8px; 37 | padding-top: 2px; 38 | padding-bottom: 3px; 39 | border-radius: 20px; 40 | border-radius: 20px 41 | border: 1px solid #85abbd; 42 | background-color: #91b9cc; 43 | width: "100%"; 44 | # flex: 1; 45 | # display: flex; 46 | # text-align: center; 47 | # justify-content: center; 48 | } 49 | .TKLabel:hover { 50 | background-color: #5f9bb6; 51 | border-color: #6a828e; 52 | } 53 | 54 | .TKLabel span { 55 | display: none; 56 | } 57 | 58 | ` 59 | 60 | /** 61 | * Return a script that initializes a tangle object for `document.body`. 62 | * 63 | * @param names: a list of all of the variables to initialize 64 | * @param defaultValues: a mapping of variable name to default values (for all variables) 65 | * @param outputFormulas: a mapping of variable names to update functions (only for output variables) 66 | */ 67 | export const createTangleSetUp = (names: string[], defaultValues: Record, outputFormulas: Record) => ` 68 | var tangle = new Tangle (document.body, { 69 | initialize: function () { 70 | ${names.map(name => (` this.${name} = ${defaultValues?.[name] ?? null};`)).join("\n")} 71 | }, 72 | update: function () { 73 | var scope = {${names.map((name: string) => `${name}: this.${name}`).join(", ")}} 74 | 75 | 76 | ${Object.keys(outputFormulas).map(key => (` this.${key} = math.evaluate("${outputFormulas?.[key] ?? null}", scope);`)).join("\n")} 77 | } 78 | }); 79 | ` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remark-Tangle 2 | 3 | Remark and mdast plugins for creating interactive markdown documents. 4 | 5 | --- 6 | 7 | Remark-Tangle extends plain text markdown with a special notation (based on links) for adding controls and variables with [Tangle](http://worrydream.com/Tangle/guide.html). It's like a lightweight and inline version of [Jupyter](https://jupyter.org/), [Observable](https://observablehq.com/), [R](https://bookdown.org/yihui/rmarkdown/notebook.html), or [Wolfram](https://www.wolfram.com/notebooks/) notebooks. Or, if you like, a messier 8 | alternative to spreadsheets. 9 | 10 | A sample Model-Driven Markdown file looks like this: 11 | 12 | ``` 13 | ### How many cookies? 14 | 15 | 16 | When you eat [3 cookies](cookies=[0..100]), you consume **[150 calories](calories)**. 17 | That's [7.5%](daily_percent&margin-right=0.5ch) of your recommended daily calories. 18 | 19 | 20 | | Calculation for daily % | | 21 | | ----------------------- | ----------------------- | 22 | | [`cookies`](cookies) | = [5 cookies](cookies&margin-left=1ch) | 23 | | [`calories_per_cookie`](calories_per_cookie) | = [50 calories](calories_per_cookie=[10..100;5]&margin-left=1ch) | 24 | | [`calories`](calories) | = [`calories_per_cookie`](calories_per_cookie&margin-left=1ch&margin-right=1ch)*[`cookies`](cookies&margin-left=1ch&margin-right=1ch) = [150 calories](calories=calories_per_cookie*cookies&margin-left=1ch) | 25 | | [`calories_per_day`](calories_per_day) | = [2000 calories](calories_per_day=[0..10000;100]&margin-left=1ch) | 26 | | [`daily_percent`](daily_percent) | = [`calories`](calories&margin-left=1ch&margin-right=1ch)/[`calories_per_day`](calories_per_day&margin-left=1ch&margin-right=1ch) = [7.5%](daily_percent=calories/calories_per_day&margin-left=1ch) | 27 | 28 | ``` 29 | 30 | And, when compiled to `html`, yields a page that looks like this : 31 | 32 | ![demo-video](docs/media/demo.gif) 33 | 34 | The best part about the format (IMHO) is that it has a clean fallback in standard HTML (as long as you can ignore broken links): 35 | 36 | > ### How many cookies? 37 | > When you eat [3 cookies](cookies=[0..100]), you consume **[150 calories](calories)**. 38 | That's [7.5%](daily_percent&margin-right=0.5ch) of your recommended daily calories. 39 | > 40 | > | Calculation for daily % | | 41 | > | ----------------------- | ----------------------- | 42 | > | [`cookies`](cookies) | = [5 cookies](cookies&margin-left=1ch) | 43 | > | [`calories_per_cookie`](calories_per_cookie) | = [50 calories](calories_per_cookie=[10..100;5]&margin-left=1ch) | 44 | > | [`calories`](calories) | = [`calories_per_cookie`](calories_per_cookie&margin-left=1ch&margin-right=1ch)*[`cookies`](cookies&margin-left=1ch&margin-right=1ch) = [150 calories](calories=calories_per_cookie*cookies&margin-left=1ch) | 45 | > | [`calories_per_day`](calories_per_day) | = [2000 calories](calories_per_day=[0..10000;100]&margin-left=1ch) | 46 | > | [`daily_percent`](daily_percent) | = [`calories`](calories&margin-left=1ch&margin-right=1ch)/[`calories_per_day`](calories_per_day&margin-left=1ch&margin-right=1ch) = [7.5%](daily_percent=calories/calories_per_day&margin-left=1ch) | 47 | 48 | For a more in-depth example, [take a look at this post introducing remark-tangle](https://jessehoogland.com/articles/post-rhetoric). 49 | 50 | ## Structure 51 | 52 | The project exports a plugin from `/dist/index.js` that you should use after `remarkParse` and before `remarkRehype`. 53 | 54 | 55 | ## How to use 56 | 57 | To get this to work with Next.js, I've had to downgrade `hastscript -> hastscript@^6.0.0` and `unist-util-visit@^2.0.1`. 58 | 59 | The unified plugin ecosystem is making a push to full esm modules, but `next.config.js` only allows `require` imports. 60 | 61 | When `next.js` makes the transition, I'll deprecate the commonjs build. 62 | 63 | ## Notation 64 | 65 | The default notation takes advantage of link notation to provide a clean fallback in standard markdown. 66 | Tangle fields take the format: `[display string](variable-configuration)`. 67 | 68 | There's an alternative notation via 69 | [remark-directive](https://github.com/remarkjs/remark-directive) if you prefer to leave your links untouched: `:t[display string]{variable configuration}`. 70 | 71 | For example: 72 | 73 | - `:t[3 cookies]{num_cookies=[1..10;.5]}` defines a variable, `num_cookies`, with default value `3`, display template `%d cookies` (using inferred [printf](https://alvinalexander.com/programming/printf-format-cheat-sheet/) notation), that can take values from the range `1` to `10` inclusive, with step size `0.5`. 74 | - `:t[150. calories]{num_calories=num_cookies*num_calories_per_cookie}` defines a variable, `num_calories` that depends on the values of `num_cookies` and `num_calories_per_cookie`, with display template `%.0f calories`. 75 | - `:t[150 calories]{num_calories}` creates a reference to a variable already defined elsewhere. These are automatically synchronized. 76 | 77 | See [the reference](/docs/reference.md) for a full account of different types of fields and their configuration. 78 | 79 | > Note: you can add other key-value pairs to customize the styling (which is useful for spacing). 80 | > Use the separator `&` in link format and a blank space in directive-format: `[5 cookies](cookies=[0..10]&margin-left=1ch)` vs. `:t[5 cookies]{cookies=[0..10] margin-left=1ch}` 81 | 82 | ## Timeline 83 | 84 | There's still a lot to do. 85 | 86 | 1. Syncing hovers & actives. If you scroll over a field, you should see any references to and dependencies of that field light up. 87 | 2. More inputs & more data types (such as lists/distributions). 88 | 3. Replacing [Tangle](https://github.com/worrydream/Tangle), which hasn't been maintained in over a decade. It's time to move on. 89 | 90 | ## Security 91 | 92 | This opens you up for some serious XSS vulnerabilities. Be careful, know what you are doing. 93 | 94 | ## Contribute 95 | 96 | This project is still very early on, so I welcome any kind of support you might want to offer. 97 | 98 | ## Authors 99 | 100 | - [Jesse Hoogland](https://jessehoogland.com) 101 | 102 | The concepts and notation are inspired by a bunch of different projects: 103 | 104 | - 🙌 **[Tangle](http://worrydream.com/Tangle/guide.html)** by [Bret Victor](http://worrydream.com/) is at the root of all of these projects. 105 | - [Active Markdown](https://github.com/alecperkins/active-markdown) by [Alec Perkins](https://github.com/alecperkins) most inspired the syntax. 106 | - [Dynamic Markdown](https://github.com/tal-baum/dynamic-markdown) by [Tal Lorberbaum](https://github.com/tal-baum) 107 | - [Fangle](https://jotux.github.io/fangle/) by [@jotux](https://github.com/jotux) 108 | - [TangleDown](https://github.com/bollwyvl/TangleDown/tree/master/tangledown) by [Nicholas Bollweg](https://github.com/bollwyvl) 109 | 110 | ## License 111 | 112 | [MIT]() © Jesse Hoogland 113 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | # React-Tangle > Reference 2 | 3 | --- 4 | 5 | ## Contents 6 | 7 | - Definition Fields 8 | - Input Fields 9 | - Range Input 10 | - Select Input 11 | - Output Fields 12 | - Allowed Expressions 13 | - Reference Fields 14 | - Display Strings 15 | - Customizing Notation 16 | - Wiki-link-style fields 17 | - Active-Markdown-style fields 18 | 19 | # Notation 20 | 21 | 22 | Remark-Tangle is built around "**fields**" that compile to [Tangle](http://worrydream.com/Tangle/guide.html) elements. 23 | 24 | There are (1) **definition fields**, which define a new variable, such as 25 | 26 | `[display string](new_variable=configuration)`, 27 | 28 | and (2) **reference fields**, which display an already defined variable, such as 29 | 30 | `[display_string](old_variable)`. 31 | 32 | Just like the text content of a link or the alt text for an image, `display string` tells Remark-Tangle how to display `new_variable` or `old_variable`. It may also determine the default (or fallback) value for that variable. The `configuration` in a definition field determines what kind of field it is. 33 | 34 | #### Link Notation vs. Generic Directive Notation 35 | By default, Remark Tangle uses link notation (pictured above). 36 | This is useful because it offers an elegant fallback if Remark Tangle is unavailable. 37 | 38 | Alternatively, you can use a notation based on the [generic directive syntax](https://talk.commonmark.org/t/generic-directives-plugins-syntax/444) (with the identifier `t`). 39 | 40 | The two previous examples would look like: 41 | 42 | `:t[display string]{new_variable=configuration}`, 43 | `:t[display string]{old_variable}`. 44 | 45 | Besides the preface `:t` and the curly braces `{}` instead of round brackets `()`, the only other difference is that 46 | additional key-value pairs (used in the `style` attribute) are separated by an ampersand (`&`) in link notation and a space (` `) in directive notation. 47 | 48 | > Note: for this to work, you must use this plugin after `remarkDirective` 49 | > 50 | ```js 51 | unified() 52 | .use(remarkParse) 53 | // Do stuff 54 | .use(remarkDirective) 55 | .use(tanglePlugin) 56 | // Do more stuff 57 | ``` 58 | 59 | ## Definition Fields 60 | 61 | There are two kinds of definition fields: 62 | - **Input fields** 63 | - **Output fields** 64 | 65 | ### Input Fields 66 | 67 | Input fields define variables that users can directly change by clicking, dragging, etc. 68 | 69 | There are currently two types of input fields: 70 | 71 | - **Range Inputs** 72 | - **Select Inputs** 73 | 74 | #### Range Inputs 75 | 76 | Range inputs define numeric variables that users can adjust by clicking and dragging. 77 | 78 | `[display string](range_var=[min..max;step])` 79 | 80 | - As in CoffeeScript, the range is inclusive for `..` but excludes `max` for `...` . 81 | - Unlike in CoffeeScript, an omitted `min` defaults to `-Infinity`, and an omitted `max` defaults to `+Infinity`. 82 | - `step` is optional (`[min..max]` is shorthand for `[min..max;1]`) 83 | 84 | **Example 1**: [50. calories](calories_per_cookie=[10..100;5]) - `[50 calories](calories_per_cookie=[10..100;10])` 85 | 86 | | property | value | 87 | | --- | --- | 88 | | `name` | `calories_per_cookie` | 89 | | `value` | [50](calories_per_cookie) | 90 | | `interval` | [10, 100] | 91 | | `step` | 5 | 92 | | `display template` | `"%.0f cal."`| 93 | 94 | **Example 2**: [3 cookies](cookies_per_day=[0..]) - `[3 cookies](cookies_per_day=[10..])` 95 | 96 | | property | value | 97 | | --- | --- | 98 | | `name` | `cookies_per_day` | 99 | | `value` | [3](cookies_per_day) | 100 | | `interval` | `[10, Infinity)` | 101 | | `step` | `1` | 102 | | `display template` | `"%d cookies"`| 103 | 104 | > Note: You can use expressions like `3*pi` and `sqrt(5)` for `min`, `max`, and `step`. For a full list of allowed expressions, see [Allowed Expressions](#Allowed Expressions) 105 | 106 | #### Select Inputs 107 | 108 | > NOTE: This is not yet implemented. 109 | 110 | Select inputs define variables that take their value from a set. Users can cycle through these values by clicking on the element. 111 | 112 | `[display string](select_var=[option_1,option_2,option_3])` 113 | 114 | **Example**: [chocolate_chip cookie](cookie=[chocolate_chip,oatmeal_raisin,ginger_snap]) - `[chocolate_chip](cookie=[chocolate_chip,oatmeal_raisin,ginger_snap])` 115 | 116 | | property | value | 117 | | --- | --- | 118 | | `name` | `cookie` | 119 | | `value` | [chocolate_chip](cookie) | 120 | | `choices` | `["chocolate_chip", "oatmeal_raisin", "ginger_snap"]` | 121 | | `display template` | `"%s"`| 122 | 123 | > Note: Just as for the range input, you can use expressions like `3*pi` and `sqrt(5)` among your choices. For a full list of allowed expressions, see [Allowed Expressions](#Allowed Expressions) 124 | 125 | ### Output Fields 126 | 127 | Output fields represent variables that are calculated from other variables. They cannot be directly manipulated. Instead, they update whenever the variables they depend on change their values. 128 | 129 | **Example 1**: [150 calories](calories_per_day=calories_per_cookie*cookies_per_day) = `[150 calories](calories_per_day=calories_per_cookie*cookies_per_day)` 130 | 131 | | property | value | 132 | | --- | --- | 133 | | `name` | `calories_per_day` | 134 | | `value` | [150 calories](calories_per_day) | 135 | | `formula` | `= calories_per_cookie * cookies_per_day` | 136 | | `display template` | `"%d"`| 137 | 138 | #### Allowed Expressions 139 | You can do a lot with a formula: 140 | - **math**: Model-Driven Markdown uses `mathjs`'s [expression parsing functionality](https://mathjs.org/docs/expressions/parsing.html) to calculate formulas, so you can use all the functions on [this page](https://mathjs.org/docs/reference/functions.html).
141 | (Note: there's no need to preface expressions with `math.`). 142 | 143 | In the future, I might add support for: 144 | - **logic & conditionals**: Use familiar javascript conditional statements:
145 | `!`, `a&&b`, `a||b`, `value??fallback`, `condition?a:b`, `condition_1?a:condition_2?b:condition_3?...` 146 | - **dictionaries & lists**:
`(key:value)[a]`, `[a,b,c][index]` 147 | 148 | > Note on default values: with output fields, the default value is only used to determine your desired display precision. You can alternatively use a printf style format string (see [Display Strings](#Display Strings)). 149 | ## Reference Fields 150 | 151 | Reference fields reference variables that are defined elsewhere. If the variable changes according to its original definition, then any references to that variable will change in sync. 152 | 153 | `[display string](variable_name)` 154 | 155 | Reference fields act a little differently depending on whether they reference an input or output field. 156 | - **Input fields**: References to input fields will also let you change the original value. They are indistinguishable to the reader. 157 | - Example: [50 calories](calories_per_cookie) - `[50 calories](calories_per_cookie)` 158 | - **Output fields**: References to output fields contain links to the original definition. So for the sake of interpretation, I recommend you explain to the reader how you calculate an output field right next to its definition. If it requires a particularly meaty calculation, you might consider moving the definition and explanation to an appendix. 159 | - Example: [150 calories](calories_per_day) - `[50 calories]{calories_per_cookie)` 160 | 161 | ## Display Strings 162 | The numbers in a display string serve two purposes: 163 | 164 | 1. Default (or fallback) value. 165 | - `[50. calories]{calories_per_cookie=[10..100;5])` sets the initial value of `calories_per_cookie` to be `50`. 166 | - This is also the value that is rendered if you are using a standard markdown interpreter. 167 | 2. Display precision. 168 | - `50.` tells us we want to render a decimal point but no digits afterwards, 169 | - `50.0` would require an additional digit after the decimal point, 170 | - `50` would hide the decimal point, etc. 171 | 172 | Behind the scenes, the display number is converted to a "format specifier" similar (but not exactly the same) as [printf-style strings in C](https://www.cplusplus.com/reference/cstdio/printf/): 173 | - `%d` for a signed decimal integer (e.g., `1`, `1000`, `0`). 174 | - `%'d` to display large decimal integers with a comma or period every 3 decimal places (depending on locale) (e.g., `1`, `1,000`, `0`). 175 | - `%.xf` for a signed float with `x` digits after the decimal point 176 | (e.g., `%.2f->1.0`, `%.1f->1000.0`, `%.0f->0.`). 177 | - `%'.xf` combines `%'d` and `%.xf`. 178 | - `%s` for a string of characters (useful with, e.g, the select input). 179 | 180 | If you want, you can skip inputting a default value, and use one of these format specifiers directly. (E.g., `[%d calories]{calories_per_day)`). This will save you time with output definitions or references where it quickly becomes tedious to compute or look up the right numbers. 181 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es5", 15 | /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 16 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 17 | // "jsx": "react", /* Specify what JSX code is generated. */ 18 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 19 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 20 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 21 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 22 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 23 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 24 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 25 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 26 | 27 | /* Modules */ 28 | "module": "es6", 29 | 30 | /* Specify what module code is generated. */ 31 | "rootDir": "src", /* Specify the root folder within your source files. */ 32 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 33 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 34 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 35 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 36 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 37 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 38 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 39 | "resolveJsonModule": true, /* Enable importing .json files */ 40 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 41 | 42 | /* JavaScript Support */ 43 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 44 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 45 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 46 | 47 | /* Emit */ 48 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 49 | "declarationDir": "./dist", 50 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 51 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 52 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 53 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 54 | "outDir": "./dist", 55 | /* Specify an output folder for all emitted files. */ 56 | // "removeComments": true, /* Disable emitting comments. */ 57 | // "noEmit": true, /* Disable emitting files from a compilation. */ 58 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 59 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 60 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 61 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 62 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 63 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 64 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 65 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 66 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 67 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 68 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 69 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 70 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 71 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 72 | 73 | /* Interop Constraints */ 74 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 75 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 76 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 77 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 78 | "forceConsistentCasingInFileNames": true, 79 | /* Ensure that casing is correct in imports. */ 80 | 81 | /* Type Checking */ 82 | "strict": false, 83 | /* Enable all strict type-checking options. */ 84 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 85 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 86 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 87 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 88 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 89 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 90 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 91 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 92 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 93 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 94 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 95 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 96 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 97 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 98 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 99 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 100 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 101 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 102 | 103 | /* Completeness */ 104 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 105 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 106 | }, 107 | "lib": ["es2015"], 108 | } 109 | -------------------------------------------------------------------------------- /src/tanglePlugin.ts: -------------------------------------------------------------------------------- 1 | import visit from 'unist-util-visit'; // TODO: Update these to new versions once next.js allows imports in next.config.js 2 | import h from 'hastscript'; 3 | import {Link, Root} from "mdast"; 4 | import {MdastNode} from "mdast-util-to-hast/lib"; 5 | 6 | import {addExternalScripts, addScript, addStyleSheets, addStyleTag} from "./utils/mdast"; 7 | import {createTangleSetUp, TANGLE_SCRIPTS, TANGLE_STYLESHEETS, TANGLE_STYLING} from "./utils/tangle"; 8 | import {getGuid} from "./utils/misc"; 9 | import {TanglePluginOptions, FieldAttributes, TangleField, TangleFieldShorthand} from "./index.d" 10 | import math from "mathjs"; 11 | 12 | const defaultTanglePluginOptions: TanglePluginOptions = { 13 | start: "t", 14 | allowLinkNotation: true 15 | } 16 | 17 | /** 18 | * Determines whether a Link element `[...](...)` is a link or tangle-field 19 | * 20 | * Returns `true` if `node.url` is of the kind: 21 | * - `variable_name` 22 | * - `variable_name=[configuration]` 23 | * - `variable_name=[configuration]&style_attr=some_value]` 24 | * - etc. 25 | * 26 | * Returns `false` if `node.url` is of the kind: 27 | * - `/relative-path` 28 | * - `https://domain.ext/etc` 29 | * - `#title-2` 30 | * 31 | */ 32 | const isField = (node: Link): boolean => 33 | !["/", "#"].includes(node.url?.[0]) && node.url?.slice(0, Math.min(4, node.url.length)) !== "http"; 34 | 35 | 36 | /** 37 | * 38 | * Convert a tangle field from a shorthand of link type to remark-directive type. 39 | * (`[display](x=1&y=2)` -> `:t[display]{x=1 y=2}`) 40 | * 41 | * Mutates a node from: 42 | * 43 | * ``` js 44 | * { 45 | * type: "link", 46 | * url: "variable_name=[configuration]&optional-style=some-value&more-optional-styles=some-other-value", 47 | * ... 48 | * } 49 | * ``` 50 | * 51 | * to 52 | * 53 | * ``` js 54 | * { 55 | * type: "textDirective", 56 | * attributes: { 57 | * variable_name: "[configuration]", 58 | * "optional-style": "some_value", 59 | * "more-optional-styles": "some_other_value" 60 | * }, 61 | * ... 62 | * } 63 | * ``` 64 | * 65 | * TODO: do not change this in-place. 66 | * 67 | */ 68 | const processFieldShorthand = (node: TangleFieldShorthand, options: Partial): TangleField => { 69 | node.type = "textDirective"; 70 | node.name = options.start; 71 | node.attributes = {}; 72 | 73 | // Convert url ("x=1&y=2") -> attributes ({x: "1", y: "2"}) 74 | const attributeTuples = node.url.split("&").map(pair => pair.split("=")); 75 | attributeTuples.forEach(([key, value]) => { 76 | // @ts-ignore 77 | node.attributes[key] = value; 78 | }); 79 | 80 | delete node.title; 81 | delete node.url; 82 | 83 | return node; 84 | } 85 | 86 | 87 | /** 88 | * Read a default value and display template from a display string. 89 | * 90 | * For example: 91 | * - `"3 cookies"` -> `[3, "%d cookies"]` 92 | * - `"150. calories"` -> `[150, "%.0f cookies"]` 93 | * 94 | * TODO: These next two still have to be successfully implemented 95 | * - `"7.5%"` -> `[7.5, "%.1f%"]` 96 | * - `"chocolate_chip"` (with `{ choices: ["chocolate_chip", "oatmeal", ...] }`) -> `["chocolate_chip", "%s"]` 97 | * 98 | * @param displayString from a tangle directive of the kind `:t[displayString]{...}` 99 | * @param attributes required to process select-type inputs. 100 | */ 101 | function parseDisplayString( 102 | displayString: string, 103 | attributes: FieldAttributes 104 | ):[(null | string | number), string] { 105 | if (!displayString) return [null, "%s"]; 106 | else if ( 107 | !!["percent", "p2", "p3", "e6", "abs_e6", "freq", "dollars", "free"].find(str => str === displayString) 108 | || displayString.match(/(%('?)(.\d+)?[sdf])/)?.[0] 109 | ) return [null, displayString]; 110 | // If the display format includes an explicit fstring (e.g., `%.0f`, `%'d`, `%s`), return as is 111 | 112 | // See if the display format includes a number (e.g., `100`, `1000.0`, `1,000.0`) 113 | const defaultValueString = displayString 114 | .match(/(((\d)*(,\d\d\d)*(\.)(\d+))|((\d)+(,\d\d\d)*(\.?)(\d*)))/)?.[0] 115 | .replace("-", ""); 116 | // TODO: Add support for non-American numbers (1.000,00) 117 | 118 | if (defaultValueString !== undefined) { 119 | const defaultValue = parseInt(defaultValueString.replace(",", "")); 120 | 121 | const includeCommas = defaultValueString.includes(","); 122 | const precisionString = defaultValueString.match(/\.(\d*)/)?.[0]; 123 | const precision = precisionString && precisionString.length-1 124 | 125 | // TODO: Add an option for `%'d` and `$'f` (to display `1000` as `1,000`) 126 | const fstring = `%${includeCommas ? "" : ""}${precision !== undefined ? `.${precision}f` : "d" }` 127 | 128 | const includesPercent = displayString.includes("%"); 129 | 130 | return [defaultValue * (includesPercent ? 0.01 : 1), !includesPercent ? displayString.replace(defaultValueString, fstring) : "percent" ]; 131 | } else { 132 | // TODO: Add support for string-valued variables 133 | // NOTE: use TKSwitch 134 | const choices = attributes?.["data-choices"] ?? [] 135 | 136 | const defaultValue = choices?.findIndex(choice => displayString.includes(choice)); 137 | return [ 138 | defaultValue, 139 | displayString.replace(choices[defaultValue], "%s"), 140 | ]; 141 | } 142 | 143 | return [null, displayString]; 144 | } 145 | 146 | 147 | /** 148 | * 149 | * Add an appropriate `hName` and `hProperties` to subsequently convert remark-tangle fields to Tangle-proper fields. 150 | * 151 | * @param node 152 | */ 153 | const processFieldDirective = (node: TangleField) => { 154 | // BASIC FIELD PROPERTIES 155 | 156 | const [name, ...styleKeys] = Object.keys(node.attributes); 157 | 158 | let formula = node.attributes[name] ?? ""; 159 | const fieldType = (formula === "") ? "reference" : "definition"; 160 | 161 | const attributes: FieldAttributes = { 162 | "data-var": name, 163 | "data-type": fieldType, 164 | }; 165 | 166 | // A special kind of reference has a display string of the kind "`variable_name`". 167 | // This will render as an interactive label (hovering will highlight all instances & dependencies) 168 | // This is automatically read as a reference even if it includes a definition. 169 | if (node.children?.[0]?.type === "inlineCode" && node.children?.[0]?.value === name) { 170 | 171 | // TODO: Dry this up a bit 172 | attributes.class = "TKLabel"; 173 | attributes["data-format"] = ""; 174 | attributes["data-type"] = "reference"; 175 | const hast = h("span", attributes) 176 | 177 | node.data = node.data || {} 178 | node.data.hName = hast.tagName 179 | node.data.hProperties = hast.properties 180 | 181 | return node 182 | } 183 | 184 | if (styleKeys.length > 0) { 185 | attributes.style = {} 186 | styleKeys.forEach((key: string) => { 187 | // @ts-ignore 188 | attributes.style[key] = node.attributes[key] 189 | }) 190 | } 191 | 192 | // VARIABLE DEFINITIONS 193 | 194 | if (fieldType === "definition") { 195 | 196 | // Extract any parameters specific to this type of field. 197 | const rangeMatch = formula.match(/\[((?:\w?\d?)*)\.{2,3}((?:\w?\d?)*);?((?:\w?\.?\d?)*)\]/) 198 | const selectMatch = formula.match(/\[((?:(\d?\w?)+,?\s*)+)\]/); 199 | 200 | if (rangeMatch !== null) { // ----------------- RANGE INPUT FIELD 201 | const [_, min, max, step] = rangeMatch 202 | attributes["data-min"] = math.evaluate(min); 203 | attributes["data-max"] = math.evaluate(max); 204 | attributes["data-step"] = math.evaluate(step); 205 | attributes["class"] = "TKAdjustableNumber"; 206 | 207 | 208 | } else if (selectMatch !== null) { // -------- SELECT INPUT FIELD 209 | const choices = selectMatch?.[1].split(","); 210 | attributes["data-choices"] = choices; 211 | attributes["class"] = "TKSwitch"; 212 | 213 | } else { // ---------------------------------------- OUTPUT FIELD 214 | attributes["data-formula"] = decodeURI(formula); 215 | attributes["class"] = "TKOutput"; // NOTE: This is not included in TangleKit; it is my own extension. 216 | 217 | const id = getGuid() 218 | attributes.id = id; 219 | } 220 | } 221 | 222 | // DEFAULT VALUE & DISPLAY TEMPLATE 223 | 224 | const displayString = node.children?.[0]?.value ?? ""; // TODO: Get this working with emphasis children 225 | const [defaultValue, displayTemplate] = parseDisplayString(displayString, attributes); 226 | 227 | attributes["data-default"] = defaultValue; 228 | attributes["data-format"] = displayTemplate; 229 | 230 | // UPDATE THE SYNTAX TREE 231 | const hast = h("span", attributes) 232 | 233 | node.data = node.data || {} 234 | node.data.hName = hast.tagName 235 | node.data.hProperties = hast.properties 236 | 237 | node.children = []; // TODO: Add children from TKSwitch 238 | 239 | return node; 240 | } 241 | 242 | /** 243 | * TODO: Sync hover/active between definitions & references. 244 | * TODO: Fix spacing bug (workaround with `margin-right: 1ch`) 245 | * @param options 246 | */ 247 | export default function tanglePlugin(this: any, options: Partial = defaultTanglePluginOptions) { 248 | 249 | // Globally track all tangle fields so we can correctly link reference fields. 250 | 251 | const names = new Set([]); // Keep track of the names so we can correctly initialize Tangle 252 | const defaultValues: Record = {}; // "" 253 | const outputFormulas: Record = {}; // Keep track of formulas to correctly update Tangle. 254 | const outputIds: Record = {}; // Keep track of ids to link output references to their definitions. 255 | const variableClasses: Record = {}; // Keep track of classes for TangleKit. 256 | 257 | 258 | return (tree: Root) => { 259 | // MDX compatibility 260 | const isMdx = !!tree.children.find(child => ["jsx", "import", "export"].includes(child.type)); 261 | 262 | visit(tree, (node: MdastNode) => { 263 | 264 | // (OPTIONAL) Process link notation `[...](...)` -> `:t[...]{...}` 265 | if (node.type === "link" && isField(node) && options.allowLinkNotation) { 266 | processFieldShorthand(node, options); 267 | }; 268 | 269 | // FIRST PASS 270 | // Process textDirective (if the directive starts with the correct `options.start` name, by default `"t"`) 271 | if (node.type === "textDirective" && node?.name === options.start) { 272 | processFieldDirective(node); 273 | 274 | const properties = node.data.hProperties; 275 | 276 | // Add to global tracking so that we can initialize reference fields correctly on the second pass 277 | if (properties.dataType === "definition") { 278 | 279 | const name = properties?.dataVar; 280 | names.add(name); 281 | 282 | if (properties?.className) 283 | variableClasses[name] = properties.className; 284 | 285 | if (properties?.dataFormula) 286 | outputFormulas[name] = properties.dataFormula; 287 | 288 | if (properties?.id) 289 | outputIds[name] = properties.id; 290 | 291 | if (!defaultValues?.[name]) { 292 | defaultValues[name] = properties.dataDefault; 293 | } 294 | 295 | } 296 | } 297 | }); 298 | 299 | // SECOND PASS 300 | // After having visited all the nodes, visit them again to add the appropriate classes & links 301 | visit(tree, (node: MdastNode) => { 302 | if ( node.type === 'textDirective' && node?.name === options.start ) { 303 | 304 | if (!node.data.hProperties.className?.includes("TKLabel")) { 305 | node.data.hProperties.className = variableClasses[node.data.hProperties.dataVar]; 306 | 307 | // Add a link from output references to the original definitions 308 | if (node.data.hProperties.className?.includes("TKOutput") && !node.data.hProperties.id) { 309 | node.data.hProperties.href = `#${outputIds[node.data.hProperties.dataVar]}` 310 | node.data.hName = "a" 311 | } 312 | } 313 | } 314 | }) 315 | 316 | const _names = Array.from(names); 317 | console.log("Found variables: ", _names); 318 | console.log("Default values:", defaultValues); 319 | console.log("Output formulas:", outputFormulas); 320 | 321 | if (_names.length > 0) { 322 | // ADD TANGLE SET-UP & DEPENDENCIES 323 | 324 | // TODO: Get rid of all of these external dependencies. 325 | addExternalScripts(tree, TANGLE_SCRIPTS); 326 | addStyleSheets(tree, TANGLE_STYLESHEETS); 327 | addStyleTag(tree, TANGLE_STYLING); 328 | // NOTE: This opens you up to XSS attacks. Tread carefully! 329 | 330 | const tangleSetUp = createTangleSetUp(_names, defaultValues, outputFormulas); 331 | console.log("Tangle Start up script:", tangleSetUp) 332 | addScript(tree, {value: tangleSetUp}) 333 | 334 | console.log("Tree:", tree) 335 | 336 | } 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /demo/example.md: -------------------------------------------------------------------------------- 1 | ## Interactive Writing: A plea for better communication 2 | 3 | > Note: You can find the interactive version [here](https://jessehoogland.com/articles/post-rhetoric). 4 | 5 | A few years ago, I first read the excellent essay by Bret Victor, "[What can a technologist do about climate change?](http://worrydream.com/ClimateChange/)." For its treatment of climate change alone, I can't recommend the essay enough—there's food for thought to keep you satiated for a few months. But, then, near the end, Victor sneaks in a little section titled ["Model-driven debate"](http://worrydream.com/ClimateChange/#media-debate) that has has kept me thinking *for years*. 6 | 7 | 8 | He begins with the example of Alan Blinder's "Cash for Clunkers" proposal. The federal government would offer car owners a rebate to exchange old, inefficient vehicles for newer ones. Proponents claimed it would cause massive emissions reductions. Meanwhile, critics claimed there were more cost-effective ways to reduce emissions. Who's right? 9 | 10 | Of course, it's both and neither—the answer depends entirely on the parameters of the program. As Victor writes: 11 | 12 | > "Many claims made during the debate offered no numbers to back them up. Claims with numbers rarely provided context to interpret those numbers. And _never_ were readers shown the _calculations_ behind any numbers. Readers had to make up their minds on the basis of hand-waving, rhetoric, bombast." 13 | 14 | Victor asks us to imagine a better world: what if the author had proposed a *model* rather than mere words? Then, we, the readers, could make up our own minds. Instead of bombast, we get an informed debate about the underlying assumptions and resulting tradeoffs. 15 | 16 | Let's look at an example (a slight modification of Victor's [original example](http://worrydream.com/ClimateChange/#media-debate)[^1]): 17 | 18 | [^1]: The difference is that I haven't yet added the possibility of inputting a distribution. So the calculations for average MPG of old versus new cars is less precise than in Victor's case. (On the flip side, this coarser model is easier to modify for today's transportation fleet.) 19 | 20 | > Say we allocate [$3.0 billion](budget=[0..10;0.1]&margin-right=1ch) for the following program: Car-owners who trade in an old car that gets less than [17 MPG](old_MPG_limit=[5..30]), and purchase a new car that gets better than [24 MPG](new_MPG_limit=[5..50]), will receive a [$3,500](rebate=[0..20000;100]&margin-right=1ch) rebate. 21 | > 22 | > We estimate that this will get [828,571 old cars](cars_traded&margin-right=0.5ch) off the road. It will save [1,068 million gallons](gallons_saved&margin-right=0.5ch) of gas (or [68 hours](hours_of_gas&margin-right=1ch&margin-right=0.5ch) worth of U.S. gas consumption.) It will avoid [9.97 million tons](tons_CO2_saved&margin-right=1ch) CO2e, or [0.14](percent_annual_emissions)% of annual U.S. greenhouse gas emissions. 23 | > 24 | > The abatement cost is [$301](dollars_per_ton_CO2e&margin-right=0.5ch) per ton CO2e of federal spending, although it’s [-\$20](dollars_per_ton_CO2e_on_balance&margin-right=0.5ch) per ton CO2e on balance if you account for the money saved by consumers buying less gas. 25 | 26 | Try sliding clicking and dragging the items in green to update their values. You'll see the items in blue change as a result. To see how these outputs are computed, click on one of the blue items, and you'll see the calculation in the appendix to this article. 27 | 28 | When I first saw this example, I had the kind of feeling that I imagine people in the '80s must have had when [they first saw wheels on a suitcase](https://betafactory.com/what-came-first-wheeled-luggage-or-a-man-on-the-moon-20f8b22529a3), that of dockworkers when [they first encountered shipping containers in the 60s](https://www.freightos.com/the-history-of-the-shipping-container/), or of late 15th century Europeans when they first read the results of movable type. A combination of "oh that's 29 | so obvious!" with the shame of your civilization not having come up with the idea earlier and something akin to disgust at how we used to do things (or are still doing them). 30 | 31 | Victor's vision is what journalism and argumentative writing should look like. Next to this better system, hand-waving opinion pieces border on offensive. 32 | 33 | Unfortunately, his vision has gotten almost no attention since its conception. Victor provided a small library, [Tangle](http://worrydream.com/Tangle/guide.html), to implement models like these, but not much has happened with it in the last half decade. That's understandable—the library requires prior experience with web development, which makes it unapproachable for most people, but it also offers no direct integration with any major JavaScript (JS) framework, which does not encourage actual web 34 | developers to use it. 35 | 36 | In its place, we've seen success with somewhat similar projects like [Observable](https://observablehq.com/?utm_source=talk). Observable helps you write JS notebooks that are highly interactive and relatively easy to embed in other websites. But the experience is not seamless: you still need familiarity with JS. Of course, we've had Jupyter notebooks and R Markdown for a while. Unfortunately all of these notebook-based models remain somewhat clunky and cumbersome. None of them 37 | offer a really fluent and easy inline input option like Tangle. 38 | 39 | In this post, I'd like to look at a middleground—a (almost) no-code way to create interactive documents, which offers a much easier writing experience at the cost of sacrificing some of the customizability of Tangle or Observable/Python/R notebooks. Let's call it interactive Markdown. 40 | 41 | Now, I'm not the first. Shortly after Victor published Tangle, there was an explosion in Markdown related integrations: [dynamic Markdown](https://tal-baum.github.io/dynamic-Markdown/index.html), [active Markdown](https://web.archive.org/web/20150219200704/http://activeMarkdown.org/) [fangle](https://jotux.github.io/fangle/), and [TangleDown](https://bollwyvl.github.io/TangleDown/) are what I could find. I'm sure there are yet more. 42 | 43 | Still, I think there's a good reason to reinvent this wheel. For one, I'm not happy about the syntax of any of these options (though least unhappy with that of active/dynamic Markdown). The problem is that none of them are backwards compatible with existing Markdown interpreters. I'm of the strong opinion that since there are so many Markdown extensions already, if you come up with a new, it had better be backwards compatible. 44 | 45 | Second, all but fangle miss the ability to do inline calculations. Third, none is actively maintained. Fourth, all of them work by compiling `.md` to `.html`; I'd like an option to compile to [`.jsx` from `.mdx`](https://mdxjs.com/), which I think would generally make this much easier to adopt for other people. Five, none offer an elegant way to display supplementary calculations the way Victor's example did. 46 | 47 | There's also a good "cultural" reason to reinvent this wheel. Thanks to note-taking tools like Notion, Roam, and Obsidian, Markdown is having a moment. More people are playing around with Markdown than ever before, so if ever there were a time to build on Markdown, it's now. 48 | 49 | Without further do, let me present interactive Markdown. 50 | 51 | --- 52 | 53 | ### An example 54 | 55 | Let's take a look at a very simple example (again [from Victor](http://worrydream.com/Tangle/guide.html)): 56 | 57 | > When you eat [3 cookies](cookies=[0..100]), you consume **[150 calories](calories=50*cookies)**. That's [7.5%](daily_percent&margin-right=1ch) of your recommended daily calories. 58 | 59 | Under the hood, this looks as follows: 60 | 61 | ``` 62 | When you eat [3 cookies](cookies=[0..100]), 63 | you consume **[150 calories](calories=50*cookies)**. 64 | That's [7.5%](daily_percent) of your recommended daily calories. 65 | ``` 66 | 67 | Interactive Markdown is built around "**fields**". There are three in this example: `[3 cookies](cookies=[0..100])`, `[150 calories](calories=50*cookies)`, and `[7.5%](daily_percent)`. 68 | 69 | If you're familiar with Markdown, then you'll recognize a field as a link. Like a link, every field is made up of two parts (`[text representation](variable configuration)`): a text representation of the element between square brackets `[]`(the link text or alt text for a media element) and the variable configuration between round brackets `()`(the link `href` or image `src`). 70 | 71 | The reason for using the same syntax as a link is backwards compatibility. If there is no interactive Markdown interpreter, you only lose interactivity, not the reading experience. 72 | 73 | There are three kinds of fields: **input**, **output**, and **reference** fields. 74 | 75 | #### Input fields 76 | 77 | `[3 cookies](cookies=[0..100])` is an **input field**. In the variable configuration, `(cookies=[0..100])`, we *define* a variable, `cookies`, that takes its value from a range of `0` to `100`. In the text representation, `[3 cookies]`, we give the default value, `3`. The surrounding text is used as a template (for example, to specify units).[^2] 78 | 79 | [^2]: It's a little confusing that `cookies` shows up on both the left- and right-hand sides. On the right-hand side, it has a semantic purpose: defining the variable `cookies`. On the left-hand side it has a purely stylistic purpose (to inform the reader what units we're using). 80 | 81 | There are two kinds of input field, **range** and **select**: 82 | 83 | - **Range Input** (`my_var=[min..max;step]`): By clicking on the range input and dragging left or right, the user can adjust its value between `min` and `max` in intervals of size `step`. 84 | - **Select Input** (`my_var=[a,b,c]`): By clicking on the select input, the user cycles through the options `a`, `b`, `c`... 85 | 86 | #### Output fields 87 | 88 | `[150 calories](calories=50*cookies)` is an **output field**. On the right, we define the variable `calories` as the product of `50` and our previously defined variable `cookies`. 89 | 90 | Since the definition contains neither a range `[min..max;step]` nor select `[a,b,c]` input, an output field is not directly adjustable via user input. It is dynamically computed from the other variables in a document's scope. 91 | 92 | Because of this, the value of `150` is really more like a fallback than a default. An interactive Markdown interpreter won't ever user this value. A standard Markdown interpreter will render it as [150 calories](#) for the same experience just without the interactive part. 93 | 94 | #### Reference fields 95 | 96 | Lastly, `[7.5%](daily_percent)` is a **reference field**. Unlike definition fields (i.e., **input** and **output** fields) references do not contain an equal sign `=` in their variable configuration. They display a variable that has already (or will be) defined elsewhere in the page. 97 | 98 | For example, we might put the calculation for `daily_percent` in the appendix to avoid cluttering the body text for your reader: 99 | 100 | > ### Calculation for `daily_percent` 101 | > - Daily recommended calories limit = [2,000 calories](daily_calories=[0..5000;50]&margin-left=1ch) 102 | > - Percent cookie calories per day = [7.5%](daily_percent=calories/daily_calories&margin-left=1ch) 103 | 104 | Behind the scenes, this is: 105 | 106 | ``` 107 | ### Calculation for `daily_percent` 108 | - Daily recommended calories limit = [2,000 calories](daily_calories=[0..5000;50]) 109 | - Percent cookie calories per day = [7.5%](daily_percent=calories/daily_calories) 110 | 111 | ``` 112 | 113 | References are useful for separating long calculations from your story line. It also helps to remind readers what variable values are, so they don't have to scroll back and forth a hundred times. 114 | 115 | Each variable should only have one definition field but can have arbitrarily many reference fields. 116 | 117 | Note that reference fields act differently depending on whether they reference an input or output variable: 118 | 119 | - **Input references** let you update the original variable. To the reader, input references are indistinguishable from input definitions. 120 | - **Output references** link to the original output definition. So I recommend you define an output variable in the same place that you describe its calculation to readers. 121 | 122 | --- 123 | 124 | ## Conclusion 125 | 126 | For the time being, it will take some technical know-how to get interactive Markdown up and running for yourself. If you're interested, I've written a [remark plugin](https://github.com/jqhoogland/remark-tangle) that you can drop into an existing remark/rehype pipeline. 127 | 128 | That's because interactive Markdown is still in its infancy. There are many features I'd like to get to that I haven't had the time for yet (e.g., automatic dimensions checking to make sure your calculations make sense, popover links to calculations, more math functions, support for distributions and other data types), not to mention tools to make working with interactive Markdown easier: an in-browser editor, a plugin for Obsidian support, etc. 129 | 130 | If you're interested in all of this, make sure to subscribe to my newsletter to stay updated. And if you have any ideas, I'd love to hear from you. Check out the repository and raise an issue (or, even better, send a pull request). 131 | 132 | --- 133 | 134 | ## Appendix 135 | 136 | ### A more complicated example 137 | 138 | Let's look at the more complicated example from the beginning. 139 | 140 | Here is the example again (thanks to reference fields, it's perfectly in sync with the first instance): 141 | 142 | 143 | > Say we allocate [$3.0 billion](budget=[0..10;0.1]&margin-right=1ch) for the following program: Car-owners who trade in an old car that gets less than [17 MPG](old_MPG_limit=[5..30]), and purchase a new car that gets better than [24 MPG](new_MPG_limit=[5..50]), will receive a [$3,500](rebate=[0..20000;100]&margin-right=1ch) rebate. 144 | > 145 | > We estimate that this will get [828,571 old cars](cars_traded&margin-right=1ch) off the road. It will save [1,068 million gallons](gallons_saved&margin-right=1ch) of gas (or [68 hours](hours_of_gas&margin-right=1ch&margin-right=1ch) worth of U.S. gas consumption.) It will avoid [9.97 million tons](tons_CO2_saved&margin-right=1ch) CO2e, or [0.14](_percent_annual_emissions)% of annual U.S. greenhouse gas emissions. 146 | > 147 | > The abatement cost is [$301](dollars_per_ton_CO2e&margin-right=1ch) per ton CO2e of federal spending, although it’s [-$20](dollars_per_ton_CO2e_on_balance&margin-right=1ch) per ton CO2e on balance if you account for the money saved by consumers buying less gas. 148 | 149 | 150 | 151 | And here's what it actually looks like (the first example): 152 | 153 | ``` 154 | Say we allocate [$3.0 billion](budget=[0..10;0.1]&margin-right=1ch) for the following program: 155 | Car-owners who trade in an old car that gets less than [17 MPG](old_MPG_limit=[5..30]), 156 | and purchase a new car that gets better than [24 MPG](new_MPG_limit=[5..50]), 157 | will receive a [$3,500](rebate=[0..20000;100]&margin-right=1ch) rebate. 158 | 159 | We estimate that this will get [828,571 old cars](cars_traded&margin-right=1ch) off the road. 160 | It will save [1,068 million gallons](gallons_saved&margin-right=1ch) of gas 161 | (or [68 hours](hours_of_gas&margin-right=1ch&margin-right=1ch) worth of U.S. gas consumption). 162 | It will avoid [9.97 million tons](tons_CO2_saved&margin-right=1ch) CO2e, 163 | or [0.14](_percent_annual_emissions)% of annual U.S. greenhouse gas emissions. 164 | 165 | The abatement cost is [$301](dollars_per_ton_CO2e&margin-right=1ch) per ton CO2e of federal spending, 166 | although it’s [-\$20](dollars_per_ton_CO2e_on_balance&margin-right=1ch) per ton CO2e on balance 167 | if you account for the money saved by consumers buying less gas. 168 | 169 | ``` 170 | 171 | A few points to note: 172 | 173 | - The number in the text representation determines display precision. If you're familiar with [format strings](https://www.cprogramming.com/tutorial/printf-format-strings.html), `3.0` is converted to `%.1f`, `17` to `%d`, `3,500` to `%'d`[^3], etc.. 174 | - You can also use format strings directly in the text representation, e.g., `[%'d old cars](cars traded)`, but I don't recommend this because it won't be compatible with standard Markdown. 175 | - `[0..10;0.1]` specifies a range with a step-size equal to `0.1`. By default, the step size is `1`. 176 | - I haven't figured out spacing yet (hence `&margin-right=1ch`) 177 | 178 | [^3]: Note: `%'d` is actually nonstandard. It puts commas (or periods) in the thousands places (depending on your locale). Another useful nonstandard addition is `+` or `-` for optionally separating the amount and magnitude as in `-$20`. 179 | 180 | #### Cars Traded 181 | 182 | - [`budget`](budget) = [$3.0 billion](budget=[0..10;0.1]&margin-left=1ch) 183 | - [`overhead`](overhead) = [$100 million](overhead=[0..1000;10]&margin-left=1ch) 184 | - [`rebate`](rebate) = [$3500](rebate=[0..20000;100]&margin-left=1ch) 185 | - [`cars_traded`](cars_traded) = ([`budget`](budget) - [`overhead`](overhead)) / [`rebate`](rebate) = [828571](cars_traded=(budget-overhead/1000)*1000000000/rebate&margin-left=1ch) 186 | 187 | Here you see one more trick in interactive Markdown: A link containing an inline code element of the kind ``[`variable_name`](variable_name)`` is a reference *label*. It gets a `TKLabel` class for easier formatting, and, eventually, will synchronously darken whenever you highlight any references to or dependencies of its variable. 188 | 189 | --- 190 | 191 | #### Gallons Saved 192 | 193 | This is where my example diverges from [Victor's example](http://worrydream.com/ClimateChange/#media-debate). His calculation uses the distribution of mileage over current cars and cars being sold. I haven't yet added distributions to the interactive Markdown spec (though I plan to), so you'll have to accept a less precise version. Note that the comments come from Victor's original work. 194 | 195 | ##### Average mileage of old vehicles 196 | 197 | > Assume that traded-in cars are chosen with equal probability from the pool of eligible cars. We use the harmonic average because we'll be calculating gallons consumed for constant miles, so we really want to be averaging gallons-per-mile. 198 | 199 | - [`old_MPG_limit`](old_MPG_limit) = [17 MPG](old_MPG_limit=[5..50]&margin-left=1ch) 200 | - [`average_current_MPG`](average_current_MPG) = [21 MPG](average_current_MPG=[5..50]&margin-left=1ch) 201 | - [`var_current_MPG`](var_current_MPG) = [25 MPG](var_current_MPG=[0..200]&margin-left=1ch) 202 | - [`average_old_MPG`](average_old_MPG) = ∫ x N(x; [`average_current_MPG`](average_current_MPG), [`var_current_MPG`](var_current_MPG)) from `-Infinity` to [`old_MPG_limit`](old_MPG_limit) = [14 MPG](average_old_MPG=old_MPG_limit*(average_current_MPG/20)&margin-left=1ch) 203 | 204 | Alright so I haven't even actually added support for more complicated formulas like this. But it is coming. 205 | 206 | ##### Average mileage for vehicles currently being sold 207 | 208 | > Assume that new cars are purchased with equal probability from the pool of eligible cars. The distribution really should be sales-weighted. I'm sure the data is available, but I couldn't find it. 209 | 210 | - [`new_MPG_limit`](new_MPG_limit) = [15 MPG](new_MPG_limit=[5..50]&margin-left=1ch) 211 | - [`average_new_MPG`](average_new_MPG) = [24 MPG](average_new_MPG=[5..50]&margin-left=1ch) 212 | - [`var_new_MPG`](var_new_MPG) = [5 MPG](var_new_MPG=[0..20]&margin-left=1ch) 213 | - [`average_new_MPG`](average_new_MPG) = ∫ x N(x; [`average_new_MPG`](average_new_MPG), [`var_new_MPG`](var_new_MPG)) from [`new_MPG_limit`](new_MPG_limit) to `Infinity` = [30 MPG](average_new_MPG=new_MPG_limit*(1+average_current_MPG/30)&margin-left=1ch) 214 | 215 | #### Average gallons saved per car replaced 216 | 217 | > Assume that everyone who is buying a new car now would have eventually bought a similar car when their current car got too old. So the fuel savings from the program should be calculated over the remaining lifetime of the old car. Ideally we'd like the joint distribution of MPGs and age of the current fleet, but I can't find that data. So we'll just use averages. 218 | 219 | - [`car_lifetime_miles`](car_lifetime_miles) = [150,000 miles](car_lifetime_miles=[0..1000000;10000]&margin-left=1ch) 220 | - [`average_miles_left`](average_miles_left) = [25%](average_percent_miles_left=[0..1;0.01]) \* [`car_lifetime_miles`](car_lifetime_miles) =[37.5 miles](average_miles_left=average_percent_miles_left*car_lifetime_miles&margin-left=1ch) 221 | - [`gallons_used_by_old_car`](gallons_used_by_old_car) = [`average_miles_left`](average_miles_left) / [`average_old_MPG`](average_old_MPG) = [2,662 gallons](gallons_used_by_old_car=average_miles_left/average_old_MPG&margin-left=1ch) 222 | - [`gallons_used_by_new_car`](gallons_used_by_new_car) = [`average_miles_left`](average_miles_left) / [`average_new_MPG`](average_new_MPG) = [1,373 gallons](gallons_used_by_new_car=average_miles_left/average_new_MPG&margin-left=1ch) 223 | - [`gallons_saved_per_car`](gallons_saved_per_car) = [`gallons_used_by_old_car`](gallons_used_by_old_car) - [`gallons_used_by_new_car`](gallons_used_by_new_car) = [1,289 gallons](gallons_saved_per_car=gallons_used_by_old_car-gallons_used_by_new_car&margin-left=1ch) 224 | 225 | ### Total gallons saved 226 | 227 | - [`cars_traded`](cars_traded) = [828,571 cars](cars_traded&margin-left=1ch) 228 | - [`gallons_saved`](gallons_saved) = [`gallons_saved_per_car`](gallons_saved_per_car) \* [`cars_traded`](cars_traded) = [1,068 million gallons](gallons_saved=gallons_saved_per_car*cars_traded/1000000&margin-left=1ch) 229 | 230 | > The importance of models may need to be underscored in this age of “big data” and “data mining”. Data, no matter how big, can only tell you what happened in the _past_. Unless you’re a historian, you actually care about the _future_ — what _will_ happen, what _could_ happen, what _would_ happen if you did this or that. Exploring these questions will always require models. Let’s get over “big data” — it’s time for “big modeling”. 231 | 232 | --- 233 | 234 | #### Hours of Gas Saved 235 | 236 | - [`gallons_saved`](gallons_saved) = [1,068 million gallons](gallons_saved&margin-left=1ch) 237 | - [`gallons_consumed_per_day`](gallons_consumed_per_day) = [378 million gallons](gallons_consumed_per_day=[0..1000;1]) 238 | - [`gallons_consumed_per_hour`](gallons_consumed_per_hour) = [`gallons_consumed_per_day`](gallons_consumed_per_day) / 24 =[16 million gallons](gallons_consumed_per_hour=gallons_consumed_per_day/24&margin-left=1ch) 239 | - [`hours_of_gas`](hours_of_gas) = [`gallons_saved`](gallons_saved) / [`gallons_consumed_per_hour`](gallons_consumed_per_hour) = [68 hours](hours_of_gas=gallons_saved/gallons_consumed_per_hour&margin-left=1ch) 240 | 241 | --- 242 | 243 | #### Tons of CO2 Saved 244 | 245 | - [`gallons_saved`](gallons_saved) = [1,068 million gallons](gallons_saved&margin-left=1ch) 246 | - [`kg_CO2_per_gallon_gas`](kg_CO2_per_gallon_gas) = [8.87 kg/gallon](kg_CO2_per_gallon_gas=[0..50;0.01]&margin-left=1ch) 247 | - [`tons_CO2_saved`](tons_CO2_saved) = [`gallons_saved`](gallons_saved) \* [`kg_CO2_per_gallon_gas`](kg_CO2_per_gallon_gas) / 1000 = [9.47 million tons](tons_CO2_saved=gallons_saved*kg_CO2_per_gallon_gas/1000&margin-left=1ch) 248 | 249 | > CO2 comprises 95% of a car's greenhouse gas [effective]() emissions. The other 5% include methane, nitrous oxide, and hydroflourocarbons. To account for these other gases, we divide the amount of CO2 by 0.95 to get CO2e (“carbon dioxide equivalent”).[^1] 250 | 251 | - [`CO2_per_CO2e`](CO2_per_CO2e) = [95%](CO2_per_CO2e=[0..1;0.01]&margin-left=1ch) 252 | - [`tons_CO2e_saved`](tons_CO2e_saved) = [`tons_CO2_saved`](tons_CO2_saved) / [`CO2_per_CO2e`](CO2_per_CO2e) = [9.7 million tons](tons_CO2e_saved=tons_CO2_saved/CO2_per_CO2e&margin-left=1ch) 253 | 254 | --- 255 | 256 | #### Percent Annual Emissions 257 | 258 | - [`tons_CO2e_saved`](tons_CO2e_saved) = [9.97 million tons](tons_CO2e_saved&margin-left=1ch) 259 | - [`tons_CO2e_emitted_yearly`](tons_CO2e_emitted_yearly) = [6,983 million tons](tons_CO2e_emitted_yearly=[0..100000;1000]&margin-left=1ch) 260 | - [`percent_annual_emissions`](percent_annual_emissions) = [`tons_CO2e_saved`](tons_CO2e_saved) / [`tons_CO2e_emitted_yearly`](tons_CO2e_emitted_yearly) \* 100 = [0.13](percent_annual_emissions=tons_CO2e_saved/tons_CO2e_emitted_yearly*100&margin-left=1ch)% 261 | 262 | That last one should read something like `0.14%` for default options but not all formatting options are available yet. 263 | 264 | --- 265 | 266 | #### Dollars per ton CO2e 267 | 268 | - [`budget`](budget) = [$3.0 billion](budget&margin-left=1ch) 269 | - [`tons_CO2e_saved`](tons_CO2e_saved) = [9.97 million tons](tons_CO2e_saved&margin-left=1ch) 270 | - [`dollars_per_ton_CO2e`](dollars_per_ton_CO2e) = [`budget`](budget) / [`tons_CO2e_saved`](tons_CO2e_saved) = [$301.](dollars_per_ton_CO2e=budget*1000/tons_CO2e_saved&margin-left=1ch) 271 | 272 | --- 273 | 274 | #### Dollars per ton CO2e on balance 275 | 276 | - [`gallons_saved`](gallons_saved) = [1,068 million gallons](gallons_saved&margin-left=1ch) 277 | - [`dollars_per_gallon`](dollars_per_gallon) = [$3.00](dollars_per_gallon=[0..10;0.01]&margin-left=1ch) 278 | - [`dollars_saved_buying_less_gas`](dollars_saved_buying_less_gas) = [`gallons_saved`](gallons_saved) \* [`dollars_per_gallon`](dollars_per_gallon) = [$3.2 billion](dollars_saved_buying_less_gas=gallons_saved*dollars_per_gallon/1000&margin-left=1ch) 279 | 280 | - [`budget`](budget) = [$3.0 billion](budget) 281 | - [`dollars_saved_on_balance`](dollars_saved_on_balance) = [`budget`](budget) - [`dollars_saved_buying_less_gas`](dollars_saved_buying_less_gas) = [$200 million](dollars_saved_on_balance=1000*dollars_saved_buying_less_gas-1000*budget&margin-left=1ch) 282 | - Note: Ok so something's not going right with this calculation here. It should be some `200 million` by default. 283 | 284 | - [`tons_CO2e_saved`](tons_CO2e_saved) = [9.97 million tons](tons_CO2e_saved) 285 | - [`dollars_per_ton_CO2e_on_balance`](dollars_per_ton_CO2e_on_balance) = [`dollars_saved_on_balance`](dollars_saved_on_balance) / [`tons_CO2e_saved`](tons_CO2e_saved) = [$20.](dollars_per_ton_CO2e_on_balance=dollars_saved_on_balance/tons_CO2e_saved&margin-left=1ch) 286 | -------------------------------------------------------------------------------- /demo/example.html: -------------------------------------------------------------------------------- 1 | 2 |

Interactive Writing: A plea for better communication

3 |

A few years ago, I first read the excellent essay by Bret Victor, "What can a technologist do about climate change?." For its treatment of climate change alone, I can't recommend the essay enough—there's food for thought to keep you satiated for a few months. But, then, near the end, Victor sneaks in a little section titled "Model-driven debate" that has has kept me thinking for years.

4 |

He begins with the example of Alan Blinder's "Cash for Clunkers" proposal. The federal government would offer car owners a rebate to exchange old, inefficient vehicles for newer ones. Proponents claimed it would cause massive emissions reductions. Meanwhile, critics claimed there were more cost-effective ways to reduce emissions. Who's right?

5 |

Of course, it's both and neither—the answer depends entirely on the parameters of the program. As Victor writes:

6 |
7 |

"Many claims made during the debate offered no numbers to back them up. Claims with numbers rarely provided context to interpret those numbers. And never were readers shown the calculations behind any numbers. Readers had to make up their minds on the basis of hand-waving, rhetoric, bombast."

8 |
9 |

Victor asks us to imagine a better world: what if the author had proposed a model rather than mere words? Then, we, the readers, could make up our own minds. Instead of bombast, we get an informed debate about the underlying assumptions and resulting tradeoffs.

10 |

Let's look at an example (a slight modification of Victor's original example1):

11 |
12 |

Say we allocate for the following program: Car-owners who trade in an old car that gets less than , and purchase a new car that gets better than , will receive a rebate.

13 |

We estimate that this will get off the road. It will save of gas (or worth of U.S. gas consumption.) It will avoid CO2e, or % of annual U.S. greenhouse gas emissions.

14 |

The abatement cost is per ton CO2e of federal spending, although it’s per ton CO2e on balance if you account for the money saved by consumers buying less gas.

15 |
16 |

Try sliding clicking and dragging the items in green to update their values. You'll see the items in blue change as a result. To see how these outputs are computed, click on one of the blue items, and you'll see the calculation in the appendix to this article.

17 |

18 | When I first saw this example, I had the kind of feeling that I imagine people in the '80s must have had when they first saw wheels on a suitcase, that of dockworkers when they first encountered shipping containers in the 60s, or of late 15th century Europeans when they first read the results of movable type. A combination of "oh that's 19 | so obvious!" with the shame of your civilization not having come up with the idea earlier and something akin to disgust at how we used to do things (or are still doing them). 20 |

21 |

Victor's vision is what journalism and argumentative writing should look like. Next to this better system, hand-waving opinion pieces border on offensive.

22 |

23 | Unfortunately, his vision has gotten almost no attention since its conception. Victor provided a small library, Tangle, to implement models like these, but not much has happened with it in the last half decade. That's understandable—the library requires prior experience with web development, which makes it unapproachable for most people, but it also offers no direct integration with any major JavaScript (JS) framework, which does not encourage actual web 24 | developers to use it. 25 |

26 |

27 | In its place, we've seen success with somewhat similar projects like Observable. Observable helps you write JS notebooks that are highly interactive and relatively easy to embed in other websites. But the experience is not seamless: you still need familiarity with JS. Of course, we've had Jupyter notebooks and R Markdown for a while. Unfortunately all of these notebook-based models remain somewhat clunky and cumbersome. None of them 28 | offer a really fluent and easy inline input option like Tangle. 29 |

30 |

In this post, I'd like to look at a middleground—a (almost) no-code way to create interactive documents, which offers a much easier writing experience at the cost of sacrificing some of the customizability of Tangle or Observable/Python/R notebooks. Let's call it interactive Markdown.

31 |

Now, I'm not the first. Shortly after Victor published Tangle, there was an explosion in Markdown related integrations: dynamic Markdown, active Markdown fangle, and TangleDown are what I could find. I'm sure there are yet more.

32 |

Still, I think there's a good reason to reinvent this wheel. For one, I'm not happy about the syntax of any of these options (though least unhappy with that of active/dynamic Markdown). The problem is that none of them are backwards compatible with existing Markdown interpreters. I'm of the strong opinion that since there are so many Markdown extensions already, if you come up with a new, it had better be backwards compatible.

33 |

Second, all but fangle miss the ability to do inline calculations. Third, none is actively maintained. Fourth, all of them work by compiling .md to .html; I'd like an option to compile to .jsx from .mdx, which I think would generally make this much easier to adopt for other people. Five, none offer an elegant way to display supplementary calculations the way Victor's example did.

34 |

There's also a good "cultural" reason to reinvent this wheel. Thanks to note-taking tools like Notion, Roam, and Obsidian, Markdown is having a moment. More people are playing around with Markdown than ever before, so if ever there were a time to build on Markdown, it's now.

35 |

Without further do, let me present interactive Markdown.

36 |
37 |

An example

38 |

Let's take a look at a very simple example (again from Victor):

39 |
40 |

When you eat , you consume . That's of your recommended daily calories.

41 |
42 |

Under the hood, this looks as follows:

43 |
When you eat [3 cookies](cookies=[0..100]), 
 44 | you consume **[150 calories](calories=50*cookies)**. 
 45 | That's [7.5%](daily_percent) of your recommended daily calories.
 46 | 
47 |

Interactive Markdown is built around "fields". There are three in this example: [3 cookies](cookies=[0..100]), [150 calories](calories=50*cookies), and [7.5%](daily_percent).

48 |

If you're familiar with Markdown, then you'll recognize a field as a link. Like a link, every field is made up of two parts ([text representation](variable configuration)): a text representation of the element between square brackets [](the link text or alt text for a media element) and the variable configuration between round brackets ()(the link href or image src).

49 |

The reason for using the same syntax as a link is backwards compatibility. If there is no interactive Markdown interpreter, you only lose interactivity, not the reading experience.

50 |

There are three kinds of fields: input, output, and reference fields.

51 |

Input fields

52 |

[3 cookies](cookies=[0..100]) is an input field. In the variable configuration, (cookies=[0..100]), we define a variable, cookies, that takes its value from a range of 0 to 100. In the text representation, [3 cookies], we give the default value, 3. The surrounding text is used as a template (for example, to specify units).2

53 |

There are two kinds of input field, range and select:

54 |
    55 |
  • Range Input (my_var=[min..max;step]): By clicking on the range input and dragging left or right, the user can adjust its value between min and max in intervals of size step.
  • 56 |
  • Select Input (my_var=[a,b,c]): By clicking on the select input, the user cycles through the options a, b, c...
  • 57 |
58 |

Output fields

59 |

[150 calories](calories=50*cookies) is an output field. On the right, we define the variable calories as the product of 50 and our previously defined variable cookies.

60 |

Since the definition contains neither a range [min..max;step] nor select [a,b,c] input, an output field is not directly adjustable via user input. It is dynamically computed from the other variables in a document's scope.

61 |

Because of this, the value of 150 is really more like a fallback than a default. An interactive Markdown interpreter won't ever user this value. A standard Markdown interpreter will render it as 150 calories for the same experience just without the interactive part.

62 |

Reference fields

63 |

Lastly, [7.5%](daily_percent) is a reference field. Unlike definition fields (i.e., input and output fields) references do not contain an equal sign = in their variable configuration. They display a variable that has already (or will be) defined elsewhere in the page.

64 |

For example, we might put the calculation for daily_percent in the appendix to avoid cluttering the body text for your reader:

65 |
66 |

Calculation for daily_percent

67 |
    68 |
  • Daily recommended calories limit =
  • 69 |
  • Percent cookie calories per day =
  • 70 |
71 |
72 |

Behind the scenes, this is:

73 |
### Calculation for `daily_percent`
 74 | - Daily recommended calories limit = [2,000 calories](daily_calories=[0..5000;50])
 75 | - Percent cookie calories per day = [7.5%](daily_percent=calories/daily_calories) 
 76 | 
 77 | 
78 |

References are useful for separating long calculations from your story line. It also helps to remind readers what variable values are, so they don't have to scroll back and forth a hundred times.

79 |

Each variable should only have one definition field but can have arbitrarily many reference fields.

80 |

Note that reference fields act differently depending on whether they reference an input or output variable:

81 |
    82 |
  • Input references let you update the original variable. To the reader, input references are indistinguishable from input definitions.
  • 83 |
  • Output references link to the original output definition. So I recommend you define an output variable in the same place that you describe its calculation to readers.
  • 84 |
85 |
86 |

Conclusion

87 |

For the time being, it will take some technical know-how to get interactive Markdown up and running for yourself. If you're interested, I've written a remark plugin that you can drop into an existing remark/rehype pipeline.

88 |

That's because interactive Markdown is still in its infancy. There are many features I'd like to get to that I haven't had the time for yet (e.g., automatic dimensions checking to make sure your calculations make sense, popover links to calculations, more math functions, support for distributions and other data types), not to mention tools to make working with interactive Markdown easier: an in-browser editor, a plugin for Obsidian support, etc.

89 |

If you're interested in all of this, make sure to subscribe to my newsletter to stay updated. And if you have any ideas, I'd love to hear from you. Check out the repository and raise an issue (or, even better, send a pull request).

90 |
91 |

Appendix

92 |

A more complicated example

93 |

Let's look at the more complicated example from the beginning.

94 |

Here is the example again (thanks to reference fields, it's perfectly in sync with the first instance):

95 |
96 |

Say we allocate for the following program: Car-owners who trade in an old car that gets less than , and purchase a new car that gets better than , will receive a rebate.

97 |

We estimate that this will get off the road. It will save of gas (or worth of U.S. gas consumption.) It will avoid CO2e, or % of annual U.S. greenhouse gas emissions.

98 |

The abatement cost is per ton CO2e of federal spending, although it’s per ton CO2e on balance if you account for the money saved by consumers buying less gas.

99 |
100 |

And here's what it actually looks like (the first example):

101 |
Say we allocate [$3.0 billion](budget=[0..10;0.1]&margin-right=1ch) for the following program: 
102 | Car-owners who trade in an old car that gets less than [17 MPG](old_MPG_limit=[5..30]), 
103 | and purchase a new car that gets better than [24 MPG](new_MPG_limit=[5..50]), 
104 | will receive a [$3,500](rebate=[0..20000;100]&margin-right=1ch) rebate.
105 | 
106 | We estimate that this will get [828,571 old cars](cars_traded&margin-right=1ch) off the road. 
107 | It will save [1,068 million gallons](gallons_saved&margin-right=1ch) of gas 
108 | (or [68 hours](hours_of_gas&margin-right=1ch&margin-right=1ch) worth of U.S. gas consumption). 
109 | It will avoid [9.97 million tons](tons_CO2_saved&margin-right=1ch) CO2e, 
110 | or [0.14](_percent_annual_emissions)% of annual U.S. greenhouse gas emissions.
111 | 
112 | The abatement cost is [$301](dollars_per_ton_CO2e&margin-right=1ch) per ton CO2e of federal spending, 
113 | although it’s [-\$20](dollars_per_ton_CO2e_on_balance&margin-right=1ch) per ton CO2e on balance 
114 | if you account for the money saved by consumers buying less gas.
115 | 
116 | 
117 |

A few points to note:

118 |
    119 |
  • The number in the text representation determines display precision. If you're familiar with format strings, 3.0 is converted to %.1f, 17 to %d, 3,500 to %'d3, etc.. 120 |
      121 |
    • You can also use format strings directly in the text representation, e.g., [%'d old cars](cars traded), but I don't recommend this because it won't be compatible with standard Markdown.
    • 122 |
    123 |
  • 124 |
  • [0..10;0.1] specifies a range with a step-size equal to 0.1. By default, the step size is 1.
  • 125 |
  • I haven't figured out spacing yet (hence &margin-right=1ch)
  • 126 |
127 |

Cars Traded

128 |
    129 |
  • budget =
  • 130 |
  • overhead =
  • 131 |
  • rebate =
  • 132 |
  • cars_traded = (budget - overhead) / rebate =
  • 133 |
134 |

Here you see one more trick in interactive Markdown: A link containing an inline code element of the kind [`variable_name`](variable_name) is a reference label. It gets a TKLabel class for easier formatting, and, eventually, will synchronously darken whenever you highlight any references to or dependencies of its variable.

135 |
136 |

Gallons Saved

137 |

This is where my example diverges from Victor's example. His calculation uses the distribution of mileage over current cars and cars being sold. I haven't yet added distributions to the interactive Markdown spec (though I plan to), so you'll have to accept a less precise version. Note that the comments come from Victor's original work.

138 |
Average mileage of old vehicles
139 |
140 |

Assume that traded-in cars are chosen with equal probability from the pool of eligible cars. We use the harmonic average because we'll be calculating gallons consumed for constant miles, so we really want to be averaging gallons-per-mile.

141 |
142 |
    143 |
  • old_MPG_limit =
  • 144 |
  • average_current_MPG =
  • 145 |
  • var_current_MPG =
  • 146 |
  • average_old_MPG = ∫ x N(x; average_current_MPG, var_current_MPG) from -Infinity to old_MPG_limit =
  • 147 |
148 |

Alright so I haven't even actually added support for more complicated formulas like this. But it is coming.

149 |
Average mileage for vehicles currently being sold
150 |
151 |

Assume that new cars are purchased with equal probability from the pool of eligible cars. The distribution really should be sales-weighted. I'm sure the data is available, but I couldn't find it.

152 |
153 |
    154 |
  • new_MPG_limit =
  • 155 |
  • average_new_MPG =
  • 156 |
  • var_new_MPG =
  • 157 |
  • average_new_MPG = ∫ x N(x; average_new_MPG, var_new_MPG) from new_MPG_limit to Infinity =
  • 158 |
159 |

Average gallons saved per car replaced

160 |
161 |

Assume that everyone who is buying a new car now would have eventually bought a similar car when their current car got too old. So the fuel savings from the program should be calculated over the remaining lifetime of the old car. Ideally we'd like the joint distribution of MPGs and age of the current fleet, but I can't find that data. So we'll just use averages.

162 |
163 |
    164 |
  • car_lifetime_miles =
  • 165 |
  • average_miles_left = * car_lifetime_miles =
  • 166 |
  • gallons_used_by_old_car = average_miles_left / average_old_MPG =
  • 167 |
  • gallons_used_by_new_car = average_miles_left / average_new_MPG =
  • 168 |
  • gallons_saved_per_car = gallons_used_by_old_car - gallons_used_by_new_car =
  • 169 |
170 |

Total gallons saved

171 |
    172 |
  • cars_traded =
  • 173 |
  • gallons_saved = gallons_saved_per_car * cars_traded =
  • 174 |
175 |
176 |

The importance of models may need to be underscored in this age of “big data” and “data mining”. Data, no matter how big, can only tell you what happened in the past. Unless you’re a historian, you actually care about the future — what will happen, what could happen, what would happen if you did this or that. Exploring these questions will always require models. Let’s get over “big data” — it’s time for “big modeling”.

177 |
178 |
179 |

Hours of Gas Saved

180 |
    181 |
  • gallons_saved =
  • 182 |
  • gallons_consumed_per_day =
  • 183 |
  • gallons_consumed_per_hour = gallons_consumed_per_day / 24 =
  • 184 |
  • hours_of_gas = gallons_saved / gallons_consumed_per_hour =
  • 185 |
186 |
187 |

Tons of CO2 Saved

188 |
    189 |
  • gallons_saved =
  • 190 |
  • kg_CO2_per_gallon_gas =
  • 191 |
  • tons_CO2_saved = gallons_saved * kg_CO2_per_gallon_gas / 1000 =
  • 192 |
193 |
194 |

CO2 comprises 95% of a car's greenhouse gas emissions. The other 5% include methane, nitrous oxide, and hydroflourocarbons. To account for these other gases, we divide the amount of CO2 by 0.95 to get CO2e (“carbon dioxide equivalent”).1

195 |
196 |
    197 |
  • CO2_per_CO2e =
  • 198 |
  • tons_CO2e_saved = tons_CO2_saved / CO2_per_CO2e =
  • 199 |
200 |
201 |

Percent Annual Emissions

202 |
    203 |
  • tons_CO2e_saved =
  • 204 |
  • tons_CO2e_emitted_yearly =
  • 205 |
  • percent_annual_emissions = tons_CO2e_saved / tons_CO2e_emitted_yearly * 100 = %
  • 206 |
207 |

That last one should read something like 0.14% for default options but not all formatting options are available yet.

208 |
209 |

Dollars per ton CO2e

210 |
    211 |
  • budget =
  • 212 |
  • tons_CO2e_saved =
  • 213 |
  • dollars_per_ton_CO2e = budget / tons_CO2e_saved =
  • 214 |
215 |
216 |

Dollars per ton CO2e on balance

217 |
    218 |
  • 219 |

    gallons_saved =

    220 |
  • 221 |
  • 222 |

    dollars_per_gallon =

    223 |
  • 224 |
  • 225 |

    dollars_saved_buying_less_gas = gallons_saved * dollars_per_gallon =

    226 |
  • 227 |
  • 228 |

    budget =

    229 |
  • 230 |
  • 231 |

    dollars_saved_on_balance = budget - dollars_saved_buying_less_gas =

    232 |
      233 |
    • Note: Ok so something's not going right with this calculation here. It should be some 200 million by default.
    • 234 |
    235 |
  • 236 |
  • 237 |

    tons_CO2e_saved =

    238 |
  • 239 |
  • 240 |

    dollars_per_ton_CO2e_on_balance = dollars_saved_on_balance / tons_CO2e_saved =

    241 |
  • 242 |
243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 288 | 350 |
351 |

Footnotes

352 |
    353 |
  1. 354 |

    The difference is that I haven't yet added the possibility of inputting a distribution. So the calculations for average MPG of old versus new cars is less precise than in Victor's case. (On the flip side, this coarser model is easier to modify for today's transportation fleet.) 2

    355 |
  2. 356 |
  3. 357 |

    It's a little confusing that cookies shows up on both the left- and right-hand sides. On the right-hand side, it has a semantic purpose: defining the variable cookies. On the left-hand side it has a purely stylistic purpose (to inform the reader what units we're using).

    358 |
  4. 359 |
  5. 360 |

    Note: %'d is actually nonstandard. It puts commas (or periods) in the thousands places (depending on your locale). Another useful nonstandard addition is + or - for optionally separating the amount and magnitude as in -$20.

    361 |
  6. 362 |
363 |
364 | 365 | --------------------------------------------------------------------------------