index.html
<!doctype html>
<html lang="">
<head>
<meta charset="utf-8">
<title>Three-way scatter</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script type='text/javascript' src='https://unpkg.com/d3'></script>
<script type='text/javascript' src='https://unpkg.com/d3-selection-multi'></script>
<script type='text/javascript' src='https://unpkg.com/d3-scale-chromatic'></script>
<link href="https://fonts.googleapis.com/earlyaccess/mplus1p.css" rel="stylesheet" />
<style>
svg {
}
text {
font-family: 'Mplus 1p',Arial,sans-serif;
}
.domain {
display: none;
}
.spine {
stroke: rgba(0, 0, 0, 0.75);
}
.tick line {
opacity: 0.2
}
line.xy {
stroke: red;
}
.point circle {
stroke: rgba(0, 0, 0, 0.15);
}
.highlighted circle {
stroke: black;
stroke-width: 2;
}
.refLine{
stroke: black;
stroke-dasharray: 2px 2px;
fill: none;
}
.refText{
font-weight: 500;
}
.refTextBack{
stroke: white;
stroke-width: 4px;
}
</style>
</head>
<body>
<svg></svg>
<script type='text/javascript'>
let width = 500,
height = 500,
extent = d3.min([width, height]) * 0.4,
svg = d3.select('svg')
.attrs({
width: width,
height: height
});
let seriesNames = [];
const axes = ['x', 'y', 'z'];
const scale = d3.scaleLinear().range([0, extent]).domain([0, 100]);
d3.csv('mtcars.csv', (e,d) => {
seriesNames = Object.keys(d[0]).slice(0,3);
const data = d.map((d, i) => {
return {
x: +d[seriesNames[0]],
y: +d[seriesNames[1]],
z: +d[seriesNames[2]],
id: `_${i}`,
name: d.name
}
});
const Xextent = d3.extent(data, d => d.x);
const Yextent = d3.extent(data, d => d.y);
const Zextent = d3.extent(data, d => d.z);
const scales = {
x: scale.copy().domain(Xextent),
y: scale.copy().domain(Yextent),
z: scale.copy().domain(Zextent)
};
console.log(data);
const scoreExtent = d3.extent(data, d => d3.mean(Object.values(d).slice(0, 3))).reverse();
const colourScale = d3.scaleSequential(d3.interpolateRdBu).domain(scoreExtent);
axes.forEach((a, i) => {
const titleG = svg.append('g')
.attrs({
class: 'title',
transform: `translate(${width*0.5},${height*0.5}) rotate(${i*120 + 30})`
});
const titleText = titleG.append('text')
.attrs({
x: scales[a].range()[1],
transform: `rotate(${-30 - 120 * i},${scales[a].range()[1]},${0})`,
'text-anchor': ['start', 'end', 'middle'][i]
})
.html(seriesNames[i]);
const spine = svg.append('line')
.attrs({
class: 'spine',
x1: scales[a].range()[0],
x2: scales[a].range()[1],
y1: 0,
y2: 0,
transform: `translate(${width*0.5},${height*0.5}) rotate(${i*120 + 30})`
});
const axis = d3.axisBottom().ticks(5).tickSize(-extent).scale(scales[a]);
const axisG = svg.append('g').call(axis)
.attrs({
transform: `translate(${width*0.5},${height*0.5}) rotate(${i*120 + 30})`
});
const ticks2 = d3.axisBottom().ticks(5).tickSize(extent).scale(scales[a]);
const ticks2G = svg.append('g').call(ticks2)
.attrs({
transform: `translate(${width*0.5},${height*0.5}) rotate(${i*120 + 30})`
});
axisG.selectAll('.tick line')
.attrs({
transform: `rotate(-30)`
});
axisG.selectAll('.tick text')
.attrs({
transform: `rotate(${-30 - 120 * i})`
});
ticks2G.selectAll('.tick line')
.attrs({
transform: `rotate(30)`
});
ticks2G.selectAll('text').remove();
});
svg.selectAll('g.point.xy')
.data(data)
.enter()
.append('g')
.attrs({
class: d => `point xy ${d.id}`,
transform: d => `translate(${(width*0.5) + scales.x(d.x) * Math.cos(Math.PI*1/6)},${(height*0.5) + (-scales.y(d.y) + scales.x(d.x) * Math.sin(Math.PI*1/6))})`
})
.append('circle')
.attrs({
r: 3
})
.styles({
fill: d => colourScale(d3.mean(Object.values(d).slice(0, 3)))
});
svg.selectAll('g.point.xz')
.data(data)
.enter()
.append('g')
.attrs({
class: d => `point xz ${d.id}`,
transform: d => `translate(${(width*0.5) + scales.x(d.x) * Math.cos(Math.PI*1/6) - scales.z(d.z) * Math.cos(Math.PI*1/6)},${(height*0.5) + (scales.x(d.x) * Math.sin(Math.PI*1/6)) + (scales.z(d.z) * Math.sin(Math.PI*1/6))})`
})
.append('circle')
.attrs({
r: 3
})
.styles({
fill: d => colourScale(d3.mean(Object.values(d).slice(0, 3)))
});
svg.selectAll('g.point.yz')
.data(data)
.enter()
.append('g')
.attrs({
class: d => `point yz ${d.id}`,
transform: d => `translate(${(width*0.5) - scales.z(d.z) * Math.cos(Math.PI*1/6)},${(height*0.5) + (-scales.y(d.y) + scales.z(d.z) * Math.sin(Math.PI*1/6))})`
})
.append('circle')
.attrs({
r: 3
})
.styles({
fill: d => colourScale(d3.mean(Object.values(d).slice(0, 3)))
});
svg.selectAll('g.point').on('mouseover', e => {
svg.selectAll(`g.point`)
.classed('highlighted', p => p.id == e.id)
.selectAll('circle')
.attrs({
r: p => p.id == e.id ? 5 : 3
})
svg.insert('path', '.point')
.attrs({
class: 'refLine',
d: `M${(width*0.5) + scales.x(e.x) * Math.cos(Math.PI*1/6)},${(height*0.5) + (-scales.y(e.y) + scales.x(e.x) * Math.sin(Math.PI*1/6))} L${(width*0.5) + scales.x(e.x) * Math.cos(Math.PI*1/6)},${(height*0.5) + (-scales.y.range()[0] + scales.x(e.x) * Math.sin(Math.PI*1/6))} L${(width*0.5) + scales.x(e.x) * Math.cos(Math.PI*1/6) - scales.z(e.z) * Math.cos(Math.PI*1/6)},${(height*0.5) + (scales.x(e.x) * Math.sin(Math.PI*1/6)) + (scales.z(e.z) * Math.sin(Math.PI*1/6))} L${(width*0.5) + scales.x.range()[0] * Math.cos(Math.PI*1/6) - scales.z(e.z) * Math.cos(Math.PI*1/6)},${(height*0.5) + (scales.x.range()[0] * Math.sin(Math.PI*1/6)) + (scales.z(e.z) * Math.sin(Math.PI*1/6))} L${(width*0.5) - scales.z(e.z) * Math.cos(Math.PI*1/6)},${(height*0.5) + (-scales.y(e.y) + scales.z(e.z) * Math.sin(Math.PI*1/6))} L${(width*0.5) - scales.z.range()[0] * Math.cos(Math.PI*1/6)},${(height*0.5) + (-scales.y(e.y) + scales.z.range()[0] * Math.sin(Math.PI*1/6))}Z`
});
svg.selectAll('text.refTextBack')
.data(Object.values(e).slice(0, 3))
.enter()
.append('text')
.attrs({
class: 'refTextBack',
transform: (t,i) => {
let trans;
switch (i) {
case 0:
trans = `translate(${(width*0.5) + scales.x(e.x) * Math.cos(Math.PI*1/6)},${(height*0.5) + (-scales.y.range()[0] + scales.x(e.x) * Math.sin(Math.PI*1/6))})`;
break;
case 1:
trans = `translate(${(width*0.5) + scales.x.range()[0] * Math.cos(Math.PI*1/6) - scales.z(e.z) * Math.cos(Math.PI*1/6)},${(height*0.5) + (scales.x.range()[0] * Math.sin(Math.PI*1/6)) + (scales.z(e.z) * Math.sin(Math.PI*1/6))})`
break;
case 2:
trans = `translate(${(width*0.5) - scales.z.range()[0] * Math.cos(Math.PI*1/6)},${(height*0.5) + (-scales.y(e.y) + scales.z.range()[0] * Math.sin(Math.PI*1/6))})`
}
return trans;
},
'text-anchor': (t,i) => ['start', 'end', 'middle'][i]
})
.html(t => (d3.format(',.1f')(t)).replace(/.0$/g,''));
svg.selectAll('text.refText')
.data(Object.values(e).slice(0, 3))
.enter()
.append('text')
.attrs({
class: 'refText',
transform: (t,i) => {
let trans;
switch (i) {
case 0:
trans = `translate(${(width*0.5) + scales.x(e.x) * Math.cos(Math.PI*1/6)},${(height*0.5) + (-scales.y.range()[0] + scales.x(e.x) * Math.sin(Math.PI*1/6))})`;
break;
case 1:
trans = `translate(${(width*0.5) + scales.x.range()[0] * Math.cos(Math.PI*1/6) - scales.z(e.z) * Math.cos(Math.PI*1/6)},${(height*0.5) + (scales.x.range()[0] * Math.sin(Math.PI*1/6)) + (scales.z(e.z) * Math.sin(Math.PI*1/6))})`
break;
case 2:
trans = `translate(${(width*0.5) - scales.z.range()[0] * Math.cos(Math.PI*1/6)},${(height*0.5) + (-scales.y(e.y) + scales.z.range()[0] * Math.sin(Math.PI*1/6))})`
}
return trans;
},
'text-anchor': (t,i) => ['start', 'end', 'middle'][i]
})
.html(t => (d3.format(',.1f')(t)).replace(/.0$/g,''));
svg.selectAll('text.name')
.data([e])
.enter()
.append('text')
.attrs({
class: 'name',
x: 10,
y: 20
})
.html(e.name)
}).on('mouseout', e => {
svg.selectAll(`g.point`)
.classed('highlighted', 0)
.selectAll('circle')
.attrs({
r: 3
})
svg.selectAll('.refLine, .refText, .refTextBack, .name').remove();
});
})
</script>
</body>
</html>
mtcars.csv
disp,wt,hp,name
160,2.62,110,Mazda RX4
160,2.875,110,Mazda RX4 Wag
108,2.32,93,Datsun 710
258,3.215,110,Hornet 4 Drive
360,3.44,175,Hornet Sportabout
225,3.46,105,Valiant
360,3.57,245,Duster 360
146.7,3.19,62,Merc 240D
140.8,3.15,95,Merc 230
167.6,3.44,123,Merc 280
167.6,3.44,123,Merc 280C
275.8,4.07,180,Merc 450SE
275.8,3.73,180,Merc 450SL
275.8,3.78,180,Merc 450SLC
472,5.25,205,Cadillac Fleetwood
460,5.424,215,Lincoln Continental
440,5.345,230,Chrysler Imperial
78.7,2.2,66,Fiat 128
75.7,1.615,52,Honda Civic
71.1,1.835,65,Toyota Corolla
120.1,2.465,97,Toyota Corona
318,3.52,150,Dodge Challenger
304,3.435,150,AMC Javelin
350,3.84,245,Camaro Z28
400,3.845,175,Pontiac Firebird
79,1.935,66,Fiat X1-9
120.3,2.14,91,Porsche 914-2
95.1,1.513,113,Lotus Europa
351,3.17,264,Ford Pantera L
145,2.77,175,Ferrari Dino
301,3.57,335,Maserati Bora
121,2.78,109,Volvo 142E