grnsight
Version:
Web app and service for visualizing models of gene regulatory networks
1,260 lines (1,107 loc) • 70.2 kB
JavaScript
import Grid from "d3-v4-grid";
import { grnState } from "./grnstate";
import {
modifyChargeParameter,
modifyLinkDistanceParameter,
valueValidator,
adjustGeneNameForExpression,
hasExpressionData,
} from "./update-app";
import {
VIEWPORT_FIT,
ZOOM_INPUT,
ZOOM_PERCENT,
ZOOM_SLIDER,
ZOOM_DISPLAY_MINIMUM_VALUE,
ZOOM_DISPLAY_MAXIMUM_VALUE,
ZOOM_DISPLAY_MIDDLE,
ZOOM_ADAPTIVE_MAX_SCALE,
NETWORK_GRN_MODE,
BOUNDARY_MARGIN
} from "./constants";
/* globals d3 */
/* eslint-disable no-use-before-define, func-style */
/*
By D3's nature, it can be a bit difficult to adhere to our given guidelines..
This file needs to be organized a bit more before it can fully adhere to
them.
*/
/* http://bl.ocks.org/mbostock/4062045 used as reference
* As well as http://bl.ocks.org/mbostock/3750558
* and http://bl.ocks.org/mbostock/950642
* and http://bl.ocks.org/mbostock/1153292
*/
/* eslint no-unused-vars: [2, {"varsIgnorePattern": "text|getMappedValue|manualZoom"}] */
/* eslint-disable no-unused-vars */
/**
* Resize detection logic: to avoid "listener leaks," this is set up a single time here, with an assignable
* updateFunction being set when needed.
*/
let mutationCallback = null;
const resizeObserver = new MutationObserver((mutationsList, observer) => {
if (typeof(mutationCallback) === "function") {
mutationCallback(mutationsList, observer);
}
});
export var updaters = {
setNodesToGrid: () => {},
setNodesToForceGraph: () => {},
renderNodeColoring: () => {},
removeNodeColoring: () => {},
};
export var drawGraph = function (workbook) {
/* eslint-enable no-unused-vars */
var $container = $(".grnsight-container");
var CURSOR_CLASSES = "cursorGrab cursorGrabbing";
d3.selectAll("svg").remove();
$container.removeClass(CURSOR_CLASSES).addClass("cursorGrab"); // allow graph dragging right away
var width = $container.width();
var height = $container.height();
var nodeHeight = 30;
var grayThreshold = +$("#grayThresholdInput").val();
var dashedLine = $("#dashedGrayLineButton").prop("checked");
$("#warningMessage").html(workbook.warnings.length !== 0 ? "Click here in order to view warnings." : "");
var getNodeWidth = function (node) {
return node.name.length * 12 + 5;
};
var adaptive = !$("input[name='viewport']").prop("checked");
/**
* The *_SCALE values represent the actual zoom values used to transform the graph.
* The *_DISPLAY values represent the value that is shown in the user interface.
*
* Separating these values allows for flexible configuration of what the user sees vs. the actual scale factor
* used in transformations. This "distortion" is done so that "actual size" or 100% can be shown as the midpoint
* on the zoom slider, even if the numeric ranges to the left and right of the midpoint are asymmetric (as they
* are here).
*/
const createZoomScale = (domainMin, domainMax, rangeMin, rangeMax) => d3.scaleLinear()
.domain([domainMin, domainMax])
.range([rangeMin, rangeMax])
.clamp(true);
const MIN_DISPLAY = ZOOM_DISPLAY_MINIMUM_VALUE;
const ADAPTIVE_MAX_DISPLAY = ZOOM_DISPLAY_MAXIMUM_VALUE;
const MIN_SCALE = 0.25;
const MIDDLE_SCALE = 1;
const zoomScaleLeft = createZoomScale(MIN_DISPLAY, ZOOM_DISPLAY_MIDDLE, MIN_SCALE, MIDDLE_SCALE);
const zoomScaleRight = createZoomScale(
ZOOM_DISPLAY_MIDDLE, ADAPTIVE_MAX_DISPLAY, MIDDLE_SCALE, ZOOM_ADAPTIVE_MAX_SCALE);
// Create an array of all the network weights
var allWeights = workbook.positiveWeights.concat(workbook.negativeWeights);
// Assign the entire array weights of 1, if color edges turned off
if (!grnState.colorOptimal) {
for (var i = 0; i < allWeights.length; i++) {
if ( allWeights[i] !== 0 ) {
allWeights[i] = 1;
}
}
} else {
for (var j = 0; j < allWeights.length; j++ ) {
allWeights[j] = Math.abs((allWeights[j]).toPrecision(4));
}
}
const maxWeight = Math.max(Math.abs(d3.max(allWeights)), Math.abs(d3.min(allWeights)));
// Get the largest magnitude weight and set that as the default normalization factor
if (grnState.newWorkbook) {
grnState.normalizationMax = maxWeight;
grnState.resetNormalizationMax = maxWeight;
}
// Normalize all weights b/w 2-14
var normMax = +$("#normalization-max").val();
var totalScale = d3.scaleLinear()
.domain([0, normMax > 0 ? normMax : maxWeight])
.range([2, 14])
.clamp(true);
var unweighted = false;
// if unweighted, all weights are 2
if (workbook.sheetType === "unweighted") {
totalScale = d3.scaleQuantile()
.domain([d3.extent(allWeights)])
.range(["2"]);
unweighted = true;
$(".normalization-form").append("placeholder='unweighted'");
document.getElementById("edge-weight-normalization-factor-menu").setAttribute("placeholder", "");
} else {
document.getElementById("normalization-max").setAttribute("placeholder", maxWeight);
document.getElementById("edge-weight-normalization-factor-menu").setAttribute("placeholder", maxWeight);
}
var getEdgeThickness = function (edge) {
return Math.floor(totalScale(Math.abs(edge.value)));
};
var simulation = d3.forceSimulation()
.force("link", d3.forceLink())
.force("charge", d3.forceManyBody())
.force("center", d3.forceCenter(width / 2, height / 2));
var drag = d3.drag()
.on("start", dragstart)
.on("drag", dragged)
.on("end", dragended);
var dragended = function () {
d3.event.stopPropagation();
};
var zoomDragPrevX = 0;
var zoomDragPrevY = 0;
let graphZoom = 0;
var zoomDragStarted = function () {
zoomDragPrevX = d3.event.x;
zoomDragPrevY = d3.event.y;
$container.removeClass(CURSOR_CLASSES).addClass("cursorGrabbing");
};
var zoomDragged = function () {
var scale = 1;
if (zoomContainer.attr("transform")) {
var string = zoomContainer.attr("transform");
scale = 1 / +(string.match(/scale\(([^\)]+)\)/)[1]);
}
if (adaptive || (!adaptive &&
flexZoomInBounds(graphZoom) &&
viewportBoundsMoveDrag(graphZoom, d3.event.dx, d3.event.dy))
) {
zoom.translateBy(
zoomContainer,
scale * (d3.event.x - zoomDragPrevX),
scale * (d3.event.y - zoomDragPrevY)
);
}
zoomDragPrevX = d3.event.x;
zoomDragPrevY = d3.event.y;
};
var zoomDragEnded = function () {
$container.removeClass(CURSOR_CLASSES).addClass("cursorGrab");
};
// zoomDrag and all functions that it calls handles cursor dragging
var zoomDrag = d3.drag()
.on("start", zoomDragStarted)
.on("drag", zoomDragged)
.on("end", zoomDragEnded);
var manualZoom = false;
var svg = d3.select($container[0]).append("svg")
.attr("width", width)
.attr("height", height)
.attr("id", "exportContainer");
var zoomContainer = svg.append("g") // required for zoom to work
.attr("class", "boundingBox")
.attr("width", width)
.attr("height", height);
var boundingBoxContainer = zoomContainer.append("g"); // appended another g here...
// This rectangle catches all of the mousewheel and pan events, without letting
// them bubble up to the body.
var boundingBoxRect = boundingBoxContainer.append("rect")
.attr("width", width)
.attr("height", height)
.style("fill", "none")
.style("pointer-events", "all")
.attr("stroke", "none" )
.attr("id", "boundingBoxRect");
var flexibleContainerRect = boundingBoxContainer.append("rect")
.attr("class", "boundingBox")
.attr("fill", "none")
.attr("id", "flexibleContainerRect");
var zoom = d3.zoom()
.scaleExtent([MIN_SCALE, ZOOM_ADAPTIVE_MAX_SCALE])
.on("zoom", zoomed);
svg.style("pointer-events", "all").call(zoomDrag)
.style("font-family", "sans-serif");
// this allows zoomContainer to be zoomed, dragged
function zoomed () {
zoomContainer.attr("transform", d3.event.transform);
}
d3.select("svg").on("dblclick.zoom", null); // disables double click zooming
// this controls the D-pad
d3.selectAll(".scrollBtn").on("click", null); // Remove event handlers, if there were any.
var arrowMovement = [ "Up", "Left", "Right", "Down" ];
arrowMovement.forEach(function (direction) {
d3.select(".scroll" + direction).on("click", function () {
move(direction.toLowerCase());
});
});
d3.select(".center").on("click", center);
let xTranslation = 0;
let yTranslation = 0;
function updateZoomContainerInfo () {
// transform attribute of zoomContainer contains translation info about graph
if (zoomContainer.attr("transform")) {
xTranslation = Number(
zoomContainer
.attr("transform")
.split("(")[1]
.split(",")[0]
);
yTranslation = Number(
zoomContainer
.attr("transform")
.split("(")[1]
.split(",")[1]
.split(")")[0]
);
}
}
// controls reading movement of zoomSlider and scaling graph to that zoomScale
const setGraphZoom = zoomScale => {
if (zoomScale < MIDDLE_SCALE) {
$container.removeClass(CURSOR_CLASSES).addClass("cursorGrab");
}
var container = zoomContainer;
if (adaptive || (!adaptive && flexZoomInBounds(graphZoom))) {
zoom.scaleTo(container, zoomScale);
graphZoom = zoomScale;
}
};
// See setupZoomElements below to see how these are initialized. They are declared here because
// updateAppBasedOnZoomValue uses them (lexical positioning is chosen here for better context).
let sliderMidpoint;
let zoomScaleSliderLeft;
let zoomScaleSliderRight;
let prevGrnstateZoomVal;
let flexibleContainer = null;
const updateAppBasedOnZoomValue = () => {
let zoomDisplay;
// If the zoom value is out of bounds, reset it to the previous value.
if (adaptive) {
zoomDisplay = grnState.zoomValue;
} else if (
!adaptive &&
flexZoomInBounds(
(grnState.zoomValue <= ZOOM_DISPLAY_MIDDLE
? zoomScaleLeft
: zoomScaleRight)(grnState.zoomValue)
)
) {
zoomDisplay = grnState.zoomValue;
} else {
grnState.zoomValue = prevGrnstateZoomVal;
zoomDisplay = grnState.zoomValue;
}
const calcGraphZoom = (zoomDisplay <= ZOOM_DISPLAY_MIDDLE
? zoomScaleLeft
: zoomScaleRight)(zoomDisplay);
setGraphZoom(calcGraphZoom);
const finalDisplay = grnState.zoomValue;
$(ZOOM_PERCENT).text(`${finalDisplay}%`);
// Special handling for zoom input field: the user might be in the middle of typing a value that is
// _temporarily_ out of range (e.g., "1" while typing "100") and we don’t want to overwrite that.
// The special case can be detected if the input element currently has focus.
if (document.activeElement !== document.querySelector(ZOOM_INPUT)) {
$(ZOOM_INPUT).val(finalDisplay);
}
// This controls movement of slider and is where the zoomSlider can be restricted
if (adaptive || (!adaptive && flexZoomInBounds(calcGraphZoom))) {
if (!adaptive) {
// Recenter graph when zooming to ensure that graph stays in viewport
center();
updateZoomContainerInfo();
}
$(ZOOM_SLIDER).val(
(finalDisplay <= ZOOM_DISPLAY_MIDDLE
? zoomScaleSliderLeft
: zoomScaleSliderRight
).invert(finalDisplay)
);
}
};
/**
* To eliminate coupling between how the zoom slider element is defined in markup and how zoom values are
* calculated and displayed, we define this function to read the zoom slider for its minimum, maximum, and
* midpoint. The slider’s minimum will be shown as MIN_DISPLAY, the slider’s maximum will be shown as
* ADAPTIVE_MAX_DISPLAY, and the slider’s midpoint will be shown as ZOOM_DISPLAY_MIDDLE.
*
* Elements showing minimum and maximum display values are also updated here so that they are consistent
* with these constants. This way, all zoom calculations are based on these constants, and changing these
* constants should be all that is needed to adjust displayed and actual zoom values.
*/
var setupZoomSlider = () => {
const sliderMin = +$(ZOOM_SLIDER).attr("min");
const sliderMax = +$(ZOOM_SLIDER).attr("max");
sliderMidpoint = (sliderMin + sliderMax) / 2;
zoomScaleSliderLeft = createZoomScale(sliderMin, sliderMidpoint, MIN_DISPLAY, ZOOM_DISPLAY_MIDDLE);
zoomScaleSliderRight = createZoomScale(sliderMidpoint, sliderMax, ZOOM_DISPLAY_MIDDLE, ADAPTIVE_MAX_DISPLAY);
// Reset the zoom value to the midpoint whenever we load a new workbook.
if (grnState.newWorkbook) {
grnState.zoomValue = ZOOM_DISPLAY_MIDDLE;
}
updateAppBasedOnZoomValue();
};
setupZoomSlider();
var zoomInputValidator = function (value) {
return valueValidator(MIN_DISPLAY, ADAPTIVE_MAX_DISPLAY, value);
};
$(ZOOM_INPUT).on("input", () => {
grnState.zoomValue = zoomInputValidator(+$(ZOOM_INPUT).val());
updateAppBasedOnZoomValue();
}).blur(() => $(ZOOM_INPUT).val(grnState.zoomValue));
d3.select(ZOOM_SLIDER).on("input", function () {
const sliderValue = $(this).val();
prevGrnstateZoomVal = grnState.zoomValue;
grnState.zoomValue = Math.floor(
(sliderValue <= sliderMidpoint
? zoomScaleSliderLeft
: zoomScaleSliderRight)(sliderValue)
);
updateAppBasedOnZoomValue();
}).on("mousedown", function () {
manualZoom = true;
}).on("mouseup", function () {
manualZoom = false;
});
if (!grnState.newWorkbook) {
updateAppBasedOnZoomValue();
}
const adjustGraphSize = () => {
var newWidth = $container.width();
var newHeight = $container.height();
if (adaptive) {
width = (width < newWidth) ? newWidth : width;
height = (height < newHeight) ? newHeight : height;
} else {
width = newWidth;
height = newHeight;
}
// Subtract 1 from SVG height if we are fitting to window so as to prevent scrollbars from showing up
// Is inconsistent, but I'm tired of fighting with it...
d3.select("svg").attr("width", newWidth)
.attr("height", $(".grnsight-container").hasClass(VIEWPORT_FIT) ? newHeight : newHeight);
d3.select("rect").attr("width", width).attr("height", height);
d3.select(".boundingBox").attr("width", width).attr("height", height);
};
mutationCallback = adjustGraphSize;
resizeObserver.disconnect();
resizeObserver.observe($container.get(0), { attributes: true });
var restrictGraphToViewport = function (fixed) {
if (!fixed) {
$("#restrict-graph-to-viewport span").removeClass("glyphicon-ok");
$("input[name=viewport]").removeProp("checked");
adaptive = true;
flexibleContainer = null;
center();
} else {
$("#restrict-graph-to-viewport span").addClass("glyphicon-ok");
$("input[name=viewport]").prop("checked", "checked");
adaptive = false;
$container.removeClass(CURSOR_CLASSES);
if (grnState.zoomValue > ZOOM_DISPLAY_MIDDLE) {
grnState.zoomValue = ZOOM_DISPLAY_MIDDLE;
updateAppBasedOnZoomValue();
$container.removeClass(CURSOR_CLASSES);
}
width = $container.width();
height = $container.height();
d3.select("rect")
.attr("width", width)
.attr("height", height);
$(".boundingBox").attr("width", width).attr("height", height);
center();
}
updateAppBasedOnZoomValue(); // Update zoom value within bounds
};
d3.select("#restrict-graph-to-viewport").on("click", function () {
var fixed = $("input[name=viewport]").prop("checked");
restrictGraphToViewport(fixed);
});
d3.selectAll("input[name=viewport]").on("change", function () {
var fixed = $(this).prop("checked");
restrictGraphToViewport(fixed);
});
function center () {
var viewportWidth = $container.width();
var viewportHeight = $container.height();
zoom.translateTo(zoomContainer, viewportWidth / 2, viewportHeight / 2);
}
// move: Moves graph with D-pad
function move (direction) {
var moveWidth = direction === "left" ? -50 : direction === "right" ? 50 : 0;
var moveHeight = direction === "up" ? -50 : direction === "down" ? 50 : 0;
if (adaptive) {
zoom.translateBy(zoomContainer, moveWidth, moveHeight);
} else if (!adaptive) {
if (viewportBoundsMoveDrag(graphZoom, moveWidth, moveHeight)) {
zoom.translateBy(zoomContainer, moveWidth, moveHeight);
}
}
}
var defs = boundingBoxContainer.append("defs");
var link = boundingBoxContainer.selectAll(".links");
var node = boundingBoxContainer.selectAll(".nodes");
var weight = boundingBoxContainer.selectAll(".weight");
simulation
.nodes(workbook.genes)
.on("tick", tick);
simulation.force("link")
.links(workbook.links);
link = link.data(workbook.links)
.enter().append("g")
.attr("class", "link")
.attr("strokeWidth", getEdgeThickness);
node = node.data(workbook.genes)
.enter().append("g")
.attr("class", "node")
.attr("id", function (d) {
return "node" + d.index;
})
.attr("width", getNodeWidth)
.attr("height", nodeHeight)
.call(drag)
.on("dblclick", dblclick);
if (workbook.sheetType === "weighted") {
link.append("path")
.attr("class", "mousezone")
.style("stroke-width", function (d) {
var baseThickness = getEdgeThickness(d);
return Math.max(baseThickness, 7);
})
.attr("stroke-opacity", "0");
}
link.append("path")
.attr("class", "main")
.attr("id", function (d) {
return "path" + d.source.index + "_" + d.target.index;
}).style("stroke-width", function (d) {
d.strokeWidth = grnState.colorOptimal ? getEdgeThickness(d) : 2;
return d.strokeWidth;
}).style("stroke-dasharray", function (d) {
if (unweighted || !grnState.colorOptimal) {
return "0";
} else if (normalize(d) <= grayThreshold && dashedLine === true) {
return "6, 9";
} else {
return "0";
}
}).style("stroke", function (d) {
if (unweighted || !grnState.colorOptimal) {
return "black";
} else if (normalize(d) <= grayThreshold) {
return "gray";
} else {
return d.stroke;
}
}).attr("marker-end", function (d) {
var x1 = d.source.x;
var y1 = d.source.y;
var x2 = d.target.x;
var y2 = d.target.y;
var minimum = "";
var selfRef = "";
var yOffsets;
var xOffsets;
var color;
if (normalize(d) <= grayThreshold) {
minimum = "gray";
}
if ( x1 === x2 && y1 === y2 ) {
selfRef = "_SelfReferential";
}
// If the same ID is created twice (usually happens in the unweighted GRNS),
// it causes unpredictable behavior in the markers.
// To prevent this, first we check to make sure the ID about to be created doesn't exist.
if ( $("#" + d.type + selfRef + "_StrokeWidth" + d.strokeWidth + minimum).length !== 0 ) {
return "url(#" + d.type + selfRef + "_StrokeWidth" + d.strokeWidth + minimum + ")";
} else {
// If negative, you need one bar for horizontal and one for vertical.
// If the user is not coloring the weighted
// sheets, then we make all of the markers arrowheads.
if (d.value < 0 && grnState.colorOptimal) {
defs.append("marker")
.attr("id", "repressor" + selfRef + "_StrokeWidth" + d.strokeWidth + minimum)
.attr("refX", function () {
xOffsets = {
2 : 1, 3 : 2, 4 : 2, 5 : 2, 6 : 2.5, 7 : 3, 8 : 3.5,
9 : 4, 10 : 4.5, 11 : 5, 12 : 5, 13 : 5.5, 14 : 6
};
return xOffsets[d.strokeWidth];
})
.attr("refY", function () {
yOffsets = {
2 : 13, 3 : 13, 4 : 13.5, 5 : 14, 6 : 15.5, 7 : 17, 8 : 17,
9 : 17, 10 : 17, 11 : 17, 12 : 18.5, 13 : 18, 14 : 19.25
};
return yOffsets[d.strokeWidth];
})
.attr("markerUnits", "userSpaceOnUse")
.attr("markerWidth", function () {
return d.strokeWidth;
})
.attr("markerHeight", function () {
return 25 + d.strokeWidth;
})
.attr("orient", 180)
.append("rect")
.attr("width", function () {
return d.strokeWidth;
})
.attr("height", function () {
return 25 + d.strokeWidth;
})
.attr("rx", 10)
.attr("ry", 10)
.attr("style", function () {
if ( normalize(d) <= grayThreshold) {
color = "gray";
} else {
color = d.stroke;
}
return "stroke:" + color + "; fill: " + color + "; stroke-width: 0";
});
defs.append("marker")
.attr("id", "repressorHorizontal" + selfRef + "_StrokeWidth" + d.strokeWidth + minimum)
.attr("refX", function () {
if (x1 === x2 && y1 === y2) { // if self referential...
xOffsets = {
2 : 14, 3 : 15, 4 : 15, 5 : 15, 6 : 16, 7 : 16.5, 8 : 16.5,
9 : 17, 10 : 17.5, 11 : 18, 12 : 19, 13 : 19.5, 14 : 20.5
};
} else {
xOffsets = {
2 : 13, 3 : 13, 4 : 13.5, 5 : 14, 6 : 15.5, 7 : 16.5, 8 : 17,
9 : 16, 10 : 17, 11 : 17, 12 : 18, 13 : 18, 14 : 19
};
}
return xOffsets[d.strokeWidth];
})
.attr("refY", function () {
yOffsets = {
2 : 1, 3 : 2, 4 : 2, 5 : 2, 6 : 2.5, 7 : 3, 8 : 3.5,
9 : 4, 10 : 4.5, 11 : 5, 12 : 5, 13 : 5.5, 14 : 6
};
return yOffsets[d.strokeWidth];
})
.attr("markerUnits", "userSpaceOnUse")
.attr("markerWidth", function () {
return 25 + d.strokeWidth;
})
.attr("markerHeight", function () {
return d.strokeWidth;
})
.attr("orient", 180)
.append("rect")
.attr("width", function () {
return 25 + d.strokeWidth;
})
.attr("height", function () {
return d.strokeWidth;
})
.attr("rx", 10)
.attr("ry", 10)
.attr("style", function () {
if (normalize(d) <= grayThreshold) {
color = "gray";
} else {
color = d.stroke;
}
return "stroke:" + color + "; fill: " + color + "; stroke-width: 0";
});
} else {
// Arrowheads
if (grnState.mode === NETWORK_GRN_MODE) {
if (d.strokeWidth === 2) {
d.strokeWidth = 4;
}
defs.append("marker")
.attr("id", "arrowhead" + selfRef + "_StrokeWidth" + d.strokeWidth + minimum)
.attr("viewBox", "0 0 15 15")
.attr("preserveAspectRatio", "xMinYMin meet")
.attr("refX", function () {
// Individual offsets for each possible stroke width
return ((x1 === x2 && y1 === y2) ?
{
2: 2, 3: 10.5, 4: 11, 5: 9, 6: 9, 7: 10,
8: 9.8, 9: 9.1, 10: 10, 11: 9.5, 12: 9, 13: 8.3,
14: 8.3
} : {
2: 11.75, 3: 11, 4: 9.75, 5: 9.25, 6: 8.5, 7: 10,
8: 9.75, 9: 9.5, 10: 9, 11: 9.5, 12: 9.5, 13: 9.25,
14: 9
}
)[d.strokeWidth];
})
.attr("refY", function () {
return ((x1 === x2 && y1 === y2) ?
{
2: 6.7, 3: 5.45, 4: 5.3, 5: 5.5, 6: 5, 7: 5.4,
8: 5.65, 9: 6, 10: 5.7, 11: 5.5, 12: 5.9, 13: 6,
14: 6
} : {
2: 5, 3: 5, 4: 4.8, 5: 5, 6: 5, 7: 4.98,
8: 4.9, 9: 5.2, 10: 4.85, 11: 4.7, 12: 5.15,
13: 5, 14: 5.3
}
)[d.strokeWidth];
})
.attr("markerUnits", "userSpaceOnUse")
.attr("markerWidth", function () {
return 12 + ((d.strokeWidth < 7) ? d.strokeWidth * 2.25 : d.strokeWidth * 3);
})
.attr("markerHeight", function () {
return 5 + ((d.strokeWidth < 7) ? d.strokeWidth * 2.25 : d.strokeWidth * 3);
})
.attr("orient", function () {
return (x1 === x2 && y1 === y2) ?
{
2: 270, 3: 270, 4: 268, 5: 264, 6: 268, 7: 252,
8: 248, 9: 243, 10: 240, 11: 240, 12: 235, 13: 233,
14: 232
}[d.strokeWidth] : "auto";
})
.append("path")
.attr("d", "M 0 0 L 14 5 L 0 10 Q 6 5 0 0")
.attr("style", function () {
if (unweighted || !grnState.colorOptimal) {
color = "black";
} else if ( normalize(d) <= grayThreshold) {
color = "gray";
} else {
color = d.stroke;
}
return "stroke: " + color + "; fill: " + color;
});
}
}
return "url(#" + d.type + selfRef + "_StrokeWidth" + d.strokeWidth + minimum + ")";
}
});
if (workbook.sheetType === "weighted") {
link.append("text")
.attr("class", "weight")
.attr("text-anchor", "middle")
.attr("text-anchor", "middle")
.attr("fill", "rgb(0,0,0)")
.style("font-family", "sans-serif")
.text(function (d) {
return d.value.toPrecision(4);
});
weight = weight.data(workbook.links)
.enter().append("text")
.attr("class", "weight")
.attr("text-anchor", "middle")
.attr("fill", "rgb(0,0,0)")
.style("font-family", "sans-serif")
.text(function (d) {
return d.value.toPrecision(4);
})
.each(function (d) {
d.weightElement = d3.select(this);
});
}
/* Big thanks to the following for the smart edges
* https://github.com/cdc-leeds/PolicyCommons/blob/b0dea2a4171989123cbee377a6ae260b8612138e
/visualize/conn-net-svg.js#L119
*/
var moveTo = function (d) {
var node = d3.select("#node" + d.source.index);
var w = parseFloat(node.attr("width"));
var h = parseFloat(node.attr("height"));
d.source.newX = d.source.x + (w / 2);
d.source.newY = d.source.y + (h / 2);
return "M" + d.source.newX + "," + d.source.newY + " ";
};
var CURVE_THRESHOLD = 200;
var EDGE_OFFSET = 20;
var lineTo = function (d) {
var node = d3.select("#node" + d.target.index);
var w = +node.attr("width");
var h = +node.attr("height");
var x1 = d.source.x;
var y1 = d.source.y;
var x2 = d.target.x;
var y2 = d.target.y;
d.target.centerX = d.target.x + (w / 2);
d.target.centerY = d.target.y + (h / 2);
// This function calculates the newX and newY.
smartPathEnd(d, w, h);
x1 = d.source.newX;
y1 = d.source.newY;
x2 = d.target.newX;
y2 = d.target.newY;
// Unit vectors.
var ux = x2 - x1;
var uy = y2 - y1;
var umagnitude = Math.sqrt(ux * ux + uy * uy);
var vx = -uy; // Perpendicular vector.
var vy = ux;
var vmagnitude = Math.sqrt(vx * vx + vy * vy);
ux /= umagnitude;
uy /= umagnitude;
vx /= vmagnitude;
vy /= vmagnitude;
// Check for vector direction.
if (((d.target.newX > d.source.x) && (d.target.newY > d.source.y)) ||
((d.target.newX < d.source.x) && (d.target.newY < d.source.y))) {
vx = -vx; vy = -vy;
}
var curveToStraight = (umagnitude - CURVE_THRESHOLD) / 4;
var inlineOffset = Math.max(umagnitude / 4, curveToStraight);
var orthoOffset = Math.max(0, curveToStraight);
var cp1x = x1 + inlineOffset * ux + vx * orthoOffset;
var cp1y = y1 + inlineOffset * uy + vy * orthoOffset;
var cp2x = x2 - inlineOffset * ux + vx * orthoOffset;
var cp2y = y2 - inlineOffset * uy + vy * orthoOffset;
cp1x = Math.min(Math.max(0, cp1x), width);
cp1y = Math.min(Math.max(0, cp1y), height);
cp2x = Math.min(Math.max(0, cp2x), width);
cp2y = Math.min(Math.max(0, cp2y), height);
d.label = {
x: Math.min(Math.max((x1 + cp1x + cp2x + x2) / 4, EDGE_OFFSET), width - 2 * EDGE_OFFSET),
y: Math.min(Math.max((y1 + cp1y + cp2y + y2) / 4, EDGE_OFFSET), height - EDGE_OFFSET)
};
return "C" + cp1x + " " + cp1y + ", " +
cp2x + " " + cp2y + ", " +
x2 + " " + y2;
};
function smartPathEnd (d, w, h) {
// For arrowheads when target node is to the left of source node
var LEFT_ADJUSTMENT = 7;
var MINIMUM_DISTANCE = 8;
var NODE_HALF_HEIGHT = 30 / 2;
var targetStartX = d.target.centerX + d.target.textWidth / 2;
var currentPointX = (targetStartX - d.target.centerX) / (d.source.newX - d.target.centerX);
var currentPointY = (1 - currentPointX) * d.target.centerY + currentPointX * d.source.newY;
var upperBound = d.target.centerY + NODE_HALF_HEIGHT;
var lowerBound = d.target.centerY - NODE_HALF_HEIGHT;
if (currentPointX > 0 && currentPointY >= lowerBound && currentPointY <= upperBound) {
MINIMUM_DISTANCE = d.strokeWidth > 11 ? 16.5 : 15;
}
// Set an offset if the edge is a repressor to make room for the flat arrowhead
var globalOffset = parseFloat(d.strokeWidth);
if (d.value < 0 && grnState.colorOptimal) {
globalOffset = Math.max(globalOffset, MINIMUM_DISTANCE);
}
var thicknessAdjustment = globalOffset > MINIMUM_DISTANCE ? 1 : 0;
// We need to work out the (tan of the) angle between the
// imaginary horizontal line running through the center of the
// target node and the imaginary line connecting the center of
// the target node with the top-left corner of the same
// node. Of course, this angle is fixed.
d.tanRatioFixed = (d.target.centerY - d.target.y) / (d.target.centerX - d.target.x);
// We also need to work out the (tan of the) angle between the
// imaginary horizontal line running through the center of the
// target node and the imaginary line connecting the center of
// the target node with the center of the source node. This
// angle changes as the nodes move around the screen.
d.tanRatioMoveable = Math.abs(d.target.centerY - d.source.newY) / Math.abs(d.target.centerX - d.source.newX);
// Note, JavaScript handles division-by-zero by returning
// Infinity, which in this case is useful, especially
// since it handles the subsequent Infinity arithmetic
// correctly.
// Now work out the intersection point
if (d.tanRatioMoveable === d.tanRatioFixed) {
// Then path is intersecting at corner of textbox so draw
// path to that point
// By default assume path intersects a left-side corner
d.target.newX = d.target.x - globalOffset;
// But...
if (d.target.centerX < d.source.newX) {
// i.e. if target node is to left of the source node
// then path intersects a right-side corner
d.target.newX = d.target.x + w + globalOffset;
}
// By default assume path intersects a top corner
d.target.newY = d.target.y - globalOffset;
// But...
if (d.target.centerY < d.source.newY) {
// i.e. if target node is above the source node
// then path intersects a bottom corner
d.target.newY = d.target.y + h + globalOffset;
}
}
if (d.tanRatioMoveable < d.tanRatioFixed) {
// Then path is intersecting on a vertical side of the
// textbox, which means we know the x-coordinate of the
// path endpoint but we need to work out the y-coordinate
// By default assume path intersects left vertical side
d.target.newX = d.target.x - globalOffset;
// But...
if (d.target.centerX < d.source.newX) {
// i.e. if target node is to left of the source node
// then path intersects right vertical side
if (d.type !== "arrowhead") {
d.target.newX = d.target.x + w + globalOffset + 0.25 * d.strokeWidth - thicknessAdjustment;
} else {
d.target.newX = d.target.x + w + globalOffset + LEFT_ADJUSTMENT;
}
}
// Now use a bit of trigonometry to work out the y-coord.
// By default assume path intersects towards top of node
d.target.newY = d.target.centerY - ((d.target.centerX - d.target.x) * d.tanRatioMoveable);
// But...
if (d.target.centerY < d.source.newY) {
// i.e. if target node is above the source node
// then path intersects towards bottom of the node
d.target.newY = (2 * d.target.y) - d.target.newY + h;
}
}
if (d.tanRatioMoveable > d.tanRatioFixed) {
// Then path is intersecting on a horizontal side of the
// textbox, which means we know the y-coordinate of the
// path endpoint but we need to work out the x-coordinate
// By default assume path intersects top horizontal side
d.target.newY = d.target.y - globalOffset;
// But...
if (d.target.centerY < d.source.newY) {
// i.e. if target node is above the source node
// then path intersects bottom horizontal side
if (d.type !== "arrowhead") {
d.target.newY = d.target.y + h + globalOffset + 0.25 * d.strokeWidth - thicknessAdjustment;
} else {
d.target.newY = d.target.y + h + globalOffset;
}
}
// Now use a bit of trigonometry to work out the x-coord.
// By default assume path intersects towards lefthand side
d.target.newX = d.target.centerX - ((d.target.centerY - d.target.y) / d.tanRatioMoveable);
// But...
if (d.target.centerX < d.source.newX) {
// i.e. if target node is to left of the source node
// then path intersects towards the righthand side
d.target.newX = (2 * d.target.x) - d.target.newX + w;
}
}
}
var dblclick = function (d) {
d.fx = null;
d.fy = null;
};
var nodeTextDblclick = function (d) {
// Relay the double-click to our parent.
dblclick.call(this.parentNode, d);
};
var rect = node.append("rect")
.attr("width", function () {
return this.parentNode.getAttribute("width");
})
.attr("height", function () {
return this.parentNode.getAttribute("height");
})
.attr("stroke-width", "2px")
.on("dblclick", dblclick);
var MINIMUM_NODE_WIDTH = 68.5625;
var NODE_MARGIN = 3;
var NODE_HEIGHT = 22;
var renderNodeLabels = function () {
node.selectAll(".nodeText").remove();
var text = node.append("text")
.attr("dy", NODE_HEIGHT)
.attr("class", "nodeText")
.attr("fill", "rgb(0, 0, 0)")
.style("text-anchor", "middle")
.style("font-size", "18px")
.style("stroke-width", "0")
.style("font-family", "sans-serif")
.text(function (d) {
return d.name;
})
.attr("dx", function (d) {
var textWidth = this.getBBox().width;
d.textWidth = textWidth < MINIMUM_NODE_WIDTH ? MINIMUM_NODE_WIDTH : textWidth;
return d.textWidth / 2 + NODE_MARGIN;
})
.on("dblclick", nodeTextDblclick)
// this function triggers the gene page
.on("contextmenu", function (gene) {
const tempLink = $("<a></a>")
.attr({
href: "info?" + $.param({
symbol: gene.name,
species: grnState.genePageData.species,
jaspar: grnState.genePageData.taxonJaspar,
uniprot: grnState.genePageData.taxonUniprot,
ensembl: grnState.genePageData.ensembl,
mine: grnState.genePageData.mine
}),
target: "_blank"
});
$("body").append(tempLink);
tempLink.get(0).click();
tempLink.remove();
d3.event.preventDefault();
});
rect
.attr("width", function (d) {
return NODE_MARGIN + d.textWidth + NODE_MARGIN;
});
node
.attr("width", function (d) {
return NODE_MARGIN + d.textWidth + NODE_MARGIN;
});
};
renderNodeLabels();
function onlyUnique (value, index, self) {
return self.indexOf(value) === index;
}
const getExpressionData = (gene, strain, average) => {
const strainData = grnState.workbook.expression[strain];
if (average) {
const uniqueTimePoints = strainData.timePoints.filter(onlyUnique);
let avgMap = {};
uniqueTimePoints.forEach(function (key) {
avgMap[key] = [];
});
strainData.timePoints.forEach(function (time, index) {
avgMap[time].push(strainData.data[gene][index]);
});
let avgs = [];
Object.keys(avgMap).forEach(function (key) {
const length = avgMap[key].length;
const sum = avgMap[key].reduce(function (partialSum, currentValue) {
return partialSum + currentValue;
}, 0);
avgs.push(sum / length);
});
return {data: avgs, timePoints: uniqueTimePoints};
}
return {data: strainData.data[gene], timePoints: strainData.timePoints};
};
var colorNodes = function (position, dataset, average, logFoldChangeMaxValue) {
var timePoints = [];
node.each(function (p) {
d3.select(this)
.append("g")
.selectAll(".coloring")
.data(function () {
if (grnState.workbook.expression[dataset]) {
const geneName = adjustGeneNameForExpression(p);
if (
grnState.workbook.expression[dataset].data[geneName]
) {
const result = getExpressionData(
geneName,
dataset,
average
);
timePoints = result.timePoints;
return result.data || [];
}
}
return [];
})
.attr("class", "coloring")
.enter().append("rect")
.attr("width", function () {
var width = (p.textWidth + (2 * NODE_MARGIN)) / timePoints.length;
return width + "px";
})
.attr("class", "coloring")
.attr("height", rect.attr("height") / 2 + "px")
.attr("transform", function (d, i) {
var yOffset = position === "top" ? 0 : rect.attr("height") / 2;
var xOffset = i * ((p.textWidth + (2 * NODE_MARGIN)) / timePoints.length);
return "translate(" + xOffset + "," + yOffset + ")";
})
.attr("stroke-width", "0px")
.style("fill", function (d) {
d = d || 0; // missing values are changed to 0
if (d === 0) {
return "white";
}
var scale = d3.scaleLinear()
.domain([-logFoldChangeMaxValue, logFoldChangeMaxValue])
.range([0, 1]);
return d3.interpolateRdBu(scale(-d));
})
.text(function (d) {
return "data " + JSON.stringify(d) + " of " + p.name;
});
});
};
var renderNodeColoringLegend = function (logFoldChangeMaxValue) {
var $nodeColoringLegend = $(".node-coloring-legend");
d3.select($nodeColoringLegend[0]).selectAll("svg").remove();
var yMargin = 20;
var width = 203;
var height = 10;
var textYOffset = 10;
var svg = d3.select($nodeColoringLegend[0])
.append("svg")
.attr("width", "100%")
.attr("height", height + yMargin)
.append("g")
.attr("transform", "translate(0, 5)")
.attr("id", "nodeColoringLegendId");
// Thank you https://www.visualcinnamon.com/2016/05/smooth-color-legend-d3-svg-gradient.html
const linearGradientId = "node-coloring-color-scale";
var defs = svg.append("defs");
var linearGradient = defs.append("linearGradient")
.attr("id", linearGradientId)
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "100%")
.attr("y2", "0%");
const increment = Math.abs(logFoldChangeMaxValue) / 50; // Guarantee 50 steps regardless of the range.
var gradientValues = d3.range(-logFoldChangeMaxValue, logFoldChangeMaxValue, increment);
var scale = d3.scaleLinear()
.domain([-logFoldChangeMaxValue, logFoldChangeMaxValue])
.range([0, 1]);
linearGradient.selectAll("stop")
.data(gradientValues)
.enter().append("stop")
.attr("offset", function (d, i) {
return i / (gradientValues.length - 1);
})
.attr("stop-color", function (d) {
return d3.interpolateRdBu(scale(-d));
});
svg.append("rect")
.attr("width", `${width}px`)
.attr("height", `${height}px`)
.style("fill", `url(#${linearGradientId})`);
var legendLabels = {
left: {
textAnchor: "start",
textContent: (-logFoldChangeMaxValue).toFixed(2),
x: 0
},
center: {
textAnchor: "middle",
textContent: "0",
x: width / 2
},
right: {
textAnchor: "end",
textContent: (logFoldChangeMaxValue).toFixed(2),
x: width
}
};
/* eslint-disable max-len */
var g = document.getElementById("nodeColoringLegendId");
/* eslint-enable max-len */
for (var key in legendLabels) {
var label = document.createElementNS("http://www.w3.org/2000/svg", "text");
label.textContent = legendLabels[key].textContent;
label.setAttribute("font-size", "8px");
label.setAttribute("text-anchor", legendLabels[key].textAnchor);
label.setAttribute("x", legendLabels[key].x);
label.setAttribute("y", height + textYOffset + "px");
label.setAttribute("fill", "rgb(0,0,0)");
g.appendChild(label);
}
};
updaters.removeNodeColoring = function () {
grnState.nodeColoring.nodeColoringEnabled = false;
node.selectAll(".coloring").remove();
};
updaters.renderNodeColoring = function () {
if (grnState.nodeColoring.nodeColoringEnabled) {
colorNodes("top", grnState.nodeColoring.topDataset, grnState.nodeColoring.averageTopDataset,
grnState.nodeColoring.logFoldChangeMaxValue);
colorNodes("bottom", grnState.nodeColoring.bottomDataset, grnState.nodeColoring.averageBottomDataset,
grnState.nodeColoring.logFoldChangeMaxValue);
renderNodeLabels();
renderNodeColoringLegend(grnState.nodeColoring.logFoldChangeMaxValue);
}
};
if (!$.isEmptyObject(workbook.expression) && hasExpressionData(workbook.expression) &&
grnState.nodeColoring.topDataset !== undefined) {
updaters.renderNodeColoring();
}
$(".node").css({
"cursor": "move",
"fill": "white",
"stroke": "#000",
"stroke-width": "1.5px"
});
$(".link").css({
"stroke": "#000",
"fill": "none",
"stroke-dasharray": "0",
"stroke-width": "1.5px"
});
var currentWeightVisibilitySetting = null;
if (workbook.sheetType === "weighted") {
if ($(".weightedGraphOptions").hasClass("hidden")) {
$(".weighted