data_annotation_platform/app/static/js/makeChart.js (290 lines of code) (raw):
// Based on: //https://github.com/benalexkeen/d3-flask-blog-post/blob/master/templates/index.html
// And: https://bl.ocks.org/mbostock/35964711079355050ff1
function preprocessData(data) {
var n = 0;
cleanData = [];
run = [];
for (i=0; i<data.values[0].raw.length; i++) {
d = data.values[0].raw[i];
if (isNaN(d)) {
n++; // keep counting!
if (run.length > 0)
cleanData.push(run);
run = [];
continue;
}
// NOTE: remember that this *must* be 0-based indexing, as the
// change point index is ultimately retrieved from this X
// value and the Python code is 0-based as well. Thus, the
// first item should get X = 0.
run.push({"X": n++, "Y": d});
}
cleanData.push(run);
return cleanData;
}
function scaleAndAxis(data, width, height) {
// xScale is the active scale used for zooming, xScaleOrig is used as
// the original scale that is never changed.
var xScale = d3.scaleLinear().range([0, width]);
var xScaleOrig = d3.scaleLinear().range([0, width]);
var yScale = d3.scaleLinear().range([height, 0]);
var yScaleOrig = d3.scaleLinear().range([height, 0]); // Define yScaleOrig here
// create the axes
var xAxis = d3.axisBottom(xScale);
var yAxis = d3.axisLeft(yScale);
// turn off ticks on the y axis. We don't want annotators to be
// influenced by whether a change is big in the absolute sense.
yAxis.ticks(10);
var xmin = Math.min(...data.map(function(run) { return Math.min(...run.map(it => it.X)); }))
var xmax = Math.max(...data.map(function(run) { return Math.max(...run.map(it => it.X)); }))
var ymin = Math.min(...data.map(function(run) { return Math.min(...run.map(it => it.Y)); }))
var ymax = Math.max(...data.map(function(run) { return Math.max(...run.map(it => it.Y)); }))
var xExtent = [xmin, xmax];
var yExtent = [ymin, ymax];
// compute the domains for the axes
//var xExtent = d3.extent(data, function(d) { return d.X; });
var xRange = xExtent[1] - xExtent[0];
var xDomainMin = xExtent[0] - xRange * 0.02;
var xDomainMax = xExtent[1] + xRange * 0.02;
//var yExtent = d3.extent(data, function(d) { return d.Y; });
var yRange = yExtent[1] - yExtent[0];
var yDomainMin = yExtent[0] - yRange * 0.05;
var yDomainMax = yExtent[1] + yRange * 0.05;
// set the axis domains
xScale.domain([xDomainMin, xDomainMax]);
xScaleOrig.domain([xDomainMin, xDomainMax]);
yScale.domain([yDomainMin, yDomainMax]);
yScaleOrig.domain([yDomainMin, yDomainMax]);
return [xAxis, yAxis, xScale, xScaleOrig, yScale, yScaleOrig, yDomainMin, yDomainMax];
}
function noZoom() {
d3.event.preventDefault();
}
function baseChart(
selector,
data,
clickFunction,
annotations,
annotationFunction,
divWidth,
divHeight
) {
if (divWidth === null || typeof divWidth === 'undefined')
divWidth = 1200;
if (divHeight === null || typeof divHeight === 'undefined')
divHeight = 480;
// preprocess the data
data = preprocessData(data);
var svg = d3.select(selector)
.on("touchstart", noZoom)
.on("touchmove", noZoom)
.append("svg")
.attr("width", divWidth)
.attr("height", divHeight)
.attr("viewBox", "-25 0 " + divWidth + " " + divHeight);
var margin = {top: 20, right: 20, bottom: 50, left: 50};
var width = +svg.attr("width") - margin.left - margin.right;
var height = +svg.attr("height") - margin.top - margin.bottom;
var [xAxis, yAxis, xScale, xScaleOrig, yScale, yScaleOrig, yDomainMin, yDomainMax] = scaleAndAxis(
data,
width,
height);
var lineObjects = [];
for (let r=0; r<data.length; r++) {
var lineObj = new d3.line()
.x(function(d) { return xScale(d.X); })
.y(function(d) { return yScale(d.Y); });
lineObjects.push(lineObj);
}
var zoomX = d3.zoom()
.scaleExtent([1, 100])
.translateExtent([[0, 0], [width, height]])
.extent([[0, 0], [width, height]])
.wheelDelta(() => -d3.event.deltaY * 0.002)
.on("zoom", zoomTransformX);
var zoomY = d3.zoom()
.scaleExtent([1, 100])
.translateExtent([[0, 0], [width, height]])
.extent([[0, 0], [width, height]])
.wheelDelta(() => -d3.event.deltaY * 0.004)
.on("zoom", zoomTransformY);
var currentZoom = zoomX; // Default: X-axis zooming
function zoomTransformX() {
const transform = d3.event.transform;
const mouseX = d3.mouse(svg.node())[0]; // Get mouse X position relative to the SVG
const mouseDomainX = xScale.invert(mouseX); // Convert mouse X to domain value
// Transform only the x-axis
xScale.domain(transform.rescaleX(xScaleOrig).domain());
// Adjust the domain to center around the mouse position
const newMouseDomainX = xScale.invert(mouseX);
const domainShift = mouseDomainX - newMouseDomainX;
xScale.domain(xScale.domain().map(d => d + domainShift));
for (let r = 0; r < data.length; r++) {
svg.select(".line-" + r).attr("d", lineObjects[r]);
// Transform the circles
pointSets[r].data(data[r])
.attr("cx", d => xScale(d.X))
.attr("cy", d => yScale(d.Y));
}
// Transform annotation lines (if any)
annoLines = gView.selectAll("line");
annoLines._groups[0].forEach(l => {
l.setAttribute("x1", xScale(l.getAttribute("cp_idx")));
l.setAttribute("x2", xScale(l.getAttribute("cp_idx")));
});
svg.select(".axis--x").call(xAxis);
}
function zoomTransformY() {
const transform = d3.event.transform;
const mouseY = d3.mouse(svg.node())[1]; // Get mouse Y position relative to the SVG
const mouseDomainY = yScale.invert(mouseY); // Convert mouse Y to domain value
// Rescale the Y axis using yScaleOrig
yScale.domain(transform.rescaleY(yScaleOrig).domain());
// Adjust the domain to center around the mouse position
const newMouseDomainY = yScale.invert(mouseY);
const domainShift = mouseDomainY - newMouseDomainY;
yScale.domain(yScale.domain().map(d => d + domainShift));
for (let r = 0; r < data.length; r++) {
svg.select(".line-" + r).attr("d", lineObjects[r]);
// Transform the circles
pointSets[r].data(data[r])
.attr("cx", d => xScale(d.X))
.attr("cy", d => yScale(d.Y));
}
// Transform the annotation lines (if any)
annoLines = gView.selectAll("line");
annoLines._groups[0].forEach(function(l) {
l.setAttribute("y1", yScale(l.getAttribute("cp_idx")));
l.setAttribute("y2", yScale(l.getAttribute("cp_idx")));
});
// Update Y axis
svg.select(".axis--y").call(yAxis);
}
// Define drag behavior for panning
var drag = d3.drag()
.on("drag", function () {
// Calculate the change in X and Y based on drag
var dx = d3.event.dx * (xScale.domain()[1] - xScale.domain()[0]) / width;
var dy = d3.event.dy * (yScale.domain()[1] - yScale.domain()[0]) / height;
// Update X and Y domains
xScale.domain([xScale.domain()[0] - dx, xScale.domain()[1] - dx]);
yScale.domain([yScale.domain()[0] + dy, yScale.domain()[1] + dy]);
// Update the axes
svg.select(".axis--x").call(xAxis);
svg.select(".axis--y").call(yAxis);
// Update lines and points
for (let r = 0; r < data.length; r++) {
svg.select(".line-" + r).attr("d", lineObjects[r]);
pointSets[r].data(data[r])
.attr("cx", d => xScale(d.X))
.attr("cy", d => yScale(d.Y));
}
// Update annotation lines (if any)
annoLines = gView.selectAll("line");
annoLines._groups[0].forEach(function (l) {
l.setAttribute("x1", xScale(l.getAttribute("cp_idx")));
l.setAttribute("x2", xScale(l.getAttribute("cp_idx")));
l.setAttribute("y1", yScale(l.getAttribute("cp_idx")));
l.setAttribute("y2", yScale(l.getAttribute("cp_idx")));
});
});
var zero = xScale(0);
// clip path
svg.append("defs")
.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", width - 18)
.attr("height", height)
.attr("transform", "translate(" + zero + ",0)");
// y axis
svg.append("g")
.attr("class", "axis axis--y")
.attr("transform", "translate(" + zero + ",0)") // Use margin.left instead of zero
.call(yAxis);
// x axis
svg.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0, " + height + ")")
.call(xAxis);
// x axis label
svg.append("text")
.attr("text-anchor", "middle")
.attr("class", "axis-label")
.attr("transform", "translate(" + (width - 20) + "," + (height + 50) + ")")
.text("Time");
// wrapper for zoom
var gZoom = svg.append("g")
.call(currentZoom)
.call(drag);
gZoom.call(currentZoom);
// Add event listener for zoom toggle
// document.getElementById("zoomToggle").addEventListener("change", function() {
// if (this.checked) {
// // Y-axis zoom
// currentZoom = zoomY;
// document.getElementById("zoomLabel").innerText = "Change zoom mode (current mode is Y-Axis Zoom)";
// } else {
// // X-axis zoom
// currentZoom = zoomX;
// document.getElementById("zoomLabel").innerText = "Change zoom mode (current mode is X-Axis Zoom)";
// }
// // Apply the current zoom behavior
// gZoom.call(currentZoom);
// });
const zoomToggle = document.getElementById("zoomToggle");
if (zoomToggle) {
zoomToggle.addEventListener("change", function () {
if (this.checked) {
currentZoom = zoomY;
document.getElementById("zoomLabel").innerText =
"Change zoom mode (current mode is Y-Axis Zoom)";
} else {
currentZoom = zoomX;
document.getElementById("zoomLabel").innerText =
"Change zoom mode (current mode is X-Axis Zoom)";
}
gZoom.call(currentZoom);
});
} else {
console.warn('zoomToggle element not found, skipping event listener.');
}
// rectangle for the graph area
gZoom.append("rect")
.attr("width", width)
.attr("height", height);
// view for the graph
var gView = gZoom.append("g")
.attr("class", "view");
// add the line(s) to the view
for (let r=0; r<data.length; r++) {
gView.append("path")
.datum(data[r])
.attr("class", "line line-"+r)
.attr("d", lineObjects[r]);
}
var pointSets = [];
for (let r=0; r<data.length; r++) {
var wrap = gView.append("g");
var points = wrap.selectAll("circle")
.data(data[r])
.enter()
.append("circle")
.attr("cx", function(d) { return xScale(d.X); })
.attr("cy", function(d) { return yScale(d.Y); })
.attr("data_X", function(d) { return d.X; })
.attr("data_Y", function(d) { return d.Y; })
.attr("r", 5)
.on("click", function(d, i) {
d.element = this;
return clickFunction(d, i);
});
pointSets.push(points);
}
// handle the annotations
annotations.forEach(function(a) {
for (i=0; i<points._groups[0].length; i++) {
p = points._groups[0][i];
if (p.getAttribute("data_X") != a.index)
continue;
var elem = d3.select(p);
annotationFunction(a, elem, gView, xScale, yScale, yDomainMin, yDomainMax);
}
});
}
const changeTypes = [
['mean', 'red', 'M'],
['variance', 'orange', 'V'],
['mean_variance', 'yellow', 'B'],
['', null],
];
function annotateChart(selector, data) {
function handleClick(d, i) {
if (d3.event.defaultPrevented) return; // zoomed
var elem = d3.select(d.element);
var next = 0;
for (var i = 0; i < changeTypes.length - 1; i++) {
if (elem.node().classList.contains(changeTypes[i][0])) {
next = i + 1;
}
}
console.log(changeTypes[next]);
elem.attr('stroke', 'blue');
elem.style("fill", changeTypes[next][1]);
elem.attr('class', changeTypes[next][0]);
/*
var g = elem.select(function() { return this.parentNode; });
g.append('text')
.attr('x', elem.attr('cx'))
.attr('y', elem.attr('cy'))
.text(changeTypes[next][2]);
*/
/*
if (elem.classed("changepoint")) {
elem.style("fill", null);
elem.classed("changepoint", false);
} else {
elem.style("fill", "red");
elem.classed("changepoint", true);
}
*/
updateTable();
}
baseChart(selector, data, handleClick, [], null);
}
function viewAnnotations(selector, data, annotations) {
function handleAnnotation(ann, elem, view, xScale, yScale, yDomainMin, yDomainMax) {
elem.classed("marked", true);
view.append("line")
.attr("cp_idx", ann.index)
.attr("y1", yScale(yDomainMax))
.attr("y2", yScale(yDomainMin))
.attr("x1", xScale(ann.index))
.attr("x2", xScale(ann.index))
.attr("class", "ann-line");
}
baseChart(selector, data, function() {}, annotations, handleAnnotation, null, 300);
}
function adminViewAnnotations(selector, data, annotations) {
function handleAnnotation(ann, elem, view, xScale, yScale, yDomainMin, yDomainMax) {
elem.classed(ann.user, true);
view.append("line")
.attr("cp_idx", ann.index)
.attr("y1", yScale(yDomainMax))
.attr("y2", yScale(yDomainMin))
.attr("x1", xScale(ann.index))
.attr("x2", xScale(ann.index))
.attr("class", "ann-line" + " " + ann.user);
}
baseChart(selector, data, function() {}, annotations, handleAnnotation);
}