Commit c337d3eb authored by alain's avatar alain 🐙
Browse files

add d3 visualisation, improve scroll in table view

parent 714ed005
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"d3": "^6.1.1",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-scripts": "3.4.3", "react-scripts": "3.4.3",
......
...@@ -17,6 +17,15 @@ ...@@ -17,6 +17,15 @@
font-size: 18px; font-size: 18px;
background-color: #E6E6E6; background-color: #E6E6E6;
} }
* {
box-sizing: border-box;
}
#root {
width: 100vw;
height: 100vh;
}
</style> </style>
<noscript> <noscript>
You need to enable JavaScript to run this app. You need to enable JavaScript to run this app.
......
...@@ -2,31 +2,49 @@ import React, { useState, useEffect } from 'react'; ...@@ -2,31 +2,49 @@ import React, { useState, useEffect } from 'react';
import Tabletop from 'tabletop'; import Tabletop from 'tabletop';
import Table from './Table'; import Table from './Table';
import Vis from './Vis';
import { prepareTableData, prepareVisData } from './util/util';
export default function App() { export default function App() {
const [view, setView] = useState('vis');
const [items, setItems] = useState(null); const [items, setItems] = useState(null);
const [itemsNested, setItemsNested] = useState(null);
useEffect(() => { useEffect(() => {
Tabletop.init({ Tabletop.init({
key: '1DWS2LDRF5QjRDjeaNWpSEPaQr8FcHHHDf_ugBXZEi88', key: '1DWS2LDRF5QjRDjeaNWpSEPaQr8FcHHHDf_ugBXZEi88',
simpleSheet: true, simpleSheet: true,
endpoint: "https://degroenestad.waag.org/data", endpoint: "https://degroenestad.waag.org/data",
callback: googleData => {
setItems(googleData)
},
postProcess: element => { postProcess: element => {
if (element['subthema']) {
element['thema'] = element['thema'] + ' » ' + element['subthema']
}
delete element['subthema']
delete element['tags'] delete element['tags']
},
callback: googleData => {
setItems(prepareTableData(googleData))
setItemsNested(prepareVisData(googleData))
} }
}) })
}, []); }, []);
return ( return (
<div id="databronnen"> <div id="databronnen">
{ items ? <Table items={items} /> : <p>Bezig met laden...</p> } <div id="view-toggle">
weergave:
<span className={view === 'table' ? 'active' : ''} onClick={() => { setView('table') }}>tabel</span>
|
<span className={view === 'vis' ? 'active' : ''} onClick={() => { setView('vis') }}>visualisatie</span>
</div>
{ items && itemsNested ?
<>
{ view === 'table' && <Table items={items} /> }
{ view === 'vis' && <Vis data={itemsNested} /> }
</>
:
(<div className="loading"><span></span>Bezig met laden...</div>)
}
</div> </div>
); );
} }
import React from 'react'; import React, { useState, useRef } from 'react';
import { useSortableData } from './util/util'; import { useSortableData } from './util/util';
import SortButton from './SortButton'; import SortButton from './SortButton';
...@@ -14,11 +14,20 @@ export default function Table(props) { ...@@ -14,11 +14,20 @@ export default function Table(props) {
const activeFilterInitial = {} const activeFilterInitial = {}
columns.forEach(column => { activeFilterInitial[column] = false }) columns.forEach(column => { activeFilterInitial[column] = false })
const [filter, setFilter] = React.useState(filterInitial); const wrapperRef = useRef(null);
const [activeFilter, setActiveFilter] = React.useState(activeFilterInitial); const [scrollTop, setScrollTop] = useState(0);
const [filter, setFilter] = useState(filterInitial);
const [activeFilter, setActiveFilter] = useState(activeFilterInitial);
const { items, requestSort, sortConfig } = useSortableData(props.items, filter, {key: "thema", direction: "ascending"}); const { items, requestSort, sortConfig } = useSortableData(props.items, filter, {key: "thema", direction: "ascending"});
const setSort = (column) => {
requestSort(column)
wrapperRef.current.scrollTop = 0
setScrollTop(0)
}
const getSortState = (name) => { const getSortState = (name) => {
if (!sortConfig) { if (!sortConfig) {
return; return;
...@@ -44,34 +53,36 @@ export default function Table(props) { ...@@ -44,34 +53,36 @@ export default function Table(props) {
} }
return ( return (
<table> <div id="databronnen-table" ref={wrapperRef} onScroll={(e) => { setScrollTop(wrapperRef.current.scrollTop) }}>
<thead> <table>
<tr> <thead>
{ columns.map(column => ( <tr>
<th key={column} className={column}>
<div>
<span onClick={ () => { requestSort(column) } }>
<SortButton column={column} classes={getSortState(column)} />
{column}
</span>
<FilterButton column={column} classes={ activeFilter[column] ? 'active' : '' } clickHandler={handleFilterButtonClick} />
</div>
{ activeFilter[column] && <input value={filter[column]} onChange={(e) => { handleInputChange(e.target.value, column) }} autoFocus placeholder="filtertekst" /> }
</th>
)) }
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item['url']}>
{ columns.map(column => ( { columns.map(column => (
<td key={column} className={column}> <th key={column} className={column} style={{ position: "relative", top: scrollTop }}>
{ column === 'url' ? <a target="_blank" href={item[column]} rel="noopener noreferrer">{item[column]}</a> : <span>{ item[column] }</span> } <div>
</td> <span onClick={ () => { setSort(column) } }>
<SortButton column={column} classes={ getSortState(column) } />
{column}
</span>
<FilterButton column={column} classes={ activeFilter[column] ? 'active' : '' } clickHandler={handleFilterButtonClick} />
</div>
{ activeFilter[column] && <input value={filter[column]} onChange={(e) => { handleInputChange(e.target.value, column) }} autoFocus placeholder="filtertekst" /> }
</th>
)) } )) }
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {items.map(item => (
<tr key={item['url']}>
{ columns.map(column => (
<td key={column} className={column}>
{ column === 'url' ? <a target="_blank" href={item[column]} rel="noopener noreferrer">{item[column]}</a> : <span>{ item[column] }</span> }
</td>
)) }
</tr>
))}
</tbody>
</table>
</div>
); );
}; };
import React, { useRef, useEffect } from 'react'
import * as d3 from 'd3'
export default function Vis(props) {
const width = document.getElementById('databronnen').clientWidth
const height = document.getElementById('databronnen').clientHeight
const margin = 20
const diameter = Math.min(width, height) - 48
var view, svg, g, circle, text
const focus = useRef(null);
const root = useRef(null);
const svgRef = useRef(null);
useEffect(() => {
init()
})
function init() {
const pack = d3.pack().size([diameter - margin, diameter - margin]).padding(4);
root.current = d3.hierarchy(props.data)
.sum(function(d) { return d.size; })
.sort(function(a, b) { return b.value - a.value; });
focus.current = root.current
const nodes = pack(root.current).descendants();
svg = d3.select(svgRef.current).on("click", function() { zoom(root.current); });
g = svg.append("g").attr("transform", `translate(${ (diameter / 2) + ((width - diameter) / 2) },${ (diameter / 2) + ((height - diameter) / 2) })`);
circle = g.selectAll("circle")
.data(nodes)
.enter().append("circle")
.attr("class", function(d) { return d.parent ? d.children ? "node" : "node node--leaf" : "node node--root"; })
.attr("id", function(d){ return d.data.name })
.style("fill", "#00954a")
.on("click", function(event, d) {
if(d.children) {
if (focus.current !== d) zoom(d);
} else {
window.open(d.data.url, '_blank');
}
event.stopPropagation();
});
text = g.selectAll("foreignObject")
.data(nodes)
.enter().append("foreignObject")
.attr("width", "110")
.attr("height", "100")
.html(function(d) { return `<div>${d.data.name}</div>`})
.style("display", function(d) { return d.parent === root.current ? "inline" : "none"; })
zoomTo([root.current.x, root.current.y, root.current.r * 2 + margin]);
}
function zoom(d) {
focus.current = d;
var transition = d3.transition()
.duration(300)
.tween("zoom", function(d) {
var i = d3.interpolateZoom(view, [focus.current.x, focus.current.y, focus.current.r * 2 + margin]);
return function(t) { zoomTo(i(t)); };
});
transition.selectAll("foreignObject")
.filter(function(d) { return d.parent === focus.current || this.style.display === "inline"; })
.style("fill-opacity", function(d) { return d.parent === focus.current ? 1 : 0; })
.on("start", function(d) { if (d.parent === focus.current) this.style.display = "inline"; })
.on("end", function(d) { if (d.parent !== focus.current) this.style.display = "none"; });
}
function zoomTo(v) {
var k = diameter / v[2];
view = v;
circle
.attr("class", function(d) { return focus.current.children.find(node => node.data.name === d.data.name) ? "clickable" : "" })
.attr("r", function(d) { return (d.r + 3) * k; })
.attr("transform", function(d) { return `translate(${(d.x - v[0]) * k},${(d.y - v[1]) * k})`; });
text.attr("transform", function(d) { return `translate(${(d.x - v[0]) * k - 55}, ${(d.y - v[1]) * k - 50})`; });
}
return(
<svg ref={svgRef} />
)
}
body {
margin: 0
}
#databronnen { #databronnen {
position: relative;
font-family: "Maax", Arial, Helvetica, sans-serif; font-family: "Maax", Arial, Helvetica, sans-serif;
padding: 0.5rem;
overflow: auto; overflow: auto;
width: 100%; width: 100%;
height: 100%; height: 100%;
font-size: 18px; font-size: 18px;
box-sizing: border-box; box-sizing: border-box;
#databronnen-table {
position: fixed;
top: 6rem;
bottom: 0.5rem;
left: 0.5rem;
right: 0.5rem;
overflow: auto;
&::-webkit-scrollbar { width: 8px; height: 8px; }
&::-webkit-scrollbar-track { background: rgba(0,0,0,0.1); }
&::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.1); }
}
#view-toggle {
position: absolute;
top: 0.75rem;
right: 0.5rem;
font-size: 0.9rem;
span {
margin: 0 0.25rem;
cursor: pointer;
&.active, &:hover {
text-decoration: underline;
}
}
}
// LOADING
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
span {
display: inline-block;
width: 2rem;
height: 2rem;
margin: 1rem 1rem 1rem 0;
vertical-align: middle;
background-color: var(--color-loading, #00954a);
border-radius: 100%;
animation: pulseScaleOut 1.5s infinite ease-in-out;
animation-delay: 0s;
opacity: 0;
}
}
@keyframes pulseScaleOut {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0;
}
}
// table
table { table {
margin: 0 auto; margin: 0 auto;
border-collapse: collapse; border-collapse: collapse;
...@@ -142,5 +201,53 @@ body { ...@@ -142,5 +201,53 @@ body {
padding: 0.25rem 0.25rem 0; padding: 0.25rem 0.25rem 0;
line-height: 1.25rem; line-height: 1.25rem;
} }
// vis
> svg {
width: 100%;
height: 100%;
display: block;
margin: 0 auto;
}
circle {
fill: #00954a !important;
opacity: 0.15;
pointer-events: none;
&:first-child {
fill: none !important;
pointer-events: none;
}
&.clickable {
pointer-events: all;
cursor: pointer;
&:hover {
stroke: #000;
stroke-dasharray: 3;
stroke-width: 1.5px;
}
}
}
foreignObject {
position: relative;
pointer-events: none;
overflow: visible;
div {
position: absolute;
width: 100%;
top: 50%;
transform: translateY(-50%);
font-size: 0.8rem;
text-align: center;
user-select: none;
}
}
} }
import React from 'react'; import React from 'react';
export const prepareTableData = data => {
return data.map(element => {
const newElement = {...element}
if (newElement['subthema']) {
newElement['thema'] = newElement['thema'] + ' » ' + newElement['subthema']
}
delete newElement['subthema']
return newElement
})
}
export const prepareVisData = data => {
const output = {
"name": "databronnen",
"children": []
}
data.forEach(element => {
let themeIndex = output["children"].findIndex(child => child["name"] === element["thema"])
if(themeIndex === -1) {
output["children"].push({ "name": element["thema"], "children": [] })
themeIndex = output["children"].findIndex(child => child["name"] === element["thema"])
}
if(element["subthema"]) {
let subthemeIndex = output["children"][themeIndex]["children"].findIndex(child => child["name"] === element["subthema"])
if(subthemeIndex === -1) {
output["children"][themeIndex]["children"].push({ "name": element["subthema"], "children": [] })
subthemeIndex = output["children"][themeIndex]["children"].findIndex(child => child["name"] === element["subthema"])
}
output["children"][themeIndex]["children"][subthemeIndex]["children"].push({
"name": element["titel"],
"size": 1,
"url": element["url"]
})
} else {
output["children"][themeIndex]["children"].push({
"name": element["titel"],
"size": 1
})
}
})
return output
}
export const useSortableData = (items, filter, config = null) => { export const useSortableData = (items, filter, config = null) => {
const [sortConfig, setSortConfig] = React.useState(config); const [sortConfig, setSortConfig] = React.useState(config);
......
...@@ -3026,7 +3026,7 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: ...@@ -3026,7 +3026,7 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
dependencies: dependencies:
delayed-stream "~1.0.0" delayed-stream "~1.0.0"
commander@^2.11.0, commander@^2.20.0: commander@2, commander@^2.11.0, commander@^2.20.0:
version "2.20.3" version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
...@@ -3519,6 +3519,246 @@ cyclist@^1.0.1: ...@@ -3519,6 +3519,246 @@ cyclist@^1.0.1:
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=
"d3-array@1.2.0 - 2", d3-array@2, d3-array@>=2.5:
version "2.7.1"
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.7.1.tgz#b1f56065e9aba1ef6f0d0c8c9390b65421593352"
integrity sha512-dYWhEvg1L2+osFsSqNHpXaPQNugLT4JfyvbLE046I2PDcgYGFYc0w24GSJwbmcjjZYOPC3PNP2S782bWUM967Q==
d3-axis@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-2.0.0.tgz#40aebb65626ffe6d95e9441fbf9194274b328a8b"
integrity sha512-9nzB0uePtb+u9+dWir+HTuEAKJOEUYJoEwbJPsZ1B4K3iZUgzJcSENQ05Nj7S4CIfbZZ8/jQGoUzGKFznBhiiQ==
d3-brush@2:
version "2.1.0"
resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-2.1.0.tgz#adadfbb104e8937af142e9a6e2028326f0471065"
integrity sha512-cHLLAFatBATyIKqZOkk/mDHUbzne2B3ZwxkzMHvFTCZCmLaXDpZRihQSn8UNXTkGD/3lb/W2sQz0etAftmHMJQ==
dependencies:
d3-dispatch "1 - 2"
d3-drag "2"
d3-interpolate "1 - 2"
d3-selection "2"
d3-transition "2"
d3-chord@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-chord/-/d3-chord-2.0.0.tgz#32491b5665391180560f738e5c1ccd1e3c47ebae"
integrity sha512-D5PZb7EDsRNdGU4SsjQyKhja8Zgu+SHZfUSO5Ls8Wsn+jsAKUUGkcshLxMg9HDFxG3KqavGWaWkJ8EpU8ojuig==
dependencies:
d3-path "1 - 2"
"d3-color@1 - 2", d3-color@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e"
integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==
d3-contour@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-2.0.0.tgz#80ee834988563e3bea9d99ddde72c0f8c089ea40"
integrity sha512-9unAtvIaNk06UwqBmvsdHX7CZ+NPDZnn8TtNH1myW93pWJkhsV25JcgnYAu0Ck5Veb1DHiCv++Ic5uvJ+h50JA==
dependencies:
d3-array "2"
d3-delaunay@5:
version "5.3.0"
resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-5.3.0.tgz#b47f05c38f854a4e7b3cea80e0bb12e57398772d"
integrity sha512-amALSrOllWVLaHTnDLHwMIiz0d1bBu9gZXd1FiLfXf8sHcX9jrcj81TVZOqD4UX7MgBZZ07c8GxzEgBpJqc74w==
dependencies:
delaunator "4"
"d3-dispatch@1 - 2", d3-dispatch@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-2.0.0.tgz#8a18e16f76dd3fcaef42163c97b926aa9b55e7cf"
integrity sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==
d3-drag@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-2.0.0.tgz#9eaf046ce9ed1c25c88661911c1d5a4d8eb7ea6d"
integrity sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==
dependencies:
d3-dispatch "1 - 2"
d3-selection "2"
"d3-dsv@1 - 2", d3-dsv@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-2.0.0.tgz#b37b194b6df42da513a120d913ad1be22b5fe7c5"
integrity sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w==
dependencies:
commander "2"
iconv-lite "0.4"
rw "1"
"d3-ease@1 - 2", d3-ease@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-2.0.0.tgz#fd1762bfca00dae4bacea504b1d628ff290ac563"