Skip to content

Instantly share code, notes, and snippets.

@tlfrd
Last active January 1, 2020 08:45
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save tlfrd/195800611e1486b056043e51be85f966 to your computer and use it in GitHub Desktop.
Save tlfrd/195800611e1486b056043e51be85f966 to your computer and use it in GitHub Desktop.
Graphical Perception Layer
license: mit

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.

<!DOCTYPE html>
<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>
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment