├── .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 |
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 |
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 |
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 |
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 |
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 |