/* By Bo Ericsson, https://www.linkedin.com/in/boeric00/ */
/* eslint-disable no-console, no-restricted-globals, func-names, quotes, no-multi-spaces,
prefer-template, no-script-url, prefer-arrow-callback, no-param-reassign, no-use-before-define,
no-nested-ternary, max-len, no-shadow, no-multi-assign, no-plusplus, object-curly-newline
*/
/* global d3, crossfilter, L */
console.log("d3.version", d3.version);
console.log("crossfilter.version", crossfilter.version);
// variables
let chart;
// formats
const fmt = d3.format("02d");
const fmt1dec = d3.format(".1f");
const fmtM = d3.format(",d");
// ensure at least 700px height if running in an iframe (bl.ocks)
// http://bl.ocks.org/mbostock/1093025
d3.select(self.frameElement).transition().duration(500).style("height", "600px");
// build the map
const mapId = "boeric.omod7h1p"; // dark map
L.mapbox.accessToken = 'pk.eyJ1IjoiYm9lcmljIiwiYSI6IkZEU3BSTjQifQ.XDXwKy2vBdzFEjndnE4N7Q';
const map = L.mapbox.map("mapDiv", mapId).setView([33, -100], 4);
// create layer to hold shooting events
const shootingsLayer = L.geoJson(null, { pointToLayer: scaledPoint }).addTo(map);
// helper functions to set map marker attributes
function pointColor(feature) {
return feature.properties.Dead > 0 ? "red" : "orange";
}
function pointRadius(feature) {
return Math.max(feature.properties.Dead * 2, 6);
}
function scaledPoint(feature, latlng) {
// generate html for popup
let html = "
";
html += "Location: " + feature.properties.Location + '
';
html += "Date: " + feature.properties.displayDate + "
";
html += "Dead: " + feature.properties.Dead + "
";
html += "Injured: " + feature.properties.Injured + "
";
html += "References:
";
// generate an anchor element for each reference
feature.properties.References.forEach(function (d) {
// http://stackoverflow.com/questions/8498592/extract-root-domain-name-from-string
const matches = d.match(/^https?:\/\/([^/?#]+)(?:[/?#]|$)/i);
const domain = matches && matches[1];
html += "
" + domain + "";
});
// return the marker
return L.circleMarker(latlng, {
radius: pointRadius(feature),
fillColor: pointColor(feature),
fillOpacity: 0.6,
weight: 0.5,
color: "darkred",
}).bindPopup(html);
}
// get reference to chart div
const chartDiv = d3.select(".chart");
// add a reset anchor element
chartDiv.select(".title")
.append("a")
.attr("href", "javascript:reset()")
.attr("class", "reset")
.text("Reset filter")
.style("margin-left", "10px")
.style("display", "none");
// get the data
d3.json("shootings.GeoJSON", function (error, data) {
// let featureCount = data.features.length;
// process the data
data.features.forEach(function (d) {
const c = d.properties.Date.split("/");
const year = +c[2] + 2000;
const month = +c[0] - 1;
const day = +c[1];
const date = new Date(year, month, day);
const week = d3.time.week(date);
const weekOfYear = d3.time.weekOfYear(week);
d.properties.date = date;
d.properties.displayDate = date.toString().substring(0, 16);
d.properties.week = week;
d.properties.weekNumber = fmt(weekOfYear);
d.properties.year = week.getYear() + 1900;
d.properties.weekBin = (week.getYear() + 1900) + "-" + fmt(weekOfYear);
});
// get extent (earliest/latest) dates
const dateExtent = d3.extent(data.features, function (d) { return d.properties.date; });
// add one week to extent
dateExtent[1].setDate(dateExtent[1].getDate() + 7);
// sort the features in date order
data.features.sort(function (a, b) {
if (a.properties.date > b.properties.date) return 1;
if (a.properties.date < b.properties.date) return -1;
return 0;
});
// init crossfilter
const cf = crossfilter(data.features);
// add week dimension
const week = cf.dimension(function(d) { return d.properties.week; });
// create groups from the week dimension
const countGroup = week.group(d3.time.week);
const deadGroup = week.group().reduceSum(function(d) { return d.properties.Dead; });
const injuredGroup = week.group().reduceSum(function(d) { return d.properties.Injured; });
// console.log("deadGroup.top()", deadGroup.top(Infinity))
// console.log("deadGroup.all())", deadGroup.all())
// console.log("injuredGroup.top()", injuredGroup.top(Infinity))
// add tracking dimension and group
const weekTracker = cf.dimension(function (d) { return d.properties.week; });
const countTracker = weekTracker.group(d3.time.week);
// add event handlers to toggle data series view (based on the groups defined above)
d3.selectAll("input[type=radio][name=view]")
.on("change", function () {
const elem = d3.select(this);
const value = elem.property("value");
const group = value === "count" ? countGroup : value === "dead" ? deadGroup : injuredGroup;
// rebuild the svg bar chart with the new data series
chart
.removeContents()
.brushDirty(true)
.group(group);
// refresh viz
renderAll();
});
// create the bar chart
chart = barChart()
.dimension(week)
.group(countGroup)
.round(d3.time.week.round) // ensures whole week
.x(d3.time.scale()
.domain(dateExtent)
.rangeRound([0, 5 * countGroup.all().length + 1]))
.filter([new Date(2014, 9, 5), new Date(2015, 9, 4)])
// .filter([new Date(2013, 1, 1), new Date(2013, 5, 1)])
// .filter(null)
.on("brush", function () { renderAll(); })
.on("brushend", function () { renderAll(); })
.enableBrush(true);
// init animator
const anim = animator()
.speed(1000)
.chart(chart)
.container(d3.select(".animControls"));
// start the animator
anim(countGroup.all());
// window.anim = anim; // uncomment this to get access to the animator object from the browser console
// called at init, by the brush event handlers and by filter reset "a" elem
function renderAll() {
// run the chart function (these three are equivalent functionally)
// d3.select(".chart").call(chart);
chartDiv.call(chart);
// chart(chartDiv)
// get the filtered data
const selected = week.top(Infinity);
// console.log("selected", selected)
// create geojson structure (needed for mapbox api)
const geoJson = {
type: "FeatureCollection",
features: selected,
};
// clear the shootings layer and add the new filtered data
shootingsLayer
.clearLayers()
.addData(geoJson);
// update info window
const totalEvents = selected.length;
const groups = countTracker.all();
const weeks = groups.reduce(function (p, v, i) { return groups[i].value > 0 ? p + 1 : p + 0; }, 0);
const deadInjuredCount = selected
.reduce(function (p, c, i) {
return { dead: selected[i].properties.Dead + p.dead, injured: selected[i].properties.Injured + p.injured };
}, { dead: 0, injured: 0 });
const avgDead = deadInjuredCount.dead / weeks;
const avgInjured = deadInjuredCount.injured / weeks;
// create html for info window
let html = "";
html += "
";
html += " Number of Weeks | " + weeks + " |
";
html += " Total Shootings | " + fmtM(totalEvents) + " |
";
html += " Total Dead | " + fmtM(deadInjuredCount.dead) + " |
";
html += " Total Injured | " + fmtM(deadInjuredCount.injured) + " |
";
if (weeks !== 1) {
html += " Dead per Week | " + fmt1dec(avgDead) + " |
";
html += " Injured per Week | " + fmt1dec(avgInjured) + " |
";
}
html += "
";
// set the html
d3.select(".infoDiv").html(html);
}
// initial render
renderAll();
// resets the week filter (driven by the bar chart brush)
window.reset = function () {
if (anim) anim.exit();
chart.filter(null);
renderAll();
};
// setup the bar chart (used to filter weeks)
function barChart() {
// TODO: remove this instance counter (in this viz, there is only one instance)
if (!barChart.id) barChart.id = 0;
let margin = { top: 10, right: 10, bottom: 20, left: 50 };
let x;
let y = d3.scale.linear().range([59, 0]);
const id = barChart.id++;
const axis = d3.svg.axis().orient("bottom");
const brush = d3.svg.brush();
let brushDirty;
let dimension;
let group;
let round;
let gBrush;
let enableBrush;
let theDiv;
// main bar chart function; will be called repeatedly by renderAll
function chart(div) {
// determine dimensions of svg
const width = x.range()[1];
const height = y.range()[0];
// update y scale domain
y.domain([0, group.top(1)[0].value]); // set y domain to max value in this group
// TODO: inefficient code; div is an array of one
div.each(function() {
const div = theDiv = d3.select(this);
let g = div.select("g");
// if g is empty, reubild the svg
if (g.empty()) {
// create svg and group
g = div.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.on("click", function () {
// exit the animator
if (anim) anim.exit();
// TODO: need to handle the case of mouse drag as well...
});
// reset the clip path to full width
g.append("clipPath")
.attr("id", "clip-" + id)
.append("rect")
.attr("width", width)
.attr("height", height);
// generate two paths, one background, one foreground
g.selectAll(".bar")
.data(["background", "foreground"])
.enter().append("path")
.attr("class", function(d) { return d + " bar"; })
// assign all the data in the group to the path
.datum(group.all());
// assign the clip path to the foreground bars
g.selectAll(".foreground.bar")
.attr("clip-path", "url(#clip-" + id + ")");
// Initialize the brush component with pretty resize handles.
if (enableBrush) {
gBrush = g.append("g").attr("class", "brush").call(brush);
gBrush.selectAll("rect").attr("height", height);
gBrush.selectAll(".resize").append("path").attr("d", resizePath);
}
// add the x-axis last (so it's drawn on top of brush)
g.append("g")
.attr("class", "axis")
.attr("transform", "translate(0," + height + ")")
.call(axis);
}
// Only redraw the brush if set externally.
// at init, the date chart has an externally set brush
if (brushDirty) {
brushDirty = false;
g.selectAll(".brush").call(brush);
div.select(".title a").style("display", brush.empty() ? "none" : null);
if (brush.empty()) {
g.selectAll("#clip-" + id + " rect")
.attr("x", 0)
.attr("width", width);
} else {
const extent = brush.extent();
g.selectAll("#clip-" + id + " rect")
.attr("x", x(extent[0]))
.attr("width", x(extent[1]) - x(extent[0]));
}
}
// set the d attribute on the path
g.selectAll(".bar").attr("d", barPath);
});
// generate the bar chart path item
function barPath(groups) {
const path = [];
let i = -1;
const n = groups.length;
let d;
while (++i < n) {
d = groups[i];
path.push("M", x(d.key), ",", height, "V", y(d.value), "h4V", height);
}
return path.join("");
}
// generate pretty brush left and right "handles"
function resizePath(d) {
const e = +(d === "e");
const x = e ? 1 : -1;
const y = height / 3;
return "M" + (0.5 * x) + "," + y
+ "A6,6 0 0 " + e + " " + (6.5 * x) + "," + (y + 6)
+ "V" + (2 * y - 6)
+ "A6,6 0 0 " + e + " " + (0.5 * x) + "," + (2 * y)
+ "Z"
+ "M" + (2.5 * x) + "," + (y + 8)
+ "V" + (2 * y - 8)
+ "M" + (4.5 * x) + "," + (y + 8)
+ "V" + (2 * y - 8);
}
}
// brush handlers
brush.on("brushstart.chart", function() {
// get the containing div
const div = d3.select(this.parentNode.parentNode.parentNode);
// remove the display property from the reset anchor elem
div.select(".title a").style("display", null);
});
brush.on("brush.chart", function() {
const g = d3.select(this.parentNode);
let extent = brush.extent();
// handle rounding of extent (only integers)
if (round) {
g.select(".brush")
.call(brush.extent(extent = extent.map(round))) // set a rounded brush extent
.selectAll(".resize")
.style("display", null); // remove the resize handles (why?)
}
// update clip rectangle
g.select("#clip-" + id + " rect")
.attr("x", x(extent[0]))
.attr("width", x(extent[1]) - x(extent[0]));
// update the filter
dimension.filterRange(extent);
});
brush.on("brushend.chart", function () {
if (brush.empty()) {
emptyBrush.call(this);
}
});
// function to sync UI and crossfilter to an empty brush
function emptyBrush() {
// console.log("emptyBrush", this);
if (!brush.empty()) {
console.error("brush not empty");
return;
}
// get reference to containing div
const div = d3.select(this.parentNode.parentNode.parentNode);
// hide the reset anchor element
div.select(".title a").style("display", "none");
// remove the clip rectangle
div.select("#clip-" + id + " rect")
.attr("x", null) // remove the x attribute which will render the clipRect invalid
.attr("width", "100%");
// reset the filter
dimension.filterAll();
}
// chart configuration functions
chart.margin = function (_) {
if (!arguments.length) return margin;
margin = _;
return chart;
};
chart.x = function (_) {
if (!arguments.length) return x;
x = _;
axis.scale(x);
brush.x(x);
return chart;
};
chart.y = function (_) {
if (!arguments.length) return y;
y = _;
return chart;
};
chart.dimension = function (_) {
// console.log("chart.dimension..." + _)
if (!arguments.length) return dimension;
dimension = _;
return chart;
};
chart.filter = function (_) {
if (_) {
brush.extent(_);
dimension.filterRange(_);
} else {
brush.clear();
dimension.filterAll();
}
brushDirty = true;
return chart;
};
chart.group = function (_) {
if (!arguments.length) return group;
group = _;
return chart;
};
chart.round = function (_) {
if (!arguments.length) return round;
round = _;
return chart;
};
chart.removeContents = function () {
theDiv.selectAll("svg").remove();
// theDiv.selectAll("a").remove();
// console.log("theDiv", theDiv)
return chart;
};
chart.brushDirty = function (_) {
if (!arguments.length) return brushDirty;
brushDirty = _;
return chart;
};
chart.enableBrush = function (_) {
if (!arguments.length) return enableBrush;
enableBrush = _;
return chart;
};
chart.clearBrush = function () {
// console.log("---", d3.select(".brush"))
d3.select(".brush").call(brush.clear());
emptyBrush.call(d3.select(".brush").node());
return chart;
};
chart.brushExtent = function (_) {
if (!arguments.length) return brush.extent();
// console.log("setting brush extent...", _, "current", brush.extent())
brush.extent(_);
d3.select(".brush").call(brush);
brush.event(d3.select(".brush"));
return chart;
};
// copy "on" event handlers from "brush" to "chart"
return d3.rebind(chart, brush, "on");
}
});
// animator object/function
function animator() {
let speed = 1000;
let currPos = 0;
let animating = false;
let data;
let ready = false;
let inTimeout = false;
let chart;
let container;
function main(_) {
data = _;
if (data.length > 0) ready = true;
// data good?
if (!ready) {
console.error("bad data");
return;
}
// container good?
if (!container) {
console.error("No animation control container provided");
return;
}
// event handlers
function startStop() {
this.blur();
const elem = d3.select(this);
const value = elem.attr("value");
// console.log("startStop...", this, value)
if (value === "start") {
// start the animation...
main.resume();
// change this button text
elem.text("Exit Animation").attr("value", "stop");
// show buttons
d3.selectAll(".anim2").style("display", "inline-block");
d3.selectAll(".anim4").style("display", "inline-block");
} else {
// stop the animation
main.stop();
// change this button text
elem.text("Start Animation").attr("value", "start");
// hide buttons
d3.selectAll(".anim").style("display", "none");
// ensure the halt/resume button says halt
d3.select(".anim2").text("Halt").attr("value", "halt");
}
}
function haltResume() {
this.blur();
const elem = d3.select(this);
const value = elem.attr("value");
// console.log("haltResume...", this, value)
if (value === "halt") {
// halt the animation
main.stop();
// change this button text
elem.text("Resume").attr("value", "resume");
// hide buttons
d3.selectAll(".anim4").style("display", "none");
// show buttons
d3.selectAll(".anim3").style("display", "inline-block");
} else {
// resume the animation
main.resume();
// change this button text
elem.text("Halt").attr("value", "halt");
// hide buttons
d3.selectAll(".anim3").style("display", "none");
// show buttons
d3.selectAll(".anim4").style("display", "inline-block");
}
}
function fasterSlower() {
this.blur();
const elem = d3.select(this);
const value = elem.attr("value");
// console.log("fasterSlower...", this, value)
if (value === "faster") main.faster();
else main.slower();
}
function forwardBackward() {
this.blur();
const elem = d3.select(this);
const value = elem.attr("value");
// console.log("forwardBackward...", this, value, this.value)
if (value === "forward") main.forward();
else main.backward();
}
function reset() {
this.blur();
main.reset();
}
// animation controls
const controls = [
{ text: "Start Animation", handler: startStop, id: "startStop", display: "inline-block", value: "start", class: "anim1", width: "110px" },
{ text: "Halt", handler: haltResume, display: "none", value: "halt", class: "anim anim2", width: "60px" },
{ text: "Backward", handler: forwardBackward, display: "none", value: "backward", class: "anim anim3" },
{ text: "Forward", handler: forwardBackward, display: "none", value: "forward", class: "anim anim3" },
{ text: "Faster", handler: fasterSlower, display: "none", value: "faster", class: "anim anim4" },
{ text: "Slower", handler: fasterSlower, display: "none", value: "slower", class: "anim anim4" },
{ text: "Reset", handler: reset, display: "none", class: "anim anim3 anim4" }
];
container.selectAll("button")
.data(controls)
.enter().append("button")
.attr("id", function (d) { return d.id ? "anim_" + d.id : null; })
.style("display", "inline-block")
.style("cursor", "default")
.style("width", function (d) { return d.width ? d.width : "70px"; })
.style("text-align", "center")
.attr("class", function (d) { return d.class; })
.style("display", function (d) { /* return "inline-block"; */ return d.display; })
.attr("value", function (d) { return d.value ? d.value : null; })
// .attr("class", "reset")
.text(function (d) { return d.text; })
.on("click", function (d) { /* console.log("adfads", d.handler); */ d.handler.call(this); });
// set index
currPos = data.length - 1;
setInterval(function () {
if (animating && !inTimeout) go(0);
}, 100);
function go(useSpeed) {
inTimeout = true;
setTimeout(function () {
if (animating) {
currPos++;
if (currPos === data.length) currPos = 0;
oneCycle();
}
if (animating) go(speed);
else inTimeout = false;
}, useSpeed);
}
}
function oneCycle() {
// console.log("oneCycle")
const dateExtent = [];
dateExtent[0] = new Date(data[currPos].key);
dateExtent[1] = new Date(data[currPos].key);
dateExtent[1].setDate(dateExtent[1].getDate() + 7);
chart.brushExtent(dateExtent);
}
// action functions
main.reset = function () {
speed = 1000;
currPos = 0;
oneCycle();
// animating = false;
};
main.resume = function () {
animating = true;
};
main.stop = function () {
animating = false;
};
main.exit = function () {
animating = false;
// change this button text
d3.select("#anim_startStop").text("Start Animation").attr("value", "start");
// hide buttons
d3.selectAll(".anim").style("display", "none");
// ensure the halt/resume button says halt
d3.select(".anim2").text("Halt").attr("value", "halt");
};
main.slower = function () {
speed = Math.min(4000, speed * 2);
};
main.faster = function () {
speed = Math.max(250, speed / 2);
};
main.forward = function () {
if (ready && !animating) {
currPos++;
if (currPos === data.length) currPos = 0;
oneCycle();
} else console.log("not ready");
};
main.backward = function () {
if (ready && !animating) {
currPos--;
if (currPos < 0) currPos = data.length - 1;
oneCycle();
} else console.log("not ready");
};
// configuration functions
main.speed = function (_) {
if (!arguments.length) return speed;
speed = _;
return main;
};
main.chart = function (_) {
if (!arguments.length) return chart;
chart = _;
return main;
};
main.container = function (_) {
if (!arguments.length) return container;
container = _;
return main;
};
return main;
} // end animator