|
<!doctype html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<title>D3 Key Function</title> |
|
<!-- Author: Bo Ericsson, [email protected] --> |
|
<script src="https://d3js.org/d3.v5.js"></script> |
|
<style> |
|
body { |
|
font-family: helvetica; |
|
margin: 0px; |
|
} |
|
button { |
|
background-color: #eee; |
|
border-radius: 5px; |
|
font-size: 14px; |
|
font-weight: bold; |
|
height: 25px; |
|
width: 100px; |
|
} |
|
svg { |
|
outline: 1px solid lightgray; |
|
} |
|
.container {} |
|
.headerContainer { |
|
display: flex; |
|
flex-direction: column; |
|
justify-content: space-between; |
|
} |
|
.headerTopContainer { |
|
display: flex; |
|
flex-direction: row; |
|
font-size: 18px; |
|
font-weight: bold; |
|
justify-content: space-between; |
|
margin: 10px; |
|
} |
|
.headerBottomContainer { |
|
display: flex; |
|
flex-direction: column; |
|
font-size: 15px; |
|
font-weight: normal; |
|
margin: 10px; |
|
} |
|
.titleContainer { |
|
width: 500px; |
|
} |
|
.checkboxContainer { |
|
font-size: 13px; |
|
font-weight: normal; |
|
display: flex; |
|
flex-direction: row; |
|
width: 150px; |
|
} |
|
.buttonContainer { |
|
height: 40px; |
|
display: flex; |
|
flex-direction: row; |
|
justify-content: space-between; |
|
} |
|
.svgContainer { |
|
display: flex; |
|
flex-direction: row; |
|
justify-content: space-between; |
|
} |
|
.svgTitle { |
|
font-weight: bold; |
|
} |
|
.info { |
|
background-color: white; |
|
border-top: 1px solid lightgray; |
|
display: none; |
|
height: 90px; |
|
left: 660px; |
|
position: absolute; |
|
top: 370px; |
|
width: 270px; |
|
} |
|
.infoHeader { |
|
font-size: 14px; |
|
font-weight: bold; |
|
margin-top: 5px; |
|
} |
|
.infoDetails { |
|
font-size: 14px; |
|
font-weight: normal; |
|
margin-top: 5px; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div class="container"> |
|
<div class="headerContainer"> |
|
<div class="headerTopContainer"> |
|
<div class="titleContainer"> |
|
D3 Selections, Data Binding and Key Function Demo |
|
</div> |
|
<div class="checkboxContainer"> |
|
<div> |
|
<input type="checkbox" id="key-function" name="key-function" checked> |
|
<label for="key-function">Use key function</label> |
|
</div> |
|
</div> |
|
</div> |
|
<div class="headerBottomContainer"> |
|
<div class="buttonContainer"> |
|
</div> |
|
</div> |
|
</div> |
|
<div class="svgContainer"> |
|
</div> |
|
</div> |
|
<div class="info"> |
|
<div class="infoHeader"> |
|
Info |
|
</div> |
|
<div class="infoDetails"> |
|
Stuff |
|
</div> |
|
</div> |
|
</body> |
|
|
|
<script> |
|
'use strict'; |
|
|
|
// Average bar width and generator of random bar widths |
|
const avgWidth = 120; |
|
const generateWidth = () => avgWidth + ((Math.random() - 0.5) * avgWidth * 0.7); |
|
|
|
// Create data |
|
const data = []; |
|
const dataSpec = [ |
|
{ color: 'Red', count: 4 }, |
|
{ color: 'Green', count: 3 }, |
|
{ color: 'Blue', count: 4 }, |
|
{ color: 'Violet', count: 3 }, |
|
{ color: 'Gray', count: 3 }, |
|
{ color: 'Tan', count: 3 }, |
|
].forEach((d) => { |
|
const { color, count } = d; |
|
for (let i = 0; i < count; i++) { |
|
data.push({ |
|
color, |
|
idx: `${i}`, |
|
name: `${color}-${i}`, |
|
width: generateWidth(), |
|
}); |
|
} |
|
}); |
|
|
|
// Then sort the data in place (Firsher-Yates) |
|
for (let i = data.length - 1; i > 0; i--) { |
|
const j = ~~(Math.random() * i); |
|
const iValue = data[i]; |
|
data[i] = data[j]; |
|
data[j] = iValue; |
|
} |
|
|
|
// Constants |
|
const LOAD = 'Load'; |
|
const MUTATE1 = 'Mutate1'; |
|
const MUTATE2 = 'Mutate2'; |
|
const REMOVE = 'Remove'; |
|
const RESTORE = 'Restore'; |
|
const MUTATE3 = 'Mutate3'; |
|
const MUTATE4 = 'Mutate4'; |
|
const RESET = 'Reset'; |
|
|
|
// Dimensions |
|
const width = 960; |
|
const height = 500; |
|
const padding = 10; |
|
const containerWidth = width - padding * 2; |
|
const containerHeight = height - padding * 2; |
|
const headerHeight = 90; |
|
const svgContainerHeight = containerHeight - headerHeight; |
|
const tileWidth = (containerWidth - padding) / 3; |
|
const svgMainWidth = tileWidth * 2; |
|
const svgExitWidth = tileWidth; |
|
|
|
// Boolean that determines whether the key function should be used |
|
let useKeyFn = true; |
|
|
|
// Buttons |
|
const buttons = [ |
|
LOAD, |
|
MUTATE1, |
|
MUTATE2, |
|
REMOVE, |
|
RESTORE, |
|
MUTATE3, |
|
MUTATE4, |
|
RESET |
|
]; |
|
|
|
// Enabbles specific button |
|
function enableButton(button) { |
|
d3.select('.buttonContainer').selectAll('button') |
|
.property('disabled', d => d !== button); |
|
} |
|
|
|
// Disables all buttons |
|
function disableButtons() { |
|
d3.select('.buttonContainer').selectAll('button') |
|
.property('disabled', true); |
|
} |
|
|
|
// Button click handler, which will initiate mutation of the data |
|
function onClick(button) { |
|
switch(button) { |
|
case LOAD: |
|
refresh(data); |
|
enableButton(MUTATE1); |
|
break; |
|
case MUTATE1: |
|
data.forEach(d => d.width = generateWidth()); |
|
refresh(data); |
|
enableButton(MUTATE2); |
|
break; |
|
case MUTATE2: |
|
data.forEach(d => d.width = generateWidth()); |
|
refresh(data); |
|
enableButton(REMOVE); |
|
break; |
|
case REMOVE: |
|
// Remove some elements |
|
const filtered = data.filter(d => d.color !== 'Blue'); |
|
refresh(filtered); |
|
enableButton(RESTORE); |
|
break; |
|
case RESTORE: |
|
refresh(data); |
|
enableButton(MUTATE3); |
|
break; |
|
case MUTATE3: |
|
data.forEach(d => d.width = generateWidth()); |
|
refresh(data); |
|
enableButton(MUTATE4); |
|
break; |
|
case MUTATE4: |
|
data.forEach(d => d.width = generateWidth()); |
|
refresh(data); |
|
enableButton(RESET); |
|
break; |
|
case RESET: |
|
updateStaticDom(); |
|
enableButton(LOAD); |
|
break; |
|
default: |
|
throw new Error(`Illegal option: ${button}`); |
|
} |
|
} |
|
|
|
// Handler that triggers when mouse is hovered over a button and causes display of the info panel |
|
function onMouseover(button) { |
|
const header = button; |
|
let details; |
|
|
|
switch(button) { |
|
case LOAD: |
|
details = 'Loads the initial data bars with random widths. All data is new, and therefore will be placed in the enter selection'; |
|
break; |
|
case MUTATE1: |
|
details = 'The data is now being mutated (bar widths are changing). Nothing is added or removed, therefore all data will be in the update selection'; |
|
break; |
|
case MUTATE2: |
|
details = 'The data is now being mutated again'; |
|
break; |
|
case REMOVE: |
|
details = 'The blue bars are now removed (by being filtered out), and therefore are removed from the update selection and placed inte exit selection. Here they are reconstructed in the right pane (a different svg)'; |
|
break; |
|
case RESTORE: |
|
details = 'The blue bars are now added back in (by no longer being filtered out), and therefore are placed in the enter selection'; |
|
break; |
|
case MUTATE3: |
|
details = 'The data is now being mutated again. The previously added blue bars are now part of the update selection'; |
|
break; |
|
case MUTATE4: |
|
details = 'Another data mutation'; |
|
break; |
|
case RESET: |
|
details = 'Clears the DOM and resets the visualization'; |
|
break; |
|
default: |
|
throw new Error(`Illegal option: ${button}`); |
|
} |
|
|
|
// Update the info panel |
|
d3.select('.info').style('display', 'block'); |
|
d3.select('.infoHeader').text(header); |
|
d3.select('.infoDetails').text(details); |
|
} |
|
|
|
// Handler that turns off the info panel |
|
function onMouseout() { |
|
d3.select('.info').style('display', 'none'); |
|
} |
|
|
|
// Handler that controls the use of the key function |
|
function onChange() { |
|
useKeyFn = d3.select("#key-function").property('checked'); |
|
} |
|
|
|
// Triggers after every button click |
|
function refresh(data) { |
|
const svgMain = d3.select('.svgMain'); |
|
const svgExit = d3.select('.svgExit'); |
|
|
|
// Set key function |
|
const keyFn = useKeyFn ? (d) => d.name : null; |
|
|
|
// Get the selections |
|
const updateSelection = svgMain.selectAll('rect').data(data, keyFn); |
|
const enterSelection = updateSelection.enter(); |
|
const exitSelection = updateSelection.exit(); |
|
|
|
// Get any data from the exit selection |
|
const exitData = exitSelection.data(); |
|
|
|
// Bar layout variables |
|
const barHeight = 14; |
|
const pitch = 17; |
|
const enterY = 35; |
|
const enterX = 10; |
|
const updateX = enterX + tileWidth; |
|
|
|
// Define two generator functions that will be used to generate y (instead of the array index) |
|
function* updateYPosGen() { |
|
let index = 0; |
|
while (true) yield pitch * index++; |
|
} |
|
|
|
function* enterYPosGen() { |
|
let index = 0; |
|
while (true) yield pitch * index++; |
|
} |
|
|
|
const updateYPos = updateYPosGen(pitch); |
|
const enterYPos = enterYPosGen(pitch); |
|
|
|
updateSelection |
|
.transition() |
|
.duration(500) |
|
.attr('x', updateX) |
|
.attr('y', d => updateYPos.next().value + enterY) |
|
.attr('width', d => d.width) |
|
.attr('height', barHeight) |
|
.style('fill', d => d.color); |
|
|
|
enterSelection |
|
.append('rect') |
|
.transition() |
|
.duration(500) |
|
.attr('x', enterX) |
|
.attr('y', d => enterYPos.next().value + enterY) |
|
.attr('height', barHeight) |
|
.style('fill', d => d.color) |
|
.attr('width', d => d.width); |
|
|
|
exitSelection |
|
.transition() |
|
.duration(500) |
|
.style('opacity', 0) |
|
.remove(); |
|
|
|
svgExit.selectAll('rect').remove(); |
|
svgExit.selectAll('rect') |
|
.data(exitData, d => d.name) |
|
.enter() |
|
.append('rect') |
|
.transition() |
|
.duration(500) |
|
.attr('x', enterX) |
|
.attr('y', (d, i) => i * pitch + enterY) |
|
.attr('width', d => d.width) |
|
.attr('height', barHeight) |
|
.style('fill', d => d.color); |
|
} |
|
|
|
// First update the dom with dimensions and add buttons and svg elements |
|
updateStaticDom(); |
|
|
|
// Enable Load button |
|
enableButton('Load'); |
|
|
|
// Update the static DOM |
|
function updateStaticDom() { |
|
d3.select('.svgContainer').selectAll('svg').remove(); |
|
|
|
// Adjust dimensions of container divs |
|
d3.select('.container') |
|
.style('padding', `${padding}px`) |
|
.style('max-width', `${containerWidth}px`) |
|
.style('max-height', `${containerHeight}px`); |
|
|
|
const headerContainer = d3.select('.headerContainer') |
|
.style('height', `${headerHeight}px`) |
|
.on('mouseout', onMouseout); |
|
|
|
const svgContainer = d3.select('.svgContainer') |
|
.style('max-height', svgContainerHeight); |
|
|
|
d3.select('.info') |
|
.style('width', `${svgExitWidth - padding * 2}px`) |
|
.style('left', `${width - svgExitWidth}px`); |
|
|
|
// Add the buttons and event handlers |
|
d3.select('.buttonContainer').selectAll('button') |
|
.data(buttons) |
|
.enter() |
|
.append('button') |
|
.on('click', onClick) |
|
.on('mouseover', onMouseover) |
|
.on('mouseout', onMouseout) |
|
.text(d => d); |
|
|
|
// Add event handler to checkbox |
|
d3.select('#key-function') |
|
.on('change', onChange); |
|
|
|
// Create main svg |
|
const svgMain = svgContainer |
|
.append('svg') |
|
.attr('class', 'svg svgMain') |
|
.attr('width', svgMainWidth) |
|
.attr('height', svgContainerHeight); |
|
|
|
// Create exit svg |
|
const svgExit = svgContainer |
|
.append('svg') |
|
.attr('class', 'svg svgExit') |
|
.attr('width', svgExitWidth) |
|
.attr('height', svgContainerHeight); |
|
|
|
// Set svg titles in the main svg |
|
svgMain |
|
.append('text') |
|
.attr('x', padding) |
|
.attr('y', 20) |
|
.attr('class', 'svgTitle') |
|
.text('Enter Selection'); |
|
|
|
svgMain |
|
.append('text') |
|
.attr('x', padding + tileWidth) |
|
.attr('y', 20) |
|
.attr('class', 'svgTitle') |
|
.text('Update Selection'); |
|
|
|
// Set the title in exit svg |
|
svgExit |
|
.append('text') |
|
.attr('x', padding) |
|
.attr('y', 20) |
|
.attr('class', 'svgTitle') |
|
.text('Exit Selection'); |
|
} |
|
|
|
</script> |