A prototype graphical perception layer for D3 visualisations.
Supports the collection of coordinates or attributes (selected via a provided accesor) from click events. For example, by providing a selection of areas on a map and the accessor attribute ("id").
Also supports compare questions, where a number of points or areas to compare are selected. The participant must the select (click) one of these. These can be defined by providing [x, y] coordinates of comparison regions (and a radius) or by supplying an accessor function to access and calculate the coordinates of the region from the given selection. In the above example, the accessor function finds the centroid of each constituency.
Part of a larger project, Viz Test, which aims to make crowdsourcing graphical perception experiments more accessible.
xxxxxxxxxx
<head>
<meta charset="utf-8">
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/d3-scale-chromatic.v1.min.js"></script>
<script src="https://unpkg.com/topojson@3"></script>
<style>
body {
margin: 0;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.constituency {
stroke: #ddd; */
stroke-width: .75px;
}
.london-outline {
fill: none;
stroke: #333; */
stroke-width: .75px;
}
.perception-layer rect {
fill: white;
stroke: red;
pointer-events: all;
}
.record-text, .timer-text, .coordinates-text {
font-family: sans-serif;
font-weight: bold;
fill: red;
}
.record-circle {
fill: red;
}
.info-text {
font-family: sans-serif;
font-weight: bold;
fill: red;
}
.task-text {
font-size: 24px;
}
.click-text {
font-size: 18px;
}
.marker line {
stroke: black;
stroke-width: 2px;
}
.compare-circle {
fill: white;
fill-opacity: 0;
stroke: black;
stroke-width: 3px;
}
.compare-text {
font-family: sans-serif;
font-weight: bold;
font-size: 20px;
text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, -1px 0 0 #fff, 0 -1px 0 #fff;
}
</style>
</head>
<body>
<script>
var margin = {top: 50, right: 50, bottom: 50, left: 50};
var width = 960 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;
var svg = d3.select("body").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("class", "top-group")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
var colour = d3.scaleSequential(d3.interpolateGreens);
var mapGroup = svg.append("g")
.attr("class", "map");
d3.json("topo_wpc_london.json", (error, map) => {
if (error) throw error;
var constituencies = topojson.feature(map, map.objects.wpc).features;
var londonOutline = topojson.merge(map, map.objects.wpc.geometries);
var projection = d3.geoAlbers()
.rotate(0)
.fitSize([width, height], londonOutline);
var path = d3.geoPath()
.projection(projection);
var areas = mapGroup.append("g");
areas.selectAll("path")
.data(constituencies)
.enter().append("path")
.attr("class", "constituency")
.attr("id", d => d.id)
.attr("d", path)
.attr("fill", () => colour(Math.random()));
var outline = mapGroup.append("g")
.append("path")
.datum(londonOutline)
.attr("class", "london-outline")
.attr("d", path);
var config = {
task: "Which of the circled areas is darker?",
selectionAccessor: "id",
compareArray: [{id:"E14000615", x:0, y:0, label:"B"}, {id:"E14000732", x:0, y:0, label:"A"}, {id: "E14000687", x:0, y:0, label:"C"}],
compareAccessor: d => {
return projection(d3.polygonCentroid(d3.select("#" + d.id).data()[0].geometry.coordinates[0]))
},
compareRadius: 30
}
appendPerceptionLayer(mapGroup, areas.selectAll("path"), config);
});
function appendPerceptionLayer(group, selection, config) {
var newWidth = width + margin.left / 2 + margin.right / 2,
newHeight = height + margin.top / 2 + margin.bottom / 2;
var perception = group.append("g")
.attr("class", "perception-layer")
.attr("transform", "translate(" + [-margin.left / 2, -margin.top / 2] + ")")
var outlineRect = perception.append("rect")
.attr("width", newWidth)
.attr("height", newHeight);
var info = perception.append("g")
.attr("class", "info-text")
.attr("text-anchor", "middle")
.attr("transform", "translate(" + [newWidth / 2, newHeight / 2] + ")");
info.append("text")
.attr("class", "task-text")
.attr("y", -15)
.text('"' + config.task + '"');
info.append("text")
.attr("class", "click-text")
.attr("y", 15)
.text("Click to stark the task");
var timer = perception.append("text")
.attr("class", "timer-text")
.attr("transform", "translate(" + [newWidth, newHeight] + ")")
.attr("x", -15)
.attr("y", -15)
.attr("text-anchor", "end")
.text("0s");
var coordinates = perception.append("text")
.attr("class", "coordinates-text")
.attr("transform", "translate(" + [0, newHeight] + ")")
.attr("x", 15)
.attr("y", -15)
.attr("text-anchor", "start")
.text("");
/* Extract this into a separate function or as a path string */
var markerLength = 10;
var marker = perception.append("g")
.attr("class", "marker")
.attr("transform", "translate(" + [newWidth / 2, newHeight / 2] + ")")
.attr("opacity", 0);
marker.append("line")
.attr("x1", -markerLength).attr("y1", -markerLength)
.attr("x2", markerLength).attr("y2", markerLength);
marker.append("line")
.attr("x1", -markerLength).attr("y1", markerLength)
.attr("x2", markerLength).attr("y2", -markerLength);
var recordGroup = perception.append("g")
.attr("transform", "translate(" + [-60 + newWidth, 30] + ")");
var radius = 6;
recordGroup.append("text")
.attr("class", "record-text")
.attr("text-anchor", "start")
.attr("x", radius * 1.5)
.text("REC");
recordGroup.append("circle")
.attr("class", "record-circle")
.attr("cy", -radius)
.attr("r", radius);
// If a selection has been provided, bind a click event to this with a given accessor
if (selection) {
selection.on("click", d => {
console.log(d[config.selectionAccessor]);
stopTimer(perception.node());
});
}
var compareLayer = svg.append("g")
.attr("class", "compare-layer");
if (config.compareArray) {
// If a compare accessor exists, use it - otherwise use [x, y] instead
config.compareArray.forEach(c => {
if (config.compareAccessor) {
c.point = config.compareAccessor(c);
} else {
c.point = [c.x, c.y];
}
});
var compareGroups = compareLayer.selectAll("g")
.data(config.compareArray)
.enter().append("g")
.attr("class", "compare-group")
.attr("transform", d => "translate(" + d.point + ")")
.style("opacity", 0);
compareGroups.append("circle")
.attr("class", "compare-circle")
.attr("r", config.compareRadius)
.on("click", function(d) {
d3.select(this)
.style("stroke", "red")
.style("fill", "red")
.style("fill-opacity", 0.2);
stopTimerWithSelection(d.id);
})
compareGroups.append("text")
.attr("class", "compare-text")
.attr("text-anchor", "middle")
.attr("x", -config.compareRadius)
.attr("y", -config.compareRadius)
.text(d => d.label);
}
var t;
var elapsedTime;
var savedCoordinates;
var savedId;
var flashTimer;
compareLayer.lower();
perception.on("click", () => {
outlineRect.transition().style("fill-opacity", 0);
info.select(".click-text").transition().attr("opacity", 0);
info.attr("transform", "translate(" + [0, 0] + ")");
info.select(".task-text")
.attr("text-anchor", "start")
.attr("x", 10)
.attr("y", 25)
.style("font-size", 18);
flashCircle(0);
perception.lower();
compareLayer.raise();
svg.selectAll(".compare-group")
.style("opacity", 1);
t = d3.interval(function(elapsed) {
timer.text((elapsed / 1000).toFixed(1) + "s");
elapsedTime = elapsed;
perception.on("click", function() {
stopTimer(this);
});
});
});
function stopTimerWithSelection(id) {
t.stop();
flashTimer.stop();
savedId = id;
coordinates.text("Selected: " + id);
perception.raise();
outlineRect.transition().style("fill-opacity", 0.5);
}
function stopTimer(clickArea) {
t.stop();
flashTimer.stop();
savedCoordinates = d3.mouse(clickArea);
coordinates.text("(" + Math.round(savedCoordinates[0]) + ", " + Math.round(savedCoordinates[1]) + ")");
perception.raise();
outlineRect.transition().style("fill-opacity", 0.5);
marker.attr("transform", "translate(" + savedCoordinates + ")")
.attr("opacity", 1);
}
function flashCircle() {
var opacity = 0;
flashTimer = d3.interval(function() {
opacity = 1 - opacity;
recordGroup.select("circle")
.transition()
.attr("opacity", opacity);
}, 600);
}
}
</script>
</body>
https://d3js.org/d3.v4.min.js
https://d3js.org/d3-scale-chromatic.v1.min.js
https://unpkg.com/topojson@3