replay-table
Version:
Visualize sport seasons with interactive standings
426 lines (341 loc) • 15.9 kB
JavaScript
import Skeleton from '../skeleton';
import skeletonCell from '../cell';
import numberToChange from '../../helpers/general/number-to-change';
import isBetween from '../../helpers/general/is-between';
import getItemResults from '../../helpers/data/get-item-results';
import getSparkColor from '../helpers/sparklines/get-spark-color';
import getSparkClasses from '../helpers/sparklines/get-spark-classes';
const columns = {
left: ['position', 'item'],
right: ['score', 'opponent', 'points.change', 'equal', 'points', 'pointsLabel'],
drilldown: ['score', 'opponent', 'wins', 'draws', 'losses', 'labeledPoints']
};
export default class extends Skeleton {
constructor (data, params) {
super(data, params);
this.durations.scale = d3.scaleLinear()
.domain([1, data.meta.lastRound])
.range([this.durations.move, 1.5*this.durations.move]);
['right', 'slider', 'sparks'].forEach(el => this[el].roundIndex = this.currentRound);
}
renderTable (data, classes = ['main']) {
this.left = {};
this.sparks = {};
this.right = {};
this.slider = {};
this.left.columns = columns.left;
this.right.columns = columns.right;
[this.left.table, this.left.rows, this.left.cells] = this.makeTable(data, [...classes, 'left'], this.left.columns);
[this.sparks.table, this.sparks.rows, this.sparks.cells] = this.makeSparks(data);
[this.right.table, this.right.rows, this.right.cells] = this.makeTable(data, [...classes, 'right'], this.right.columns);
this.sparks.width = this.sparks.rows.node().offsetWidth - this.sparks.cells.node().offsetWidth;
this.scale = d3.scaleLinear()
.domain([1, this.data.meta.lastRound])
.range([0, this.sparks.width])
.clamp(true);
this.moveRightTable(this.currentRound);
this.slider.top = this.makeSlider('top');
this.slider.bottom = this.makeSlider('bottom');
this.right.table.call(d3.drag()
.on("start", () => {
this.right.drag = {
x: d3.event.x,
roundIndex: this.right.roundIndex
};
})
.on("drag", () => {
const difference = Math.abs(this.right.drag.x - d3.event.x);
const sign = Math.sign(this.right.drag.x - d3.event.x);
const index = this.right.drag.roundIndex - sign*Math.round(this.scale.invert(difference)) + 1;
const roundIndex = Math.min(Math.max(index, 1), this.data.meta.lastRound);
this.moveRightTable(roundIndex);
this.preview(roundIndex);
})
.on("end", () => this.endPreview(true))
);
return ['table', 'rows', 'cells'].map(el => {
const nodes = ['left', 'sparks', 'right'].map(part => this[part][el].nodes());
return d3.selectAll(d3.merge(nodes));
});
}
makeTable (data, classes, columns) {
const table = this.tableContainer
.append('table')
.attr('class', classes.join(' '));
const tbody = table.append('tbody');
const rows = tbody.selectAll('tr')
.data(data, k => k.item)
.enter().append('tr');
const cells = rows.selectAll('td')
.data(result => columns.map(column => new Cell(column, result, this.params)))
.enter().append('td')
.attr('class', cell => cell.classes.join(' '))
.style('color', cell => cell.color)
.text(cell => cell.text);
cells.filter('.clickable')
.on('click', cell => {
switch(cell.column) {
case 'item':
if (this.drilldown.item !== cell.result.item) {
return this.drillDown(cell.result.item);
} else {
return this.endDrillDown();
}
default:
return null;
}
});
return [table, rows, cells];
}
makeSparks (data) {
const table = this.tableContainer
.append('table')
.attr('class', 'sparks');
const tbody = table.append('tbody');
const sparksData = data.map(result => ({
item: result.item,
results: getItemResults(this.data.results, result.item)
}));
const rows = tbody.selectAll('tr')
.data(sparksData, k => k.item)
.enter().append('tr');
const cells = rows.selectAll('td')
.data(row => this.data.results.slice(1, this.data.meta.lastRound + 1).map((round, i) => ({
result: row.results[i+1],
roundMeta: row.results[i+1].roundMeta
})))
.enter().append('td')
.attr('class', cell => getSparkClasses(cell, this.currentRound))
.style('background-color', cell => getSparkColor(cell, this.currentRound, this.params))
.on('mouseover', cell => this.preview(cell.roundMeta.index))
.on('mouseout', cell => this.endPreview(false))
.on('click', cell => this.endPreview(true));
const scale = d3.scaleLinear()
.domain([1, sparksData.length])
.range([0, 100]);
cells.filter(cell => cell.result.change !== null)
.append('span')
.attr('class', 'spark-position')
.style('top', cell => `${scale(cell.result.position.strict)}%`);
cells.filter(cell => cell.result.change !== null)
.append('span')
.attr('class', 'spark-score muted')
.style('color', cell => this.params.colors[cell.result.outcome] || 'black')
.text(cell => cell.result.match ? `${cell.result.match.score}:${cell.result.match.opponentScore}` : '');
cells.filter(cell => cell.roundMeta.index > this.currentRound)
.classed('overlapped', true);
this.dispatch.on('roundPreview.sparks', roundMeta => this.moveSparks(roundMeta.index, 0));
return [table, rows, cells];
}
makeSlider (position = 'top') {
const slider = position === 'top'
? this.sparks.table.select('tbody').insert('tr', 'tr')
: this.sparks.table.select('tbody').append('tr');
slider
.attr('class', `sparklines-slider ${position}`)
.append('td')
.attr('class', 'slider-cell')
.attr('colspan', this.roundsTotalNumber);
const left = `${this.scale(this.currentRound)}px`;
return slider.select('.slider-cell')
.append('span')
.attr('class', 'slider-toggle')
.style('left', left)
.text(this.data.results[this.currentRound].meta.name)
.call(d3.drag()
.on("drag", () => {
const roundIndex = Math.round(this.scale.invert(d3.event.x));
this.moveRightTable(roundIndex);
this.preview(roundIndex);
})
.on("end", () => this.endPreview(true))
);
}
to (roundIndex) {
if (roundIndex < 1 || roundIndex > this.data.meta.lastRound) {
return Promise.reject(`Sorry we can't go to round #${roundIndex}`);
}
if (roundIndex === this.currentRound) {
return Promise.resolve();
}
const change = roundIndex - this.currentRound;
this.dispatch.call('roundChange', this, this.data.results[roundIndex].meta);
['left', 'right'].forEach(side => {
this[side].rows
.data(this.data.results[roundIndex].results, k => k.item);
this[side].cells = this[side].cells
.data(result => this[side].columns.map(column => new Cell(column, result, this.params)));
});
this.right.cells.filter('.change')
.attr('class', cell => cell.classes.join(' '))
.style('color', cell => cell.color)
.text(cell => cell.text);
const preAnimations = ['right', 'slider', 'sparks']
.filter(element => this[element].roundIndex !== this.currentRound);
preAnimations.forEach(element => {
return {
right: this.moveRightTable,
slider: this.moveSlider,
sparks: this.moveSparks
}[element].bind(this)(roundIndex, this.durations.pre)
});
const duration = this.durations.scale(Math.abs(change));
return this.move(roundIndex, preAnimations.length ? this.durations.pre : 0, duration)
.then(() => {
const merged = d3.merge([this.left.cells.nodes(), this.right.cells.filter(':not(.change)').nodes()]);
d3.selectAll(merged)
.attr('class', cell => cell.classes.join(' '))
.style('color', cell => cell.color)
.text(cell => cell.text);
});
}
moveSlider (roundIndex, duration = 0) {
const left =`${this.scale(roundIndex)}px`;
[this.slider.top, this.slider.bottom].map(slider => {
slider
.transition()
.duration(duration)
.style('left', left)
.text(this.data.results[roundIndex].meta.name)
.on('end', () => this.slider.roundIndex = roundIndex);
});
}
moveRightTable (roundIndex, duration = 0) {
this.right.table
.transition()
.duration(duration)
.style('left', `-${this.sparks.width - this.scale(roundIndex)}px`)
.on('end', () => this.right.roundIndex = roundIndex);
}
moveSparks (roundIndex, duration = 0) {
const changed = this.sparks.cells
.filter(cell => isBetween(cell.roundMeta.index, roundIndex, this.sparks.roundIndex));
if (!duration) {
changed
.style('background-color', cell => getSparkColor(cell, roundIndex, this.params))
.style('opacity', cell => cell.roundMeta.index > roundIndex ? 0.15 : 1);
this.sparks.roundIndex = roundIndex
} else {
changed
.transition()
.duration(duration)
.style('background-color', cell => getSparkColor(cell, roundIndex, this.params))
.style('opacity', cell => cell.roundMeta.index > roundIndex ? 0.15 : 1)
.on('end', () => this.sparks.roundIndex = roundIndex);
}
}
first () {
return this.to(1);
}
preview (roundIndex) {
if (roundIndex < 1 || roundIndex > this.data.meta.lastRound) {
return Promise.reject(`Sorry we can't preview round #${roundIndex}`);
}
const previousPreviewedRound = this.previewedRound;
if (previousPreviewedRound === roundIndex) {
return Promise.resolve();
}
this.dispatch.call('roundPreview', this, this.data.results[roundIndex].meta);
this.moveSlider(roundIndex);
['left', 'right'].forEach(side => {
this[side].rows
.data(this.data.results[roundIndex].results, k => k.item);
this[side].cells = this[side].cells
.data(result => this[side].columns.map(column => new Cell(column, result, this.params)))
.attr('class', cell => cell.classes.join(' '))
.style('color', cell => cell.color)
.text(cell => cell.text);
});
return Promise.resolve();
}
drillDown (item) {
this.dispatch.call('drillDown', this, item);
if (!this.drilldown.controls) {
this.drilldown.controls = this.controls.append('div')
.attr('class', 'drilldown-control')
.on('click', this.endDrillDown)
.text(this.params.allLabel);
}
this.right.columns = columns.drilldown;
this.right.cells
.data(result => this.right.columns.map(column => new Cell(column, result, this.params)))
.attr('class', cell => cell.classes.join(' '))
.style('color', cell => cell.color)
.text(cell => cell.text);
this.right.rows.classed('muted', row => row.item !== item);
this.sparks.cells
.classed('muted', cell => !cell.result.match || (cell.result.item !== item && cell.result.match.opponent !== item));
this.sparks.cells.selectAll('.spark-score')
.classed('muted', cell => !cell.result.match || cell.result.item === item || cell.result.match.opponent !== item);
return Promise.resolve();
}
endDrillDown () {
this.drilldown.controls.remove();
this.drilldown.controls = null;
this.sparks.cells.classed('muted', false);
this.sparks.cells.selectAll('.spark-score')
.classed('muted', true);
this.right.columns = columns.right;
this.right.cells
.data(result => this.right.columns.map(column => new Cell(column, result, this.params)))
.attr('class', cell => cell.classes.join(' '))
.style('color', cell => cell.color)
.text(cell => cell.text);
this.right.rows.classed('muted', false);
this.dispatch.call('endDrillDown', this, null);
return Promise.resolve();
}
};
class Cell extends skeletonCell {
score (result, params) {
this.text = result.match && result.match.score !== null ? `${result.match.score}:${result.match.opponentScore}` : '';
this.classes = ['score', 'change'];
this.color = params.colors[result.outcome];
return this;
}
opponent (result, params) {
this.text = result.match ? result.match.opponent : '';
this.classes = ['opponent', 'change'];
return this;
}
equal (result, params) {
this.text = result.position.strict === 1 ? '=' : '';
this.classes = ['label'];
return this;
}
pointsLabel (result, params) {
this.text = result.position.strict === 1 ? params.pointsLabel : '';
this.classes = ['label'];
return this;
}
wins (result, params) {
this.text = `${result.wins.total} ${params.shortOutcomeLabels.win}`;
this.classes = ['change'];
this.color = params.colors.win;
return this;
}
draws (result, params) {
this.text = `${result.draws.total} ${params.shortOutcomeLabels.draw}`;
this.classes = ['calculation'];
this.color = params.colors.draw;
return this;
}
losses (result, params) {
this.text = `${result.losses.total} ${params.shortOutcomeLabels.loss}`;
this.classes = ['calculation'];
this.color = params.colors.loss;
return this;
}
labeledPoints (result, params) {
this.text = `${result.points.total} ${params.pointsLabel}`;
this.classes = ['calculation'];
return this;
}
makeChange (column, result, params) {
const calc = column.replace('.change', '');
this.text = result.change !== null ? numberToChange(result[calc].change, '0') : '';
this.classes = ['change'];
this.color = params.colors[result.outcome];
return this;
}
}