├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .yarnrc.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── nyt-example.png ├── penguin-example.png └── reviz-logo.svg ├── lerna.json ├── package.json ├── packages ├── compiler │ ├── .gitattributes │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── __tests__ │ │ ├── attributes.spec.ts │ │ ├── generate.spec.ts │ │ ├── ir.spec.ts │ │ ├── scales.spec.ts │ │ ├── test-utils.ts │ │ └── walk.spec.ts │ ├── package.json │ ├── src │ │ ├── analyze.ts │ │ ├── attributes.ts │ │ ├── constants.ts │ │ ├── generate.ts │ │ ├── index.ts │ │ ├── ir.ts │ │ ├── scales.ts │ │ └── walk.ts │ └── tsconfig.json ├── examples │ ├── CHANGELOG.md │ ├── README.md │ ├── app │ │ ├── examples │ │ │ ├── NPR-covid-shift │ │ │ │ └── page.tsx │ │ │ ├── bar │ │ │ │ └── page.tsx │ │ │ ├── bubble │ │ │ │ └── page.tsx │ │ │ ├── histogram │ │ │ │ └── page.tsx │ │ │ ├── new-york-times-vaccine-voting │ │ │ │ └── page.tsx │ │ │ ├── scatterplot │ │ │ │ └── page.tsx │ │ │ ├── stacked-bar │ │ │ │ └── page.tsx │ │ │ └── strip-plot │ │ │ │ └── page.tsx │ │ ├── index.css │ │ ├── layout.tsx │ │ └── page.tsx │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── public │ │ ├── NPR-covid-shift.png │ │ ├── bar.png │ │ ├── bubble.png │ │ ├── favicon.png │ │ ├── histogram.png │ │ ├── new-york-times-vaccine-voting.png │ │ ├── scatterplot.png │ │ ├── stacked-bar.png │ │ └── strip-plot.png │ ├── src │ │ ├── components │ │ │ ├── charts │ │ │ │ ├── BarChart.tsx │ │ │ │ ├── BubbleChart.tsx │ │ │ │ ├── Histogram.tsx │ │ │ │ ├── Scatterplot.tsx │ │ │ │ ├── StackedBarChart.tsx │ │ │ │ └── StripPlot.tsx │ │ │ └── shared │ │ │ │ ├── Card.tsx │ │ │ │ ├── CodePane.tsx │ │ │ │ ├── RevizOutput.tsx │ │ │ │ └── Visualization.tsx │ │ ├── data │ │ │ ├── alphabet.json │ │ │ ├── alphabet.ts │ │ │ ├── athletes.json │ │ │ ├── athletes.ts │ │ │ ├── cars.json │ │ │ ├── cars.ts │ │ │ ├── crimea.json │ │ │ ├── crimea.ts │ │ │ ├── states.ts │ │ │ └── us-distribution-state-age.csv │ │ └── helpers │ │ │ ├── isomorphic.ts │ │ │ ├── logos.tsx │ │ │ ├── metadata.tsx │ │ │ └── server.ts │ ├── tailwind.config.js │ └── tsconfig.json ├── extension │ ├── CHANGELOG.md │ ├── README.md │ ├── devtools │ │ ├── devtools.html │ │ └── extension.ts │ ├── manifest.json │ ├── package.json │ ├── panel │ │ └── panel.html │ ├── postcss.config.js │ ├── public │ │ └── reviz.png │ ├── sandbox │ │ ├── sandbox.html │ │ └── sandbox.ts │ ├── scripts │ │ ├── inspect.css │ │ ├── inspect.ts │ │ └── service-worker.ts │ ├── src │ │ ├── App.tsx │ │ ├── components │ │ │ ├── ExtensionErrorBoundary.tsx │ │ │ ├── data │ │ │ │ ├── DataGrid.tsx │ │ │ │ ├── DataPanel.tsx │ │ │ │ └── DataUpload.tsx │ │ │ ├── interaction │ │ │ │ ├── ElementSelect.tsx │ │ │ │ └── ElementSelectPrompt.tsx │ │ │ ├── program │ │ │ │ ├── ProgramEditor.tsx │ │ │ │ ├── ProgramViewer.tsx │ │ │ │ └── theme.ts │ │ │ ├── retarget │ │ │ │ ├── RetargetedVisualization.tsx │ │ │ │ └── Retargeter.tsx │ │ │ ├── shared │ │ │ │ ├── Heading.tsx │ │ │ │ └── TooltipMessage.tsx │ │ │ └── spec │ │ │ │ ├── ColorValue.tsx │ │ │ │ └── SpecViewer.tsx │ │ ├── hooks │ │ │ └── usePrevious.ts │ │ ├── index.css │ │ ├── main.tsx │ │ ├── types │ │ │ ├── data.ts │ │ │ └── message.ts │ │ ├── utils │ │ │ └── formatters.tsx │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── ui │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ ├── components │ │ └── CodePane.tsx │ └── index.ts │ └── tsconfig.json ├── paper ├── refs.bib ├── reviz.pdf ├── reviz.png └── reviz.tex ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── renovate.json /.eslintignore: -------------------------------------------------------------------------------- 1 | # Build 2 | dist 3 | 4 | # ESLint 5 | .eslintrc.js 6 | 7 | # Vite 8 | vite.config.ts 9 | 10 | # Tailwind 11 | tailwind.config.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | env: { browser: true, node: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 8 | 'plugin:react-hooks/recommended', 9 | 'plugin:import/errors', 10 | 'plugin:import/warnings', 11 | 'plugin:import/typescript', 12 | 'next/core-web-vitals', 13 | 'prettier', 14 | ], 15 | overrides: [ 16 | { 17 | files: ['packages/compiler/__tests__/*.ts'], 18 | plugins: ['jest'], 19 | rules: { 20 | '@typescript-eslint/unbound-method': 'off', 21 | 'jest/unbound-method': 'error', 22 | }, 23 | }, 24 | ], 25 | parser: '@typescript-eslint/parser', 26 | parserOptions: { 27 | ecmaVersion: 'latest', 28 | project: 'packages/*/tsconfig.json', 29 | }, 30 | plugins: ['@typescript-eslint'], 31 | root: true, 32 | rules: { 33 | 'no-console': ['error', { allow: ['warn', 'error'] }], 34 | camelcase: 'error', 35 | '@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '_' }], 36 | '@typescript-eslint/explicit-function-return-type': ['error'], 37 | 'import/newline-after-import': 2, 38 | 'import/order': [ 39 | 'error', 40 | { 41 | 'newlines-between': 'always', 42 | }, 43 | ], 44 | }, 45 | settings: { 46 | next: { 47 | rootDir: 'packages/examples/', 48 | }, 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build 2 | dist 3 | 4 | # System 5 | .DS_Store 6 | 7 | # Node 8 | node_modules 9 | 10 | # Editor 11 | .vscode 12 | 13 | # Next 14 | .next 15 | 16 | # TypeScript 17 | tsconfig.tsbuildinfo 18 | 19 | # Jest 20 | coverage 21 | 22 | # Latex 23 | *.aux 24 | *.bbl 25 | *.blg 26 | *.fdb_latexmk 27 | *.fls 28 | *.log 29 | *.out 30 | *.synctex.gz 31 | 32 | # Logs 33 | lerna-debug.log* 34 | 35 | # Yarn 36 | .pnp.* 37 | .yarn/* 38 | !.yarn/patches 39 | !.yarn/plugins 40 | !.yarn/releases 41 | !.yarn/sdks 42 | !.yarn/versions -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Build 2 | dist 3 | 4 | # Next 5 | .next 6 | 7 | # Yarn 8 | .yarn -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "overrides": [ 5 | { 6 | "files": ["**/*.css"], 7 | "options": { 8 | "singleQuote": false 9 | } 10 | } 11 | ], 12 | "plugins": ["prettier-plugin-tailwindcss"] 13 | } 14 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | yarnPath: .yarn/releases/yarn-1.22.21.cjs 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in making `reviz` better. Here's how to get started. 4 | 5 | ## Local Development 6 | 7 | To develop `reviz` locally, ensure you have an installation of [Node.js](https://nodejs.org/en) and [`npm`](https://docs.npmjs.com/). We recommend using current LTS for Node.js (18.16.1) and `npm` (>= v9). From here, run `npm install` at the root of the directory. 8 | 9 | We use [`npm` workspaces](https://docs.npmjs.com/cli/v9/using-npm/workspaces?v=true) and [`lerna`](https://lerna.js.org/) to manage, link, and version packages during development. Running `npm install` at the root will hoist shared dependencies to the root `node_modules` folder and handle linking packages together. 10 | 11 | ### Building Packages 12 | 13 | To build all packages, run `npm run build` from the root directory. If you only want to build a single package, you can `cd` into the package you'd like to build and run `npm run build`. 14 | 15 | ### Running the Development Server and Examples 16 | 17 | `reviz` is developed and benchmarked against examples in the [Next.js](https://nextjs.org/) app in the `packages/examples` folder. To develop the examples locally, `cd` into this directory and start the development server: 18 | 19 | ```sh 20 | cd packages/examples 21 | npm run dev 22 | ``` 23 | 24 | This will open a local development server at `http://localhost:3000`. 25 | 26 | #### Using Local Source 27 | 28 | With `npm` workspaces, all local dependencies are symlinked automatically. For example, the `@reviz/examples` package depends on both `@reviz/compiler` and `@reviz/ui`; `npm` workspaces handles resolution to those local directories. However, if you make a change in a dependee directory and want to see the change upstream in the dependent, you'll need to remember to rerun the build for the depended-on directory. We'll likely change this in the future in favor of `lerna`'s [Workspace Watching feature](https://lerna.js.org/docs/features/workspace-watching#running-with-package-managers). 29 | 30 | ### Linting and Formatting 31 | 32 | We lint the codebase with ESLint and format it with Prettier. To manually lint the codebase, run `npm run lint` from the root directory. To manually format the codebase, run `npm run format` from the root directory. 33 | 34 | We recommend installing plugins in your editor of choice to run ESLint and Prettier on save. If using VSCode, you can install: 35 | 36 | - [`vscode-eslint`](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) 37 | - [`prettier-vscode`](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) 38 | 39 | To add support for for format on save, add the following to your workspace settings: 40 | 41 | ```json 42 | "[typescript]": { 43 | "editor.formatOnSave": true, 44 | "editor.defaultFormatter": "esbenp.prettier-vscode" 45 | } 46 | ``` 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Parker Ziegler 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | reviz 7 |
8 |
9 | 10 | A lightweight engine for reverse engineering data visualizations from the DOM 11 | 12 |
13 |
14 |
15 | 16 | `reviz` is a lightweight engine for reverse engineering data visualizations from the DOM. Its core goal is to assist in rapid visualization sketching and prototyping by automatically generating partial programs written using [Observable Plot](https://observablehq.com/@observablehq/plot) from input `svg` subtrees. `reviz` can be used directly through the core library, [`@reviz/compiler`](./packages/compiler/README.md), or the Chrome extension, [`@reviz/extension`](./packages/extension/README.md). 17 | 18 | For a hands-on, interactive introduction to `reviz`, check out [the `Hello reviz!` notebook on Observable](https://observablehq.com/@parkerziegler/hello-reviz). 19 | 20 | To get familiar with the various packages in this codebase, check out their respective READMEs. 21 | 22 | - [`@reviz/compiler`](/packages/compiler/README.md) – The core library and compiler. 23 | - [`@reviz/examples`](/packages/examples/README.md) – The [examples site](https://reviz.vercel.app). 24 | - [`@reviz/extension`](/packages/extension/README.md) – The Chrome extension. 25 | - [`@reviz/ui`](/packages/ui/README.md) – Shared UI components used across the `reviz` ecosystem. 26 | 27 | ## Installation 28 | 29 | ### Compiler 30 | 31 | ```sh 32 | npm install @reviz/compiler 33 | ``` 34 | 35 | ### Extension 36 | 37 | 🚧 Under Construction 🚧 38 | 39 | ## API 40 | 41 | The `reviz` API is very small; in fact, it consists of only a single function, `analyzeVisualization`! 42 | 43 | ```js 44 | import { analyzeVisualization } from '@reviz/compiler'; 45 | 46 | const viz = document.querySelector('#my-viz'); 47 | 48 | const { spec, program } = analyzeVisualization(viz); 49 | ``` 50 | 51 | ### `analyzeVisualization` 52 | 53 | ```ts 54 | export declare const analyzeVisualization: (root: SVGSVGElement) => { 55 | spec: VizSpec; 56 | program: string; 57 | }; 58 | ``` 59 | 60 | `analyzeVisualization` is a function that takes in an `svg` `Element` as input and returns an `Object` containing two properties, `spec` and `program`. 61 | 62 | `spec` refers to the intermediate representation used by `reviz` to generate partial Observable Plot programs. It encodes semantic information about the input `svg` subtree, including its inferred visualization type, geometric attributes of its marks (either `circle` or `rect` elements), and presentational attributes of its marks. `reviz`'s architecture mimics that of a traditional compiler, with `spec` acting as the intermediate representation (IR). It can be useful to examine `spec` to see whether or not `reviz` has inferred the correct visualization type for your input `svg` subtree. 63 | 64 | `program` refers to the _partial_ Observable Plot program that `reviz` generates. These programs are intentionally _incomplete_ and contain "holes" represented by the string `'??'`. The presence of a hole indicates that the value for a particular attribute (e.g. the `r` attribute of a bubble chart or the `fill` attribute of a stacked bar chart) should be mapped to a column in a user's input dataset rather than kept static across all data elements. After filling in holes with column names from your input dataset, you'll have a complete visualization program ready to run in the browser! 65 | 66 | ### By Example 67 | 68 | Let's look at an example to see how `reviz` works in practice. We'll use [this visualization](https://www.nytimes.com/interactive/2021/04/17/us/vaccine-hesitancy-politics.html) from the New York Times: 69 | 70 |
71 | A scatterplot visualization from the New York Times 76 |
77 | 78 | If we point `reviz` at the root `svg` `Element` of this visualization, it generates the following (partial) program: 79 | 80 | ```js 81 | Plot.plot({ 82 | color: { 83 | scale: 'categorical', 84 | range: ['#C67371', '#ccc', '#709DDE', '#A7B9D3', '#C23734'], 85 | }, 86 | marks: [ 87 | Plot.dot(data, { 88 | fill: '??', 89 | stroke: '??', 90 | fillOpacity: 0.8, 91 | strokeOpacity: 1, 92 | strokeWidth: 1, 93 | x: '??', 94 | y: '??', 95 | r: 7, 96 | }), 97 | ], 98 | }); 99 | ``` 100 | 101 | Notice that `fill`, `stroke`, `x` and `y` are all inferred to be holes (indicated by`'??'`) that must be mapped to columns of an input dataset. Conversely, attributes like `fillOpacity` and `strokeWidth` are automatically inferred because they are found to be consistent across all mark elements. We can also see that `reviz` has inferred that the visualization is using a [categorical color scale](https://observablehq.com/plot/features/scales#color-scales) and automatically configures the scale for us. 102 | 103 | We can now apply this partial program to a new dataset. Let's use [this delightful dataset about penguins](https://observablehq.com/@observablehq/plot-exploration-penguins) from @Fil's Plot Exploration notebook. We can choose input columns from this dataset to "fill in" the holes like so: 104 | 105 | ```diff 106 | Plot.plot({ 107 | color: { 108 | scale: 'categorical', 109 | range: ['#C67371', '#ccc', '#709DDE', '#A7B9D3', '#C23734'], 110 | }, 111 | marks: [ 112 | Plot.dot(data, { 113 | - fill: '??', 114 | + fill: 'island', 115 | - stroke: '??', 116 | + stroke: 'island', 117 | fillOpacity: 0.8, 118 | strokeOpacity: 1, 119 | strokeWidth: 1, 120 | - x: '??', 121 | + x: 'flipper_length_mm', 122 | - y: '??', 123 | + y: 'body_mass_g', 124 | r: 7, 125 | }), 126 | ], 127 | }); 128 | ``` 129 | 130 | The result that we get is a new visualization that takes the _appearance_ of the original New York Times piece and applies it to our data. 131 | 132 |
133 | A scatterplot visualization of penguins. 138 |
139 | 140 | In this way, `reviz` allows end users to quickly experiment with _seeing_ their data in the form of a visualization they encounter anywhere in the wild. 141 | 142 | To see more examples of the partial programs `reviz` generates, check out [our examples site](https://reviz.vercel.app). To understand how `reviz` works at a deeper level, consider reading [our paper](/paper/reviz.pdf). 143 | 144 | ### Supported Visualization Types 145 | 146 | `reviz` is restricted to only work on a small subset of visualization types. We hope to extend `reviz` to include more visualization types in the future. 147 | 148 | | Visualization Type | Description | 149 | | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 150 | | [Bar Chart](https://reviz.vercel.app/examples/bar-chart) | Old trusty. The bar chart represents data values using the height of each `rect` mark. The data values mapped to the x-axis must be **discrete**, not continuous. | 151 | | [Bubble Chart](https://reviz.vercel.app/examples/bubble-chart) | The bubble chart is similar to the scatterplot, with the radius of each `circle` mark mapped to the square root of a data value. | 152 | | [Histogram](https://reviz.vercel.app/examples/histogram) | Similar to a bar chart, but the data values mapped to the x-axis must be **continuous**, not discrete. Histograms are typically used to visualize distributions in a dataset. | 153 | | [Scatterplot](https://reviz.vercel.app/examples/scatterplot) | The scatterplot places `circle` marks in an x-y coordinate plane, often to show a correlation between two variables. | 154 | | [Stacked Bar Chart](https://reviz.vercel.app/examples/scatterplot) | A dressed up version of the bar chart in which subcategories of data can be compared across groups. | 155 | | [Strip Plot](https://reviz.vercel.app/examples/strip-plot) | Many rows of `circle` marks are placed on the same continous scale to visualize distributions in a dataset. | 156 | -------------------------------------------------------------------------------- /assets/nyt-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parkerziegler/reviz/e2dcfbe9e1dee8288835bba30841856a3ff05c5d/assets/nyt-example.png -------------------------------------------------------------------------------- /assets/penguin-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parkerziegler/reviz/e2dcfbe9e1dee8288835bba30841856a3ff05c5d/assets/penguin-example.png -------------------------------------------------------------------------------- /assets/reviz-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 | "version": "0.5.0", 4 | "packages": ["packages/*"] 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@reviz/monorepo", 3 | "version": "0.0.0", 4 | "description": "An engine for reverse engineering data visualizations from the DOM.", 5 | "private": true, 6 | "author": { 7 | "name": "Parker Ziegler", 8 | "email": "peziegler@cs.berkeley.edu" 9 | }, 10 | "license": "MIT", 11 | "homepage": "https://github.com/parkerziegler/reviz#readme", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/parkerziegler/reviz.git" 15 | }, 16 | "bugs": "https://github.com/parkerziegler/reviz/issues", 17 | "keywords": [ 18 | "data visualization", 19 | "reverse engineering", 20 | "observable", 21 | "observable plot", 22 | "typescript" 23 | ], 24 | "workspaces": [ 25 | "packages/*" 26 | ], 27 | "devDependencies": { 28 | "@typescript-eslint/eslint-plugin": "^7.0.0", 29 | "@typescript-eslint/parser": "^7.0.0", 30 | "eslint": "^8.57.0", 31 | "eslint-config-next": "^14.0.0", 32 | "eslint-config-prettier": "^9.0.0", 33 | "eslint-plugin-import": "^2.27.5", 34 | "eslint-plugin-jest": "^27.9.0", 35 | "lerna": "^8.0.0", 36 | "prettier": "^3.2.5", 37 | "prettier-plugin-tailwindcss": "^0.5.11" 38 | }, 39 | "scripts": { 40 | "build": "lerna run build", 41 | "format": "prettier --write .", 42 | "lint": "eslint --ext .ts,.tsx .", 43 | "lint:fix": "eslint --ext .ts,.tsx . --fix", 44 | "version": "lerna version" 45 | }, 46 | "packageManager": "yarn@1.22.21" 47 | } -------------------------------------------------------------------------------- /packages/compiler/.gitattributes: -------------------------------------------------------------------------------- 1 | *.tex linguist-vendored -------------------------------------------------------------------------------- /packages/compiler/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. If a change is missing an attribution, it may have been made by a Core Contributor. 4 | 5 | - Critical bugfixes or breaking changes are marked using a warning symbol: ⚠️ 6 | - Significant new features or enhancements are marked using the sparkles symbol: ✨ 7 | 8 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 9 | 10 | ## [0.5.0] 11 | 12 | This release includes a large migration to a monorepo. As part of this migration, `reviz` is now distributed on NPM as `@reviz/compiler`. 13 | 14 | ### Minor Changes 15 | 16 | - ⚠️ ✨ Rename the package to `@reviz/compiler` as part of the monorepo migration. 17 | - ⚠️ Remove the `const plot =` prefix from `reviz` generated programs. 18 | 19 | ## [0.4.1] 20 | 21 | ### Patch Changes 22 | 23 | - Export the `RevizOutput` interface to ease TypeScript integration with Chrome extension. 24 | 25 | ## [0.4.0] 26 | 27 | ### Minor Changes 28 | 29 | - Restrict analysis of marks to `` and `` elements. 30 | - ✨ Automatically infer the type of the x-axis scale, either `'discrete'` or `'continuous'`. 31 | 32 | ### Patch Changes 33 | 34 | - ⚠️ Correctly infer strip plots when a given category contains only a single data point. 35 | 36 | ## [0.3.1] 37 | 38 | ### Patch Changes 39 | 40 | - ⚠️ Restrict the type of `analyzeVisualization` from any `SVGElement` to `SVGSVGElement`. 41 | 42 | ## [0.3.0] 43 | 44 | ### Minor Changes 45 | 46 | - ⚠️ Publish package under `@plait-lab` scope as `@plait-lab/reviz`. 47 | - ✨ Properly infer the range for radii on bubble charts. 48 | - Add a program hole (`'??'`) for the `r` attribute on bubble charts. 49 | 50 | ### Patch Changes 51 | 52 | - Rewrite internal code generation logic to follow the formalized contextual semantics documented in [the accompanying paper](/paper/reviz.pdf). 53 | - Flag package as side effect-free in `package.json`. 54 | 55 | ## [0.2.0] 56 | 57 | ### Minor Changes 58 | 59 | - ⚠️ Use `'??'` to represent program holes in output partial program. 60 | 61 | ### Patch Changes 62 | 63 | - Use a ranking-based heuristic to infer visualization type. 64 | - ⚠️ Alter strip plot inference logic to handle plots with more lanes than data points per lane. 65 | - Add an additional predicate function to differentiate bar and stacked bar charts after regression introduced in [v0.1.2](#[0.1.2]). 66 | 67 | ## [0.1.2] 68 | 69 | ### Patch Changes 70 | 71 | - ⚠️ Add more robust heuristics to distinguish strip plots from scatterplots after overapproximation introduced in [v0.1.1](#[0.1.1]). 72 | - Remove `d3-array` dependency in favor of a smaller local `mode` implementation. 73 | 74 | ## [0.1.1] 75 | 76 | ### Patch Changes 77 | 78 | - ⚠️ Update the logic for inferring strip plots to handle cases where DOM nodes that are visual siblings are not siblings in the `svg` subtree. 79 | - Improve internal TypeScript implementation to persist type information about `rect`- and `circle`-specific attributes. 80 | 81 | ## [0.1.0] 82 | 83 | - ✨ This marks the initial release of `reviz`! 84 | -------------------------------------------------------------------------------- /packages/compiler/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Parker Ziegler 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 | -------------------------------------------------------------------------------- /packages/compiler/README.md: -------------------------------------------------------------------------------- 1 |
2 | reviz 7 |
8 |
9 | 10 | A lightweight engine for reverse engineering data visualizations from the DOM 11 | 12 |
13 |
14 |
15 | 16 | `reviz` is a lightweight engine for reverse engineering data visualizations from the DOM. Its core goal is to assist in rapid visualization sketching and prototyping by automatically generating partial programs written using [Observable Plot](https://observablehq.com/@observablehq/plot) from input `svg` subtrees. 17 | 18 | At the heart of `reviz` is its compiler. 19 | 20 | ## Installation 21 | 22 | ```sh 23 | npm install @reviz/compiler 24 | ``` 25 | 26 | ## API 27 | 28 | The compiler is exposed via a tiny API—a single function! 29 | 30 | ```js 31 | import { analyzeVisualization } from '@reviz/compiler'; 32 | 33 | const viz = document.querySelector('#my-viz'); 34 | 35 | const { spec, program } = analyzeVisualization(viz); 36 | ``` 37 | 38 | ### `analyzeVisualization` 39 | 40 | ```ts 41 | export declare const analyzeVisualization: (root: SVGSVGElement) => { 42 | spec: VizSpec; 43 | program: string; 44 | }; 45 | ``` 46 | 47 | `analyzeVisualization` is a function that takes in an `svg` `Element` as input and returns an `Object` containing two properties, `spec` and `program`. 48 | 49 | `spec` refers to the intermediate representation used by `reviz` to generate partial Observable Plot programs. It encodes semantic information about the input `svg` subtree, including its inferred visualization type, geometric attributes of its marks (either `circle` or `rect` elements), and presentational attributes of its marks. `reviz`'s architecture mimics that of a traditional compiler, with `spec` acting as the intermediate representation (IR). It can be useful to examine `spec` to see whether or not `reviz` has inferred the correct visualization type for your input `svg` subtree. 50 | 51 | `program` refers to the _partial_ Observable Plot program that `reviz` generates. These programs are intentionally _incomplete_ and contain "holes" represented by the string `'??'`. The presence of a hole indicates that the value for a particular attribute (e.g. the `r` attribute of a bubble chart or the `fill` attribute of a stacked bar chart) should be mapped to a column in a user's input dataset rather than kept static across all data elements. After filling in holes with column names from your input dataset, you'll have a complete visualization program ready to run in the browser! 52 | -------------------------------------------------------------------------------- /packages/compiler/__tests__/ir.spec.ts: -------------------------------------------------------------------------------- 1 | import { buildVizSpec } from '../src/ir'; 2 | 3 | import { 4 | generatePointDataset, 5 | defaultPresAttrs, 6 | generateRectDataset, 7 | } from './test-utils'; 8 | 9 | describe('buildVizSpec', () => { 10 | describe('Scatterplot', () => { 11 | it(`should infer a Scatterplot for charts with: 12 | - markType === 'circle' 13 | - A consistent radius attribute for all marks 14 | - Sibling elements with vartion in the cy attribute 15 | `, () => { 16 | const points = generatePointDataset(); 17 | 18 | const vizAttrs = { 19 | markType: 'circle' as const, 20 | xScaleType: 'continuous' as const, 21 | geomAttrs: new Map>([ 22 | ['cx', new Set(points.map((d) => d.cx.toString()))], 23 | ['cy', new Set(points.map((d) => d.cy.toString()))], 24 | ['r', new Set([points[0].r.toString()])], 25 | ]), 26 | presAttrs: new Map(), 27 | positionAttrs: points.map((d) => { 28 | return { 29 | type: 'circle' as const, 30 | cy: d.cy.toString(), 31 | }; 32 | }), 33 | }; 34 | 35 | expect(buildVizSpec(vizAttrs)).toEqual({ 36 | type: 'Scatterplot', 37 | r: +points[0].r, 38 | ...defaultPresAttrs, 39 | }); 40 | }); 41 | }); 42 | 43 | describe('StripPlot', () => { 44 | it(`should infer a StripPlot for charts with: 45 | - markType === 'circle' 46 | - A consistent radius attribute for all marks, 47 | - Sibling elements with a consistent cy attribute 48 | `, () => { 49 | const points = generatePointDataset(100, { 50 | createLanes: true, 51 | varyRadius: false, 52 | }); 53 | 54 | const vizAttrs = { 55 | markType: 'circle' as const, 56 | xScaleType: 'continuous' as const, 57 | geomAttrs: new Map>([ 58 | ['cx', new Set(points.map((d) => d.cx.toString()))], 59 | ['cy', new Set(points.map((d) => d.cy.toString()))], 60 | ['r', new Set([points[0].r.toString()])], 61 | ]), 62 | presAttrs: new Map(), 63 | positionAttrs: points.map((d) => { 64 | return { 65 | type: 'circle' as const, 66 | cy: d.cy.toString(), 67 | }; 68 | }), 69 | }; 70 | 71 | expect(buildVizSpec(vizAttrs)).toEqual({ 72 | type: 'StripPlot', 73 | r: +points[0].r, 74 | ...defaultPresAttrs, 75 | }); 76 | }); 77 | }); 78 | 79 | describe('BubbleChart', () => { 80 | it(`should infer a BubbleChart for charts with: 81 | - markType === 'circle' 82 | - A divergent radius attribute for marks 83 | - Sibling elements with any variation in the cy attribute 84 | `, () => { 85 | const points = generatePointDataset(100, { 86 | createLanes: false, 87 | varyRadius: true, 88 | }); 89 | 90 | const vizAttrs = { 91 | markType: 'circle' as const, 92 | xScaleType: 'continuous' as const, 93 | geomAttrs: new Map>([ 94 | ['cx', new Set(points.map((d) => d.cx.toString()))], 95 | ['cy', new Set(points.map((d) => d.cy.toString()))], 96 | ['r', new Set(points.map((d) => d.r.toString()))], 97 | ]), 98 | presAttrs: new Map(), 99 | positionAttrs: points.map((d) => { 100 | return { 101 | type: 'circle' as const, 102 | cy: d.cy.toString(), 103 | }; 104 | }), 105 | }; 106 | 107 | expect(buildVizSpec(vizAttrs)).toEqual({ 108 | type: 'BubbleChart', 109 | r: Array.from(new Set(points.map((d) => +d.r))), 110 | ...defaultPresAttrs, 111 | }); 112 | }); 113 | }); 114 | 115 | describe('BarChart', () => { 116 | it(`should infer a BarChart for charts with: 117 | - markType === 'rect' 118 | - A consistent width attribute for all marks 119 | - A discrete xScale 120 | `, () => { 121 | const rects = generateRectDataset(); 122 | 123 | const vizAttrs = { 124 | markType: 'rect' as const, 125 | xScaleType: 'discrete' as const, 126 | geomAttrs: new Map>([ 127 | ['x', new Set(rects.map((d) => d.x.toString()))], 128 | ['y', new Set(rects.map((d) => d.y.toString()))], 129 | ['width', new Set(rects.map((d) => d.width.toString()))], 130 | ['height', new Set(rects.map((d) => d.height.toString()))], 131 | ]), 132 | presAttrs: new Map(), 133 | positionAttrs: rects.map((d) => { 134 | return { 135 | type: 'rect' as const, 136 | x: d.x.toString(), 137 | }; 138 | }), 139 | }; 140 | 141 | expect(buildVizSpec(vizAttrs)).toEqual({ 142 | type: 'BarChart', 143 | width: +rects[0].width, 144 | ...defaultPresAttrs, 145 | }); 146 | }); 147 | }); 148 | 149 | describe('Histogram', () => { 150 | it(`should infer a Histogram for charts with: 151 | - markType === 'rect' 152 | 2. A consistent width attribute for all marks, 153 | 3. A continuous xScale 154 | `, () => { 155 | const rects = generateRectDataset(); 156 | 157 | const vizAttrs = { 158 | markType: 'rect' as const, 159 | xScaleType: 'continuous' as const, 160 | geomAttrs: new Map>([ 161 | ['x', new Set(rects.map((d) => d.x.toString()))], 162 | ['y', new Set(rects.map((d) => d.y.toString()))], 163 | ['width', new Set(rects.map((d) => d.width.toString()))], 164 | ['height', new Set(rects.map((d) => d.height.toString()))], 165 | ]), 166 | presAttrs: new Map(), 167 | positionAttrs: rects.map((d) => { 168 | return { 169 | type: 'rect' as const, 170 | x: d.x.toString(), 171 | }; 172 | }), 173 | }; 174 | 175 | expect(buildVizSpec(vizAttrs)).toEqual({ 176 | type: 'Histogram', 177 | width: +rects[0].width, 178 | ...defaultPresAttrs, 179 | }); 180 | }); 181 | }); 182 | 183 | describe('StackedBarChart', () => { 184 | it(`should infer a StackedBarChart for charts with: 185 | 1. markType === 'rect', 186 | 2. A consistent width attribute for all marks, 187 | 3. A discrete xScale, 188 | 4. Multiple sibling elements with the same x attribute, 189 | 5. The same number of sibling elements with a common x attribute 190 | `, () => { 191 | const rects = generateRectDataset(100, { createLanes: true }); 192 | 193 | const vizAttrs = { 194 | markType: 'rect' as const, 195 | xScaleType: 'discrete' as const, 196 | geomAttrs: new Map>([ 197 | ['x', new Set(rects.map((d) => d.x.toString()))], 198 | ['y', new Set(rects.map((d) => d.y.toString()))], 199 | ['width', new Set(rects.map((d) => d.width.toString()))], 200 | ['height', new Set(rects.map((d) => d.height.toString()))], 201 | ]), 202 | presAttrs: new Map(), 203 | positionAttrs: rects.map((d) => { 204 | return { 205 | type: 'rect' as const, 206 | x: d.x.toString(), 207 | }; 208 | }), 209 | }; 210 | 211 | expect(buildVizSpec(vizAttrs)).toEqual({ 212 | type: 'StackedBarChart', 213 | width: +rects[0].width, 214 | ...defaultPresAttrs, 215 | }); 216 | }); 217 | }); 218 | 219 | it('should forward all presentational attributes to the visualization specification', () => { 220 | const points = generatePointDataset(); 221 | 222 | const vizAttrs = { 223 | markType: 'circle' as const, 224 | xScaleType: 'continuous' as const, 225 | geomAttrs: new Map>([ 226 | ['cx', new Set(points.map((d) => d.cx.toString()))], 227 | ['cy', new Set(points.map((d) => d.cy.toString()))], 228 | ['r', new Set(points.map((d) => d.r.toString()))], 229 | ]), 230 | presAttrs: new Map([ 231 | ['fill', new Set(['steelblue', 'orange'])], 232 | ['stroke', new Set(['#000000', '#ffffff'])], 233 | ['fill-opacity', new Set(['0.25', '0.5', '1'])], 234 | ]), 235 | positionAttrs: points.map((d) => { 236 | return { 237 | type: 'circle' as const, 238 | cy: d.cy.toString(), 239 | }; 240 | }), 241 | }; 242 | 243 | expect(buildVizSpec(vizAttrs)).toHaveProperty('fill', [ 244 | 'steelblue', 245 | 'orange', 246 | ]); 247 | expect(buildVizSpec(vizAttrs)).toHaveProperty('stroke', [ 248 | '#000000', 249 | '#ffffff', 250 | ]); 251 | expect(buildVizSpec(vizAttrs)).toHaveProperty('fill-opacity', [ 252 | '0.25', 253 | '0.5', 254 | '1', 255 | ]); 256 | }); 257 | }); 258 | -------------------------------------------------------------------------------- /packages/compiler/__tests__/scales.spec.ts: -------------------------------------------------------------------------------- 1 | import { inferXScaleType } from '../src/scales'; 2 | import type { RevizTextDatum } from '../src/attributes'; 3 | 4 | import { getRandChar, getRandInt } from './test-utils'; 5 | 6 | describe('scales', () => { 7 | describe('inferXScaleType', () => { 8 | it('should infer the xScaleType as continuous for numeric axis values', () => { 9 | const textAttrs = new Array(50).fill({ 10 | x: `${getRandInt()}`, 11 | y: `${getRandInt()}`, 12 | text: `${getRandInt()}`, 13 | }); 14 | 15 | expect(inferXScaleType(textAttrs)).toBe('continuous'); 16 | }); 17 | 18 | it('should treat strings with leading numbers as numeric', () => { 19 | const textAttrs = new Array(50).fill({ 20 | x: '0', 21 | y: '0', 22 | text: `${getRandInt()}px`, 23 | }); 24 | 25 | expect(inferXScaleType(textAttrs)).toBe('continuous'); 26 | }); 27 | 28 | it('should infer the xScaleType as discrete for all non-numeric axis values', () => { 29 | const textAttrs = new Array(50).fill({ 30 | x: '0', 31 | y: '0', 32 | text: getRandChar(), 33 | }); 34 | 35 | expect(inferXScaleType(textAttrs)).toBe('discrete'); 36 | }); 37 | 38 | it('should only read text values from text nodes with the mode y value', () => { 39 | const axisValues = new Array(30).fill({ 40 | x: '0', 41 | y: '0', 42 | text: `${getRandInt()}`, 43 | }); 44 | 45 | // Add an additional 20 "noisy" characters to represent text features like labels. 46 | const labels = new Array(20).fill({ 47 | x: `${getRandInt()}`, 48 | y: `${getRandInt()}`, 49 | text: getRandChar(), 50 | }); 51 | 52 | // Expect the scale to be continuous because axisValues are all numeric. 53 | expect(inferXScaleType(axisValues.concat(labels))).toBe('continuous'); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /packages/compiler/__tests__/test-utils.ts: -------------------------------------------------------------------------------- 1 | import chunk from 'lodash.chunk'; 2 | /** 3 | * Generate a random character from A-Z using ASCII codes. 4 | * 5 | * @returns – a random character in the set {A...Z}. 6 | */ 7 | export const getRandChar = (): string => 8 | String.fromCharCode(65 + Math.floor(Math.random() * 26)); 9 | 10 | /** 11 | * Generate a random integer in the range [min, max). 12 | * 13 | * @param min - the lower bound of the random integer to generate. 14 | * @param max - the upper bound of the random integer to generate. 15 | * @returns - a random integer in the range [min, max). 16 | */ 17 | export const getRandInt = (min = 0, max = 1000): number => 18 | Math.floor(Math.random() * max) + min; 19 | 20 | interface Point { 21 | cx: string; 22 | cy: string; 23 | r: string; 24 | } 25 | 26 | const DEFAULT_RADIUS = 3; 27 | 28 | /** 29 | * Generate a random dataset of points to simulate circle-based charts. 30 | * 31 | * @param max - the maximum number of points to generate. 32 | * @param options - options to control the generation of the dataset. 33 | * createLanes – place points in "lanes" (shared cy attribute) to simulate strip plots. 34 | * varyRadius – vary the radius of points to simulate bubble charts. 35 | * 36 | * @returns – a Point dataset. 37 | */ 38 | export const generatePointDataset = ( 39 | max = 100, 40 | { createLanes, varyRadius } = { createLanes: false, varyRadius: false } 41 | ): Point[] => { 42 | const numPoints = getRandInt(2, max); 43 | 44 | const points = new Array(numPoints).fill(undefined).map(() => { 45 | return { 46 | cx: getRandInt().toString(), 47 | cy: getRandInt().toString(), 48 | r: varyRadius ? getRandInt().toString() : DEFAULT_RADIUS.toString(), 49 | }; 50 | }); 51 | 52 | // If creating lanes, partition the dataset into n < numPoints lanes. 53 | // For each point in a given lane, ensure its cy attribute is consistent with its siblings. 54 | if (createLanes) { 55 | const lanes = chunk(points, getRandInt(2, numPoints / 4)); 56 | const strips = lanes.flatMap((lane) => { 57 | const cy = getRandInt().toString(); 58 | 59 | return lane.map((point) => { 60 | return { 61 | ...point, 62 | cy, 63 | }; 64 | }); 65 | }); 66 | 67 | return strips; 68 | } 69 | 70 | return points; 71 | }; 72 | 73 | interface Rect { 74 | x: string; 75 | y: string; 76 | width: string; 77 | height: string; 78 | } 79 | 80 | /** 81 | * Generate a random dataset of rectangles to simulate rect-based charts. 82 | * 83 | * @param max - the maximum number of rectangles to generate. 84 | * @param options - options to control the generation of the dataset. 85 | * createLanes – place rects in "lanes" (shared x attribute) to simulate stacked bar charts. 86 | * 87 | * @returns – a Rect dataset. 88 | */ 89 | export const generateRectDataset = ( 90 | max = 100, 91 | { createLanes } = { createLanes: false } 92 | ): Rect[] => { 93 | const numRects = getRandInt(2, max); 94 | const width = getRandInt(); 95 | 96 | // Define a standard baseline to use for all rects. 97 | const height = getRandInt(); 98 | const y = 1000 - height; 99 | 100 | const rects = new Array(numRects).fill(undefined).map(() => { 101 | return { 102 | x: getRandInt().toString(), 103 | y: y.toString(), 104 | width: width.toString(), 105 | height: height.toString(), 106 | }; 107 | }); 108 | 109 | // If creating lanes, partition the dataset into n < numRects lanes. 110 | // For each rect in a given lane, ensure its x attribute is consistent with its siblings. 111 | if (createLanes) { 112 | const numBarsInLane = getRandInt(2, numRects / 4); 113 | const lanes = chunk(rects, numBarsInLane); 114 | 115 | // Ensure that each lane has the same number of bars. 116 | if (lanes[lanes.length - 1].length < lanes[0].length) { 117 | lanes.pop(); 118 | } 119 | 120 | const strips = lanes.reduce((acc, el) => { 121 | const x = getRandInt().toString(); 122 | 123 | // If a lane with this x attribute already exists, skip it. 124 | if (acc.find((lane) => lane.x === x)) { 125 | return acc; 126 | } 127 | 128 | const bars = el.map((rect) => { 129 | return { 130 | ...rect, 131 | x, 132 | y: (1000 - height).toString(), 133 | }; 134 | }); 135 | 136 | return acc.concat(bars); 137 | }, []); 138 | 139 | return strips; 140 | } 141 | 142 | return rects; 143 | }; 144 | 145 | // Default presentational attributes for tests. 146 | export const defaultPresAttrs = { 147 | fill: [], 148 | 'fill-opacity': [], 149 | stroke: [], 150 | 'stroke-opacity': [], 151 | 'stroke-width': [], 152 | }; 153 | 154 | /** 155 | * Normalize a program string by removing all newlines and whitespace and 156 | * converting single quotes to double quotes. 157 | * 158 | * @param program – the raw program string to format. 159 | * @returns – the formatted program string. 160 | */ 161 | export const normalizeProgram = (program: string): string => 162 | program 163 | .replaceAll(/[\n\s]+/g, '') 164 | .replaceAll("'", '"') 165 | .trim(); 166 | -------------------------------------------------------------------------------- /packages/compiler/__tests__/walk.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import * as Plot from '@observablehq/plot'; 5 | 6 | import { walk } from '../src/walk'; 7 | 8 | import { getRandInt } from './test-utils'; 9 | 10 | const NUM_NODES = 100; 11 | const PLOT_SUPPLIED_NODE_COUNT = 15; 12 | const PLOT_DEFAULT_WIDTH = 640; 13 | const PLOT_DEFAULT_HEIGHT = 400; 14 | 15 | const createSubtree = (): Node => { 16 | const data = new Array(NUM_NODES).fill({ 17 | x: getRandInt(PLOT_DEFAULT_WIDTH), 18 | y: getRandInt(PLOT_DEFAULT_HEIGHT), 19 | }); 20 | 21 | const plot = Plot.dot(data, { x: 'x', y: 'y' }).plot(); 22 | 23 | return plot; 24 | }; 25 | 26 | describe('walk', () => { 27 | let subtree: Node; 28 | 29 | beforeEach(() => { 30 | subtree = createSubtree(); 31 | }); 32 | 33 | it('invokes supplied callbacks on every node in the subtree', () => { 34 | const cb1 = jest.fn(); 35 | const cb2 = jest.fn(); 36 | 37 | walk(subtree, [cb1, cb2]); 38 | 39 | expect(cb1).toHaveBeenCalledTimes(NUM_NODES + PLOT_SUPPLIED_NODE_COUNT); 40 | expect(cb2).toHaveBeenCalledTimes(NUM_NODES + PLOT_SUPPLIED_NODE_COUNT); 41 | }); 42 | 43 | it('ignores Nodes that are not Elements', () => { 44 | // Create alternate types of nodes that walk should ignore. 45 | subtree.appendChild(document.createTextNode('Text Node')); 46 | 47 | const cb1 = jest.fn(); 48 | const cb2 = jest.fn(); 49 | 50 | walk(subtree, [cb1, cb2]); 51 | 52 | expect(cb1).toHaveBeenCalledTimes(NUM_NODES + PLOT_SUPPLIED_NODE_COUNT); 53 | expect(cb2).toHaveBeenCalledTimes(NUM_NODES + PLOT_SUPPLIED_NODE_COUNT); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/compiler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@reviz/compiler", 3 | "version": "0.5.0", 4 | "description": "An engine for reverse engineering data visualizations from the DOM.", 5 | "author": { 6 | "name": "Parker Ziegler", 7 | "email": "peziegler@cs.berkeley.edu" 8 | }, 9 | "main": "dist/reviz.js", 10 | "module": "dist/reviz.esm.js", 11 | "types": "dist/types/src/index.d.ts", 12 | "sideEffects": false, 13 | "license": "MIT", 14 | "homepage": "https://github.com/parkerziegler/reviz/blob/main/packages/compiler/README.md", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/parkerziegler/reviz.git" 18 | }, 19 | "bugs": "https://github.com/parkerziegler/reviz/issues", 20 | "keywords": [ 21 | "compiler", 22 | "data visualization", 23 | "reverse engineering", 24 | "observable", 25 | "observable plot", 26 | "typescript" 27 | ], 28 | "scripts": { 29 | "build": "npm run check:ts && npm run emit:types && npm run build:esm:dev && npm run build:esm:prod && npm run build:cjs:dev && npm run build:cjs:prod", 30 | "build:esm:dev": "esbuild src/index.ts --bundle --sourcemap --format=esm --outfile=dist/reviz.esm.js", 31 | "build:esm:prod": "esbuild src/index.ts --bundle --minify --format=esm --outfile=dist/reviz.esm.min.js", 32 | "build:cjs:dev": "esbuild src/index.ts --bundle --sourcemap --format=cjs --outfile=dist/reviz.js", 33 | "build:cjs:prod": "esbuild src/index.ts --bundle --minify --outfile=dist/reviz.min.js", 34 | "check:ts": "tsc --noEmit", 35 | "clean": "rimraf ./dist ./node_modules/.cache", 36 | "coverage": "jest --collectCoverage", 37 | "emit:types": "tsc --declaration --emitDeclarationOnly --outDir dist/types", 38 | "preversion": "npm run clean && npm run check:ts && npm run build", 39 | "test": "jest" 40 | }, 41 | "files": [ 42 | "README.md", 43 | "CHANGELOG.md", 44 | "LICENSE", 45 | "dist/", 46 | "!dist/types/tsconfig.tsbuildinfo", 47 | "!dist/types/__tests__/" 48 | ], 49 | "dependencies": { 50 | "lodash.camelcase": "^4.3.0", 51 | "lodash.groupby": "^4.6.0", 52 | "lodash.orderby": "^4.6.0" 53 | }, 54 | "devDependencies": { 55 | "@observablehq/plot": "^0.6.9", 56 | "@sucrase/jest-plugin": "^3.0.0", 57 | "@types/jest": "^29.5.12", 58 | "@types/lodash.camelcase": "^4.3.7", 59 | "@types/lodash.chunk": "^4.2.7", 60 | "@types/lodash.groupby": "^4.6.7", 61 | "@types/lodash.orderby": "^4.6.7", 62 | "@types/node": "^18.16.1", 63 | "esbuild": "^0.20.0", 64 | "jest": "^29.7.0", 65 | "jest-environment-jsdom": "^29.7.0", 66 | "lodash.chunk": "^4.2.0", 67 | "rimraf": "^5.0.1", 68 | "typescript": "^5.1.6" 69 | }, 70 | "jest": { 71 | "transform": { 72 | "^.+\\.tsx?$": "@sucrase/jest-plugin" 73 | }, 74 | "moduleNameMapper": { 75 | "@observablehq/plot": "/../../node_modules/@observablehq/plot/dist/plot.umd.min.js", 76 | "d3": "/../../node_modules/d3/dist/d3.min.js" 77 | }, 78 | "testMatch": [ 79 | "/__tests__/*.spec.ts" 80 | ], 81 | "collectCoverageFrom": [ 82 | "src/**/*.ts" 83 | ] 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /packages/compiler/src/analyze.ts: -------------------------------------------------------------------------------- 1 | import { walk } from './walk'; 2 | import { 3 | initializeAttrSets, 4 | collectMarkType, 5 | collectGeomAttrs, 6 | collectPresAttrs, 7 | collectTextAttrs, 8 | AttrSets, 9 | RevizTextDatum, 10 | collectPositionAttrs, 11 | RevizPositionDatum, 12 | } from './attributes'; 13 | import { inferXScaleType } from './scales'; 14 | import { buildVizSpec, VizSpec } from './ir'; 15 | import { generate } from './generate'; 16 | import { 17 | CIRCLE_ATTR_NAMES, 18 | GeomAttrNames, 19 | PresAttrNames, 20 | PRES_ATTR_NAMES, 21 | RECT_ATTR_NAMES, 22 | } from './constants'; 23 | 24 | export interface RevizOutput { 25 | spec: VizSpec; 26 | program: string; 27 | } 28 | 29 | export const analyzeVisualization = (root: SVGSVGElement): RevizOutput => { 30 | const markTypes = new Map<'circle' | 'rect', number>([ 31 | ['circle', 0], 32 | ['rect', 0], 33 | ]); 34 | const geomAttrs: AttrSets = initializeAttrSets([ 35 | ...CIRCLE_ATTR_NAMES, 36 | ...RECT_ATTR_NAMES, 37 | ]); 38 | 39 | const presAttrs: AttrSets = 40 | initializeAttrSets(PRES_ATTR_NAMES); 41 | 42 | const textAttrs: RevizTextDatum[] = []; 43 | const positionAttrs: RevizPositionDatum[] = []; 44 | 45 | walk(root, [ 46 | collectMarkType(markTypes), 47 | collectGeomAttrs(geomAttrs), 48 | collectPresAttrs(presAttrs), 49 | collectTextAttrs(textAttrs), 50 | collectPositionAttrs(positionAttrs), 51 | ]); 52 | 53 | const vizSpec = buildVizSpec({ 54 | markType: [...markTypes.entries()].reduce((acc, el) => 55 | el[1] > acc[1] ? el : acc 56 | )[0], 57 | xScaleType: inferXScaleType(textAttrs), 58 | geomAttrs, 59 | presAttrs, 60 | positionAttrs, 61 | }); 62 | const program = generate(vizSpec); 63 | 64 | return { 65 | spec: vizSpec, 66 | program, 67 | }; 68 | }; 69 | -------------------------------------------------------------------------------- /packages/compiler/src/attributes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CIRCLE_ATTR_NAMES, 3 | GeomAttrNames, 4 | PresAttrNames, 5 | PRES_ATTR_NAMES, 6 | RECT_ATTR_NAMES, 7 | } from './constants'; 8 | import { WalkCallback } from './walk'; 9 | 10 | export type AttrSets = Map>; 11 | 12 | export const initializeAttrSets = ( 13 | attrs: ReadonlyArray 14 | ): AttrSets => { 15 | return attrs.reduce((acc, attr) => { 16 | acc.set(attr, new Set()); 17 | 18 | return acc; 19 | }, new Map>()); 20 | }; 21 | 22 | export const collectMarkType = 23 | (markTypes: Map<'circle' | 'rect', number>): WalkCallback => 24 | (element): void => { 25 | if (element.nodeName === 'circle' || element.nodeName === 'rect') { 26 | markTypes.set( 27 | element.nodeName, 28 | (markTypes.get(element.nodeName) ?? 0) + 1 29 | ); 30 | } 31 | }; 32 | 33 | const readAttrs = ( 34 | element: Element, 35 | attrs: ReadonlyArray, 36 | attrSets: AttrSets 37 | ): void => { 38 | attrs.forEach((attr) => { 39 | const value = element.getAttribute(attr); 40 | 41 | if (value) { 42 | attrSets.get(attr)?.add(value); 43 | } else { 44 | const computedStyles = window.getComputedStyle(element); 45 | const computedValue = computedStyles.getPropertyValue(attr); 46 | 47 | attrSets.get(attr)?.add(computedValue); 48 | } 49 | }); 50 | }; 51 | 52 | export const collectGeomAttrs = 53 | (geomAttrSets: AttrSets): WalkCallback => 54 | (element): void => { 55 | const nodeName = element.nodeName; 56 | 57 | switch (nodeName) { 58 | case 'circle': 59 | readAttrs(element, CIRCLE_ATTR_NAMES, geomAttrSets); 60 | break; 61 | case 'rect': 62 | readAttrs(element, RECT_ATTR_NAMES, geomAttrSets); 63 | break; 64 | default: 65 | break; 66 | } 67 | }; 68 | 69 | export const collectPresAttrs = 70 | (presAttrSets: AttrSets): WalkCallback => 71 | (element): void => { 72 | const nodeName = element.nodeName; 73 | 74 | switch (nodeName) { 75 | case 'rect': 76 | case 'circle': 77 | readAttrs(element, PRES_ATTR_NAMES, presAttrSets); 78 | break; 79 | default: 80 | break; 81 | } 82 | }; 83 | 84 | export interface RevizTextDatum { 85 | x: string; 86 | y: string; 87 | text: string; 88 | } 89 | 90 | export const collectTextAttrs = 91 | (textAttrs: RevizTextDatum[]): WalkCallback => 92 | (element): void => { 93 | if (element.nodeName !== 'text') { 94 | return undefined; 95 | } 96 | 97 | const { x, y } = element.getBoundingClientRect(); 98 | const text = element.textContent; 99 | 100 | // We arbitrarily limit text position precision to 3 decimal places. 101 | textAttrs.push({ 102 | x: x.toFixed(3), 103 | y: y.toFixed(3), 104 | text: text ?? '', 105 | }); 106 | }; 107 | 108 | export type RevizCirclePositionDatum = { type: 'circle'; cy: string }; 109 | export type RevizRectPositionDatum = { type: 'rect'; x: string }; 110 | export type RevizPositionDatum = 111 | | RevizCirclePositionDatum 112 | | RevizRectPositionDatum; 113 | 114 | export const collectPositionAttrs = 115 | (data: RevizPositionDatum[]): WalkCallback => 116 | (element): void => { 117 | const nodeName = element.nodeName; 118 | 119 | switch (nodeName) { 120 | case 'circle': 121 | { 122 | const cySet = new Set(); 123 | readAttrs(element, ['cy'], new Map([['cy', cySet]])); 124 | 125 | data.push({ 126 | type: 'circle', 127 | cy: Array.from(cySet)[0], 128 | }); 129 | } 130 | break; 131 | case 'rect': 132 | { 133 | const xSet = new Set(); 134 | readAttrs(element, ['x'], new Map([['x', xSet]])); 135 | 136 | data.push({ 137 | type: 'rect', 138 | x: Array.from(xSet)[0], 139 | }); 140 | } 141 | break; 142 | default: 143 | break; 144 | } 145 | }; 146 | -------------------------------------------------------------------------------- /packages/compiler/src/constants.ts: -------------------------------------------------------------------------------- 1 | // Attribute constants. 2 | export const PRES_ATTR_NAMES = [ 3 | 'fill', 4 | 'fill-opacity', 5 | 'stroke', 6 | 'stroke-opacity', 7 | 'stroke-width', 8 | ]; 9 | export type PresAttrNames = (typeof PRES_ATTR_NAMES)[number]; 10 | 11 | export const CIRCLE_ATTR_NAMES = ['cx', 'cy', 'r']; 12 | export const RECT_ATTR_NAMES = ['x', 'y', 'width', 'height']; 13 | type CircleAttrNames = (typeof CIRCLE_ATTR_NAMES)[number]; 14 | type RectAttrNames = (typeof RECT_ATTR_NAMES)[number]; 15 | export type GeomAttrNames = CircleAttrNames | RectAttrNames; 16 | 17 | export const ATTR_NAMES = [ 18 | ...PRES_ATTR_NAMES, 19 | ...CIRCLE_ATTR_NAMES, 20 | ...RECT_ATTR_NAMES, 21 | ]; 22 | 23 | // Code generation constants. 24 | export const PROGRAM_HOLE = '??'; 25 | export const EVAL_HOLE = '•'; 26 | 27 | // Observable Plot constants. 28 | export const OBSERVABLE_DEFAULT_R = 3; 29 | -------------------------------------------------------------------------------- /packages/compiler/src/generate.ts: -------------------------------------------------------------------------------- 1 | import camelCase from 'lodash.camelcase'; 2 | 3 | import type { VizSpec } from './ir'; 4 | import { EVAL_HOLE, PROGRAM_HOLE } from './constants'; 5 | 6 | /** 7 | * Replace a hole ('??') with a given replacement string. 8 | * 9 | * @param program – The program being generated. 10 | * @param replacement – A replacement string to replace the hole in the 11 | * generated program. 12 | * @returns – A program with the earliest hole encountered replaced by the 13 | * replacement string. 14 | */ 15 | const replaceHole = (program: string, replacement: string): string => { 16 | return program.replace(EVAL_HOLE, replacement); 17 | }; 18 | 19 | /** 20 | * Interperse a set of values by a separator (without leaving a dangling 21 | * separator after the last value). 22 | * 23 | * @param values — The input array of values to separate. 24 | * @param sep – A string separator between values. 25 | * @returns – A string with values separated by @param sep. 26 | */ 27 | const intersperse = (values: string[], sep: string): string => { 28 | if (values.length === 0) { 29 | return ''; 30 | } 31 | 32 | return values 33 | .slice(1) 34 | .reduce((acc, el) => acc.concat(`${sep}${el}`), `${values[0]}`); 35 | }; 36 | 37 | /** 38 | * Generate a partial Observable Plot program from the reviz IR. 39 | * 40 | * @param spec – The reviz IR. 41 | * @returns – A partial Observable Plot program, with fields that are inferred 42 | * to map to data represented by a hole ('??'). 43 | */ 44 | export const generate = (spec: VizSpec): string => { 45 | const marks = Object.entries(spec).reduce( 46 | (program, [attrName, attrValue], i, arr) => { 47 | switch (attrName) { 48 | case 'type': 49 | return evalType(program, attrValue as string); 50 | case 'r': 51 | return evalGeomAttr(program, attrName, attrValue as number); 52 | case 'fill': 53 | case 'fill-opacity': 54 | case 'stroke': 55 | case 'stroke-opacity': 56 | case 'stroke-width': 57 | return evalPresAttr({ 58 | program, 59 | attrName, 60 | attrValue: attrValue as string[], 61 | arr, 62 | i, 63 | }); 64 | default: 65 | return program; 66 | } 67 | }, 68 | `[${EVAL_HOLE}]` 69 | ); 70 | 71 | const color = evalColor(spec); 72 | const r = evalR(spec); 73 | 74 | const members = [ 75 | `${typeof color !== 'undefined' ? `color: ${color}` : ''}`, 76 | `${typeof r !== 'undefined' ? `r: ${r}` : ''}`, 77 | `marks: ${marks}`, 78 | ].filter(Boolean); 79 | 80 | return `Plot.plot({ 81 | ${intersperse(members, ', ')} 82 | })`; 83 | }; 84 | 85 | /** 86 | * Apply rules of reviz's reduction semantics to incrementally write a partial 87 | * Observable Plot program for a given visualization type. 88 | * 89 | * @param program – The program being generated. 90 | * @param type – The visualization type, as specified in the reviz IR. 91 | * @returns – An Observable Plot partial program fragment. 92 | */ 93 | const evalType = (program: string, type: string): string => { 94 | let nextProgram = ''; 95 | 96 | switch (type) { 97 | case 'BarChart': 98 | case 'StackedBarChart': 99 | nextProgram = `Plot.barY(data, { x: '${PROGRAM_HOLE}', y: '${PROGRAM_HOLE}', ${EVAL_HOLE} })`; 100 | break; 101 | case 'Histogram': 102 | nextProgram = `Plot.barY(data, Plot.binX({ y: 'count' }, { x: '${PROGRAM_HOLE}', ${EVAL_HOLE} }))`; 103 | break; 104 | case 'BubbleChart': 105 | case 'Scatterplot': 106 | nextProgram = `Plot.dot(data, { x: '${PROGRAM_HOLE}', y: '${PROGRAM_HOLE}', ${EVAL_HOLE} })`; 107 | break; 108 | case 'StripPlot': 109 | nextProgram = `Plot.dotX(data, { x: '${PROGRAM_HOLE}', y: '${PROGRAM_HOLE}', ${EVAL_HOLE} })`; 110 | break; 111 | default: 112 | break; 113 | } 114 | 115 | return replaceHole(program, nextProgram); 116 | }; 117 | 118 | /** 119 | * Apply rules of reviz's reduction semantics to incrementally write a partial 120 | * Observable Plot program for a given geometric attribute. 121 | * 122 | * @param program – The program being generated. 123 | * @param attrName – The name of the geometric attribute being written. 124 | * @param attrValue – The value the geometric attribute is mapped to in the 125 | * reviz IR. 126 | * @returns – An Observable Plot partial program fragment. 127 | */ 128 | const evalGeomAttr = ( 129 | program: string, 130 | attrName: 'r', 131 | attrValue: number | number[] 132 | ): string => { 133 | // If our attribute value from the spec is an array, this suggests that the 134 | // attribute is mapped to a column in the input dataset rather than kept 135 | // static across the visualization. Return early with a hole ('??') for the 136 | // associated attribute. 137 | if (Array.isArray(attrValue)) { 138 | return replaceHole(program, `${attrName}: '${PROGRAM_HOLE}', ${EVAL_HOLE}`); 139 | } 140 | 141 | const replacement = `${attrName}: ${attrValue}, ${EVAL_HOLE}`; 142 | 143 | return replaceHole(program, replacement); 144 | }; 145 | 146 | interface EvalPresAttrParams { 147 | program: string; 148 | attrName: string; 149 | attrValue: string[]; 150 | arr: [string, unknown][]; 151 | i: number; 152 | } 153 | 154 | /** 155 | * Apply rules of reviz's reduction semantics to incrementally write a partial 156 | * Observable Plot program for a given presentational attribute. 157 | * 158 | * @param program – The program being generated. 159 | * @param attrName – The name of the presentational attribute being written. 160 | * @param attrValue – The value the presentational attribute is mapped to in the 161 | * reviz IR. 162 | * @returns – An Observable Plot partial program fragment. 163 | */ 164 | const evalPresAttr = ({ 165 | program, 166 | attrName, 167 | attrValue, 168 | arr, 169 | i, 170 | }: EvalPresAttrParams): string => { 171 | // If this is the last attr to be evaluated, do not leave an eval hole. 172 | const isLastAttr = i === arr.length - 1; 173 | 174 | // If our attribute value from the spec is an array with length > 1, this 175 | // suggests that the attribute is mapped to a column in the input dataset 176 | // rather than kept static across the entire visualization. Return early with 177 | // a hole ('??') for the associated attribute. 178 | if (attrValue.length > 1) { 179 | return replaceHole( 180 | program, 181 | `${camelCase(attrName)}: '${PROGRAM_HOLE}'${ 182 | !isLastAttr ? `, ${EVAL_HOLE}` : '' 183 | }` 184 | ); 185 | } 186 | 187 | // Parse all numeric values to floats and single quote all strings. 188 | const attrValueFloat = parseFloat(attrValue[0]); 189 | const val = Number.isNaN(attrValueFloat) 190 | ? `'${attrValue[0]}'` 191 | : attrValueFloat; 192 | 193 | const replacement = `${camelCase(attrName)}: ${val}${ 194 | !isLastAttr ? `, ${EVAL_HOLE}` : '' 195 | }`; 196 | 197 | return replaceHole(program, replacement); 198 | }; 199 | 200 | /** 201 | * Apply reviz's reduction semantics to incrementally write a partial 202 | * Observable Plot program for a categorical color scale. 203 | * 204 | * @param spec – The reviz IR. 205 | * @returns – An Observable Plot partial program fragment for a categorical 206 | * color scale, or undefined if color is static across the visualization. 207 | */ 208 | const evalColor = (spec: VizSpec): string | undefined => { 209 | // Return early if fill or stroke are static across the visualization. 210 | if (spec.fill.length <= 1 && spec.stroke.length <= 1) { 211 | return undefined; 212 | } 213 | 214 | let range: string[] = []; 215 | 216 | if (spec.fill.length > 1) { 217 | range = spec.fill; 218 | } else if (spec.stroke.length > 1) { 219 | range = spec.stroke; 220 | } 221 | 222 | return `{ type: "categorical", range: [${intersperse( 223 | range.map((color) => `'${color}'`), 224 | ', ' 225 | )}] }`; 226 | }; 227 | 228 | /** 229 | * Apply reviz's reduction semantics to incrementally write a partial Observable 230 | * Plot program for the "r" attribute when marks have variable radii. 231 | * 232 | * @param spec – The reviz IR. 233 | * @returns – An Observable Plot partial program fragment for the "r" attribute, 234 | * or undefined if mark radii are kept static across the visualization. 235 | */ 236 | const evalR = (spec: VizSpec): string | undefined => { 237 | if (spec.type !== 'BubbleChart') { 238 | return undefined; 239 | } 240 | 241 | return `{ range: [0, ${Math.max(...Array.from(spec.r))}] }`; 242 | }; 243 | -------------------------------------------------------------------------------- /packages/compiler/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './analyze'; 2 | -------------------------------------------------------------------------------- /packages/compiler/src/ir.ts: -------------------------------------------------------------------------------- 1 | import groupBy from 'lodash.groupby'; 2 | import orderBy from 'lodash.orderby'; 3 | 4 | import { OBSERVABLE_DEFAULT_R, PRES_ATTR_NAMES } from './constants'; 5 | import type { 6 | AttrSets, 7 | RevizCirclePositionDatum, 8 | RevizPositionDatum, 9 | RevizRectPositionDatum, 10 | } from './attributes'; 11 | 12 | export interface PresAttrs { 13 | fill: string[]; 14 | stroke: string[]; 15 | ['fill-opacity']: string[]; 16 | ['stroke-opacity']: string[]; 17 | ['stroke-width']: string[]; 18 | } 19 | 20 | interface Scatterplot extends PresAttrs { 21 | type: 'Scatterplot'; 22 | r: number; 23 | } 24 | 25 | interface BubbleChart extends PresAttrs { 26 | type: 'BubbleChart'; 27 | r: number[]; 28 | } 29 | 30 | interface StripPlot extends PresAttrs { 31 | type: 'StripPlot'; 32 | r: number; 33 | } 34 | 35 | interface BarChart extends PresAttrs { 36 | type: 'BarChart'; 37 | width: number; 38 | } 39 | 40 | interface StackedBarChart extends PresAttrs { 41 | type: 'StackedBarChart'; 42 | width: number; 43 | } 44 | 45 | interface Histogram extends PresAttrs { 46 | type: 'Histogram'; 47 | width: number; 48 | } 49 | 50 | type VizType = 51 | | 'BarChart' 52 | | 'BubbleChart' 53 | | 'Histogram' 54 | | 'Scatterplot' 55 | | 'StackedBarChart' 56 | | 'StripPlot'; 57 | 58 | export type VizSpec = 59 | | Scatterplot 60 | | BubbleChart 61 | | StripPlot 62 | | BarChart 63 | | StackedBarChart 64 | | Histogram; 65 | 66 | /** 67 | * Predicate functions. 68 | * 69 | * Predicates are used to encode constraints about a particular characteristic of a visualization. 70 | * A visualization type is _uniquely_ defined by composing a particular set of constraints. 71 | * We then use lodash's overEvery function to compose these predicates; a visualization is of a 72 | * particular type if it returns true for all of that visualization's associated predicates. 73 | */ 74 | interface VizAttrs { 75 | markType: 'circle' | 'rect'; 76 | xScaleType: 'continuous' | 'discrete'; 77 | geomAttrs: AttrSets; 78 | presAttrs: AttrSets; 79 | positionAttrs: RevizPositionDatum[]; 80 | } 81 | 82 | type Predicate = (vizAttrs: VizAttrs) => boolean; 83 | 84 | // markType predicates. 85 | const hasMarkType = 86 | (markType: 'rect' | 'circle'): Predicate => 87 | (vizAttrs): boolean => 88 | vizAttrs.markType === markType; 89 | 90 | // geomAttr predicates. 91 | const hasConsistentGeomAttr = 92 | (attr: 'r' | 'width'): Predicate => 93 | (vizAttrs): boolean => { 94 | if (vizAttrs.geomAttrs.get(attr)?.size === 1) { 95 | return true; 96 | } else if (vizAttrs.geomAttrs.has(attr)) { 97 | const approxValueSet = new Set(); 98 | 99 | vizAttrs.geomAttrs.get(attr)?.forEach((val) => { 100 | approxValueSet.add((+val).toFixed(3)); 101 | }); 102 | 103 | return approxValueSet.size === 1; 104 | } 105 | 106 | return false; 107 | }; 108 | 109 | const hasDivergentGeomAttr = 110 | (attr: 'r' | 'width'): Predicate => 111 | (vizAttrs): boolean => 112 | (vizAttrs.geomAttrs.get(attr)?.size || 0) > 1; 113 | 114 | // Element predicates. 115 | const hasSiblingsWithConsistentCyAttr: Predicate = (vizAttrs): boolean => { 116 | const circles = vizAttrs.positionAttrs 117 | .filter((d): d is RevizCirclePositionDatum => d.type === 'circle') 118 | .sort((a, b) => +a.cy - +b.cy); 119 | 120 | const lanes = Object.values(groupBy(circles, (d) => d.cy)); 121 | 122 | let laneStartCount = 0; 123 | let laneMiddleCount = 0; 124 | let laneEndCount = 0; 125 | let singleDatumCount = 0; 126 | 127 | circles.forEach(({ cy }, i, arr) => { 128 | const [prevCy, nextCy] = [arr[i - 1]?.cy, arr[i + 1]?.cy]; 129 | 130 | if (cy !== prevCy && cy === nextCy) { 131 | laneStartCount += 1; 132 | } else if (cy === prevCy && cy === nextCy) { 133 | laneMiddleCount += 1; 134 | } else if (cy === prevCy && cy !== nextCy) { 135 | laneEndCount += 1; 136 | } else { 137 | singleDatumCount += 1; 138 | } 139 | }); 140 | 141 | const distinctLanes = laneStartCount + singleDatumCount === lanes.length; 142 | const majorityInLane = 143 | laneStartCount + laneMiddleCount + laneEndCount > singleDatumCount; 144 | 145 | return distinctLanes && majorityInLane; 146 | }; 147 | 148 | const hasMultipleGroups: Predicate = (vizAttrs): boolean => { 149 | const rects = vizAttrs.positionAttrs 150 | .filter((d): d is RevizRectPositionDatum => d.type === 'rect') 151 | .sort((a, b) => +a.x - +b.x); 152 | 153 | const lanes = Object.values(groupBy(rects, (d) => d.x)); 154 | 155 | return lanes.every((lane) => lane.length > 1); 156 | }; 157 | 158 | const hasEqualSizedGroups: Predicate = (vizAttrs): boolean => { 159 | const rects = vizAttrs.positionAttrs 160 | .filter((d): d is RevizRectPositionDatum => d.type === 'rect') 161 | .sort((a, b) => +a.x - +b.x); 162 | 163 | const lanes = Object.values(groupBy(rects, (d) => d.x)); 164 | 165 | return lanes.every((lane) => lane.length === lanes[0].length); 166 | }; 167 | 168 | // Scale predicates. 169 | const hasXScaleType = 170 | (scaleType: 'continuous' | 'discrete'): Predicate => 171 | (vizAttrs): boolean => 172 | vizAttrs.xScaleType === scaleType; 173 | 174 | const vizTypeToPredicates = new Map([ 175 | ['Scatterplot', [hasMarkType('circle'), hasConsistentGeomAttr('r')]], 176 | ['BubbleChart', [hasMarkType('circle'), hasDivergentGeomAttr('r')]], 177 | [ 178 | 'StripPlot', 179 | [ 180 | hasMarkType('circle'), 181 | hasConsistentGeomAttr('r'), 182 | hasSiblingsWithConsistentCyAttr, 183 | ], 184 | ], 185 | [ 186 | 'BarChart', 187 | [ 188 | hasMarkType('rect'), 189 | hasConsistentGeomAttr('width'), 190 | hasXScaleType('discrete'), 191 | ], 192 | ], 193 | [ 194 | 'StackedBarChart', 195 | [ 196 | hasMarkType('rect'), 197 | hasConsistentGeomAttr('width'), 198 | hasXScaleType('discrete'), 199 | hasMultipleGroups, 200 | hasEqualSizedGroups, 201 | ], 202 | ], 203 | [ 204 | 'Histogram', 205 | [ 206 | hasMarkType('rect'), 207 | hasConsistentGeomAttr('width'), 208 | hasXScaleType('continuous'), 209 | ], 210 | ], 211 | ]); 212 | 213 | /** 214 | * determineVizType takes in a normalized schema containing visualization attributes and 215 | * checks this schema against the array of predicates associated with each visualization type. 216 | * It returns the visualization type for which it has the highest predicate solve rate, with 217 | * number of predicates used as a tie breaker. 218 | * 219 | * @param vizAttrs – the schema of visualization attributes. 220 | * @returns – the visualization type of the svg subtree. 221 | */ 222 | interface PredicateStats { 223 | type: VizType; 224 | numPredicates: number; 225 | solvedPredicates: number; 226 | solveRate: number; 227 | } 228 | 229 | const determineVizType = (vizAttrs: VizAttrs): VizType => { 230 | const predicateStats = [...vizTypeToPredicates.entries()].reduce< 231 | PredicateStats[] 232 | >((acc, [type, predicates]) => { 233 | const numPredicates = predicates.length; 234 | let solvedPredicates = 0; 235 | 236 | for (const predicate of predicates) { 237 | if (predicate(vizAttrs)) { 238 | solvedPredicates += 1; 239 | } 240 | } 241 | 242 | acc.push({ 243 | type, 244 | numPredicates, 245 | solvedPredicates, 246 | solveRate: solvedPredicates / numPredicates, 247 | }); 248 | 249 | return acc; 250 | }, []); 251 | 252 | const possibleVizTypes = orderBy( 253 | predicateStats, 254 | ['solveRate', 'numPredicates'], 255 | ['desc', 'desc'] 256 | ); 257 | 258 | return possibleVizTypes[0].type; 259 | }; 260 | 261 | /** 262 | * buildVizSpec generates the visualization specification from a normalized schema 263 | * containing visualization attributes. 264 | * 265 | * @param vizAttrs – the schema of visualization attributes. 266 | * @returns – the visualization specification for the svg subtree. 267 | */ 268 | export const buildVizSpec = (vizAttrs: VizAttrs): VizSpec => { 269 | const vizType = determineVizType(vizAttrs); 270 | 271 | const presAttrs = PRES_ATTR_NAMES.reduce( 272 | (acc, attr) => { 273 | acc[attr] = Array.from(vizAttrs.presAttrs.get(attr) ?? []); 274 | 275 | return acc; 276 | }, 277 | {} as Record 278 | ); 279 | 280 | switch (vizType) { 281 | case 'Scatterplot': 282 | case 'StripPlot': 283 | return { 284 | type: vizType, 285 | r: +Array.from( 286 | vizAttrs.geomAttrs.get('r') ?? [`${OBSERVABLE_DEFAULT_R}`] 287 | )[0], 288 | ...presAttrs, 289 | }; 290 | case 'BubbleChart': 291 | return { 292 | type: vizType, 293 | r: Array.from( 294 | vizAttrs.geomAttrs.get('r') ?? [`${OBSERVABLE_DEFAULT_R}`] 295 | ).map((r) => +r), 296 | ...presAttrs, 297 | }; 298 | case 'BarChart': 299 | case 'StackedBarChart': 300 | case 'Histogram': 301 | return { 302 | type: vizType, 303 | width: Number(Array.from(vizAttrs.geomAttrs.get('width') ?? ['20'])[0]), 304 | ...presAttrs, 305 | }; 306 | } 307 | }; 308 | -------------------------------------------------------------------------------- /packages/compiler/src/scales.ts: -------------------------------------------------------------------------------- 1 | import groupBy from 'lodash.groupby'; 2 | 3 | import type { RevizTextDatum } from './attributes'; 4 | 5 | /** 6 | * inferXScaleType determines the type of the xAxis scale, either discrete or continuous. 7 | * The algorithm works by grouping all text elements by their y position and finding 8 | * the group that has the greatest length. Since xAxis values are likely to have identical 9 | * y positions in most visualizations, we make the assumption that the group with the 10 | * greatest length pertains to the xAxis values. We then use the same heuristic as Plot to 11 | * deduce 'continuous' vs. 'discrete' scale types: 12 | * 13 | * - Numeric values pertain to a continuous scale 14 | * - All other values are considered discrete scales 15 | * 16 | * We intentionally don't have support for continuous date ranges yet, because we do not 17 | * support chart types (mainly line charts) that would use this scale type. 18 | * 19 | * @param textAttrs - the array of normalized RevizTextDatum elements. 20 | * @returns - the scale type of the x axis, either 'continuous' or 'discrete'. 21 | */ 22 | export const inferXScaleType = ( 23 | textAttrs: RevizTextDatum[] 24 | ): 'continuous' | 'discrete' => { 25 | const groups = groupBy(textAttrs, (d) => d.y); 26 | 27 | const xAxis = Object.values(groups).reduce((acc, group) => { 28 | if (group.length > acc.length) { 29 | acc = group.map((d) => d.text); 30 | } 31 | 32 | return acc; 33 | }, []); 34 | 35 | const areAxisValuesNumeric = xAxis.every( 36 | (label) => !Number.isNaN(parseInt(label, 10) || parseFloat(label)) 37 | ); 38 | 39 | return areAxisValuesNumeric ? 'continuous' : 'discrete'; 40 | }; 41 | -------------------------------------------------------------------------------- /packages/compiler/src/walk.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A TypeScript type guard that tests a node to see if it's of type Element. 3 | * 4 | * @param node – the candidate Node to test. 5 | * @returns boolean — true if the Node is an Element, otherwise false. 6 | */ 7 | function isElement(node: Node): node is Element { 8 | return Boolean(node && node.nodeType === Node.ELEMENT_NODE); 9 | } 10 | 11 | export type WalkCallback = (element: Element) => void; 12 | 13 | /** 14 | * A function to walk a DOM subtree and return an array of Elements in the subtree. 15 | * This implementation only visits Nodes of type Element. 16 | * 17 | * @param subtree – a DOM subtree. 18 | */ 19 | export function walk(subtree: Node, cbs: WalkCallback[] = []): void { 20 | const treeWalker = document.createTreeWalker( 21 | subtree, 22 | NodeFilter.SHOW_ELEMENT, 23 | { 24 | acceptNode: (node: Node) => { 25 | if (node.nodeType === Node.ELEMENT_NODE) { 26 | return NodeFilter.FILTER_ACCEPT; 27 | } 28 | 29 | return NodeFilter.FILTER_REJECT; 30 | }, 31 | } 32 | ); 33 | 34 | let currentNode: Node | null = treeWalker.currentNode; 35 | 36 | while (currentNode) { 37 | if (isElement(currentNode)) { 38 | const el = currentNode; 39 | cbs.forEach((cb) => cb(el)); 40 | } 41 | currentNode = treeWalker.nextNode(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/compiler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["esnext", "dom"], 5 | "noUnusedLocals": true, 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "noImplicitAny": true, 9 | "noUnusedParameters": true, 10 | "pretty": true, 11 | "skipLibCheck": true, 12 | "sourceMap": true, 13 | "strict": true, 14 | "target": "esnext", 15 | "allowJs": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "incremental": true, 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": ["src/*", "__tests__/*"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /packages/examples/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. If a change is missing an attribution, it may have been made by a Core Contributor. 4 | 5 | - Critical bugfixes or breaking changes are marked using a warning symbol: ⚠️ 6 | - Significant new features or enhancements are marked using the sparkles symbol: ✨ 7 | 8 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 9 | 10 | ## [0.5.0] 11 | 12 | - This marks the initial release of `@reviz/examples`. We're starting versioning at 0.5.0 to continue upwards from the last release of `reviz` at 0.4.1. 13 | -------------------------------------------------------------------------------- /packages/examples/README.md: -------------------------------------------------------------------------------- 1 |
2 | reviz 7 |
8 |
9 | 10 | A lightweight engine for reverse engineering data visualizations from the DOM 11 | 12 |
13 |
14 |
15 | 16 | `reviz` is a lightweight engine for reverse engineering data visualizations from the DOM. Its core goal is to assist in rapid visualization sketching and prototyping by automatically generating partial programs written using [Observable Plot](https://observablehq.com/@observablehq/plot) from input `svg` subtrees. 17 | 18 | This package contains the source for [our examples site](https://reviz.vercel.app). 19 | 20 | ## Local Development 21 | 22 | To run the examples site locally, ensure you have [dependencies installed](../../CONTRIBUTING.md#local-development). Next, run `npm run dev` to start a development server at http://localhost:3000. That's it! 23 | 24 | ## Application Structure 25 | 26 | The examples site is a [Next.js](https://nextjs.org/) application, built on TypeScript, React, and [Tailwind CSS](https://tailwindcss.com/). The root route (`/`) is located at `app/page.tsx`. Each individual example is located in its respective directory (e.g., `app/examples/bar/page.tsx`, `app/examples/bubble/page.tsx`, etc.). 27 | 28 | Data for each example is decoupled from its route. You can find the raw data used in each example in the `src/data` directory. Data is read directly by the top-level route component from the filesystem. This is made possible by the use of [React Server Components](https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023#react-server-components) in Next >= 13. While this makes data fetching a breeze, it also forces us to break down each route into clearly server-only or client-server components. For example, each route has an associated chart type component located in `src/components/charts`. These components rely on calling `useEffect` to render the Plot chart on the client; hence, they need to be separate components annotated with the `'use client'` directive. 29 | -------------------------------------------------------------------------------- /packages/examples/app/examples/bar/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | 3 | import RevizBarChart from '../../../src/components/charts/BarChart'; 4 | import Visualization from '../../../src/components/shared/Visualization'; 5 | import type { Letter } from '../../../src/data/alphabet'; 6 | import { readData } from '../../../src/helpers/server'; 7 | 8 | export const metadata: Metadata = { 9 | title: 'reviz: Bar Chart', 10 | description: 11 | 'The reviz compiler can reverse engineer bar charts from the DOM, such as this example from the Observable team.', 12 | }; 13 | 14 | const BarChart: React.FC = () => { 15 | const data = readData('alphabet'); 16 | 17 | return ( 18 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default BarChart; 28 | -------------------------------------------------------------------------------- /packages/examples/app/examples/bubble/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | 3 | import RevizBubbleChart from '../../../src/components/charts/BubbleChart'; 4 | import Visualization from '../../../src/components/shared/Visualization'; 5 | import type { Car } from '../../../src/data/cars'; 6 | import { readData } from '../../../src/helpers/server'; 7 | 8 | export const metadata: Metadata = { 9 | title: 'reviz: Bubble Chart', 10 | description: 11 | 'The reviz compiler can reverse engineer bubble charts from the DOM, such as this example from the Observable team.', 12 | }; 13 | 14 | const BubbleChart: React.FC = () => { 15 | const data = readData('cars'); 16 | 17 | return ( 18 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default BubbleChart; 28 | -------------------------------------------------------------------------------- /packages/examples/app/examples/histogram/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | 3 | import RevizHistogram from '../../../src/components/charts/Histogram'; 4 | import Visualization from '../../../src/components/shared/Visualization'; 5 | 6 | export const metadata: Metadata = { 7 | title: 'reviz: Histogram', 8 | description: 9 | 'The reviz compiler can reverse engineer histograms from the DOM, such as this example from Parker Ziegler on Observable.', 10 | }; 11 | 12 | const Histogram: React.FC = () => { 13 | return ( 14 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default Histogram; 24 | -------------------------------------------------------------------------------- /packages/examples/app/examples/scatterplot/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | 3 | import RevizScatterplot from '../../../src/components/charts/Scatterplot'; 4 | import Visualization from '../../../src/components/shared/Visualization'; 5 | import type { Athlete } from '../../../src/data/athletes'; 6 | import { readData } from '../../../src/helpers/server'; 7 | 8 | export const metadata: Metadata = { 9 | title: 'reviz: Scatterplot', 10 | description: 11 | 'The reviz compiler can reverse engineer scatterplots from the DOM, such as this example from the Observable team.', 12 | }; 13 | 14 | const Scatterplot: React.FC = () => { 15 | const data = readData('athletes'); 16 | 17 | return ( 18 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default Scatterplot; 28 | -------------------------------------------------------------------------------- /packages/examples/app/examples/stacked-bar/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | 3 | import RevizStackedBarChart from '../../../src/components/charts/StackedBarChart'; 4 | import Visualization from '../../../src/components/shared/Visualization'; 5 | import type { DeathRecord } from '../../../src/data/crimea'; 6 | import { readData } from '../../../src/helpers/server'; 7 | 8 | export const metadata: Metadata = { 9 | title: 'reviz: Stacked Bar Chart', 10 | description: 11 | 'The reviz compiler can reverse engineer stacked bar charts from the DOM, such as this example from the Observable team.', 12 | }; 13 | 14 | const StackedBarChart: React.FC = () => { 15 | const data = readData('crimea'); 16 | 17 | return ( 18 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default StackedBarChart; 28 | -------------------------------------------------------------------------------- /packages/examples/app/examples/strip-plot/page.tsx: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | import path from 'node:path'; 3 | 4 | import type { Metadata } from 'next'; 5 | import * as d3 from 'd3'; 6 | 7 | import RevizStripPlot from '../../../src/components/charts/StripPlot'; 8 | import Visualization from '../../../src/components/shared/Visualization'; 9 | import { State } from '../../../src/data/states'; 10 | 11 | export const metadata: Metadata = { 12 | title: 'reviz: Strip Plot', 13 | description: 14 | 'The reviz compiler can reverse engineer strip plots from the DOM, such as this example from the Observable team.', 15 | }; 16 | 17 | const StripPlot: React.FC = () => { 18 | const stateage = readFileSync( 19 | path.join(process.cwd(), 'src/data/us-distribution-state-age.csv') 20 | ); 21 | const data = d3.csvParse(stateage.toString(), (d) => { 22 | return { 23 | state: d.state, 24 | age: d.age, 25 | population: +d.population, 26 | }; 27 | }); 28 | 29 | return ( 30 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default StripPlot; 40 | -------------------------------------------------------------------------------- /packages/examples/app/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | * { 7 | box-sizing: border-box; 8 | } 9 | 10 | html { 11 | font-size: 10px; 12 | } 13 | 14 | svg { 15 | font: 16 | 10px system-ui, 17 | sans-serif; 18 | } 19 | } 20 | 21 | @layer utilities { 22 | .stack { 23 | @apply flex flex-col justify-start; 24 | } 25 | 26 | .stack-sm > * + * { 27 | @apply mt-4; 28 | } 29 | 30 | .stack-base > * + * { 31 | @apply mt-8; 32 | } 33 | 34 | .stack-lg > * + * { 35 | @apply mt-16; 36 | } 37 | } 38 | 39 | @layer components { 40 | .viz-grid { 41 | grid-template-areas: 42 | "viz spec" 43 | "viz program"; 44 | grid-template-columns: minmax(40rem, 1fr) minmax(40rem, 50rem); 45 | } 46 | 47 | .viz-grid :first-child { 48 | grid-area: viz; 49 | } 50 | 51 | .viz-grid :nth-child(2) { 52 | grid-area: spec; 53 | } 54 | 55 | .viz-grid :last-child { 56 | grid-area: program; 57 | } 58 | } 59 | 60 | /* Transferred CSS for New York Times – Least Vaccinated U.S. Counties Have Something in Common:Trump Voters */ 61 | .layer.svelte-jdsvwm.svelte-jdsvwm { 62 | width: 100%; 63 | height: 450px; 64 | } 65 | 66 | .g-tick.svelte-jdsvwm.svelte-jdsvwm { 67 | stroke: #eeeeee; 68 | stroke-width: 1; 69 | } 70 | 71 | .state_label { 72 | font-size: 13px; 73 | font-weight: 300; 74 | } 75 | 76 | /* Transferred CSS for NPR – America's 200,000 COVID-19 Deaths: Small Cities And Towns Bear A Growing Share */ 77 | .npr-axis { 78 | font-size: 11px; 79 | -webkit-font-smoothing: antialiased; 80 | fill: #999; 81 | } 82 | 83 | .npr-axis path, 84 | .npr-axis line { 85 | fill: none; 86 | stroke: #ccc; 87 | shape-rendering: crispEdges; 88 | } 89 | 90 | .npr-axis.npr-y .npr-tick line, 91 | .npr-axis.npr-y path, 92 | .npr-grid path { 93 | display: none; 94 | } 95 | 96 | .npr-bar text { 97 | fill: #fff; 98 | font-size: 12px; 99 | } 100 | 101 | .npr-grid .npr-tick { 102 | stroke: #eee; 103 | color: #eee; 104 | stroke-width: 1px; 105 | } 106 | -------------------------------------------------------------------------------- /packages/examples/app/layout.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line camelcase 2 | import { Source_Serif_4 } from 'next/font/google'; 3 | 4 | import './index.css'; 5 | 6 | const sourceSerifPro = Source_Serif_4({ 7 | weight: ['400', '600'], 8 | subsets: ['latin'], 9 | }); 10 | 11 | export default function RootLayout({ 12 | children, 13 | }: { 14 | children: React.ReactNode; 15 | }): React.ReactElement { 16 | return ( 17 | 18 | 19 |
{children}
20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /packages/examples/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { readdirSync } from 'node:fs'; 2 | import path from 'node:path'; 3 | 4 | import * as React from 'react'; 5 | import type { Metadata } from 'next'; 6 | 7 | import Card from '../src/components/shared/Card'; 8 | import { normalizeExampleName } from '../src/helpers/isomorphic'; 9 | import { RevizLogo } from '../src/helpers/logos'; 10 | import { metadata as examplesMetadata } from '../src/helpers/metadata'; 11 | 12 | export const metadata: Metadata = { 13 | title: 'reviz: Examples', 14 | description: 15 | 'A collection of examples showing how reviz can reverse engineer diverse SVG visualizations from the DOM.', 16 | }; 17 | 18 | /** 19 | * Fetch and sort examples from the filesystem. 20 | * 21 | * @returns – A sorted array of example names. 22 | */ 23 | function getExamples(): string[] { 24 | const examples = readdirSync( 25 | path.join(process.cwd(), 'app', 'examples') 26 | ).sort((a, b) => (a.toLowerCase() > b.toLowerCase() ? 1 : -1)); 27 | 28 | return examples; 29 | } 30 | 31 | const Index: React.FC = () => { 32 | const examples = getExamples(); 33 | 34 | return ( 35 |
36 |
37 |

38 | {RevizLogo} 39 | Examples 40 |

41 |
42 |
43 |

44 | See how reviz generates partial Observable Plot programs 45 | from this collection of example data visualizations. 46 |

47 |
    48 | {examples.map((example) => { 49 | const name = normalizeExampleName(example); 50 | 51 | return ( 52 |
  • 56 | 63 |
  • 64 | ); 65 | })} 66 |
67 |
68 |
69 | ); 70 | }; 71 | 72 | export default Index; 73 | -------------------------------------------------------------------------------- /packages/examples/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /packages/examples/next.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @type {import('next').NextConfig} 5 | */ 6 | module.exports = { 7 | reactStrictMode: true, 8 | productionBrowserSourceMaps: true, 9 | transpilePackages: ['@reviz/compiler', '@reviz/ui'], 10 | }; 11 | -------------------------------------------------------------------------------- /packages/examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@reviz/examples", 3 | "private": true, 4 | "version": "0.5.0", 5 | "description": "Examples for reviz, an engine for reverse engineering data visualizations from the DOM.", 6 | "author": { 7 | "name": "Parker Ziegler", 8 | "email": "peziegler@cs.berkeley.edu" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/parkerziegler/reviz.git" 13 | }, 14 | "bugs": "https://github.com/parkerziegler/reviz/issues", 15 | "homepage": "https://reviz.vercel.app", 16 | "keywords": [ 17 | "data visualization", 18 | "documentation", 19 | "reverse engineering", 20 | "observable", 21 | "observable plot", 22 | "typescript" 23 | ], 24 | "scripts": { 25 | "dev": "next dev", 26 | "build": "next build", 27 | "start": "next start" 28 | }, 29 | "dependencies": { 30 | "@observablehq/plot": "^0.6.9", 31 | "@reviz/compiler": "^0.5.0", 32 | "@reviz/ui": "^0.5.0", 33 | "classnames": "^2.3.2", 34 | "d3": "^7.8.4", 35 | "lodash.groupby": "^4.6.0", 36 | "next": "^14.1.1", 37 | "prettier": "^3.2.5", 38 | "react": "^18.2.0", 39 | "react-dom": "^18.2.0", 40 | "sharp": "^0.32.1" 41 | }, 42 | "devDependencies": { 43 | "@types/d3": "^7.4.0", 44 | "@types/lodash.groupby": "^4.6.7", 45 | "@types/node": "^18.16.1", 46 | "@types/react": "^18.2.62", 47 | "@types/react-dom": "^18.2.19", 48 | "autoprefixer": "^10.4.14", 49 | "postcss": "^8.4.24", 50 | "tailwindcss": "^3.3.2", 51 | "typescript": "^5.1.6" 52 | } 53 | } -------------------------------------------------------------------------------- /packages/examples/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/examples/public/NPR-covid-shift.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parkerziegler/reviz/e2dcfbe9e1dee8288835bba30841856a3ff05c5d/packages/examples/public/NPR-covid-shift.png -------------------------------------------------------------------------------- /packages/examples/public/bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parkerziegler/reviz/e2dcfbe9e1dee8288835bba30841856a3ff05c5d/packages/examples/public/bar.png -------------------------------------------------------------------------------- /packages/examples/public/bubble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parkerziegler/reviz/e2dcfbe9e1dee8288835bba30841856a3ff05c5d/packages/examples/public/bubble.png -------------------------------------------------------------------------------- /packages/examples/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parkerziegler/reviz/e2dcfbe9e1dee8288835bba30841856a3ff05c5d/packages/examples/public/favicon.png -------------------------------------------------------------------------------- /packages/examples/public/histogram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parkerziegler/reviz/e2dcfbe9e1dee8288835bba30841856a3ff05c5d/packages/examples/public/histogram.png -------------------------------------------------------------------------------- /packages/examples/public/new-york-times-vaccine-voting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parkerziegler/reviz/e2dcfbe9e1dee8288835bba30841856a3ff05c5d/packages/examples/public/new-york-times-vaccine-voting.png -------------------------------------------------------------------------------- /packages/examples/public/scatterplot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parkerziegler/reviz/e2dcfbe9e1dee8288835bba30841856a3ff05c5d/packages/examples/public/scatterplot.png -------------------------------------------------------------------------------- /packages/examples/public/stacked-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parkerziegler/reviz/e2dcfbe9e1dee8288835bba30841856a3ff05c5d/packages/examples/public/stacked-bar.png -------------------------------------------------------------------------------- /packages/examples/public/strip-plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parkerziegler/reviz/e2dcfbe9e1dee8288835bba30841856a3ff05c5d/packages/examples/public/strip-plot.png -------------------------------------------------------------------------------- /packages/examples/src/components/charts/BarChart.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as d3 from 'd3'; 5 | 6 | import type { Letter } from '../../data/alphabet'; 7 | 8 | const dimensions = { 9 | width: 640, 10 | height: 400, 11 | }; 12 | 13 | const margin = { 14 | top: 40, 15 | left: 60, 16 | right: 40, 17 | bottom: 40, 18 | }; 19 | 20 | interface Props { 21 | data: Letter[]; 22 | } 23 | 24 | const BarChart: React.FC = ({ data }) => { 25 | const xDomain = new d3.InternSet(d3.map(data, (d) => d.letter)); 26 | 27 | const x = d3 28 | .scaleBand(xDomain, [margin.left, dimensions.width - margin.right]) 29 | .padding(0.1); 30 | const y = d3.scaleLinear(d3.extent(d3.map(data, (d) => d.frequency)), [ 31 | dimensions.height - margin.bottom, 32 | margin.top, 33 | ]); 34 | 35 | const xAxis = d3.axisBottom(x).tickSizeOuter(0); 36 | const yAxis = d3.axisLeft(y).ticks(dimensions.height / 40, '%'); 37 | 38 | const xAxisRef = React.useRef(null); 39 | const yAxisRef = React.useRef(null); 40 | 41 | React.useEffect(() => { 42 | const xAx = xAxisRef.current; 43 | const yAx = yAxisRef.current; 44 | 45 | d3.select(xAx).call(xAxis); 46 | d3.select(yAx) 47 | .call(yAxis) 48 | .call((g) => g.select('.domain').remove()) 49 | .call((g) => 50 | g 51 | .append('text') 52 | .attr('x', -margin.left) 53 | .attr('y', margin.top) 54 | .attr('fill', 'currentColor') 55 | .attr('text-anchor', 'start') 56 | .text('↑ Frequency') 57 | ); 58 | 59 | return (): void => { 60 | d3.select(yAx).call((g) => g.selectAll('.tick line ~ line').remove()); 61 | d3.select(yAx).call((g) => g.selectAll('text').remove()); 62 | }; 63 | }, [xAxis, yAxis]); 64 | 65 | return ( 66 | 72 | 73 | 74 | 75 | {data.map((d) => ( 76 | 84 | ))} 85 | 86 | 87 | ); 88 | }; 89 | 90 | export default BarChart; 91 | -------------------------------------------------------------------------------- /packages/examples/src/components/charts/BubbleChart.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as Plot from '@observablehq/plot'; 5 | 6 | import type { Car } from '../../data/cars'; 7 | 8 | interface Props { 9 | data: Car[]; 10 | } 11 | 12 | const BubbleChart: React.FC = ({ data }) => { 13 | const root = React.useRef(null); 14 | 15 | React.useEffect(() => { 16 | const node = root.current; 17 | 18 | const plot = Plot.plot({ 19 | grid: true, 20 | r: { 21 | range: [0, 8], 22 | }, 23 | marks: [ 24 | Plot.dot(data, { 25 | x: '0-60 mph (s)', 26 | y: 'power (hp)', 27 | stroke: 'orange', 28 | r: 'economy (mpg)', 29 | }), 30 | ], 31 | }); 32 | 33 | node.appendChild(plot); 34 | 35 | return (): void => { 36 | plot.remove(); 37 | }; 38 | }, [data]); 39 | 40 | return
; 41 | }; 42 | 43 | export default BubbleChart; 44 | -------------------------------------------------------------------------------- /packages/examples/src/components/charts/Histogram.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as d3 from 'd3'; 5 | 6 | /** 7 | * Compute the number of steps required to reach 1 for a given integer by 8 | * applying the algorithm of the Collatz conjecture. 9 | * 10 | * @param n – The input integer. 11 | * @param stoppingTime – The current number of steps. Used for recursvie calls. 12 | * @returns – The number of steps required to reach 1. 13 | */ 14 | function collatz(n: number, stoppingTime = 0): number { 15 | // Base case, n has reached 1. 16 | if (n === 1) { 17 | return stoppingTime; 18 | } 19 | 20 | // Recursive case: n is even. 21 | if (n % 2 === 0) { 22 | return collatz(n / 2, stoppingTime + 1); 23 | } 24 | 25 | // Recursive case: n is odd. 26 | return collatz(3 * n + 1, stoppingTime + 1); 27 | } 28 | 29 | const data = new Array(1000).fill(0).map((_, i) => collatz(i + 1, 0)); 30 | 31 | const dimensions = { 32 | width: 640, 33 | height: 400, 34 | }; 35 | 36 | const margin = { 37 | top: 40, 38 | left: 60, 39 | right: 40, 40 | bottom: 40, 41 | }; 42 | 43 | const Histogram: React.FC = () => { 44 | const max = d3.max(data); 45 | 46 | // Generate a threshold for each stopping time value. 47 | const thresholds = []; 48 | for (let i = 0; i <= max; i++) { 49 | thresholds.push(i); 50 | } 51 | 52 | // Apply the data to the thresholds. 53 | const bins = d3.bin().thresholds(thresholds)(data); 54 | 55 | // Define scales. 56 | const x = d3 57 | .scaleLinear() 58 | .domain([bins[0].x0, bins[bins.length - 1].x1]) 59 | .range([margin.left, dimensions.width - margin.right]); 60 | 61 | const y = d3 62 | .scaleLinear() 63 | .domain([0, d3.max(bins, (d) => d.length)]) 64 | .nice() 65 | .range([dimensions.height - margin.bottom, margin.top]); 66 | 67 | const xAxisRef = React.useRef(null); 68 | const yAxisRef = React.useRef(null); 69 | 70 | const xAxis = d3 71 | .axisBottom(x) 72 | .ticks(dimensions.width / 80) 73 | .tickSizeOuter(0); 74 | 75 | const yAxis = d3.axisLeft(y).tickSizeOuter(0); 76 | 77 | React.useEffect(() => { 78 | const xAx = xAxisRef.current; 79 | const yAx = yAxisRef.current; 80 | 81 | d3.select(xAx).call(xAxis); 82 | d3.select(yAx).call(yAxis); 83 | }, [xAxis, yAxis]); 84 | 85 | return ( 86 | 92 | 93 | {bins.map((d, i) => ( 94 | 102 | ))} 103 | 104 | 108 | 114 | Stopping Time 115 | 116 | 117 | 118 | 125 | Frequency 126 | 127 | 128 | 129 | ); 130 | }; 131 | 132 | export default Histogram; 133 | -------------------------------------------------------------------------------- /packages/examples/src/components/charts/Scatterplot.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as Plot from '@observablehq/plot'; 5 | 6 | import type { Athlete } from '../../data/athletes'; 7 | 8 | interface Props { 9 | data: Athlete[]; 10 | } 11 | 12 | const Scatterplot: React.FC = ({ data }) => { 13 | const root = React.useRef(null); 14 | 15 | React.useEffect(() => { 16 | const node = root.current; 17 | 18 | const plot = Plot.plot({ 19 | marks: [ 20 | Plot.dot(data, { 21 | x: 'weight', 22 | y: 'height', 23 | stroke: 'sex', 24 | }), 25 | ], 26 | }); 27 | 28 | node.appendChild(plot); 29 | 30 | return (): void => { 31 | plot.remove(); 32 | }; 33 | }, [data]); 34 | 35 | return
; 36 | }; 37 | 38 | export default Scatterplot; 39 | -------------------------------------------------------------------------------- /packages/examples/src/components/charts/StackedBarChart.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as d3 from 'd3'; 5 | import groupBy from 'lodash.groupby'; 6 | 7 | import type { DeathRecord, StackRecord } from '../../data/crimea'; 8 | 9 | const dimensions = { 10 | width: 640, 11 | height: 400, 12 | }; 13 | 14 | const margin = { 15 | top: 40, 16 | left: 60, 17 | right: 40, 18 | bottom: 40, 19 | }; 20 | 21 | interface Props { 22 | data: DeathRecord[]; 23 | } 24 | 25 | const StackedBarChart: React.FC = ({ data }) => { 26 | // Compute x (date) domain and z (cause) domain. We use d3's InternSet so we 27 | // can compute Sets even with Date values. 28 | const xDomain = new d3.InternSet(d3.map(data, (d) => d.date)); 29 | const zDomain = new d3.InternSet(d3.map(data, (d) => d.cause)); 30 | 31 | // Massage the data into a format appropriate for d3.stack. 32 | const deathsByMonth = groupBy(data, 'date'); 33 | const stackData = Object.entries(deathsByMonth).map( 34 | ([date, record]) => { 35 | return { 36 | date, 37 | disease: record.find(({ cause }) => cause === 'disease')?.deaths ?? 0, 38 | wounds: record.find(({ cause }) => cause === 'wounds')?.deaths ?? 0, 39 | other: record.find(({ cause }) => cause === 'other')?.deaths ?? 0, 40 | }; 41 | } 42 | ); 43 | 44 | // Use d3.stack to create a stack generator. d3.stack will create a 3D array like so: 45 | // [[[y1, y2], [y1, y2], ...], [[y1, y2], ...]] 46 | // This captures each set of bars associated with a particular cause of death. 47 | const stack = d3 48 | .stack() 49 | .keys(zDomain) 50 | .order(d3.stackOrderNone) 51 | .offset(d3.stackOffsetNone); 52 | 53 | // @ts-expect-error – d3 will ignore string properties on stack data. 54 | const series = stack(stackData); 55 | const yDomain = d3.extent(series.flat(2)); 56 | 57 | // Set up x, y, and color scales. 58 | const x = d3 59 | .scaleBand(xDomain, [margin.left, dimensions.width - margin.right]) 60 | .paddingInner(0.1); 61 | const y = d3.scaleLinear(yDomain, [ 62 | dimensions.height - margin.bottom, 63 | margin.top, 64 | ]); 65 | const color = d3.scaleOrdinal(zDomain, d3.schemeTableau10); 66 | 67 | // Define axis generators. 68 | const xAxis = d3 69 | .axisBottom(x) 70 | .tickSizeOuter(0) 71 | .tickFormat((d) => 72 | new Date(d).toLocaleString('default', { month: 'short' }) 73 | ); 74 | const yAxis = d3.axisLeft(y).ticks(dimensions.height / 60); 75 | 76 | const xAxisRef = React.useRef(null); 77 | const yAxisRef = React.useRef(null); 78 | 79 | React.useEffect(() => { 80 | const xAx = xAxisRef.current; 81 | const yAx = yAxisRef.current; 82 | 83 | d3.select(xAx).call(xAxis); 84 | 85 | d3.select(yAx) 86 | .call(yAxis) 87 | .call((g) => g.select('.domain').remove()) 88 | .call((g) => 89 | g 90 | .selectAll('.tick line') 91 | .clone() 92 | .attr('x2', dimensions.width - margin.left - margin.right) 93 | .attr('stroke-opacity', 0.1) 94 | ); 95 | 96 | return (): void => { 97 | d3.select(yAx).call((g) => g.selectAll('.tick line ~ line').remove()); 98 | }; 99 | }, [data, xAxis, yAxis]); 100 | 101 | return ( 102 | 108 | 109 | 110 | 111 | {series.map((datum, i) => { 112 | const fill = color(datum.key); 113 | 114 | return ( 115 | 116 | {datum.map((d, i) => ( 117 | 124 | ))} 125 | 126 | ); 127 | })} 128 | 129 | 130 | ); 131 | }; 132 | 133 | export default StackedBarChart; 134 | -------------------------------------------------------------------------------- /packages/examples/src/components/charts/StripPlot.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as d3 from 'd3'; 5 | 6 | import type { State } from '../../data/states'; 7 | 8 | const dimensions = { 9 | width: 640, 10 | height: 400, 11 | }; 12 | 13 | const margin = { 14 | top: 40, 15 | left: 60, 16 | right: 40, 17 | bottom: 40, 18 | }; 19 | 20 | interface Props { 21 | data: State[]; 22 | } 23 | 24 | const StripPlot: React.FC = ({ data }) => { 25 | const x = d3 26 | .scaleLinear() 27 | .domain(d3.extent(data, (d) => d.population)) 28 | .rangeRound([margin.left + 10, dimensions.width - margin.right]); 29 | 30 | const y = d3 31 | .scalePoint() 32 | .domain(data.map((d) => d.age)) 33 | .rangeRound([margin.top, dimensions.height - margin.bottom]) 34 | .padding(1); 35 | 36 | const xAxis = d3.axisTop(x).ticks(null, '%'); 37 | const yAxis = d3.axisLeft(y); 38 | 39 | const xAxisRef = React.useRef(null); 40 | const yAxisRef = React.useRef(null); 41 | 42 | React.useEffect(() => { 43 | const xAx = xAxisRef.current; 44 | const yAx = yAxisRef.current; 45 | 46 | d3.select(xAx) 47 | .call(xAxis) 48 | .call((g) => 49 | g 50 | .selectAll('.tick line') 51 | .clone() 52 | .attr('stroke-opacity', 0.1) 53 | .attr('y2', dimensions.height - margin.bottom - margin.top) 54 | ) 55 | .call((g) => g.selectAll('.domain').remove()); 56 | 57 | d3.select(yAx) 58 | .call(yAxis) 59 | .call((g) => 60 | g 61 | .selectAll('.tick line') 62 | .clone() 63 | .attr('stroke-opacity', 0.1) 64 | .attr('x2', dimensions.width - margin.right - margin.left) 65 | ) 66 | .call((g) => g.selectAll('.domain').remove()); 67 | 68 | return (): void => { 69 | d3.select(xAx).call((g) => g.selectAll('.tick line ~ line').remove()); 70 | d3.select(yAx).call((g) => g.selectAll('.tick line ~ line').remove()); 71 | }; 72 | }, [xAxis, yAxis]); 73 | 74 | return ( 75 | 81 | 82 | {data.map((d) => ( 83 | 92 | ))} 93 | 94 | 95 | 96 | 97 | ); 98 | }; 99 | 100 | export default StripPlot; 101 | -------------------------------------------------------------------------------- /packages/examples/src/components/shared/Card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Image, { type StaticImageData } from 'next/image'; 3 | 4 | interface Props { 5 | name: string; 6 | href: string; 7 | description: string; 8 | image: { 9 | src: StaticImageData; 10 | alt: string; 11 | }; 12 | icon?: React.ReactNode; 13 | } 14 | 15 | const Card: React.FC = ({ name, href, description, image, icon }) => { 16 | return ( 17 | 21 | {image.alt} 22 | {icon ? ( 23 |
24 | {icon} 25 |

{name}

26 |
27 | ) : ( 28 | name 29 | )} 30 |

{description}

31 |
32 | ); 33 | }; 34 | 35 | export default Card; 36 | -------------------------------------------------------------------------------- /packages/examples/src/components/shared/CodePane.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { CodePane as Code } from '@reviz/ui'; 3 | 4 | interface Props { 5 | code: string; 6 | name: string; 7 | compile: () => void; 8 | perf: number; 9 | } 10 | 11 | const CodePane: React.FC = ({ code, name, compile, perf }) => { 12 | return ( 13 |
14 | 20 |
21 | 44 | {perf ? ( 45 |

46 | Compiled in {Math.round(perf)} ms 47 |

48 | ) : null} 49 |

{name}

50 |
51 |
52 | ); 53 | }; 54 | 55 | export default CodePane; 56 | -------------------------------------------------------------------------------- /packages/examples/src/components/shared/RevizOutput.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { format } from 'prettier/standalone'; 5 | import babel from 'prettier/plugins/babel'; 6 | import estree from 'prettier/plugins/estree'; 7 | import { analyzeVisualization } from '@reviz/compiler'; 8 | 9 | import CodePane from './CodePane'; 10 | 11 | const RevizOutput: React.FC = () => { 12 | const [vizSpec, setVizSpec] = React.useState('Click to compile...'); 13 | const [vizProgram, setVizProgram] = React.useState('Click to compile...'); 14 | const [perf, setPerf] = React.useState(0); 15 | 16 | const compile = React.useCallback(() => { 17 | const viz = document.querySelector('svg'); 18 | 19 | performance.mark('pre-compile'); 20 | 21 | const { spec, program } = analyzeVisualization(viz); 22 | 23 | performance.mark('post-compile'); 24 | performance.measure('compile', 'pre-compile', 'post-compile'); 25 | 26 | const entries = performance.getEntriesByName('compile'); 27 | const result = entries.map((entry) => entry.duration); 28 | 29 | format(program, { 30 | parser: 'babel', 31 | plugins: [babel, estree], 32 | }) 33 | .then((formattedProgram) => { 34 | setVizSpec(JSON.stringify(spec, null, 2)); 35 | setVizProgram(formattedProgram); 36 | setPerf(result[0]); 37 | }) 38 | .catch((error) => { 39 | console.error(error); 40 | setVizSpec(JSON.stringify(spec, null, 2)); 41 | setVizProgram(''); 42 | setPerf(result[0]); 43 | }); 44 | }, []); 45 | 46 | return ( 47 | <> 48 | 49 | 55 | 56 | ); 57 | }; 58 | 59 | export default RevizOutput; 60 | -------------------------------------------------------------------------------- /packages/examples/src/components/shared/Visualization.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import RevizOutput from './RevizOutput'; 4 | 5 | interface Props { 6 | href: string; 7 | title: string; 8 | } 9 | 10 | const Visualization: React.FC> = ({ 11 | href, 12 | title, 13 | children, 14 | }) => { 15 | return ( 16 |
17 |
18 |
{children}
19 | 25 | {title} 26 | 27 |
28 | 29 |
30 | ); 31 | }; 32 | 33 | export default Visualization; 34 | -------------------------------------------------------------------------------- /packages/examples/src/data/alphabet.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "letter": "E", "frequency": 0.12702 }, 3 | { "letter": "T", "frequency": 0.09056 }, 4 | { "letter": "A", "frequency": 0.08167 }, 5 | { "letter": "O", "frequency": 0.07507 }, 6 | { "letter": "I", "frequency": 0.06966 }, 7 | { "letter": "N", "frequency": 0.06749 }, 8 | { "letter": "S", "frequency": 0.06327 }, 9 | { "letter": "H", "frequency": 0.06094 }, 10 | { "letter": "R", "frequency": 0.05987 }, 11 | { "letter": "D", "frequency": 0.04253 }, 12 | { "letter": "L", "frequency": 0.04025 }, 13 | { "letter": "C", "frequency": 0.02782 }, 14 | { "letter": "U", "frequency": 0.02758 }, 15 | { "letter": "M", "frequency": 0.02406 }, 16 | { "letter": "W", "frequency": 0.0236 }, 17 | { "letter": "F", "frequency": 0.02288 }, 18 | { "letter": "G", "frequency": 0.02015 }, 19 | { "letter": "Y", "frequency": 0.01974 }, 20 | { "letter": "P", "frequency": 0.01929 }, 21 | { "letter": "B", "frequency": 0.01492 }, 22 | { "letter": "V", "frequency": 0.00978 }, 23 | { "letter": "K", "frequency": 0.00772 }, 24 | { "letter": "J", "frequency": 0.00153 }, 25 | { "letter": "X", "frequency": 0.0015 }, 26 | { "letter": "Q", "frequency": 0.00095 }, 27 | { "letter": "Z", "frequency": 0.00074 } 28 | ] 29 | -------------------------------------------------------------------------------- /packages/examples/src/data/alphabet.ts: -------------------------------------------------------------------------------- 1 | export interface Letter { 2 | letter: string; 3 | frequency: number; 4 | } 5 | -------------------------------------------------------------------------------- /packages/examples/src/data/athletes.ts: -------------------------------------------------------------------------------- 1 | export interface Athlete { 2 | id: number; 3 | name: string; 4 | nationality: string; 5 | sex: string; 6 | date_of_birth: string; 7 | height: number; 8 | weight: number; 9 | sport: string; 10 | gold: number; 11 | silver: number; 12 | bronze: number; 13 | info: string | null; 14 | } 15 | -------------------------------------------------------------------------------- /packages/examples/src/data/cars.ts: -------------------------------------------------------------------------------- 1 | export interface Car { 2 | name: string; 3 | 'economy (mpg)': number; 4 | cylinders: number; 5 | 'displacement (cc)': number; 6 | 'power (hp)': number; 7 | 'weight (lb)': number; 8 | '0-60 mph (s)': number; 9 | year: number; 10 | } 11 | -------------------------------------------------------------------------------- /packages/examples/src/data/crimea.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "date": "1854-04-01T00:00:00.000Z", "cause": "disease", "deaths": 1 }, 3 | { "date": "1854-05-01T00:00:00.000Z", "cause": "disease", "deaths": 12 }, 4 | { "date": "1854-06-01T00:00:00.000Z", "cause": "disease", "deaths": 11 }, 5 | { "date": "1854-07-01T00:00:00.000Z", "cause": "disease", "deaths": 359 }, 6 | { "date": "1854-08-01T00:00:00.000Z", "cause": "disease", "deaths": 828 }, 7 | { "date": "1854-09-01T00:00:00.000Z", "cause": "disease", "deaths": 788 }, 8 | { "date": "1854-10-01T00:00:00.000Z", "cause": "disease", "deaths": 503 }, 9 | { "date": "1854-11-01T00:00:00.000Z", "cause": "disease", "deaths": 844 }, 10 | { "date": "1854-12-01T00:00:00.000Z", "cause": "disease", "deaths": 1725 }, 11 | { "date": "1855-01-01T00:00:00.000Z", "cause": "disease", "deaths": 2761 }, 12 | { "date": "1855-02-01T00:00:00.000Z", "cause": "disease", "deaths": 2120 }, 13 | { "date": "1855-03-01T00:00:00.000Z", "cause": "disease", "deaths": 1205 }, 14 | { "date": "1855-04-01T00:00:00.000Z", "cause": "disease", "deaths": 477 }, 15 | { "date": "1855-05-01T00:00:00.000Z", "cause": "disease", "deaths": 508 }, 16 | { "date": "1855-06-01T00:00:00.000Z", "cause": "disease", "deaths": 802 }, 17 | { "date": "1855-07-01T00:00:00.000Z", "cause": "disease", "deaths": 382 }, 18 | { "date": "1855-08-01T00:00:00.000Z", "cause": "disease", "deaths": 483 }, 19 | { "date": "1855-09-01T00:00:00.000Z", "cause": "disease", "deaths": 189 }, 20 | { "date": "1855-10-01T00:00:00.000Z", "cause": "disease", "deaths": 128 }, 21 | { "date": "1855-11-01T00:00:00.000Z", "cause": "disease", "deaths": 178 }, 22 | { "date": "1855-12-01T00:00:00.000Z", "cause": "disease", "deaths": 91 }, 23 | { "date": "1856-01-01T00:00:00.000Z", "cause": "disease", "deaths": 42 }, 24 | { "date": "1856-02-01T00:00:00.000Z", "cause": "disease", "deaths": 24 }, 25 | { "date": "1856-03-01T00:00:00.000Z", "cause": "disease", "deaths": 15 }, 26 | { "date": "1854-04-01T00:00:00.000Z", "cause": "wounds", "deaths": 0 }, 27 | { "date": "1854-05-01T00:00:00.000Z", "cause": "wounds", "deaths": 0 }, 28 | { "date": "1854-06-01T00:00:00.000Z", "cause": "wounds", "deaths": 0 }, 29 | { "date": "1854-07-01T00:00:00.000Z", "cause": "wounds", "deaths": 0 }, 30 | { "date": "1854-08-01T00:00:00.000Z", "cause": "wounds", "deaths": 1 }, 31 | { "date": "1854-09-01T00:00:00.000Z", "cause": "wounds", "deaths": 81 }, 32 | { "date": "1854-10-01T00:00:00.000Z", "cause": "wounds", "deaths": 132 }, 33 | { "date": "1854-11-01T00:00:00.000Z", "cause": "wounds", "deaths": 287 }, 34 | { "date": "1854-12-01T00:00:00.000Z", "cause": "wounds", "deaths": 114 }, 35 | { "date": "1855-01-01T00:00:00.000Z", "cause": "wounds", "deaths": 83 }, 36 | { "date": "1855-02-01T00:00:00.000Z", "cause": "wounds", "deaths": 42 }, 37 | { "date": "1855-03-01T00:00:00.000Z", "cause": "wounds", "deaths": 32 }, 38 | { "date": "1855-04-01T00:00:00.000Z", "cause": "wounds", "deaths": 48 }, 39 | { "date": "1855-05-01T00:00:00.000Z", "cause": "wounds", "deaths": 49 }, 40 | { "date": "1855-06-01T00:00:00.000Z", "cause": "wounds", "deaths": 209 }, 41 | { "date": "1855-07-01T00:00:00.000Z", "cause": "wounds", "deaths": 134 }, 42 | { "date": "1855-08-01T00:00:00.000Z", "cause": "wounds", "deaths": 164 }, 43 | { "date": "1855-09-01T00:00:00.000Z", "cause": "wounds", "deaths": 276 }, 44 | { "date": "1855-10-01T00:00:00.000Z", "cause": "wounds", "deaths": 53 }, 45 | { "date": "1855-11-01T00:00:00.000Z", "cause": "wounds", "deaths": 33 }, 46 | { "date": "1855-12-01T00:00:00.000Z", "cause": "wounds", "deaths": 18 }, 47 | { "date": "1856-01-01T00:00:00.000Z", "cause": "wounds", "deaths": 2 }, 48 | { "date": "1856-02-01T00:00:00.000Z", "cause": "wounds", "deaths": 0 }, 49 | { "date": "1856-03-01T00:00:00.000Z", "cause": "wounds", "deaths": 0 }, 50 | { "date": "1854-04-01T00:00:00.000Z", "cause": "other", "deaths": 5 }, 51 | { "date": "1854-05-01T00:00:00.000Z", "cause": "other", "deaths": 9 }, 52 | { "date": "1854-06-01T00:00:00.000Z", "cause": "other", "deaths": 6 }, 53 | { "date": "1854-07-01T00:00:00.000Z", "cause": "other", "deaths": 23 }, 54 | { "date": "1854-08-01T00:00:00.000Z", "cause": "other", "deaths": 30 }, 55 | { "date": "1854-09-01T00:00:00.000Z", "cause": "other", "deaths": 70 }, 56 | { "date": "1854-10-01T00:00:00.000Z", "cause": "other", "deaths": 128 }, 57 | { "date": "1854-11-01T00:00:00.000Z", "cause": "other", "deaths": 106 }, 58 | { "date": "1854-12-01T00:00:00.000Z", "cause": "other", "deaths": 131 }, 59 | { "date": "1855-01-01T00:00:00.000Z", "cause": "other", "deaths": 324 }, 60 | { "date": "1855-02-01T00:00:00.000Z", "cause": "other", "deaths": 361 }, 61 | { "date": "1855-03-01T00:00:00.000Z", "cause": "other", "deaths": 172 }, 62 | { "date": "1855-04-01T00:00:00.000Z", "cause": "other", "deaths": 57 }, 63 | { "date": "1855-05-01T00:00:00.000Z", "cause": "other", "deaths": 37 }, 64 | { "date": "1855-06-01T00:00:00.000Z", "cause": "other", "deaths": 31 }, 65 | { "date": "1855-07-01T00:00:00.000Z", "cause": "other", "deaths": 33 }, 66 | { "date": "1855-08-01T00:00:00.000Z", "cause": "other", "deaths": 25 }, 67 | { "date": "1855-09-01T00:00:00.000Z", "cause": "other", "deaths": 20 }, 68 | { "date": "1855-10-01T00:00:00.000Z", "cause": "other", "deaths": 18 }, 69 | { "date": "1855-11-01T00:00:00.000Z", "cause": "other", "deaths": 32 }, 70 | { "date": "1855-12-01T00:00:00.000Z", "cause": "other", "deaths": 28 }, 71 | { "date": "1856-01-01T00:00:00.000Z", "cause": "other", "deaths": 48 }, 72 | { "date": "1856-02-01T00:00:00.000Z", "cause": "other", "deaths": 19 }, 73 | { "date": "1856-03-01T00:00:00.000Z", "cause": "other", "deaths": 35 } 74 | ] 75 | -------------------------------------------------------------------------------- /packages/examples/src/data/crimea.ts: -------------------------------------------------------------------------------- 1 | export interface DeathRecord { 2 | date: string; 3 | cause: string; 4 | deaths: number; 5 | } 6 | 7 | export interface StackRecord { 8 | date: string; 9 | disease: number; 10 | wounds: number; 11 | other: number; 12 | } 13 | -------------------------------------------------------------------------------- /packages/examples/src/data/states.ts: -------------------------------------------------------------------------------- 1 | export interface State { 2 | state: string; 3 | age: string; 4 | population: number; 5 | } 6 | -------------------------------------------------------------------------------- /packages/examples/src/helpers/isomorphic.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert an example file name to a human readable title. 3 | * 4 | * @example 5 | * // returns 'NPR Covid Shift' 6 | * normalizeExampleName('NPR-covid-shift'); 7 | * @param name – The example's file name. Assumes the file name is kebab case. 8 | * @returns – A human readable title for the example. 9 | */ 10 | export function normalizeExampleName(name: string): string { 11 | const parts = name.split('-'); 12 | 13 | return parts 14 | .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) 15 | .join(' '); 16 | } 17 | -------------------------------------------------------------------------------- /packages/examples/src/helpers/logos.tsx: -------------------------------------------------------------------------------- 1 | export const RevizLogo = ( 2 | 8 | 9 | 13 | 17 | 18 | 22 | 26 | 27 | 28 | 32 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ); 48 | 49 | export const NYTimesLogo = ( 50 | 57 | 64 | 69 | 70 | 74 | 75 | 76 | 77 | 78 | ); 79 | 80 | export const NPRLogo = ( 81 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | ); 96 | -------------------------------------------------------------------------------- /packages/examples/src/helpers/metadata.tsx: -------------------------------------------------------------------------------- 1 | import type { StaticImageData } from 'next/image'; 2 | 3 | import BarThumbnail from '../../public/bar.png'; 4 | import BubbleThumbnail from '../../public/bubble.png'; 5 | import HistogramThumbnail from '../../public/histogram.png'; 6 | import ScatterplotThumbnail from '../../public/scatterplot.png'; 7 | import StackedBarThumbnail from '../../public/stacked-bar.png'; 8 | import StripPlotThumbnail from '../../public/strip-plot.png'; 9 | import NewYorkTimesVaccineVotingThumbnail from '../../public/new-york-times-vaccine-voting.png'; 10 | import NPRCovidShiftThumbnail from '../../public/NPR-covid-shift.png'; 11 | 12 | import { NYTimesLogo, NPRLogo } from './logos'; 13 | 14 | interface ExampleMeta { 15 | src: StaticImageData; 16 | alt: string; 17 | icon?: React.ReactNode; 18 | } 19 | 20 | export const metadata: Record = { 21 | bar: { 22 | src: BarThumbnail, 23 | alt: "A modification of Mike Bostock's Bar Chart example using D3. The chart shows the frequency of each letter in the English language.", 24 | }, 25 | bubble: { 26 | src: BubbleThumbnail, 27 | alt: "A modified bubble chart from the Observable Plot documentation showing relationships between a vehicle's 0-60, horsepower, and fuel economy.", 28 | }, 29 | histogram: { 30 | src: HistogramThumbnail, 31 | alt: 'A histogram visualizing the stopping times of the first 1000 integers when run through the algorithm underlying the Collatz conjecture.', 32 | }, 33 | scatterplot: { 34 | src: ScatterplotThumbnail, 35 | alt: 'The example scatterplot from the Observable Plot documentation showing the relationship between weight, height, and sex of Olympic athletes.', 36 | }, 37 | ['stacked-bar']: { 38 | src: StackedBarThumbnail, 39 | alt: "A modification of Mike Bostock's Stacked Bar Chart example using D3. The chart, based on data from Florence Nightingale, shows deaths in the Crimean War by month, with each bar group representing the cause of death.", 40 | }, 41 | ['strip-plot']: { 42 | src: StripPlotThumbnail, 43 | alt: "An example strip plot by Mike Bostock using D3. The strip plot shows the age distribution of each US state's population.", 44 | }, 45 | ['NPR-covid-shift']: { 46 | src: NPRCovidShiftThumbnail, 47 | alt: 'A stacked bar chart from NPR showing how deaths related to Covid-19 have shifted away from urban centers to rural towns over the course of the pandemic.', 48 | icon: NPRLogo, 49 | }, 50 | ['new-york-times-vaccine-voting']: { 51 | src: NewYorkTimesVaccineVotingThumbnail, 52 | alt: 'A scatterplot from the New York Times showing correlations between voting patterns and vaccination status.', 53 | icon: NYTimesLogo, 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /packages/examples/src/helpers/server.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | import path from 'node:path'; 3 | 4 | /** 5 | * Reads a JSON file synchronously from the file system and returns the parsed 6 | * JSON. Assumes the JSON file is in the `src/data` directory. 7 | * 8 | * @param jsonName – The name of the JSON file to read. 9 | * @returns – The parsed JSON. 10 | */ 11 | export function readData(jsonName: string): T[] { 12 | const json = readFileSync( 13 | path.join(process.cwd(), `src/data/${jsonName}.json`) 14 | ).toString(); 15 | 16 | return JSON.parse(json) as T[]; 17 | } 18 | -------------------------------------------------------------------------------- /packages/examples/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['app/**/*.tsx', 'src/components/**/*.tsx', '../ui/**/*.tsx'], 3 | theme: { 4 | extend: { 5 | colors: { 6 | linework: '#e2e2e2', 7 | accent: '#e7131a', 8 | editor: '#fbfbfb', 9 | }, 10 | fontFamily: { 11 | serif: ['Source Serif 4', 'serif'], 12 | }, 13 | }, 14 | }, 15 | plugins: [], 16 | }; 17 | -------------------------------------------------------------------------------- /packages/examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "incremental": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ] 22 | }, 23 | "include": [ 24 | "**/*.ts", 25 | "**/*.tsx", 26 | "next-env.d.ts", 27 | ".next/types/**/*.ts", 28 | "next.config.js" 29 | ], 30 | "exclude": ["node_modules"] 31 | } 32 | -------------------------------------------------------------------------------- /packages/extension/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. If a change is missing an attribution, it may have been made by a Core Contributor. 4 | 5 | - Critical bugfixes or breaking changes are marked using a warning symbol: ⚠️ 6 | - Significant new features or enhancements are marked using the sparkles symbol: ✨ 7 | 8 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 9 | 10 | ## [0.5.0] 11 | 12 | - This marks the initial release of `@reviz/extension`. We're starting versioning at 0.5.0 to continue upwards from the last release of `reviz` at 0.4.1. 13 | -------------------------------------------------------------------------------- /packages/extension/README.md: -------------------------------------------------------------------------------- 1 |
2 | reviz 7 |
8 |
9 | 10 | A lightweight engine for reverse engineering data visualizations from the DOM 11 | 12 |
13 |
14 |
15 | 16 | `reviz` is a lightweight engine for reverse engineering data visualizations from the DOM. Its core goal is to assist in rapid visualization sketching and prototyping by automatically generating partial programs written using [Observable Plot](https://observablehq.com/@observablehq/plot) from input `svg` subtrees. 17 | 18 | This package contains the source for the `reviz` Chrome extension. 19 | 20 | ## Local Development 21 | 22 | To develop the Chrome extension locally, ensure you have [dependencies installed](../../CONTRIBUTING.md#local-development). From there, development largely follows [the standard pattern for Chrome extension development](https://developer.chrome.com/docs/extensions/mv3/getstarted/development-basics/). To summarize, this involves: 23 | 24 | - Running `npm run build` to build the extension. This script invokes [Vite](https://vitejs.dev/), our build tool of choice, to produce the production extension build in the `dist` directory. 25 | - Uploading the extension to your browser by clicking the `Load Unpacked` button on the `chrome://extensions`. Select the `dist` directory when prompted with the file upload interface. 26 | 27 | When you open the DevTools on a webpage, you should have a new tab visible marked `reviz`. 28 | -------------------------------------------------------------------------------- /packages/extension/devtools/devtools.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /packages/extension/devtools/extension.ts: -------------------------------------------------------------------------------- 1 | chrome.devtools.panels.create('reviz', 'icon.png', 'panel/panel.html'); 2 | -------------------------------------------------------------------------------- /packages/extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "reviz", 4 | "description": "Adds tools for reverse engineering data visualizations to Chrome Developer tools.", 5 | "version": "0.5.0", 6 | "devtools_page": "devtools/devtools.html", 7 | "action": { 8 | "default_icon": "reviz.png" 9 | }, 10 | "content_scripts": [ 11 | { 12 | "matches": ["https://*/*", "http://*/*"], 13 | "js": ["inspect.js"], 14 | "css": ["inspect.css"] 15 | } 16 | ], 17 | "background": { 18 | "service_worker": "service-worker.js" 19 | }, 20 | "sandbox": { 21 | "pages": ["sandbox/sandbox.html"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@reviz/extension", 3 | "private": true, 4 | "version": "0.5.0", 5 | "description": "The Chrome extension for reviz, an engine for reverse engineering data visualizations from the DOM.", 6 | "author": { 7 | "name": "Parker Ziegler", 8 | "email": "peziegler@cs.berkeley.edu" 9 | }, 10 | "type": "module", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/parkerziegler/reviz.git" 14 | }, 15 | "bugs": "https://github.com/parkerziegler/reviz/issues", 16 | "homepage": "https://github.com/parkerziegler/reviz/blob/main/packages/extension/README.md", 17 | "keywords": [ 18 | "chrome extension", 19 | "data visualization", 20 | "reverse engineering", 21 | "observable", 22 | "observable plot", 23 | "typescript" 24 | ], 25 | "scripts": { 26 | "dev": "vite", 27 | "build": "tsc && vite build && cp manifest.json public/reviz.png scripts/inspect.css dist", 28 | "preview": "vite preview" 29 | }, 30 | "dependencies": { 31 | "@codemirror/lang-javascript": "^6.1.9", 32 | "@codemirror/language": "^6.10.1", 33 | "@codemirror/view": "^6.25.1", 34 | "@lezer/highlight": "^1.2.0", 35 | "@observablehq/plot": "^0.6.9", 36 | "@radix-ui/react-tabs": "^1.0.4", 37 | "@radix-ui/react-tooltip": "^1.0.6", 38 | "@reviz/compiler": "^0.5.0", 39 | "@reviz/ui": "^0.5.0", 40 | "@tanstack/react-table": "^8.9.3", 41 | "classnames": "^2.3.2", 42 | "codemirror": "^6.0.1", 43 | "d3-dsv": "^3.0.1", 44 | "prettier": "^3.2.5", 45 | "react": "^18.2.0", 46 | "react-dom": "^18.2.0" 47 | }, 48 | "devDependencies": { 49 | "@types/chrome": "^0.0.263", 50 | "@types/d3-dsv": "^3.0.1", 51 | "@types/react": "^18.2.62", 52 | "@types/react-dom": "^18.2.19", 53 | "@vitejs/plugin-react": "^4.0.1", 54 | "autoprefixer": "^10.4.14", 55 | "postcss": "^8.4.24", 56 | "tailwindcss": "^3.3.2", 57 | "typescript": "^5.1.6", 58 | "vite": "^5.0.0" 59 | } 60 | } -------------------------------------------------------------------------------- /packages/extension/panel/panel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | reviz 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/extension/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/extension/public/reviz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parkerziegler/reviz/e2dcfbe9e1dee8288835bba30841856a3ff05c5d/packages/extension/public/reviz.png -------------------------------------------------------------------------------- /packages/extension/sandbox/sandbox.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /packages/extension/sandbox/sandbox.ts: -------------------------------------------------------------------------------- 1 | import * as Plot from '@observablehq/plot'; 2 | 3 | import type { ExecuteMessage } from '../src/types/message'; 4 | import { formatExecutableProgram } from '../src/utils/formatters'; 5 | 6 | window.addEventListener('message', (event: MessageEvent) => { 7 | if (event.data.name !== 'execute') { 8 | return; 9 | } 10 | 11 | const { data, program, dimensions } = event.data; 12 | 13 | formatExecutableProgram(program, dimensions) 14 | .then((formattedProgram) => { 15 | try { 16 | // Here we evaluate the user's program, as a string, in a sandboxed environ- 17 | // ment. See: https://developer.chrome.com/docs/extensions/mv3/sandboxingEval/ 18 | // We'll ignore lint warnings for use of new Function and calling an any 19 | // typed value; we know we have a function that, when called, returns an 20 | // svg Element generated by Observable Plot. 21 | // eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call 22 | const plot = new Function(`return ${formattedProgram}`)()( 23 | Plot, 24 | data 25 | ) as SVGSVGElement; 26 | 27 | event.source?.postMessage( 28 | { 29 | name: 'render', 30 | plot: plot.outerHTML, 31 | }, 32 | { targetOrigin: event.origin } 33 | ); 34 | } catch (err) { 35 | console.error(err); 36 | } 37 | }) 38 | .catch((err) => { 39 | console.error(err); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/extension/scripts/inspect.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --inspect-color: #419af7; 3 | } 4 | 5 | .mouse-visited { 6 | outline: 2px dotted var(--inspect-color); 7 | } 8 | -------------------------------------------------------------------------------- /packages/extension/scripts/inspect.ts: -------------------------------------------------------------------------------- 1 | import { analyzeVisualization } from '@reviz/compiler'; 2 | 3 | import type { AnalyzeMessage } from '../src/types/message'; 4 | 5 | // The class name to apply to an element when hovered. 6 | const MOUSE_VISITED_CLASSNAME = 'mouse-visited'; 7 | 8 | /** 9 | * Adds a class to an SVG element on mouseenter to visually indicate selection. 10 | * The element cast is safe because we only register this event listener on SVG 11 | * elements. 12 | * 13 | * @param event – The mouseenter event. 14 | */ 15 | function onMouseEnter(event: MouseEvent): void { 16 | const el = event.target as SVGSVGElement; 17 | el.classList.add(MOUSE_VISITED_CLASSNAME); 18 | } 19 | 20 | /** 21 | * Removes a class from an SVG element on mouseleave to visually indicate 22 | * deselection. The element cast is safe because we only register this event 23 | * listener on SVG elements. 24 | * 25 | * @param event – The mouseleave event. 26 | */ 27 | function onMouseLeave(event: MouseEvent): void { 28 | const el = event.target as SVGSVGElement; 29 | el.classList.remove(MOUSE_VISITED_CLASSNAME); 30 | } 31 | 32 | /** 33 | * Analyzes a visualization on click and sends the result to the extension 34 | * service worker. The element cast is safe because we only register this event 35 | * listener on SVG elements. 36 | * 37 | * @param event – The click event. 38 | */ 39 | function onClick(event: MouseEvent): void { 40 | async function handleClick(): Promise { 41 | const el = event.target as SVGSVGElement; 42 | 43 | const nodeName = el.nodeName; 44 | const classNames = el.getAttribute('class') ?? ''; 45 | const { spec, program } = analyzeVisualization(el); 46 | 47 | await chrome.runtime.sendMessage({ 48 | name: 'analyze', 49 | nodeName, 50 | classNames, 51 | spec, 52 | program, 53 | }); 54 | } 55 | 56 | handleClick() 57 | .then(() => { 58 | return; 59 | }) 60 | .catch((err: Error) => { 61 | console.error( 62 | 'Failed to send reviz spec and program to DevTools panel. Original Error: ' + 63 | err.message 64 | ); 65 | }); 66 | } 67 | 68 | /** 69 | * Activates the inspector by registering event listeners on all SVG elements in 70 | * the inspected document. 71 | */ 72 | function activateInspector(): void { 73 | document.querySelectorAll('svg').forEach((el) => { 74 | el.addEventListener('mouseenter', onMouseEnter); 75 | el.addEventListener('mouseleave', onMouseLeave); 76 | el.addEventListener('click', onClick); 77 | }); 78 | } 79 | 80 | /** 81 | * Deactivates the inspector by removing event listeners on all SVG elements in 82 | * the inspected document. 83 | */ 84 | function deactivateInspector(): void { 85 | document.querySelectorAll('svg').forEach((el) => { 86 | el.removeEventListener('mouseenter', onMouseEnter); 87 | el.removeEventListener('mouseleave', onMouseLeave); 88 | el.removeEventListener('click', onClick); 89 | }); 90 | } 91 | 92 | /** 93 | * This is a bit goofy — allow me to explain. vite + esbuild optimize away this 94 | * entire module because (it's not imported anywhere. Even as an entry point, it 95 | * doesn't connect to other modules in the module dependency graph. Attaching 96 | * functions we intend to make global to window ensures it's still compiled; 97 | * then, it's included in the extension as a content script. 98 | */ 99 | ( 100 | window as Window & 101 | typeof globalThis & { 102 | activateInspector: typeof activateInspector; 103 | deactivateInspector: typeof deactivateInspector; 104 | } 105 | ).activateInspector = activateInspector; 106 | 107 | ( 108 | window as Window & 109 | typeof globalThis & { 110 | activateInspector: typeof activateInspector; 111 | deactivateInspector: typeof deactivateInspector; 112 | } 113 | ).deactivateInspector = deactivateInspector; 114 | -------------------------------------------------------------------------------- /packages/extension/scripts/service-worker.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Skeleton implementation taken from: 3 | * https://developer.chrome.com/docs/extensions/mv3/devtools/#content-script-to-devtools 4 | */ 5 | const connections: Record = {}; 6 | 7 | chrome.runtime.onConnect.addListener((port) => { 8 | const extensionListener = (message: { 9 | name: string; 10 | tabId: number; 11 | }): void => { 12 | // The original connection event doesn't include the tabId of the DevTools 13 | // page, so we need to send it explicitly. 14 | if (message.name === 'init') { 15 | connections[message.tabId] = port; 16 | return; 17 | } 18 | }; 19 | 20 | port.onMessage.addListener(extensionListener); 21 | 22 | port.onDisconnect.addListener((port) => { 23 | // Remove the extension listener on port disconnect. 24 | port.onMessage.removeListener(extensionListener); 25 | 26 | // Delete the reference to the port in the connections object. 27 | const tabs = Object.keys(connections).map((id) => +id); 28 | 29 | for (let i = 0, len = tabs.length; i < len; i++) { 30 | if (connections[tabs[i]] === port) { 31 | delete connections[tabs[i]]; 32 | break; 33 | } 34 | } 35 | }); 36 | }); 37 | 38 | // When we receive a message from the content script, forward it to the DevTools 39 | // page to handle. 40 | chrome.runtime.onMessage.addListener((request, sender) => { 41 | if (sender.tab) { 42 | const tabId = sender.tab.id; 43 | 44 | if (tabId && tabId in connections) { 45 | connections[tabId].postMessage(request); 46 | } else { 47 | console.warn('Tab not found in connection list.'); 48 | } 49 | } else { 50 | console.warn('sender.tab not defined.'); 51 | } 52 | }); 53 | -------------------------------------------------------------------------------- /packages/extension/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as Tabs from '@radix-ui/react-tabs'; 3 | import * as prettier from 'prettier'; 4 | import babel from 'prettier/plugins/babel'; 5 | import estree from 'prettier/plugins/estree'; 6 | 7 | import ExtensionErrorBoundary from './components/ExtensionErrorBoundary'; 8 | import ElementSelect from './components/interaction/ElementSelect'; 9 | import ProgramViewer from './components/program/ProgramViewer'; 10 | import Retargeter from './components/retarget/Retargeter'; 11 | import SpecViewer from './components/spec/SpecViewer'; 12 | import type { AnalyzeMessage } from './types/message'; 13 | 14 | // Here we use a mapped type to make the spec property optional. 15 | // Additionally, we omit the message name property, since we don't need it. 16 | export type VisualizationState = Omit< 17 | { 18 | [Property in keyof AnalyzeMessage]: Property extends 'spec' 19 | ? AnalyzeMessage[Property] | undefined 20 | : AnalyzeMessage[Property]; 21 | }, 22 | 'name' 23 | >; 24 | 25 | const App: React.FC = () => { 26 | const [{ spec, program, nodeName, classNames }, setViz] = 27 | React.useState({ 28 | spec: undefined, 29 | program: '', 30 | nodeName: '', 31 | classNames: '', 32 | }); 33 | 34 | React.useEffect(() => { 35 | // Establish a long-lived connection to the service worker. 36 | const serviceWorkerConnection = chrome.runtime.connect({ 37 | name: 'panel', 38 | }); 39 | 40 | serviceWorkerConnection.postMessage({ 41 | name: 'init', 42 | tabId: chrome.devtools.inspectedWindow.tabId, 43 | }); 44 | 45 | // Listen for messages from the content script sent via the service worker. 46 | serviceWorkerConnection.onMessage.addListener((message: AnalyzeMessage) => { 47 | if (message.name !== 'analyze') { 48 | return; 49 | } 50 | 51 | const { spec, program, nodeName, classNames } = message; 52 | 53 | prettier 54 | .format(program, { 55 | parser: 'babel', 56 | plugins: [babel, estree], 57 | }) 58 | .then((formattedProgram) => { 59 | setViz({ 60 | spec, 61 | program: formattedProgram, 62 | nodeName, 63 | classNames, 64 | }); 65 | }) 66 | .catch((err) => { 67 | console.error('Failed to format the program. Original Error: ', err); 68 | setViz({ 69 | spec, 70 | program, 71 | nodeName, 72 | classNames, 73 | }); 74 | }); 75 | }); 76 | 77 | return () => { 78 | serviceWorkerConnection.disconnect(); 79 | }; 80 | }, []); 81 | 82 | return ( 83 |
84 | ( 86 |

An error occurred. {message}

87 | )} 88 | > 89 | 93 |
94 | 95 | 96 | 100 | Analyze 101 | 102 | 106 | Visualize 107 | 108 | 109 |
110 | 114 | 115 | 116 | 117 | 121 | 122 | 123 |
124 |
125 |
126 | ); 127 | }; 128 | 129 | export default App; 130 | -------------------------------------------------------------------------------- /packages/extension/src/components/ExtensionErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface Props { 4 | fallback: (message: string) => React.ReactNode; 5 | } 6 | 7 | interface State { 8 | hasError: boolean; 9 | message: string; 10 | } 11 | 12 | class ExtensionErrorBoundary extends React.Component< 13 | React.PropsWithChildren, 14 | State 15 | > { 16 | constructor(props: Props) { 17 | super(props); 18 | this.state = { hasError: false, message: '' }; 19 | } 20 | 21 | static getDerivedStateFromError(error: Error): State { 22 | return { hasError: true, message: error.message }; 23 | } 24 | 25 | render(): React.ReactNode { 26 | if (this.state.hasError) { 27 | return this.props.fallback(this.state.message); 28 | } 29 | 30 | return this.props.children; 31 | } 32 | } 33 | 34 | export default ExtensionErrorBoundary; 35 | -------------------------------------------------------------------------------- /packages/extension/src/components/data/DataGrid.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | useReactTable, 4 | flexRender, 5 | getCoreRowModel, 6 | } from '@tanstack/react-table'; 7 | 8 | import type { Data } from '../../types/data'; 9 | 10 | interface Props { 11 | data: Data; 12 | } 13 | 14 | const DataGrid: React.FC = ({ data }) => { 15 | const tableData = React.useMemo(() => { 16 | return Array.from(data.data); 17 | }, [data]); 18 | 19 | const columns = React.useMemo(() => { 20 | const cols = Object.keys(tableData[0]); 21 | 22 | return cols.map((col) => { 23 | return { 24 | accessorKey: col, 25 | cell: (info: { getValue: () => string }) => info.getValue(), 26 | }; 27 | }); 28 | }, [tableData]); 29 | 30 | const table = useReactTable({ 31 | data: tableData.slice(1), 32 | columns, 33 | getCoreRowModel: getCoreRowModel(), 34 | }); 35 | 36 | return ( 37 |
38 | 39 | 40 | {table.getHeaderGroups().map((headerGroup) => ( 41 | 42 | {headerGroup.headers.map((header) => { 43 | return ( 44 | 53 | ); 54 | })} 55 | 56 | ))} 57 | 58 | 59 | {table.getRowModel().rows.map((row) => ( 60 | 61 | {row.getVisibleCells().map((cell) => ( 62 | 65 | ))} 66 | 67 | ))} 68 | 69 |
48 | {flexRender( 49 | header.column.columnDef.header, 50 | header.getContext() 51 | )} 52 |
63 | {flexRender(cell.column.columnDef.cell, cell.getContext())} 64 |
70 |
71 | ); 72 | }; 73 | 74 | export default DataGrid; 75 | -------------------------------------------------------------------------------- /packages/extension/src/components/data/DataPanel.tsx: -------------------------------------------------------------------------------- 1 | import Heading from '../shared/Heading'; 2 | import type { Data } from '../../types/data'; 3 | 4 | import DataGrid from './DataGrid'; 5 | import DataUpload from './DataUpload'; 6 | 7 | interface Props { 8 | data?: Data; 9 | setData: (data: Data) => void; 10 | } 11 | 12 | const DataPanel: React.FC = ({ data, setData }) => { 13 | return ( 14 |
15 | Data 16 | {data ? : } 17 |
18 | ); 19 | }; 20 | 21 | export default DataPanel; 22 | -------------------------------------------------------------------------------- /packages/extension/src/components/data/DataUpload.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { csvParseRows, autoType } from 'd3-dsv'; 3 | 4 | import type { Data } from '../../types/data'; 5 | 6 | interface Props { 7 | setData: (data: Data) => void; 8 | } 9 | 10 | const UploadIcon = ( 11 | 18 | 24 | 30 | 36 | 45 | 49 | 58 | 65 | 72 | 79 | 86 | 93 | 100 | 107 | 114 | 121 | 128 | 129 | ); 130 | 131 | const DataUpload: React.FC = ({ setData }) => { 132 | const onChange = React.useCallback( 133 | (event: React.ChangeEvent) => { 134 | if (event.target.files?.length) { 135 | const type = event.target.files[0].type; 136 | const reader = new FileReader(); 137 | 138 | reader.onload = (theFile): void => { 139 | if ( 140 | theFile.target?.result && 141 | typeof theFile.target.result === 'string' 142 | ) { 143 | switch (type) { 144 | case 'application/json': 145 | setData({ 146 | type: 'json', 147 | // We're assuming JSON data is passed as an array of objects. 148 | data: JSON.parse(theFile.target.result) as ArrayLike, 149 | }); 150 | break; 151 | case 'text/csv': { 152 | let cols: string[] = []; 153 | 154 | setData({ 155 | type: 'csv', 156 | data: csvParseRows(theFile.target.result, (d, i) => { 157 | // Treat the first row as the column names. 158 | if (i === 0) { 159 | cols = d; 160 | return; 161 | } 162 | 163 | const typedRow = autoType(d); 164 | 165 | return cols.reduce( 166 | (acc, col, i) => { 167 | // @types/d3 is dreadfully wrong here. d is a string[] and 168 | // after calling autoType we still have an array, just of 169 | // mixed types. Accessing individual values by index is fine. 170 | acc[col] = (typedRow as unknown[])[i]; 171 | 172 | return acc; 173 | }, 174 | {} as Record 175 | ); 176 | }), 177 | }); 178 | break; 179 | } 180 | } 181 | } 182 | }; 183 | 184 | reader.readAsText(event.target.files[0]); 185 | } 186 | }, 187 | [setData] 188 | ); 189 | 190 | return ( 191 |
192 | {UploadIcon} 193 |

194 | Upload a{' '} 195 | {' '} 204 | or{' '} 205 | {' '} 214 | file 215 |

216 |
217 | ); 218 | }; 219 | 220 | export default DataUpload; 221 | -------------------------------------------------------------------------------- /packages/extension/src/components/interaction/ElementSelect.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import cs from 'classnames'; 3 | import * as Tooltip from '@radix-ui/react-tooltip'; 4 | 5 | import type { VisualizationState } from '../../App'; 6 | import TooltipMessage from '../shared/TooltipMessage'; 7 | import { formatClassNames } from '../../utils/formatters'; 8 | 9 | import ElementSelectPrompt from './ElementSelectPrompt'; 10 | 11 | const MousePointer = ( 12 | 19 | 24 | 29 | 30 | ); 31 | 32 | type Props = Pick; 33 | 34 | const ElementSelect: React.FC = ({ nodeName, classNames }) => { 35 | const [isElementSelectActive, setElementSelectActive] = React.useState(false); 36 | 37 | function toggleElementSelectActive(): void { 38 | setElementSelectActive((prevElementSelectActive) => { 39 | if (prevElementSelectActive) { 40 | chrome.devtools.inspectedWindow.eval('deactivateInspector()', { 41 | useContentScriptContext: true, 42 | }); 43 | } else { 44 | chrome.devtools.inspectedWindow.eval('activateInspector()', { 45 | useContentScriptContext: true, 46 | }); 47 | } 48 | 49 | return !prevElementSelectActive; 50 | }); 51 | } 52 | 53 | return ( 54 |
55 | 56 | 57 | 58 | 67 | 68 | 69 | 70 | 71 | {isElementSelectActive ? 'Disable' : 'Enable'} SVG selector 72 | 73 | 74 | 75 | 76 | 77 | 78 | {nodeName ? ( 79 |

80 | 81 | {nodeName} 82 | {classNames ? {formatClassNames(classNames)} : null} 83 | 84 |

85 | ) : ( 86 | 87 | )} 88 |
89 | ); 90 | }; 91 | 92 | export default ElementSelect; 93 | -------------------------------------------------------------------------------- /packages/extension/src/components/interaction/ElementSelectPrompt.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | className?: string; 3 | } 4 | 5 | const ElementSelectPrompt: React.FC = ({ className }) => { 6 | return ( 7 |

8 | Select an{' '} 9 | svg{' '} 10 | element to inspect. 11 |

12 | ); 13 | }; 14 | 15 | export default ElementSelectPrompt; 16 | -------------------------------------------------------------------------------- /packages/extension/src/components/program/ProgramEditor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { EditorView, basicSetup } from 'codemirror'; 3 | import { javascript } from '@codemirror/lang-javascript'; 4 | import { syntaxHighlighting } from '@codemirror/language'; 5 | import * as Tooltip from '@radix-ui/react-tooltip'; 6 | 7 | import ElementSelectPrompt from '../interaction/ElementSelectPrompt'; 8 | import Heading from '../shared/Heading'; 9 | import TooltipMessage from '../shared/TooltipMessage'; 10 | import { usePrevious } from '../../hooks/usePrevious'; 11 | import type { Data } from '../../types/data'; 12 | import type { RenderMessage } from '../../types/message'; 13 | 14 | import { syntaxTheme, editorTheme } from './theme'; 15 | 16 | interface Props { 17 | program: string; 18 | data?: Data; 19 | setRetargetdVisualization: (retargetedVisualization: string) => void; 20 | dimensions: { 21 | width: number; 22 | height: number; 23 | }; 24 | } 25 | 26 | const ProgramEditor: React.FC = ({ 27 | program, 28 | data, 29 | setRetargetdVisualization, 30 | dimensions, 31 | }) => { 32 | const editorRef = React.useRef(null); 33 | const iframeRef = React.useRef(null); 34 | 35 | const editor = React.useRef(); 36 | const prevDimensions = usePrevious(dimensions); 37 | 38 | const [edited, setEdited] = React.useState(false); 39 | 40 | // Set up a callback to send the program, data, and retargeted visualization 41 | // dimensions to the sandboxed iframe to execute the Plot program. 42 | const onExecute = React.useCallback(() => { 43 | if (iframeRef.current && data) { 44 | iframeRef.current.contentWindow?.postMessage( 45 | { 46 | name: 'execute', 47 | program: editor.current?.state.doc.toString() ?? '', 48 | data: data.data, 49 | dimensions, 50 | }, 51 | '*' 52 | ); 53 | setEdited(false); 54 | } 55 | }, [data, dimensions]); 56 | 57 | // Initialize the editor. 58 | React.useEffect(() => { 59 | if (editorRef.current) { 60 | editor.current = new EditorView({ 61 | extensions: [ 62 | basicSetup, 63 | editorTheme, 64 | javascript(), 65 | syntaxHighlighting(syntaxTheme), 66 | EditorView.updateListener.of((v) => { 67 | if (v.docChanged) { 68 | setEdited(true); 69 | } 70 | }), 71 | EditorView.domEventHandlers({ 72 | keydown: (event) => { 73 | if ( 74 | (event.metaKey || event.ctrlKey) && 75 | (event.key === 'Enter' || event.key === 's') 76 | ) { 77 | event.preventDefault(); 78 | onExecute(); 79 | } 80 | }, 81 | }), 82 | ], 83 | parent: editorRef.current, 84 | doc: program, 85 | }); 86 | } 87 | 88 | return () => { 89 | editor.current?.destroy(); 90 | editor.current = undefined; 91 | }; 92 | }, [program, onExecute]); 93 | 94 | // Establish a listener for the render message sent from the sandboxed iframe. 95 | React.useEffect(() => { 96 | const listener = (event: MessageEvent): void => { 97 | if (event.data.name !== 'render') { 98 | return; 99 | } 100 | 101 | setRetargetdVisualization(event.data.plot); 102 | }; 103 | 104 | window.addEventListener('message', listener); 105 | 106 | return () => { 107 | window.removeEventListener('message', listener); 108 | }; 109 | }, [setRetargetdVisualization]); 110 | 111 | // Re-execute the program when the dimensions change. 112 | React.useEffect(() => { 113 | if (iframeRef.current && data && dimensions !== prevDimensions) { 114 | iframeRef.current.contentWindow?.postMessage( 115 | { 116 | name: 'execute', 117 | program: editor.current?.state.doc.toString() ?? '', 118 | data: data.data, 119 | dimensions, 120 | }, 121 | '*' 122 | ); 123 | } 124 | }, [data, dimensions, prevDimensions]); 125 | 126 | const metaCtrl = React.useMemo(() => { 127 | const userAgent = window.navigator.userAgent; 128 | 129 | if (userAgent.includes('Mac')) { 130 | return '⌘'; 131 | } else { 132 | return '^'; 133 | } 134 | }, []); 135 | 136 | return ( 137 |
138 |
139 | Program 140 | {program ? ( 141 | 142 | 143 | 144 | 162 | 163 | 164 | 165 | 166 | Run program ({`${metaCtrl}+S / ${metaCtrl}+⏎`}) 167 | 168 | 169 | 170 | 171 | 172 | 173 | ) : null} 174 |
175 | {program ? ( 176 |
180 | ) : ( 181 | 182 | )} 183 |