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

add d3 visualisation, improve scroll in table view

parent 714ed005
......@@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"d3": "^6.1.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-scripts": "3.4.3",
......
......@@ -17,6 +17,15 @@
font-size: 18px;
background-color: #E6E6E6;
}
* {
box-sizing: border-box;
}
#root {
width: 100vw;
height: 100vh;
}
</style>
<noscript>
You need to enable JavaScript to run this app.
......
......@@ -2,31 +2,49 @@ import React, { useState, useEffect } from 'react';
import Tabletop from 'tabletop';
import Table from './Table';
import Vis from './Vis';
import { prepareTableData, prepareVisData } from './util/util';
export default function App() {
const [view, setView] = useState('vis');
const [items, setItems] = useState(null);
const [itemsNested, setItemsNested] = useState(null);
useEffect(() => {
Tabletop.init({
key: '1DWS2LDRF5QjRDjeaNWpSEPaQr8FcHHHDf_ugBXZEi88',
simpleSheet: true,
endpoint: "https://degroenestad.waag.org/data",
callback: googleData => {
setItems(googleData)
},
postProcess: element => {
if (element['subthema']) {
element['thema'] = element['thema'] + ' » ' + element['subthema']
}
delete element['subthema']
delete element['tags']
},
callback: googleData => {
setItems(prepareTableData(googleData))
setItemsNested(prepareVisData(googleData))
}
})
}, []);
return (
<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>
);
}
import React from 'react';
import React, { useState, useRef } from 'react';
import { useSortableData } from './util/util';
import SortButton from './SortButton';
......@@ -14,11 +14,20 @@ export default function Table(props) {
const activeFilterInitial = {}
columns.forEach(column => { activeFilterInitial[column] = false })
const [filter, setFilter] = React.useState(filterInitial);
const [activeFilter, setActiveFilter] = React.useState(activeFilterInitial);
const wrapperRef = useRef(null);
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 setSort = (column) => {
requestSort(column)
wrapperRef.current.scrollTop = 0
setScrollTop(0)
}
const getSortState = (name) => {
if (!sortConfig) {
return;
......@@ -44,14 +53,15 @@ export default function Table(props) {
}
return (
<div id="databronnen-table" ref={wrapperRef} onScroll={(e) => { setScrollTop(wrapperRef.current.scrollTop) }}>
<table>
<thead>
<tr>
{ columns.map(column => (
<th key={column} className={column}>
<th key={column} className={column} style={{ position: "relative", top: scrollTop }}>
<div>
<span onClick={ () => { requestSort(column) } }>
<SortButton column={column} classes={getSortState(column)} />
<span onClick={ () => { setSort(column) } }>
<SortButton column={column} classes={ getSortState(column) } />
{column}
</span>
<FilterButton column={column} classes={ activeFilter[column] ? 'active' : '' } clickHandler={handleFilterButtonClick} />
......@@ -73,5 +83,6 @@ export default function Table(props) {
))}
</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 {
position: relative;
font-family: "Maax", Arial, Helvetica, sans-serif;
padding: 0.5rem;
overflow: auto;
width: 100%;
height: 100%;
font-size: 18px;
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 {
margin: 0 auto;
border-collapse: collapse;
......@@ -142,5 +201,53 @@ body {
padding: 0.25rem 0.25rem 0;
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';
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) => {
const [sortConfig, setSortConfig] = React.useState(config);
......
......@@ -3026,7 +3026,7 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
dependencies:
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"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
......@@ -3519,6 +3519,246 @@ cyclist@^1.0.1:
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
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"
integrity sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==
d3-fetch@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-fetch/-/d3-fetch-2.0.0.tgz#ecd7ef2128d9847a3b41b548fec80918d645c064"
integrity sha512-TkYv/hjXgCryBeNKiclrwqZH7Nb+GaOwo3Neg24ZVWA3MKB+Rd+BY84Nh6tmNEMcjUik1CSUWjXYndmeO6F7sw==
dependencies:
d3-dsv "1 - 2"
d3-force@2:
version "2.1.1"
resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-2.1.1.tgz#f20ccbf1e6c9e80add1926f09b51f686a8bc0937"
integrity sha512-nAuHEzBqMvpFVMf9OX75d00OxvOXdxY+xECIXjW6Gv8BRrXu6gAWbv/9XKrvfJ5i5DCokDW7RYE50LRoK092ew==
dependencies:
d3-dispatch "1 - 2"
d3-quadtree "1 - 2"
d3-timer "1 - 2"
"d3-format@1 - 2", d3-format@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767"
integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==
d3-geo@2:
version "2.0.1"
resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-2.0.1.tgz#2437fdfed3fe3aba2812bd8f30609cac83a7ee39"
integrity sha512-M6yzGbFRfxzNrVhxDJXzJqSLQ90q1cCyb3EWFZ1LF4eWOBYxFypw7I/NFVBNXKNqxv1bqLathhYvdJ6DC+th3A==
dependencies:
d3-array ">=2.5"
d3-hierarchy@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-2.0.0.tgz#dab88a58ca3e7a1bc6cab390e89667fcc6d20218"
integrity sha512-SwIdqM3HxQX2214EG9GTjgmCc/mbSx4mQBn+DuEETubhOw6/U3fmnji4uCVrmzOydMHSO1nZle5gh6HB/wdOzw==
"d3-interpolate@1 - 2", "d3-interpolate@1.2.0 - 2", d3-interpolate@2:
version "2.0.1"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163"
integrity sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==
dependencies:
d3-color "1 - 2"
"d3-path@1 - 2", d3-path@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-2.0.0.tgz#55d86ac131a0548adae241eebfb56b4582dd09d8"
integrity sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA==
d3-polygon@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-2.0.0.tgz#13608ef042fbec625ba1598327564f03c0396d8e"
integrity sha512-MsexrCK38cTGermELs0cO1d79DcTsQRN7IWMJKczD/2kBjzNXxLUWP33qRF6VDpiLV/4EI4r6Gs0DAWQkE8pSQ==
"d3-quadtree@1 - 2", d3-quadtree@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-2.0.0.tgz#edbad045cef88701f6fee3aee8e93fb332d30f9d"
integrity sha512-b0Ed2t1UUalJpc3qXzKi+cPGxeXRr4KU9YSlocN74aTzp6R/Ud43t79yLLqxHRWZfsvWXmbDWPpoENK1K539xw==
d3-random@2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-2.2.2.tgz#5eebd209ef4e45a2b362b019c1fb21c2c98cbb6e"
integrity sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw==
d3-scale-chromatic@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-2.0.0.tgz#c13f3af86685ff91323dc2f0ebd2dabbd72d8bab"
integrity sha512-LLqy7dJSL8yDy7NRmf6xSlsFZ6zYvJ4BcWFE4zBrOPnQERv9zj24ohnXKRbyi9YHnYV+HN1oEO3iFK971/gkzA==
dependencies:
d3-color "1 - 2"
d3-interpolate "1 - 2"
d3-scale@3:
version "3.2.2"
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-3.2.2.tgz#36d4cbc94dc38bbb5bd91ba5eddb44c6d19bad3e"
integrity sha512-3Mvi5HfqPFq0nlyeFlkskGjeqrR/790pINMHc4RXKJ2E6FraTd3juaRIRZZHyMAbi3LjAMW0EH4FB1WgoGyeXg==
dependencies:
d3-array "1.2.0 - 2"
d3-format "1 - 2"
d3-interpolate "1.2.0 - 2"
d3-time "1 - 2"
d3-time-format "2 - 3"
d3-selection@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-2.0.0.tgz#94a11638ea2141b7565f883780dabc7ef6a61066"
integrity sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==
d3-shape@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-2.0.0.tgz#2331b62fa784a2a1daac47a7233cfd69301381fd"
integrity sha512-djpGlA779ua+rImicYyyjnOjeubyhql1Jyn1HK0bTyawuH76UQRWXd+pftr67H6Fa8hSwetkgb/0id3agKWykw==
dependencies:
d3-path "1 - 2"
"d3-time-format@2 - 3", d3-time-format@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-3.0.0.tgz#df8056c83659e01f20ac5da5fdeae7c08d5f1bb6"
integrity sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==
dependencies:
d3-time "1 - 2"
"d3-time@1 - 2", d3-time@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-2.0.0.tgz#ad7c127d17c67bd57a4c61f3eaecb81108b1e0ab"
integrity sha512-2mvhstTFcMvwStWd9Tj3e6CEqtOivtD8AUiHT8ido/xmzrI9ijrUUihZ6nHuf/vsScRBonagOdj0Vv+SEL5G3Q==
"d3-timer@1 - 2", d3-timer@2: