Skip to content

Commit

Permalink
fix(FieldSelector): add whisker/box plot option
Browse files Browse the repository at this point in the history
Show a whisker plot of the data by default, and let the user
toggle to a histogram. Uses fewer svg/dom elements, allowing for
larger lists of fields. Modify HistogramSelector example to show
whiskers. Include script that generated whisker data from nba.csv.
  • Loading branch information
aronhelser authored and scottwittenburg committed Jan 31, 2017
1 parent ef60cca commit 739c627
Show file tree
Hide file tree
Showing 6 changed files with 1,455 additions and 71 deletions.
228 changes: 165 additions & 63 deletions src/InfoViz/Native/FieldSelector/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import d3 from 'd3';
import style from 'PVWStyle/InfoVizNative/FieldSelector.mcss';

import CompositeClosureHelper from '../../../Common/Core/CompositeClosureHelper';
import template from './template.html';

// ----------------------------------------------------------------------------
// Global
Expand Down Expand Up @@ -42,21 +41,34 @@ function fieldSelector(publicAPI, model) {
model.container = el;

if (el) {
d3.select(model.container).html(template);
d3.select(model.container).select('.fieldSelector').classed(style.fieldSelector, true);
const table = d3.select(model.container).append('table').classed(style.fieldSelector, true);
const theadRow = table.append('thead').classed(style.thead, true).append('tr');
theadRow.append('th').classed(style.jsFieldSelectorMode, true).append('i');
theadRow.append('th').classed(style.jsFieldSelectorLabel, true);
table.append('tbody').classed(style.tbody, true);

model.fieldShowHistogram = model.fieldShowHistogram && (model.provider.isA('Histogram1DProvider'));
// append headers for histogram columns
if (model.fieldShowHistogram) {
const header = d3.select(model.container).select('thead').select('tr');
header.append('th').text('Min').classed(style.jsHistMin, true);
header.append('th').text('Histogram').classed(style.jsSparkline, true);
header.append('th').text('Max').classed(style.jsHistMax, true);
theadRow.append('th').text('Min').classed(style.jsHistMin, true);
const chartHeader = theadRow.append('th').classed(style.jsSparkline, true);
chartHeader.append('span').text('Histogram').classed(style.chartHeader, true);
chartHeader.append('i');
theadRow.append('th').text('Max').classed(style.jsHistMax, true);
}
publicAPI.render();
}
};

const clickSelected = (d) => {
model.displayUnselected = !model.displayUnselected;
publicAPI.render();
};
const clickChart = (d) => {
model.showHist = !model.showHist;
publicAPI.render();
};

publicAPI.render = () => {
if (!model.container) {
return;
Expand All @@ -65,14 +77,8 @@ function fieldSelector(publicAPI, model) {
const legendSize = 15;

// Apply style
d3.select(model.container).select('thead').classed(style.thead, true);
d3.select(model.container).select('tbody').classed(style.tbody, true);
d3.select(model.container)
.select('th.field-selector-mode')
.on('click', (d) => {
model.displayUnselected = !model.displayUnselected;
publicAPI.render();
})
d3.select(`.${style.jsFieldSelectorMode}`)
.on('click', clickSelected)
.select('i')
// apply class - 'false' should come first to not remove common base class.
.classed(!model.displayUnselected ? style.allFieldsIcon : style.selectedFieldsIcon, false)
Expand All @@ -83,14 +89,10 @@ function fieldSelector(publicAPI, model) {
const totalNum = model.displayUnselected ? data.length : model.provider.getFieldNames().length;

// Update header label
d3.select(model.container)
.select('th.field-selector-label')
d3.select(`.${style.jsFieldSelectorLabel}`)
.style('text-align', 'left')
.text(model.displayUnselected ? `Only Selected (${data.length} total)` : `Only Selected (${data.length} / ${totalNum} total)`)
.on('click', (d) => {
model.displayUnselected = !model.displayUnselected;
publicAPI.render();
});
.on('click', clickSelected);

// test for too-long rows
const hideMore = model.container.scrollWidth > model.container.clientWidth;
Expand Down Expand Up @@ -120,30 +122,34 @@ function fieldSelector(publicAPI, model) {
}
}
}
const header = d3.select(model.container).select('thead').select('tr');
const header = d3.select(`.${style.jsTHead}`).select('tr');
header.selectAll(`.${style.jsHistMin}`)
.style('display', hideField.minMax ? 'none' : null);
header.selectAll(`.${style.jsSparkline}`)
const chartHeader = header.selectAll(`.${style.jsSparkline}`)
.style('display', hideField.hist ? 'none' : null);
header.selectAll(`.${style.jsHistMax}`)
.style('display', hideField.minMax ? 'none' : null);

// Handle variables
const variablesContainer = d3
.select(model.container)
.select('tbody.fields')
.select(`.${style.jsTBody}`)
.selectAll('tr')
.data(data);

variablesContainer.enter().append('tr');
variablesContainer.exit().remove();

// track if any quantiles are available at all.
let foundQuantile = false;

// Apply on each data item
function renderField(fieldName, index) {
const field = model.provider.getField(fieldName);
const fieldContainer = d3.select(this);
let legendCell = fieldContainer.select(`.${style.jsLegend}`);
let fieldCell = fieldContainer.select(`.${style.jsFieldName}`);
if (field.quantiles) foundQuantile = true;


// Apply style to row (selected/unselected)
fieldContainer
Expand Down Expand Up @@ -198,58 +204,152 @@ function fieldSelector(publicAPI, model) {

// make sure our data is ready. If not, render will be called when loaded.
const hobj = model.histograms ? model.histograms[fieldName] : null;
if (hobj) {
if (hobj || field.quantiles) {
histCell
.style('display', hideField.hist ? 'none' : null);

// only do work if histogram is displayed.
if (!hideField.hist) {
const cmax = 1.0 * d3.max(hobj.counts);
const hsize = hobj.counts.length;
const hdata = histCell.select('svg')
.selectAll(`.${style.jsHistRect}`).data(hobj.counts);

hdata.enter().append('rect');
// changes apply to both enter and update data join:
hdata
.attr('class', (d, i) => (i % 2 === 0 ? style.histRectEven : style.histRectOdd))
.attr('pname', fieldName)
.attr('y', d => model.fieldHistHeight * (1.0 - (d / cmax)))
.attr('x', (d, i) => (model.fieldHistWidth / hsize) * i)
.attr('height', d => model.fieldHistHeight * (d / cmax))
.attr('width', model.fieldHistWidth / hsize);

hdata.exit().remove();

if (model.provider.isA('HistogramBinHoverProvider')) {
histCell.select('svg')
.on('mousemove', function inner(d, i) {
const mCoords = d3.mouse(this);
const binNum = Math.floor((mCoords[0] / model.fieldHistWidth) * hsize);
const state = {};
state[fieldName] = [binNum];
model.provider.setHoverState({ state });
})
.on('mouseout', (d, i) => {
const state = {};
state[fieldName] = [-1];
model.provider.setHoverState({ state });
});
// ignore whisker/histogram toggle if whisker data is unavailable
if (model.showHist || !field.quantiles) {
if (!model.lastShowHist) {
histCell.select('svg').selectAll('*').remove();
}
const cmax = 1.0 * d3.max(hobj.counts);
const hsize = hobj.counts.length;
const hdata = histCell.select('svg')
.selectAll(`.${style.jsHistRect}`).data(hobj.counts);

hdata.enter().append('rect');
// changes apply to both enter and update data join:
hdata
.attr('class', (d, i) => (i % 2 === 0 ? style.histRectEven : style.histRectOdd))
.attr('pname', fieldName)
.attr('y', d => model.fieldHistHeight * (1.0 - (d / cmax)))
.attr('x', (d, i) => (model.fieldHistWidth / hsize) * i)
.attr('height', d => model.fieldHistHeight * (d / cmax))
.attr('width', model.fieldHistWidth / hsize);

hdata.exit().remove();

if (model.provider.isA('HistogramBinHoverProvider')) {
histCell.select('svg')
.on('mousemove', function inner(d, i) {
const mCoords = d3.mouse(this);
const binNum = Math.floor((mCoords[0] / model.fieldHistWidth) * hsize);
const state = {};
state[fieldName] = [binNum];
model.provider.setHoverState({ state });
})
.on('mouseout', (d, i) => {
const state = {};
state[fieldName] = [-1];
model.provider.setHoverState({ state });
});
}
} else {
// whisker/box plot
// draw verical line for lowerWhisker
const svg = histCell.select('svg');
svg.selectAll('line').remove();
svg.selectAll('rect').remove();
const xScale = d3.scale.linear()
// allows 1 pixel whiskers at the extremes to not be cut off.
.range([0.5, model.fieldHistWidth - 0.5])
.domain(field.range);
const midline = model.fieldHistHeight * 0.5;
let lowerWhisker = 0;
let q1Val = 0;
let medianVal = 0;
let meanVal = 0;
let iqr = 0;
let upperWhisker = 0;
const bh = Math.floor(0.5 * (model.fieldHistHeight - 4));
if (field.quantiles) {
lowerWhisker = field.quantiles[0];
q1Val = field.quantiles[1];
medianVal = field.quantiles[2];
meanVal = field.mean;
iqr = field.quantiles[3] - q1Val;
upperWhisker = field.quantiles[4];
}

svg.append('line')
.attr('class', style.whisker)
.attr('x1', xScale(lowerWhisker))
.attr('x2', xScale(lowerWhisker))
.attr('y1', midline - bh)
.attr('y2', midline + bh);

// draw vertical line for upperWhisker
svg.append('line')
.attr('class', style.whisker)
.attr('x1', xScale(upperWhisker))
.attr('x2', xScale(upperWhisker))
.attr('y1', midline - bh)
.attr('y2', midline + bh);

// draw horizontal line from lowerWhisker to upperWhisker
svg.append('line')
.attr('class', style.whisker)
.attr('x1', xScale(lowerWhisker))
.attr('x2', xScale(upperWhisker))
.attr('y1', midline)
.attr('y2', midline);

// mean, behind the rect
svg.append('line')
.attr('class', style.mean)
.attr('x1', xScale(meanVal))
.attr('x2', xScale(meanVal))
.attr('y1', midline - bh - 3)
.attr('y2', midline + bh + 3);

// draw rect for iqr
svg.append('rect')
.attr('class', style.iqr)
.attr('x', xScale(q1Val))
.attr('y', midline - bh)
.attr('width', xScale(iqr + field.range[0]))
.attr('height', 2 * bh);

// draw vertical line at median
if (field.mean) {
svg.append('line')
.attr('class', style.median)
.attr('x1', xScale(medianVal))
.attr('x2', xScale(medianVal))
.attr('y1', midline - bh)
.attr('y2', midline + bh);
}
}
}

const formatter = d3.format('.3s');
minCell.text(formatter(hobj.min))
.style('display', hideField.minMax ? 'none' : null);
maxCell.text(formatter(hobj.max))
.style('display', hideField.minMax ? 'none' : null);
const formatter = d3.format('.3s');
minCell.text(formatter(hobj ? hobj.min : field.range[0]))
.style('display', hideField.minMax ? 'none' : null);
maxCell.text(formatter(hobj ? hobj.max : field.range[1]))
.style('display', hideField.minMax ? 'none' : null);
}
}
}
}

// Render all fields
variablesContainer
.each(renderField);

// hide the hist/whisker toggle if no whisker data found.
if (!foundQuantile) model.showHist = true;

chartHeader.select('span').text(model.showHist ? 'Histogram' : 'Whisker');
chartHeader.select('i')
.style('display', foundQuantile ? null : 'none')
.on('click', clickChart)
.classed(model.showHist ? style.showHistIcon : style.showBoxIcon, false)
.classed(!model.showHist ? style.showHistIcon : style.showBoxIcon, true);


model.lastShowHist = model.showHist;
};

function handleHoverUpdate(data) {
Expand Down Expand Up @@ -305,6 +405,8 @@ const DEFAULT_VALUES = {
fieldHistWidth: 120,
fieldHistHeight: 15,
numberOfBins: 32,
showHist: false,
lastShowHist: false,
};

// ----------------------------------------------------------------------------
Expand Down
6 changes: 0 additions & 6 deletions src/InfoViz/Native/FieldSelector/template.html

This file was deleted.

2 changes: 1 addition & 1 deletion src/InfoViz/Native/HistogramSelector/example/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import SelectionProvider from '../../../../../src/InfoViz/Core/SelectionProvider
import HistogramSelector from '../../../Native/HistogramSelector';
import FieldSelector from '../../../Native/FieldSelector';

import dataModel from './state.json';
import dataModel from './state2.json';

const bodyElt = document.querySelector('body');
// '100vh' is 100% of the current screen height
Expand Down
Loading

0 comments on commit 739c627

Please sign in to comment.