Skip to content

Instantly share code, notes, and snippets.

@starcalibre
Last active January 26, 2021 11:58
Show Gist options
  • Save starcalibre/2a58a11ee4a05a247ed84054dd9f2485 to your computer and use it in GitHub Desktop.
Save starcalibre/2a58a11ee4a05a247ed84054dd9f2485 to your computer and use it in GitHub Desktop.
Westworld Host Personality Chart
height: 600
scrolling: no
border: yes
# IDE files
.idea/
*.iml

I'm absolutely loving some of the visualisations in HBO's Westworld, so I couldn't resist re-creating one in D3. This one is based on the Host personality chart, seen here.

This plot is interactive. The Host personality values can be updated by dragging over the text labels in the plot.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ATTRIBUTE MATRIX: ATRBT GROUP 01</title>
<style>
svg {
background-color: #233238;
}
.center-circle {
fill: #1B3544;
stroke: #519CB3;
stroke-width: 1px;
}
circle.axis {
fill: none;
stroke: #519CB3;
stroke-width: 1px;
stroke-opacity: 0.3;
}
circle.value {
fill: #519CB3;
stroke: none;
fill-opacity: 0.6;
}
circle.max {
fill: #1B3544;
stroke: #519CB3;
stroke-width: 1px;
}
line {
stroke: #519CB3;
stroke-width: 1px;
opacity: 0.5;
}
polygon.value {
stroke: #519CB3;
fill: #519CB3;
stroke-width: 1px;
fill-opacity: 0.3;
stroke-opacity: 0.8;
}
text {
font-family: ‘Arial Black’, Gadget, sans-serif;
font-weight: bold;
fill: #43829B;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
body.dragging,
text:hover {
cursor: ns-resize;
}
text.north {
text-anchor: middle;
alignment-baseline: baseline;
}
text.east {
text-anchor: start;
alignment-baseline: middle;
}
text.south {
text-anchor: middle;
alignment-baseline: hanging;
}
text.west {
text-anchor: end;
alignment-baseline: middle;
}
text.first-quadrant {
text-anchor: start;
alignment-baseline: hanging;
}
text.second-quadrant {
text-anchor: start;
alignment-baseline: central;
}
text.third-quadrant {
text-anchor: end;
alignment-baseline: central;
}
text.fourth-quadrant {
text-anchor: end;
alignment-baseline: hanging;
}
</style>
</head>
<body>
</body>
<script src="https://d3js.org/d3.v4.js"></script>
<script>
// configurable constants
var width = 850;
var height = 550;
var maxAttrPoints = 20;
var innerCircleRadius = 15;
var outerCircleMaxRadius = 240;
var textLabelOffset = 7.5;
var dragDelta = 10;
// non-configurable constants
var centerX = width / 2;
var centerY = height / 2;
// variables used to track values when dragging
var textDragStartY = 0;
var textDragStartValue = 0;
var data = [
{ attr: 'Bulk Apperception', val: 14 },
{ attr: 'Candor', val: 19 },
{ attr: 'Vivacity', val: 17 },
{ attr: 'Coordination', val: 10 },
{ attr: 'Meekness', val: 2 },
{ attr: 'Humility', val: 3 },
{ attr: 'Cruelty', val: 1 },
{ attr: 'Self-Preservation', val: 10 },
{ attr: 'Patience', val: 3 },
{ attr: 'Decisiveness', val: 14 },
{ attr: 'Imagination', val: 13 },
{ attr: 'Curiosity', val: 8 },
{ attr: 'Aggression', val: 10 },
{ attr: 'Loyalty', val: 16 },
{ attr: 'Empathy', val: 9 },
{ attr: 'Tenacity', val: 17 },
{ attr: 'Courage', val: 15 },
{ attr: 'Sensuality', val: 18 },
{ attr: 'Charm', val: 18 },
{ attr: 'Humor', val: 9 }
];
// scale for plotting points along lines of radar plot
// calculates length of 'r' based on host attribute value
var lineScale = d3.scaleLinear()
.domain([0, maxAttrPoints + 1])
.range([innerCircleRadius, outerCircleMaxRadius]);
// append svg element for drawing
var svg = d3.select('body')
.append('svg')
.attr('width', width)
.attr('height', height);
// create drag behaviour for changing data values via the text labels
var textDragBehaviour = d3.drag()
.on('start', function(d, i) {
d3.select('body').classed('dragging', true);
textDragStartY = d3.event.y;
textDragStartValue = data[i].val;
})
.on('drag', function(d, i) {
var dy = textDragStartY - d3.event.y;
var newValue = textDragStartValue + Math.floor(dy / dragDelta);
data[i].val = clampInt(newValue, 1, maxAttrPoints);
updatePlot();
})
.on('end', function() {
d3.select('body').classed('dragging', false);
});
// append circular grid lines
d3.range(0, maxAttrPoints + 1).forEach(function(d) {
svg.append('circle')
.attr('cx', centerX)
.attr('cy', centerY)
.attr('r', function() {
return lineScale(d);
})
.classed('axis', true);
});
// append lines grid lines
svg.selectAll('line.value')
.data(data)
.enter()
.append('line')
.attr('x1', function(d, i) {
var theta = i * (2 * Math.PI / data.length) - Math.PI / 2;
var xy = polarToCartesian(innerCircleRadius, theta);
return xy[0] + centerX;
})
.attr('y1', function(d, i) {
var theta = i * (2 * Math.PI / data.length) - Math.PI / 2;
var xy = polarToCartesian(innerCircleRadius, theta);
return xy[1] + centerY;
})
.attr('x2', function(d, i) {
var r = lineScale(maxAttrPoints);
var theta = i * (2 * Math.PI / data.length) - Math.PI / 2;
var xy = polarToCartesian(r, theta);
return xy[0] + centerX;
})
.attr('y2', function(d, i) {
var r = lineScale(maxAttrPoints);
var theta = i * (2 * Math.PI / data.length) - Math.PI / 2;
var xy = polarToCartesian(r, theta);
return xy[1] + centerY;
})
.classed('value', true);
// append 'max value' points
d3.range(0, data.length).forEach(function(i) {
var r = lineScale(20);
var theta = i * (2 * Math.PI / data.length) - Math.PI / 2;
var xy = polarToCartesian(r, theta);
svg.append('circle')
.attr('cx', xy[0] + centerX)
.attr('cy', xy[1] + centerY)
.attr('r', 5)
.classed('max', true);
});
// append the polygon defined by the values, we bind the data again for
// the same reasons above
svg.selectAll('polygon')
.data([data]) // put data array in own array so data binder iterates once
.enter()
.append('polygon')
.classed('value', true);
// call a separate function to update the polygon points so we can update
// the points when the data changes
updatePolygonPoints();
// append circle values, bind data here so the values can be updated on the fly
svg.selectAll('circle.value')
.data(data)
.enter()
.append('circle')
.attr('r', 5)
.classed('value', true);
// update the cx and cy attributes of the circles in a separate function so
// we can update these separately
updatePoints();
// append the text labels to the plot border
// this is a little complicated as the text-anchor depends upon
// the radial position in the plot
svg.selectAll('text')
.data(data)
.enter()
.append('text')
.attr('x', function(d, i) {
var r = (outerCircleMaxRadius + textLabelOffset);
var theta = i * (2 * Math.PI / data.length) - Math.PI / 2;
var xy = polarToCartesian(r, theta);
return xy[0] + centerX;
})
.attr('y', function(d, i) {
var r = (outerCircleMaxRadius + textLabelOffset);
var theta = i * (2 * Math.PI / data.length) - Math.PI / 2;
var xy = polarToCartesian(r, theta);
return xy[1] + centerY;
})
.attr('class', function(d, i) {
var theta = i * (2 * Math.PI / data.length) - Math.PI / 2;
return getTextAnchorClass(theta);
})
.call(textDragBehaviour);
// updating the text itself is handled in a separate function so it can be
// updated later. call this function now so the label text appears when the plot
// is first rendered
updateTextLabels();
// append center circle element
// append this last so it appears on the top
svg.append('circle')
.attr('cx', centerX)
.attr('cy', centerY)
.attr('r', innerCircleRadius)
.classed('center-circle', true);
function updatePlot() {
updatePoints();
updateTextLabels();
updatePolygonPoints();
}
function updatePoints() {
svg.selectAll('circle.value')
.attr('cx', function(d, i) {
var r = lineScale(d.val);
var theta = i * (2 * Math.PI / data.length) - Math.PI / 2;
var xy = polarToCartesian(r, theta);
return xy[0] + centerX;
})
.attr('cy', function(d, i) {
var r = lineScale(d.val);
var theta = i * (2 * Math.PI / data.length) - Math.PI / 2;
var xy = polarToCartesian(r, theta);
return xy[1] + centerY;
});
}
function updatePolygonPoints() {
svg.select('polygon')
.attr('points', function(d) {
return d.map(function(d, i) {
var r = lineScale(d.val);
var theta = i * (2 * Math.PI / data.length) - Math.PI / 2;
var xy = polarToCartesian(r, theta);
xy[0] += centerX;
xy[1] += centerY;
return xy.join(",")
}).join(" ");
});
}
function updateTextLabels() {
svg.selectAll('text')
.data(data).text(function(d) {
var out = [];
out.push(d.attr.toUpperCase());
out.push(' [');
out.push(d.val);
out.push(']');
return out.join('');
});
}
// return correct text anchoring class based on the angle given
function getTextAnchorClass(theta) {
theta = theta % (2 * Math.PI);
if((almostEquals(theta, -Math.PI / 2))) {
return 'north';
}
else if(almostEquals(theta, 0)) {
return 'east';
}
else if(almostEquals(theta, Math.PI / 2)) {
return 'south';
}
else if(almostEquals(theta, Math.PI)) {
return 'west';
}
else if(theta > (-Math.PI / 2) && theta < 0) {
return 'first-quadrant';
}
else if(theta > 0 && theta < (Math.PI / 2)) {
return 'second-quadrant';
}
else if(theta > (Math.PI / 2) && theta < Math.PI) {
return 'third-quadrant';
}
else {
return 'fourth-quadrant';
}
}
// almost equals function for comparing floating point numbers
function almostEquals(a, b, eps) {
if(!eps) {
eps = 0.0001;
}
return Math.abs(a - b) < eps;
}
// function for clamping and rounding a value to nearest integer
function clampInt(x, min, max) {
return Math.round(Math.max(min, Math.min(x, max)));
}
// return cartesian x, y co-ordinates give an r and theta polar coords
// value
function polarToCartesian(r, theta) {
return [r * Math.cos(theta), r * Math.sin(theta)];
}
</script>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment