First working version

This commit is contained in:
Simon Willison 2018-06-25 09:12:45 -07:00
commit 6270de3296
11 changed files with 365 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
*.pyc
__pycache__
dist
*.egg-info
# dependencies
/node_modules
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# datasette-vega
A [Datasette](https://github.com/simonw/datasette) plugin that provides tools for generating charts using [Vega](https://vega.github.io/).

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "datasette-vega",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "^16.4.1",
"react-dom": "^16.4.1",
"react-scripts": "1.1.4",
"source-map-explorer": "^1.5.0",
"vega-embed": "^3.15.0",
"vega-lib": "^4.0.0-rc.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

43
public/index.html Normal file
View file

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<div id="vis-wrapper" style="overflow: auto">
<div id="vis"></div>
</div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

15
public/manifest.json Normal file
View file

@ -0,0 +1,15 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

68
src/App.css Normal file
View file

@ -0,0 +1,68 @@
.vega-embed {
position: relative;
display: inline-block;
padding-right: 38px;
}
.vega-embed .vega-actions-wrapper {
display: inline-flex;
position: absolute;
top: 0;
right: 0;
padding: 6px;
z-index: 1000;
opacity: 0.2;
background: white;
box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.1);
color: #1b1e23;
border: 1px solid #aaa;
border-radius: 999px;
transition: opacity 0.4s ease-in;
}
.vega-embed:hover .vega-actions-wrapper {
transition: opacity 0.2s ease;
opacity: 1;
}
.vega-embed .vega-actions {
position: absolute;
top: 0;
right: 0;
display: none;
flex-direction: column;
padding-bottom: 8px;
padding-top: 8px;
border-radius: 4px;
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.2);
border: 1px solid #d9d9d9;
background: white;
}
.vega-embed .vega-actions-wrapper:hover {
background: transparent;
color: transparent;
border: none;
box-shadow: none;
}
.vega-embed .vega-actions-wrapper:hover .vega-actions {
display: flex;
}
.vega-embed .vega-actions a {
padding: 8px 16px;
font-family: sans-serif;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
color: #434a56;
text-decoration: none;
}
.vega-embed .vega-actions a:hover {
background-color: #f7f7f9;
color: #1b1e23;
}

156
src/App.js Normal file
View file

@ -0,0 +1,156 @@
import React, { Component } from 'react';
import vegaEmbed from 'vega-embed';
import './App.css';
const serialize = obj => Object.keys(obj).map(
key => obj[key] ? `g.${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}` : ''
).join('&');
const unserialize = s => {
if (s && s[0] === '#') {
s = s.slice(1);
}
if (!s) {
return {};
}
var obj = {};
s.split('&').forEach(bit => {
let pair = bit.split('=');
obj[decodeURIComponent(pair[0]).replace(/^g\./, '')] = decodeURIComponent(pair[1]);
});
return obj;
};
class App extends Component {
state = {
mark: "bar",
x_column: this.props.columns[0],
x_type: "ordinal",
y_column: this.props.columns[1],
y_type: "quantitative",
color_column: "",
size_column: ""
}
markOptions = [
{"value": "bar", "name": "bar"},
{"value": "line", "name": "line"},
{"value": "circle", "name": "circle"},
{"value": "square", "name": "square"},
{"value": "point", "name": "point"},
]
typeOptions = [
{"value": "quantitative", "name": "Numbers (quantitative)"},
{"value": "quantitative-bin", "name": "Numbers (quantitative), binned"},
{"value": "temporal", "name": "Dates and times (temporal)"},
{"value": "temporal-bin", "name": "Dates and times (temporal), binned"},
{"value": "ordinal", "name": "Labels (ordinal)"},
{"value": "nominal", "name": "Categories (nominal)"},
]
onChangeSelect(name, ev) {
ev.persist();
window.lastEv = ev;
this.setState({[name]: ev.target.value}, () => {
this.renderGraph();
});
}
componentDidMount() {
window.onpopstate = this.onPopStateChange.bind(this);
this.renderGraph();
}
onPopStateChange(ev) {
window.lastPopEv = ev;
let expected = '#' + serialize(this.state);
if (expected !== document.location.hash) {
this.setState(
unserialize(document.location.hash), this.renderGraph.bind(this)
);
}
}
renderGraph() {
const x_type = this.state.x_type.split('-bin')[0];
const y_type = this.state.y_type.split('-bin')[0];
const x_bin = !!/-bin$/.exec(this.state.x_type);
const y_bin = !!/-bin$/.exec(this.state.y_type);
let encoding = {
x: {field: this.state.x_column, type: x_type, bin: x_bin},
y: {field: this.state.y_column, type: y_type, bin: y_bin}
}
if (this.state.color_column) {
encoding.color = {field: this.state.color_column, type: "nominal"};
}
if (this.state.size_column) {
encoding.size = {field: this.state.size_column, type: "quantitative"};
}
const spec = {
data: {
url: this.props.json_url
},
mark: this.state.mark,
encoding: encoding
}
if (spec.mark) {
vegaEmbed("#vis", spec, {theme: 'quartz', tooltip: true});
document.location.hash = '#' + serialize(this.state);
this.setState({spec: spec});
}
}
toggleAxis(ev) {
ev.preventDefault();
this.setState(prevState => ({
x_column: prevState.y_column,
y_column: prevState.x_column,
x_type: prevState.y_type,
y_type: prevState.x_type,
}), this.renderGraph);
}
render() {
const onChangeSelect = this.onChangeSelect.bind(this);
if (!this.state.mark) {
return null;
}
return (
<form action="" method="GET" id="graphForm">
<div>
Mark: <select name="mark" value={this.state.mark} onChange={ev => onChangeSelect("mark", ev)}>
{this.markOptions.map(option => <option key={option.value} value={option.value}>{option.name}</option>)}
</select>
</div>
<div>
X column: <select name="x_column" value={this.state.x_column} onChange={ev => onChangeSelect("x_column", ev)}>
{this.props.columns.map(column => <option key={column} value={column}>{column}</option>)}
</select>
</div>
<div>
X type: <select name="x_type" value={this.state.x_type} onChange={ev => onChangeSelect("x_type", ev)}>
{this.typeOptions.map(option => <option key={option.value} value={option.value}>{option.name}</option>)}
</select>
</div>
<div>
Y column: <select name="y_column" value={this.state.y_column} onChange={ev => onChangeSelect("y_column", ev)}>
{this.props.columns.map(column => <option key={column} value={column}>{column}</option>)}
</select>
</div>
<div>
Y type: <select name="y_type" value={this.state.y_type} onChange={ev => onChangeSelect("y_type", ev)}>
{this.typeOptions.map(option => <option key={option.value} value={option.value}>{option.name}</option>)}
</select>
</div>
<div>
Color column: <select name="color_column" value={this.state.color_column} onChange={ev => onChangeSelect("color_column", ev)}>
<option value="">-- none --</option>
{this.props.columns.map(column => <option key={column} value={column}>{column}</option>)}
</select>
</div>
<div>
Size column: <select name="size_column" value={this.state.size_column} onChange={ev => onChangeSelect("size_column", ev)}>
<option value="">-- none --</option>
{this.props.columns.map(column => <option key={column} value={column}>{column}</option>)}
</select>
</div>
<div><button onClick={this.toggleAxis.bind(this)}>Swap X and Y</button></div>
</form>
);
}
}
export default App;

9
src/App.test.js Normal file
View file

@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

5
src/index.css Normal file
View file

@ -0,0 +1,5 @@
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}

23
src/index.js Normal file
View file

@ -0,0 +1,23 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
ReactDOM.render(
/*<App columns={[
'airline',
'avail_seat_km_per_week',
'incidents_85_99',
'fatal_accidents_85_99',
'fatalities_85_99',
'incidents_00_1',
'fatal_accidents_00_14',
'fatalities_00_14'
]}
json_url="https://fivethirtyeight.datasettes.com/fivethirtyeight-45d758d/bechdel%2Fmovies.json?_shape=array"
// json_url="https://fivethirtyeight.datasettes.com/fivethirtyeight-45d758d/airline-safety%2Fairline-safety.json?_shape=array"
*/
<App columns={"rowid,year,imdb,title,test,clean_test,binary,budget,domgross,intgross,code,budget_2013$,domgross_2013$,intgross_2013$,period code,decade code".split(",")}
json_url="https://fivethirtyeight.datasettes.com/fivethirtyeight-45d758d.json?sql=select+rowid%2C+*+from+%5Bbechdel%2Fmovies%5D+order+by+random%28%29+limit+200&_shape=array"
/>, document.getElementById('root')
);