mirror of
https://github.com/simonw/datasette-vega.git
synced 2026-10-01 14:24:14 +02:00
First working version
This commit is contained in:
commit
6270de3296
11 changed files with 365 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
3
README.md
Normal 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
19
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
43
public/index.html
Normal file
43
public/index.html
Normal 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
15
public/manifest.json
Normal 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
68
src/App.css
Normal 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
156
src/App.js
Normal 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
9
src/App.test.js
Normal 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
5
src/index.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
23
src/index.js
Normal file
23
src/index.js
Normal 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')
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue