|
(function () { |
|
const cellSize = 15; |
|
const percentFormat = d3.format('+.1%'); |
|
|
|
const chart = d3.select('#chart'); |
|
|
|
//const colorScale = d3.scaleLinear().range(['red', 'white', 'green']); |
|
//const colorScale = portfolioChartsThresholdScale(); |
|
const colorScale = myThresholdScale(); |
|
|
|
const legend = d3.legendColor() |
|
.scale(colorScale) |
|
.labels(legendThresholdLabels) |
|
.labelFormat(percentFormat); |
|
|
|
d3.csv('ftse-all-share-index.csv', row, (error, data) => { |
|
if (error) throw error; |
|
|
|
const yearlyReturns = { |
|
endYear: data[0].year, |
|
startYear: data[data.length - 1].year, |
|
data: data.reverse().map(d => d.rate) |
|
}; |
|
|
|
const cagrs = periodsCagrs(yearlyReturns); |
|
|
|
// setScaleDomainFromData(cagrs); |
|
|
|
const rows = chart.selectAll('g.row') |
|
.data(cagrs) |
|
.enter() |
|
.append('g') |
|
.classed('row', true) |
|
.attr('transform', (d, i) => `translate(0,${i * cellSize})`); |
|
|
|
const cells = rows.selectAll('rect.cell') |
|
.data(d => d) |
|
.enter() |
|
.append('rect') |
|
.classed('cell', true) |
|
.attr('width', cellSize) |
|
.attr('height', cellSize) |
|
.attr('x', (d, i) => i * cellSize) |
|
.attr('fill', colorScale); |
|
|
|
cells.selectAll('text') |
|
.data(d => [d]) |
|
.enter() |
|
.append('text') |
|
.text(percentFormat); |
|
|
|
cells.selectAll('title') |
|
.data(d => [d]) |
|
.enter() |
|
.append('title') |
|
.text(percentFormat); |
|
|
|
chart.select('#legend') |
|
.call(legend); |
|
}); |
|
|
|
function myThresholdScale() { |
|
// Chroma.js color scale helper, with the extreme and middle colors of portfoliocharts |
|
// https://gka.github.io/palettes/#colors=#C0504D,#F2F2F2,#31869B|steps=7|bez=0|coL=0 |
|
// http://colorbrewer2.org |
|
|
|
// 7 original colours + different one at each end. Lightness gradient is maintained. |
|
// not symmetric because positives have a greater range |
|
return d3.scaleThreshold() |
|
.range(['black', '#c0504d', '#d78780', '#e8bcb8', '#f2f2f2', '#b6cdd4', '#79a9b7', '#31869b', '#1d6232']) |
|
.domain([-0.12, -0.09, -0.06, -0.03, 0.03, 0.06, 0.09, 0.15]); // must have one less element than the range |
|
} |
|
|
|
function portfolioChartsThresholdScale() { |
|
// Not symmetric, which may be misleading. |
|
// Out-of-domain values don't look any different, so big gains/drops aren't noticeable. |
|
// Returns between -3 and +3 show as zero, this is probably ok given long period. |
|
// Values around thresholds which are meaningfully the same can be coloured differently. |
|
// Values around thresholds can be in bucket that doesn't match the value when formatted to 1dp. |
|
// Colorbrewer recommends 5-7 classes, or up to 9 when similar colours next to each other, making them |
|
// easier to distinguish. |
|
return d3.scaleThreshold() |
|
.range(['#C0504D', '#E38B8B', '#F2F2F2', '#B7DEE8', '#6EBBD0', '#31869B']) |
|
.domain([-0.06, -0.03, 0.03, 0.06, 0.09]); // must have one less element than the range |
|
} |
|
|
|
function setScaleDomainFromData(cagrs) { |
|
// Not symmetric. |
|
// Lots of light green |
|
// Not easy to look at a point and think _how_ good a period it was. |
|
const minCagr = d3.min(cagrs, d => d3.min(d)); |
|
const maxCagr = d3.max(cagrs, d => d3.max(d)); |
|
colorScale.domain([minCagr, 0, maxCagr]); |
|
} |
|
|
|
function legendThresholdLabels({ i, genLength, generatedLabels }) { |
|
// workaround for https://github.com/susielu/d3-legend/issues/77 |
|
const formattedNaN = percentFormat(NaN); |
|
|
|
if (i === 0) { |
|
return generatedLabels[i].replace(formattedNaN + ' to', 'Less than'); |
|
} else if (i === genLength - 1) { |
|
return `More than ${generatedLabels[genLength - 1].replace(' to ' + formattedNaN, '')}`; |
|
} |
|
return generatedLabels[i]; |
|
} |
|
|
|
function periodsCagrs(yearlyReturns) { |
|
const cagrs = []; |
|
|
|
for (let startYear = yearlyReturns.startYear; startYear <= yearlyReturns.endYear; startYear++) { |
|
const periodCagrs = []; |
|
cagrs.push(periodCagrs); |
|
|
|
for (let endYear = startYear; endYear <= yearlyReturns.endYear; endYear++) { |
|
periodCagrs.push(periodCagr(yearlyReturns, startYear, endYear)); |
|
} |
|
} |
|
|
|
return cagrs; |
|
} |
|
|
|
function periodCagr(yearlyReturns, startYearInclusive, endYearInclusive) { |
|
if (endYearInclusive < startYearInclusive) throw new Error('end < start'); |
|
if (startYearInclusive < yearlyReturns.startYear) throw new Error('start too far back'); |
|
if (endYearInclusive > yearlyReturns.endYear) throw new Error('end too far forward'); |
|
|
|
const years = endYearInclusive - startYearInclusive + 1; |
|
const fromIndex = startYearInclusive - yearlyReturns.startYear; |
|
const yearsReturns = yearlyReturns.data.slice(fromIndex, fromIndex + years); |
|
|
|
return cagr(yearsReturns); |
|
} |
|
|
|
function cagr(simpleReturns) { |
|
/* |
|
* Calculate using log returns, which are better, and aggregate across time. |
|
* |
|
* Inspiration: http://www.moneychimp.com/features/market_cagr.htm |
|
* Reasoning: http://www.dcfnerds.com/94/arithmetic-vs-logarithmic-rates-of-return/ |
|
* Method: http://www.portfolioprobe.com/2010/10/04/a-tale-of-two-returns/ (see: Transmutation, Aggregation) |
|
*/ |
|
const logReturns = simpleReturns.map(rate => Math.log(rate + 1)); |
|
|
|
const avgLogReturn = sum(logReturns) / logReturns.length; |
|
const avgSimpleReturn = Math.exp(avgLogReturn) - 1; |
|
|
|
return avgSimpleReturn; |
|
} |
|
|
|
function sum(values) { |
|
return values.reduce((a, b) => a + b, 0); |
|
} |
|
|
|
function row(d) { |
|
return { |
|
year: Number(d.Year), |
|
rate: Number(d['Total Return']) / 100 |
|
}; |
|
} |
|
|
|
})(); |