chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,950 @@
|
||||
import React from "react";
|
||||
import { select, pointer } from "d3-selection";
|
||||
import { scaleLinear, scaleTime } from "d3-scale";
|
||||
import { format } from "d3-format";
|
||||
import { timeFormat, timeParse } from "d3-time-format";
|
||||
import { axisBottom, axisLeft } from "d3-axis";
|
||||
import { line } from "d3-shape";
|
||||
import { hsl } from "d3-color";
|
||||
import {
|
||||
sortBy,
|
||||
min,
|
||||
max,
|
||||
map,
|
||||
each,
|
||||
sum,
|
||||
filter,
|
||||
debounce,
|
||||
keys,
|
||||
range,
|
||||
rangeRight,
|
||||
cloneDeep,
|
||||
findKey
|
||||
} from "lodash";
|
||||
import colors from "./color-set";
|
||||
|
||||
class AdditiveForceArrayVisualizer extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
window.lastAdditiveForceArrayVisualizer = this;
|
||||
this.topOffset = 28;
|
||||
this.leftOffset = 80;
|
||||
this.height = 350;
|
||||
this.effectFormat = format(".2");
|
||||
this.redraw = debounce(() => this.draw(), 200);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// create our permanent elements
|
||||
this.mainGroup = this.svg.append("g");
|
||||
this.onTopGroup = this.svg.append("g");
|
||||
this.xaxisElement = this.onTopGroup
|
||||
.append("g")
|
||||
.attr("transform", "translate(0,35)")
|
||||
.attr("class", "force-bar-array-xaxis");
|
||||
this.yaxisElement = this.onTopGroup
|
||||
.append("g")
|
||||
.attr("transform", "translate(0,35)")
|
||||
.attr("class", "force-bar-array-yaxis");
|
||||
this.hoverGroup1 = this.svg.append("g");
|
||||
this.hoverGroup2 = this.svg.append("g");
|
||||
this.baseValueTitle = this.svg.append("text");
|
||||
this.hoverLine = this.svg.append("line");
|
||||
this.hoverxOutline = this.svg
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("fill", "#fff")
|
||||
.attr("stroke", "#fff")
|
||||
.attr("stroke-width", "6")
|
||||
.attr("font-size", "12px");
|
||||
this.hoverx = this.svg
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("fill", "#000")
|
||||
.attr("font-size", "12px");
|
||||
this.hoverxTitle = this.svg
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("opacity", 0.6)
|
||||
.attr("font-size", "12px");
|
||||
this.hoveryOutline = this.svg
|
||||
.append("text")
|
||||
.attr("text-anchor", "end")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("fill", "#fff")
|
||||
.attr("stroke", "#fff")
|
||||
.attr("stroke-width", "6")
|
||||
.attr("font-size", "12px");
|
||||
this.hovery = this.svg
|
||||
.append("text")
|
||||
.attr("text-anchor", "end")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("fill", "#000")
|
||||
.attr("font-size", "12px");
|
||||
this.xlabel = this.wrapper.select(".additive-force-array-xlabel");
|
||||
this.ylabel = this.wrapper.select(".additive-force-array-ylabel");
|
||||
|
||||
// Create our colors and color gradients
|
||||
//Verify custom color map
|
||||
let plot_colors=undefined;
|
||||
if (typeof this.props.plot_cmap === "string")
|
||||
{
|
||||
if (!(this.props.plot_cmap in colors.colors))
|
||||
{
|
||||
console.log("Invalid color map name, reverting to default.");
|
||||
plot_colors=colors.colors.RdBu;
|
||||
}
|
||||
else
|
||||
{
|
||||
plot_colors = colors.colors[this.props.plot_cmap]
|
||||
}
|
||||
}
|
||||
else if (Array.isArray(this.props.plot_cmap)){
|
||||
plot_colors = this.props.plot_cmap
|
||||
}
|
||||
this.colors = plot_colors.map(x => hsl(x));
|
||||
this.brighterColors = [1.45, 1.6].map((v, i) => this.colors[i].brighter(v));
|
||||
|
||||
// create our axes
|
||||
let defaultFormat = format(",.4");
|
||||
if ((this.props.ordering_keys != null) && (this.props.ordering_keys_time_format != null)) {
|
||||
this.parseTime = timeParse(this.props.ordering_keys_time_format);
|
||||
this.formatTime = timeFormat(this.props.ordering_keys_time_format);
|
||||
|
||||
function condFormat(x) {
|
||||
if (typeof(x) == "object") {
|
||||
return this.formatTime(x);
|
||||
} else {
|
||||
return defaultFormat(x);
|
||||
};
|
||||
}
|
||||
|
||||
this.xtickFormat = condFormat;
|
||||
|
||||
} else {
|
||||
this.parseTime = null;
|
||||
this.formatTime = null;
|
||||
this.xtickFormat = defaultFormat;
|
||||
}
|
||||
this.xscale = scaleLinear();
|
||||
this.xaxis = axisBottom()
|
||||
.scale(this.xscale)
|
||||
.tickSizeInner(4)
|
||||
.tickSizeOuter(0)
|
||||
.tickFormat(d => this.xtickFormat(d))
|
||||
.tickPadding(-18);
|
||||
|
||||
this.ytickFormat = defaultFormat;
|
||||
this.yscale = scaleLinear();
|
||||
this.yaxis = axisLeft()
|
||||
.scale(this.yscale)
|
||||
.tickSizeInner(4)
|
||||
.tickSizeOuter(0)
|
||||
.tickFormat(d => this.ytickFormat(this.invLinkFunction(d)))
|
||||
.tickPadding(2);
|
||||
|
||||
this.xlabel.node().onchange = () => this.internalDraw();
|
||||
this.ylabel.node().onchange = () => this.internalDraw();
|
||||
|
||||
this.svg.on("mousemove", event => this.mouseMoved(event));
|
||||
this.svg.on("click", () => alert("This original index of the sample you clicked is " + this.nearestExpIndex));
|
||||
|
||||
this.svg.on("mouseout", x => this.mouseOut(x));
|
||||
|
||||
// draw and then listen for resize events
|
||||
//this.draw();
|
||||
window.addEventListener("resize", this.redraw);
|
||||
window.setTimeout(this.redraw, 50); // re-draw after interface has updated
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
mouseOut() {
|
||||
this.hoverLine.attr("display", "none");
|
||||
this.hoverx.attr("display", "none");
|
||||
this.hoverxOutline.attr("display", "none");
|
||||
this.hoverxTitle.attr("display", "none");
|
||||
this.hovery.attr("display", "none");
|
||||
this.hoveryOutline.attr("display", "none");
|
||||
this.hoverGroup1.attr("display", "none");
|
||||
this.hoverGroup2.attr("display", "none");
|
||||
}
|
||||
|
||||
mouseMoved(event) {
|
||||
let i, nearestExp;
|
||||
|
||||
this.hoverLine.attr("display", "");
|
||||
this.hoverx.attr("display", "");
|
||||
this.hoverxOutline.attr("display", "");
|
||||
this.hoverxTitle.attr("display", "");
|
||||
this.hovery.attr("display", "");
|
||||
this.hoveryOutline.attr("display", "");
|
||||
this.hoverGroup1.attr("display", "");
|
||||
this.hoverGroup2.attr("display", "");
|
||||
|
||||
let x = pointer(event, this.svg.node())[0];
|
||||
if (this.props.explanations) {
|
||||
|
||||
// Find the nearest explanation to the cursor position
|
||||
for (i = 0; i < this.currExplanations.length; ++i) {
|
||||
if (
|
||||
!nearestExp ||
|
||||
Math.abs(nearestExp.xmapScaled - x) >
|
||||
Math.abs(this.currExplanations[i].xmapScaled - x)
|
||||
) {
|
||||
nearestExp = this.currExplanations[i];
|
||||
}
|
||||
}
|
||||
this.nearestExpIndex = nearestExp.origInd;
|
||||
|
||||
this.hoverLine
|
||||
.attr("x1", nearestExp.xmapScaled)
|
||||
.attr("x2", nearestExp.xmapScaled)
|
||||
.attr("y1", 0 + this.topOffset)
|
||||
.attr("y2", this.height);
|
||||
this.hoverx
|
||||
.attr("x", nearestExp.xmapScaled)
|
||||
.attr("y", this.topOffset - 5)
|
||||
.text(this.xtickFormat(nearestExp.xmap));
|
||||
this.hoverxOutline
|
||||
.attr("x", nearestExp.xmapScaled)
|
||||
.attr("y", this.topOffset - 5)
|
||||
.text(this.xtickFormat(nearestExp.xmap));
|
||||
this.hoverxTitle
|
||||
.attr("x", nearestExp.xmapScaled)
|
||||
.attr("y", this.topOffset - 18)
|
||||
.text(
|
||||
nearestExp.count > 1 ? nearestExp.count + " averaged samples" : ""
|
||||
);
|
||||
this.hovery
|
||||
.attr("x", this.leftOffset - 6)
|
||||
.attr("y", nearestExp.joinPointy)
|
||||
.text(this.ytickFormat(this.invLinkFunction(nearestExp.joinPoint)));
|
||||
this.hoveryOutline
|
||||
.attr("x", this.leftOffset - 6)
|
||||
.attr("y", nearestExp.joinPointy)
|
||||
.text(this.ytickFormat(this.invLinkFunction(nearestExp.joinPoint)));
|
||||
|
||||
let posFeatures = [];
|
||||
let lastPos, pos;
|
||||
for (let j = this.currPosOrderedFeatures.length - 1; j >= 0; --j) {
|
||||
let i = this.currPosOrderedFeatures[j];
|
||||
let d = nearestExp.features[i];
|
||||
pos = 5 + (d.posyTop + d.posyBottom) / 2;
|
||||
if (
|
||||
(!lastPos || pos - lastPos >= 15) &&
|
||||
d.posyTop - d.posyBottom >= 6
|
||||
) {
|
||||
posFeatures.push(d);
|
||||
lastPos = pos;
|
||||
}
|
||||
}
|
||||
|
||||
let negFeatures = [];
|
||||
lastPos = undefined;
|
||||
for (let i of this.currNegOrderedFeatures) {
|
||||
let d = nearestExp.features[i];
|
||||
pos = 5 + (d.negyTop + d.negyBottom) / 2;
|
||||
if (
|
||||
(!lastPos || lastPos - pos >= 15) &&
|
||||
d.negyTop - d.negyBottom >= 6
|
||||
) {
|
||||
negFeatures.push(d);
|
||||
lastPos = pos;
|
||||
}
|
||||
}
|
||||
|
||||
let labelFunc = d => {
|
||||
let valString = "";
|
||||
if (d.value !== null && d.value !== undefined) {
|
||||
valString =
|
||||
" = " + (isNaN(d.value) ? d.value : this.ytickFormat(d.value));
|
||||
}
|
||||
if (nearestExp.count > 1) {
|
||||
return "mean(" + this.props.featureNames[d.ind] + ")" + valString;
|
||||
} else {
|
||||
return this.props.featureNames[d.ind] + valString;
|
||||
}
|
||||
};
|
||||
|
||||
let featureHoverLabels1 = this.hoverGroup1
|
||||
.selectAll(".pos-values")
|
||||
.data(posFeatures);
|
||||
featureHoverLabels1
|
||||
.enter()
|
||||
.append("text")
|
||||
.attr("class", "pos-values")
|
||||
.merge(featureHoverLabels1)
|
||||
.attr("x", nearestExp.xmapScaled + 5)
|
||||
.attr("y", d => 4 + (d.posyTop + d.posyBottom) / 2)
|
||||
.attr("text-anchor", "start")
|
||||
.attr("font-size", 12)
|
||||
.attr("stroke", "#fff")
|
||||
.attr("fill", "#fff")
|
||||
.attr("stroke-width", "4")
|
||||
.attr("stroke-linejoin", "round")
|
||||
.attr("opacity", 1)
|
||||
.text(labelFunc);
|
||||
featureHoverLabels1.exit().remove();
|
||||
|
||||
let featureHoverLabels2 = this.hoverGroup2
|
||||
.selectAll(".pos-values")
|
||||
.data(posFeatures);
|
||||
featureHoverLabels2
|
||||
.enter()
|
||||
.append("text")
|
||||
.attr("class", "pos-values")
|
||||
.merge(featureHoverLabels2)
|
||||
.attr("x", nearestExp.xmapScaled + 5)
|
||||
.attr("y", d => 4 + (d.posyTop + d.posyBottom) / 2)
|
||||
.attr("text-anchor", "start")
|
||||
.attr("font-size", 12)
|
||||
.attr("fill", this.colors[0])
|
||||
.text(labelFunc);
|
||||
featureHoverLabels2.exit().remove();
|
||||
|
||||
let featureHoverNegLabels1 = this.hoverGroup1
|
||||
.selectAll(".neg-values")
|
||||
.data(negFeatures);
|
||||
featureHoverNegLabels1
|
||||
.enter()
|
||||
.append("text")
|
||||
.attr("class", "neg-values")
|
||||
.merge(featureHoverNegLabels1)
|
||||
.attr("x", nearestExp.xmapScaled + 5)
|
||||
.attr("y", d => 4 + (d.negyTop + d.negyBottom) / 2)
|
||||
.attr("text-anchor", "start")
|
||||
.attr("font-size", 12)
|
||||
.attr("stroke", "#fff")
|
||||
.attr("fill", "#fff")
|
||||
.attr("stroke-width", "4")
|
||||
.attr("stroke-linejoin", "round")
|
||||
.attr("opacity", 1)
|
||||
.text(labelFunc);
|
||||
featureHoverNegLabels1.exit().remove();
|
||||
|
||||
let featureHoverNegLabels2 = this.hoverGroup2
|
||||
.selectAll(".neg-values")
|
||||
.data(negFeatures);
|
||||
featureHoverNegLabels2
|
||||
.enter()
|
||||
.append("text")
|
||||
.attr("class", "neg-values")
|
||||
.merge(featureHoverNegLabels2)
|
||||
.attr("x", nearestExp.xmapScaled + 5)
|
||||
.attr("y", d => 4 + (d.negyTop + d.negyBottom) / 2)
|
||||
.attr("text-anchor", "start")
|
||||
.attr("font-size", 12)
|
||||
.attr("fill", this.colors[1])
|
||||
.text(labelFunc);
|
||||
featureHoverNegLabels2.exit().remove();
|
||||
}
|
||||
}
|
||||
|
||||
draw() {
|
||||
if (!this.props.explanations || this.props.explanations.length === 0)
|
||||
return;
|
||||
|
||||
// record the order in which the explanations were given
|
||||
each(this.props.explanations, (x, i) => (x.origInd = i));
|
||||
|
||||
// Find what features are actually used
|
||||
let posDefinedFeatures = {};
|
||||
let negDefinedFeatures = {};
|
||||
let definedFeaturesValues = {};
|
||||
for (let e of this.props.explanations) {
|
||||
for (let k in e.features) {
|
||||
if (posDefinedFeatures[k] === undefined) {
|
||||
posDefinedFeatures[k] = 0;
|
||||
negDefinedFeatures[k] = 0;
|
||||
definedFeaturesValues[k] = 0;
|
||||
}
|
||||
if (e.features[k].effect > 0) {
|
||||
posDefinedFeatures[k] += e.features[k].effect;
|
||||
} else {
|
||||
negDefinedFeatures[k] -= e.features[k].effect;
|
||||
}
|
||||
if (e.features[k].value !== null && e.features[k].value !== undefined) {
|
||||
definedFeaturesValues[k] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.usedFeatures = sortBy(
|
||||
keys(posDefinedFeatures),
|
||||
i => -(posDefinedFeatures[i] + negDefinedFeatures[i])
|
||||
);
|
||||
console.log("found ", this.usedFeatures.length, " used features");
|
||||
|
||||
this.posOrderedFeatures = sortBy(
|
||||
this.usedFeatures,
|
||||
i => posDefinedFeatures[i]
|
||||
);
|
||||
this.negOrderedFeatures = sortBy(
|
||||
this.usedFeatures,
|
||||
i => -negDefinedFeatures[i]
|
||||
);
|
||||
this.singleValueFeatures = filter(
|
||||
this.usedFeatures,
|
||||
i => definedFeaturesValues[i] > 0
|
||||
);
|
||||
|
||||
let options = [
|
||||
"sample order by similarity",
|
||||
"sample order by output value",
|
||||
"original sample ordering"
|
||||
].concat(this.singleValueFeatures.map(i => this.props.featureNames[i]));
|
||||
if (this.props.ordering_keys != null) {
|
||||
options.unshift("sample order by key");
|
||||
}
|
||||
|
||||
let xLabelOptions = this.xlabel.selectAll("option").data(options);
|
||||
xLabelOptions
|
||||
.enter()
|
||||
.append("option")
|
||||
.merge(xLabelOptions)
|
||||
.attr("value", d => d)
|
||||
.text(d => d);
|
||||
xLabelOptions.exit().remove();
|
||||
|
||||
let n = this.props.outNames[0]
|
||||
? this.props.outNames[0]
|
||||
: "model output value";
|
||||
options = map(this.usedFeatures, i => [
|
||||
this.props.featureNames[i],
|
||||
this.props.featureNames[i] + " effects"
|
||||
]);
|
||||
options.unshift(["model output value", n]);
|
||||
let yLabelOptions = this.ylabel.selectAll("option").data(options);
|
||||
yLabelOptions
|
||||
.enter()
|
||||
.append("option")
|
||||
.merge(yLabelOptions)
|
||||
.attr("value", d => d[0])
|
||||
.text(d => d[1]);
|
||||
yLabelOptions.exit().remove();
|
||||
|
||||
this.ylabel
|
||||
.style(
|
||||
"top",
|
||||
(this.height - 10 - this.topOffset) / 2 + this.topOffset + "px"
|
||||
)
|
||||
.style("left", 10 - this.ylabel.node().offsetWidth / 2 + "px");
|
||||
this.internalDraw();
|
||||
}
|
||||
|
||||
internalDraw() {
|
||||
// we fill in any implicit feature values and assume they have a zero effect and value
|
||||
for (let e of this.props.explanations) {
|
||||
for (let i of this.usedFeatures) {
|
||||
if (!e.features.hasOwnProperty(i)) {
|
||||
e.features[i] = { effect: 0, value: 0 };
|
||||
}
|
||||
e.features[i].ind = i;
|
||||
}
|
||||
}
|
||||
|
||||
let explanations;
|
||||
let xsort = this.xlabel.node().value;
|
||||
|
||||
|
||||
// Set scaleTime if time ticks provided for original ordering
|
||||
let isTimeScale = ((xsort === "sample order by key") &&
|
||||
(this.props.ordering_keys_time_format != null));
|
||||
if (isTimeScale) {
|
||||
this.xscale = scaleTime();
|
||||
} else {
|
||||
this.xscale = scaleLinear();
|
||||
}
|
||||
this.xaxis.scale(this.xscale);
|
||||
|
||||
|
||||
if (xsort === "sample order by similarity") {
|
||||
explanations = sortBy(this.props.explanations, x => x.simIndex);
|
||||
each(explanations, (e, i) => (e.xmap = i));
|
||||
} else if (xsort === "sample order by output value") {
|
||||
explanations = sortBy(this.props.explanations, x => -x.outValue);
|
||||
each(explanations, (e, i) => (e.xmap = i));
|
||||
} else if (xsort === "original sample ordering") {
|
||||
explanations = sortBy(this.props.explanations, x => x.origInd);
|
||||
each(explanations, (e, i) => (e.xmap = i));
|
||||
} else if (xsort === "sample order by key") {
|
||||
explanations = this.props.explanations;
|
||||
if (isTimeScale) {
|
||||
each(explanations, (e, i) => (e.xmap = this.parseTime(this.props.ordering_keys[i])));
|
||||
} else {
|
||||
each(explanations, (e, i) => (e.xmap = this.props.ordering_keys[i]));
|
||||
}
|
||||
explanations = sortBy(explanations, e => e.xmap);
|
||||
} else {
|
||||
let ind = findKey(this.props.featureNames, x => x === xsort);
|
||||
each(this.props.explanations, (e, i) => (e.xmap = e.features[ind].value));
|
||||
let explanations2 = sortBy(this.props.explanations, x => x.xmap);
|
||||
let xvals = map(explanations2, x => x.xmap);
|
||||
if (typeof xvals[0] == "string") {
|
||||
alert("Ordering by category names is not yet supported.");
|
||||
return;
|
||||
}
|
||||
let xmin = min(xvals);
|
||||
let xmax = max(xvals);
|
||||
let binSize = (xmax - xmin) / 100;
|
||||
|
||||
// Build explanations where effects are averaged when the x values are identical
|
||||
explanations = [];
|
||||
let laste, copye;
|
||||
for (let i = 0; i < explanations2.length; ++i) {
|
||||
let e = explanations2[i];
|
||||
if (
|
||||
(laste && (!copye && e.xmap - laste.xmap <= binSize)) ||
|
||||
(copye && e.xmap - copye.xmap <= binSize)
|
||||
) {
|
||||
if (!copye) {
|
||||
copye = cloneDeep(laste);
|
||||
copye.count = 1;
|
||||
}
|
||||
for (let j of this.usedFeatures) {
|
||||
copye.features[j].effect += e.features[j].effect;
|
||||
copye.features[j].value += e.features[j].value;
|
||||
}
|
||||
copye.count += 1;
|
||||
} else if (laste) {
|
||||
if (copye) {
|
||||
for (let j of this.usedFeatures) {
|
||||
copye.features[j].effect /= copye.count;
|
||||
copye.features[j].value /= copye.count;
|
||||
}
|
||||
explanations.push(copye);
|
||||
copye = undefined;
|
||||
} else {
|
||||
explanations.push(laste);
|
||||
}
|
||||
}
|
||||
laste = e;
|
||||
}
|
||||
if (laste.xmap - explanations[explanations.length - 1].xmap > binSize) {
|
||||
explanations.push(laste);
|
||||
}
|
||||
}
|
||||
|
||||
// adjust for the correct y-value we are plotting
|
||||
this.currUsedFeatures = this.usedFeatures;
|
||||
this.currPosOrderedFeatures = this.posOrderedFeatures;
|
||||
this.currNegOrderedFeatures = this.negOrderedFeatures;
|
||||
//let filteredFeatureNames = this.props.featureNames;
|
||||
let yvalue = this.ylabel.node().value;
|
||||
if (yvalue !== "model output value") {
|
||||
let olde = explanations;
|
||||
explanations = cloneDeep(explanations); // TODO: add pointer from old explanations which is prop.explanations to new ones
|
||||
let ind = findKey(this.props.featureNames, x => x === yvalue);
|
||||
|
||||
for (let i = 0; i < explanations.length; ++i) {
|
||||
let v = explanations[i].features[ind];
|
||||
explanations[i].features = {};
|
||||
explanations[i].features[ind] = v;
|
||||
olde[i].remapped_version = explanations[i];
|
||||
}
|
||||
//filteredFeatureNames = [this.props.featureNames[ind]];
|
||||
this.currUsedFeatures = [ind];
|
||||
this.currPosOrderedFeatures = [ind];
|
||||
this.currNegOrderedFeatures = [ind];
|
||||
}
|
||||
this.currExplanations = explanations;
|
||||
|
||||
// determine the link function
|
||||
if (this.props.link === "identity") {
|
||||
// assume all links are the same
|
||||
this.invLinkFunction = x => this.props.baseValue + x;
|
||||
} else if (this.props.link === "logit") {
|
||||
this.invLinkFunction = x =>
|
||||
1 / (1 + Math.exp(-(this.props.baseValue + x))); // logistic is inverse of logit
|
||||
} else {
|
||||
console.log("ERROR: Unrecognized link function: ", this.props.link);
|
||||
}
|
||||
|
||||
this.predValues = map(explanations, e =>
|
||||
sum(map(e.features, x => x.effect))
|
||||
);
|
||||
|
||||
let width = this.wrapper.node().offsetWidth;
|
||||
if (width == 0) return setTimeout(() => this.draw(explanations), 500);
|
||||
|
||||
this.svg.style("height", this.height + "px");
|
||||
this.svg.style("width", width + "px");
|
||||
|
||||
let xvals = map(explanations, x => x.xmap);
|
||||
this.xscale
|
||||
.domain([min(xvals), max(xvals)])
|
||||
.range([this.leftOffset, width])
|
||||
.clamp(true);
|
||||
this.xaxisElement
|
||||
.attr("transform", "translate(0," + this.topOffset + ")")
|
||||
.call(this.xaxis);
|
||||
|
||||
for (let i = 0; i < this.currExplanations.length; ++i) {
|
||||
this.currExplanations[i].xmapScaled = this.xscale(
|
||||
this.currExplanations[i].xmap
|
||||
);
|
||||
}
|
||||
|
||||
let N = explanations.length;
|
||||
let domainSize = 0;
|
||||
for (let ind = 0; ind < N; ++ind) {
|
||||
let data2 = explanations[ind].features;
|
||||
//if (data2.length !== P) error("Explanations have differing numbers of features!");
|
||||
let totalPosEffects =
|
||||
sum(map(filter(data2, x => x.effect > 0), x => x.effect)) || 0;
|
||||
let totalNegEffects =
|
||||
sum(map(filter(data2, x => x.effect < 0), x => -x.effect)) || 0;
|
||||
domainSize = Math.max(
|
||||
domainSize,
|
||||
Math.max(totalPosEffects, totalNegEffects) * 2.2
|
||||
);
|
||||
}
|
||||
this.yscale
|
||||
.domain([-domainSize / 2, domainSize / 2])
|
||||
.range([this.height - 10, this.topOffset]);
|
||||
this.yaxisElement
|
||||
.attr("transform", "translate(" + this.leftOffset + ",0)")
|
||||
.call(this.yaxis);
|
||||
|
||||
for (let ind = 0; ind < N; ++ind) {
|
||||
let data2 = explanations[ind].features;
|
||||
//console.log(length(data2))
|
||||
|
||||
let totalNegEffects =
|
||||
sum(map(filter(data2, x => x.effect < 0), x => -x.effect)) || 0;
|
||||
|
||||
//let scaleOffset = height/2 - this.yscale(totalNegEffects);
|
||||
|
||||
// calculate the position of the join point between positive and negative effects
|
||||
// and also the positions of each feature effect block
|
||||
let pos = -totalNegEffects,
|
||||
i;
|
||||
for (i of this.currPosOrderedFeatures) {
|
||||
data2[i].posyTop = this.yscale(pos);
|
||||
if (data2[i].effect > 0) pos += data2[i].effect;
|
||||
data2[i].posyBottom = this.yscale(pos);
|
||||
data2[i].ind = i;
|
||||
}
|
||||
let joinPoint = pos;
|
||||
for (i of this.currNegOrderedFeatures) {
|
||||
data2[i].negyTop = this.yscale(pos);
|
||||
if (data2[i].effect < 0) pos -= data2[i].effect;
|
||||
data2[i].negyBottom = this.yscale(pos);
|
||||
}
|
||||
explanations[ind].joinPoint = joinPoint;
|
||||
explanations[ind].joinPointy = this.yscale(joinPoint);
|
||||
}
|
||||
|
||||
let lineFunction = line()
|
||||
.x(d => d[0])
|
||||
.y(d => d[1]);
|
||||
|
||||
let areasPos = this.mainGroup
|
||||
.selectAll(".force-bar-array-area-pos")
|
||||
.data(this.currUsedFeatures);
|
||||
areasPos
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("class", "force-bar-array-area-pos")
|
||||
.merge(areasPos)
|
||||
.attr("d", i => {
|
||||
let topPoints = map(range(N), j => [
|
||||
explanations[j].xmapScaled,
|
||||
explanations[j].features[i].posyTop
|
||||
]);
|
||||
let bottomPoints = map(rangeRight(N), j => [
|
||||
explanations[j].xmapScaled,
|
||||
explanations[j].features[i].posyBottom
|
||||
]);
|
||||
return lineFunction(topPoints.concat(bottomPoints));
|
||||
})
|
||||
.attr("fill", this.colors[0]);
|
||||
areasPos.exit().remove();
|
||||
|
||||
let areasNeg = this.mainGroup
|
||||
.selectAll(".force-bar-array-area-neg")
|
||||
.data(this.currUsedFeatures);
|
||||
areasNeg
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("class", "force-bar-array-area-neg")
|
||||
.merge(areasNeg)
|
||||
.attr("d", i => {
|
||||
let topPoints = map(range(N), j => [
|
||||
explanations[j].xmapScaled,
|
||||
explanations[j].features[i].negyTop
|
||||
]);
|
||||
let bottomPoints = map(rangeRight(N), j => [
|
||||
explanations[j].xmapScaled,
|
||||
explanations[j].features[i].negyBottom
|
||||
]);
|
||||
return lineFunction(topPoints.concat(bottomPoints));
|
||||
})
|
||||
.attr("fill", this.colors[1]);
|
||||
areasNeg.exit().remove();
|
||||
|
||||
let dividersPos = this.mainGroup
|
||||
.selectAll(".force-bar-array-divider-pos")
|
||||
.data(this.currUsedFeatures);
|
||||
dividersPos
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("class", "force-bar-array-divider-pos")
|
||||
.merge(dividersPos)
|
||||
.attr("d", i => {
|
||||
let points = map(range(N), j => [
|
||||
explanations[j].xmapScaled,
|
||||
explanations[j].features[i].posyBottom
|
||||
]);
|
||||
return lineFunction(points);
|
||||
})
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke", () => this.colors[0].brighter(1.2));
|
||||
dividersPos.exit().remove();
|
||||
|
||||
let dividersNeg = this.mainGroup
|
||||
.selectAll(".force-bar-array-divider-neg")
|
||||
.data(this.currUsedFeatures);
|
||||
dividersNeg
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("class", "force-bar-array-divider-neg")
|
||||
.merge(dividersNeg)
|
||||
.attr("d", i => {
|
||||
let points = map(range(N), j => [
|
||||
explanations[j].xmapScaled,
|
||||
explanations[j].features[i].negyTop
|
||||
]);
|
||||
return lineFunction(points);
|
||||
})
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke", () => this.colors[1].brighter(1.5));
|
||||
dividersNeg.exit().remove();
|
||||
|
||||
let boxBounds = function(es, ind, starti, endi, featType) {
|
||||
let maxTop, minBottom;
|
||||
if (featType === "pos") {
|
||||
maxTop = es[starti].features[ind].posyBottom;
|
||||
minBottom = es[starti].features[ind].posyTop;
|
||||
} else {
|
||||
maxTop = es[starti].features[ind].negyBottom;
|
||||
minBottom = es[starti].features[ind].negyTop;
|
||||
}
|
||||
let t, b;
|
||||
for (let i = starti + 1; i <= endi; ++i) {
|
||||
if (featType === "pos") {
|
||||
t = es[i].features[ind].posyBottom;
|
||||
b = es[i].features[ind].posyTop;
|
||||
} else {
|
||||
t = es[i].features[ind].negyBottom;
|
||||
b = es[i].features[ind].negyTop;
|
||||
}
|
||||
if (t > maxTop) maxTop = t;
|
||||
if (b < minBottom) minBottom = b;
|
||||
}
|
||||
return { top: maxTop, bottom: minBottom };
|
||||
};
|
||||
|
||||
let neededWidth = 100;
|
||||
let neededHeight = 20;
|
||||
let neededBuffer = 100;
|
||||
|
||||
// find areas on the plot big enough for feature labels
|
||||
let featureLabels = [];
|
||||
for (let featType of ["pos", "neg"]) {
|
||||
for (let ind of this.currUsedFeatures) {
|
||||
let starti = 0,
|
||||
endi = 0,
|
||||
boxWidth = 0,
|
||||
hbounds = { top: 0, bottom: 0 };
|
||||
let newHbounds;
|
||||
while (endi < N - 1) {
|
||||
// make sure our box is long enough
|
||||
while (boxWidth < neededWidth && endi < N - 1) {
|
||||
++endi;
|
||||
boxWidth =
|
||||
explanations[endi].xmapScaled - explanations[starti].xmapScaled;
|
||||
}
|
||||
|
||||
// and high enough
|
||||
hbounds = boxBounds(explanations, ind, starti, endi, featType);
|
||||
while (hbounds.bottom - hbounds.top < neededHeight && starti < endi) {
|
||||
++starti;
|
||||
hbounds = boxBounds(explanations, ind, starti, endi, featType);
|
||||
}
|
||||
boxWidth =
|
||||
explanations[endi].xmapScaled - explanations[starti].xmapScaled;
|
||||
|
||||
// we found a spot!
|
||||
if (
|
||||
hbounds.bottom - hbounds.top >= neededHeight &&
|
||||
boxWidth >= neededWidth
|
||||
) {
|
||||
//console.log(`found a spot! ind: ${ind}, starti: ${starti}, endi: ${endi}, hbounds:`, hbounds)
|
||||
// make our box as long as possible
|
||||
while (endi < N - 1) {
|
||||
++endi;
|
||||
newHbounds = boxBounds(explanations, ind, starti, endi, featType);
|
||||
if (newHbounds.bottom - newHbounds.top > neededHeight) {
|
||||
hbounds = newHbounds;
|
||||
} else {
|
||||
--endi;
|
||||
break;
|
||||
}
|
||||
}
|
||||
boxWidth =
|
||||
explanations[endi].xmapScaled - explanations[starti].xmapScaled;
|
||||
//console.log("found ",boxWidth,hbounds)
|
||||
|
||||
featureLabels.push([
|
||||
(explanations[endi].xmapScaled +
|
||||
explanations[starti].xmapScaled) /
|
||||
2,
|
||||
(hbounds.top + hbounds.bottom) / 2,
|
||||
this.props.featureNames[ind]
|
||||
]);
|
||||
|
||||
let lastEnd = explanations[endi].xmapScaled;
|
||||
starti = endi;
|
||||
while (
|
||||
lastEnd + neededBuffer > explanations[starti].xmapScaled &&
|
||||
starti < N - 1
|
||||
) {
|
||||
++starti;
|
||||
}
|
||||
endi = starti;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let featureLabelText = this.onTopGroup
|
||||
.selectAll(".force-bar-array-flabels")
|
||||
.data(featureLabels);
|
||||
featureLabelText
|
||||
.enter()
|
||||
.append("text")
|
||||
.attr("class", "force-bar-array-flabels")
|
||||
.merge(featureLabelText)
|
||||
.attr("x", d => d[0])
|
||||
.attr("y", d => d[1] + 4)
|
||||
.text(d => d[2]);
|
||||
featureLabelText.exit().remove();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("resize", this.redraw);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
ref={x => (this.wrapper = select(x))}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
.force-bar-array-wrapper {
|
||||
text-align: center;
|
||||
}
|
||||
.force-bar-array-xaxis path {
|
||||
fill: none;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.force-bar-array-xaxis .domain {
|
||||
opacity: 0;
|
||||
}
|
||||
.force-bar-array-xaxis paths {
|
||||
display: none;
|
||||
}
|
||||
.force-bar-array-yaxis path {
|
||||
fill: none;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.force-bar-array-yaxis paths {
|
||||
display: none;
|
||||
}
|
||||
.tick line {
|
||||
stroke: #000;
|
||||
stroke-width: 1px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.tick text {
|
||||
fill: #000;
|
||||
opacity: 0.5;
|
||||
font-size: 12px;
|
||||
padding: 0px;
|
||||
}
|
||||
.force-bar-array-flabels {
|
||||
font-size: 12px;
|
||||
fill: #fff;
|
||||
text-anchor: middle;
|
||||
}
|
||||
.additive-force-array-xlabel {
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 0px;
|
||||
font-size: 12px;
|
||||
font-family: arial;
|
||||
margin-left: 80px;
|
||||
max-width: 300px;
|
||||
}
|
||||
.additive-force-array-xlabel:focus {
|
||||
outline: none;
|
||||
}
|
||||
.additive-force-array-ylabel {
|
||||
position: relative;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
transform: rotate(-90deg);
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 0px;
|
||||
font-size: 12px;
|
||||
font-family: arial;
|
||||
max-width: 150px;
|
||||
}
|
||||
.additive-force-array-ylabel:focus {
|
||||
outline: none;
|
||||
}
|
||||
.additive-force-array-hoverLine {
|
||||
stroke-width: 1px;
|
||||
stroke: #fff;
|
||||
opacity: 1;
|
||||
}`
|
||||
}}
|
||||
/>
|
||||
<select className="additive-force-array-xlabel" />
|
||||
<div style={{ height: "0px", textAlign: "left" }}>
|
||||
<select className="additive-force-array-ylabel" />
|
||||
</div>
|
||||
<svg
|
||||
ref={x => (this.svg = select(x))}
|
||||
style={{
|
||||
userSelect: "none",
|
||||
display: "block",
|
||||
fontFamily: "arial",
|
||||
sansSerif: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AdditiveForceArrayVisualizer.defaultProps = {
|
||||
plot_cmap: "RdBu",
|
||||
ordering_keys: null,
|
||||
ordering_keys_time_format: null
|
||||
};
|
||||
|
||||
export default AdditiveForceArrayVisualizer;
|
||||
@@ -0,0 +1,611 @@
|
||||
import React from "react";
|
||||
import { select } from "d3-selection";
|
||||
import { scaleLinear } from "d3-scale";
|
||||
import { format } from "d3-format";
|
||||
import { axisBottom } from "d3-axis";
|
||||
import { line } from "d3-shape";
|
||||
import { hsl } from "d3-color";
|
||||
import {
|
||||
sortBy,
|
||||
map,
|
||||
each,
|
||||
sum,
|
||||
filter,
|
||||
findIndex,
|
||||
debounce
|
||||
} from "lodash";
|
||||
import colors from "./color-set";
|
||||
|
||||
class AdditiveForceVisualizer extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
window.lastAdditiveForceVisualizer = this;
|
||||
this.effectFormat = format(".2");
|
||||
this.redraw = debounce(() => this.draw(), 200);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// create our permanent elements
|
||||
this.mainGroup = this.svg.append("g");
|
||||
this.axisElement = this.mainGroup
|
||||
.append("g")
|
||||
.attr("transform", "translate(0,35)")
|
||||
.attr("class", "force-bar-axis");
|
||||
this.onTopGroup = this.svg.append("g");
|
||||
this.baseValueTitle = this.svg.append("text");
|
||||
this.joinPointLine = this.svg.append("line");
|
||||
this.joinPointLabelOutline = this.svg.append("text");
|
||||
this.joinPointLabel = this.svg.append("text");
|
||||
this.joinPointTitleLeft = this.svg.append("text");
|
||||
this.joinPointTitleLeftArrow = this.svg.append("text");
|
||||
this.joinPointTitle = this.svg.append("text");
|
||||
this.joinPointTitleRightArrow = this.svg.append("text");
|
||||
this.joinPointTitleRight = this.svg.append("text");
|
||||
|
||||
// Define the tooltip objects
|
||||
this.hoverLabelBacking = this.svg.append("text")
|
||||
.attr("x", 10)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("font-size", 12)
|
||||
.attr("stroke", "#fff")
|
||||
.attr("fill", "#fff")
|
||||
.attr("stroke-width", "4")
|
||||
.attr("stroke-linejoin", "round")
|
||||
.text("")
|
||||
.on("mouseover", () => {
|
||||
this.hoverLabel.attr("opacity", 1);
|
||||
this.hoverLabelBacking.attr("opacity", 1);
|
||||
})
|
||||
.on("mouseout", () => {
|
||||
this.hoverLabel.attr("opacity", 0);
|
||||
this.hoverLabelBacking.attr("opacity", 0);
|
||||
});
|
||||
this.hoverLabel = this.svg.append("text")
|
||||
.attr("x", 10)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("font-size", 12)
|
||||
.attr("fill", "#0f0")
|
||||
.text("")
|
||||
.on("mouseover", () => {
|
||||
this.hoverLabel.attr("opacity", 1);
|
||||
this.hoverLabelBacking.attr("opacity", 1);
|
||||
})
|
||||
.on("mouseout", () => {
|
||||
this.hoverLabel.attr("opacity", 0);
|
||||
this.hoverLabelBacking.attr("opacity", 0);
|
||||
});
|
||||
|
||||
// Create our colors and color gradients
|
||||
// Verify custom color map
|
||||
let plot_colors=undefined;
|
||||
if (typeof this.props.plot_cmap === "string")
|
||||
{
|
||||
if (!(this.props.plot_cmap in colors.colors))
|
||||
{
|
||||
console.log("Invalid color map name, reverting to default.");
|
||||
plot_colors=colors.colors.RdBu;
|
||||
}
|
||||
else
|
||||
{
|
||||
plot_colors = colors.colors[this.props.plot_cmap]
|
||||
}
|
||||
}
|
||||
else if (Array.isArray(this.props.plot_cmap)){
|
||||
plot_colors = this.props.plot_cmap
|
||||
}
|
||||
this.colors = plot_colors.map(x => hsl(x));
|
||||
this.brighterColors = [1.45, 1.6].map((v, i) => this.colors[i].brighter(v));
|
||||
this.colors.map((c, i) => {
|
||||
let grad = this.svg
|
||||
.append("linearGradient")
|
||||
.attr("id", "linear-grad-" + i)
|
||||
.attr("x1", "0%")
|
||||
.attr("y1", "0%")
|
||||
.attr("x2", "0%")
|
||||
.attr("y2", "100%");
|
||||
grad
|
||||
.append("stop")
|
||||
.attr("offset", "0%")
|
||||
.attr("stop-color", c)
|
||||
.attr("stop-opacity", 0.6);
|
||||
grad
|
||||
.append("stop")
|
||||
.attr("offset", "100%")
|
||||
.attr("stop-color", c)
|
||||
.attr("stop-opacity", 0);
|
||||
let grad2 = this.svg
|
||||
.append("linearGradient")
|
||||
.attr("id", "linear-backgrad-" + i)
|
||||
.attr("x1", "0%")
|
||||
.attr("y1", "0%")
|
||||
.attr("x2", "0%")
|
||||
.attr("y2", "100%");
|
||||
grad2
|
||||
.append("stop")
|
||||
.attr("offset", "0%")
|
||||
.attr("stop-color", c)
|
||||
.attr("stop-opacity", 0.5);
|
||||
grad2
|
||||
.append("stop")
|
||||
.attr("offset", "100%")
|
||||
.attr("stop-color", c)
|
||||
.attr("stop-opacity", 0);
|
||||
});
|
||||
|
||||
// create our x axis
|
||||
this.tickFormat = format(",.4");
|
||||
this.scaleCentered = scaleLinear();
|
||||
this.axis = axisBottom()
|
||||
.scale(this.scaleCentered)
|
||||
.tickSizeInner(4)
|
||||
.tickSizeOuter(0)
|
||||
.tickFormat(d => this.tickFormat(this.invLinkFunction(d)))
|
||||
.tickPadding(-18);
|
||||
|
||||
// draw and then listen for resize events
|
||||
//this.draw();
|
||||
window.addEventListener("resize", this.redraw);
|
||||
window.setTimeout(this.redraw, 50); // re-draw after interface has updated
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
draw() {
|
||||
// copy the feature names onto the features
|
||||
each(this.props.featureNames, (n, i) => {
|
||||
if (this.props.features[i]) this.props.features[i].name = n;
|
||||
});
|
||||
|
||||
// create our link function
|
||||
if (this.props.link === "identity") {
|
||||
this.invLinkFunction = x => this.props.baseValue + x;
|
||||
} else if (this.props.link === "logit") {
|
||||
this.invLinkFunction = x =>
|
||||
1 / (1 + Math.exp(-(this.props.baseValue + x))); // logistic is inverse of logit
|
||||
} else {
|
||||
console.log("ERROR: Unrecognized link function: ", this.props.link);
|
||||
}
|
||||
|
||||
// Set the dimensions of the plot
|
||||
let width = this.svg.node().parentNode.offsetWidth;
|
||||
if (width == 0) return setTimeout(() => this.draw(this.props), 500);
|
||||
this.svg.style("height", 150 + "px");
|
||||
this.svg.style("width", width + "px");
|
||||
let topOffset = 50;
|
||||
|
||||
let data = sortBy(this.props.features, x => -1 / (x.effect + 1e-10));
|
||||
let totalEffect = sum(map(data, x => Math.abs(x.effect)));
|
||||
let totalPosEffects =
|
||||
sum(map(filter(data, x => x.effect > 0), x => x.effect)) || 0;
|
||||
let totalNegEffects =
|
||||
sum(map(filter(data, x => x.effect < 0), x => -x.effect)) || 0;
|
||||
this.domainSize = Math.max(totalPosEffects, totalNegEffects) * 3;
|
||||
let scale = scaleLinear()
|
||||
.domain([0, this.domainSize])
|
||||
.range([0, width]);
|
||||
let scaleOffset = width / 2 - scale(totalNegEffects);
|
||||
|
||||
this.scaleCentered
|
||||
.domain([-this.domainSize / 2, this.domainSize / 2])
|
||||
.range([0, width])
|
||||
.clamp(true);
|
||||
this.axisElement
|
||||
.attr("transform", "translate(0," + topOffset + ")")
|
||||
.call(this.axis);
|
||||
|
||||
// calculate the position of the join point between positive and negative effects
|
||||
// and also the positions of each feature effect block
|
||||
let pos = 0,
|
||||
i,
|
||||
joinPoint,
|
||||
joinPointIndex;
|
||||
for (i = 0; i < data.length; ++i) {
|
||||
data[i].x = pos;
|
||||
if (data[i].effect < 0 && joinPoint === undefined) {
|
||||
joinPoint = pos;
|
||||
joinPointIndex = i;
|
||||
}
|
||||
pos += Math.abs(data[i].effect);
|
||||
}
|
||||
if (joinPoint === undefined) {
|
||||
joinPoint = pos;
|
||||
joinPointIndex = i;
|
||||
}
|
||||
|
||||
let lineFunction = line()
|
||||
.x(d => d[0])
|
||||
.y(d => d[1]);
|
||||
|
||||
let getLabel = d => {
|
||||
if (d.value !== undefined && d.value !== null && d.value !== "") {
|
||||
return (
|
||||
d.name +
|
||||
" = " +
|
||||
(isNaN(d.value) ? d.value : this.tickFormat(d.value))
|
||||
);
|
||||
} else return d.name;
|
||||
};
|
||||
|
||||
data = this.props.hideBars ? [] : data;
|
||||
let blocks = this.mainGroup.selectAll(".force-bar-blocks").data(data);
|
||||
blocks
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("class", "force-bar-blocks")
|
||||
.merge(blocks)
|
||||
.attr("d", (d, i) => {
|
||||
let x = scale(d.x) + scaleOffset;
|
||||
let w = scale(Math.abs(d.effect));
|
||||
let pointShiftStart = d.effect < 0 ? -4 : 4;
|
||||
let pointShiftEnd = pointShiftStart;
|
||||
if (i === joinPointIndex) pointShiftStart = 0;
|
||||
if (i === joinPointIndex - 1) pointShiftEnd = 0;
|
||||
return lineFunction([
|
||||
[x, 6 + topOffset],
|
||||
[x + w, 6 + topOffset],
|
||||
[x + w + pointShiftEnd, 14.5 + topOffset],
|
||||
[x + w, 23 + topOffset],
|
||||
[x, 23 + topOffset],
|
||||
[x + pointShiftStart, 14.5 + topOffset]
|
||||
]);
|
||||
})
|
||||
.attr("fill", d => (d.effect > 0 ? this.colors[0] : this.colors[1]))
|
||||
.on("mouseover", d => {
|
||||
if (scale(Math.abs(d.effect)) < scale(totalEffect) / 50 ||
|
||||
scale(Math.abs(d.effect)) < 10) {
|
||||
let x = scale(d.x) + scaleOffset;
|
||||
let w = scale(Math.abs(d.effect));
|
||||
this.hoverLabel
|
||||
.attr("opacity", 1)
|
||||
.attr("x", x + w/2)
|
||||
.attr("y", topOffset + 0.5)
|
||||
.attr("fill", d.effect > 0 ? this.colors[0] : this.colors[1])
|
||||
.text(getLabel(d));
|
||||
this.hoverLabelBacking
|
||||
.attr("opacity", 1)
|
||||
.attr("x", x + w/2)
|
||||
.attr("y", topOffset + 0.5)
|
||||
.text(getLabel(d));
|
||||
}
|
||||
})
|
||||
.on("mouseout", () => {
|
||||
this.hoverLabel.attr("opacity", 0);
|
||||
this.hoverLabelBacking.attr("opacity", 0);
|
||||
});
|
||||
blocks.exit().remove();
|
||||
|
||||
let filteredData = filter(data, d => {
|
||||
return (
|
||||
scale(Math.abs(d.effect)) > scale(totalEffect) / 50 &&
|
||||
scale(Math.abs(d.effect)) > 10
|
||||
);
|
||||
});
|
||||
|
||||
let labels = this.onTopGroup
|
||||
.selectAll(".force-bar-labels")
|
||||
.data(filteredData);
|
||||
labels.exit().remove();
|
||||
labels = labels
|
||||
.enter()
|
||||
.append("text")
|
||||
.attr("class", "force-bar-labels")
|
||||
.attr("font-size", "12px")
|
||||
.attr("y", 48 + topOffset)
|
||||
.merge(labels)
|
||||
.text(d => {
|
||||
if (d.value !== undefined && d.value !== null && d.value !== "") {
|
||||
return (
|
||||
d.name +
|
||||
" = " +
|
||||
(isNaN(d.value) ? d.value : this.tickFormat(d.value))
|
||||
);
|
||||
} else return d.name;
|
||||
})
|
||||
.attr("fill", d => (d.effect > 0 ? this.colors[0] : this.colors[1]))
|
||||
.attr("stroke", function(d) {
|
||||
d.textWidth = Math.max(
|
||||
this.getComputedTextLength(),
|
||||
scale(Math.abs(d.effect)) - 10
|
||||
);
|
||||
d.innerTextWidth = this.getComputedTextLength();
|
||||
return "none";
|
||||
});
|
||||
this.filteredData = filteredData;
|
||||
|
||||
// compute where the text labels should go
|
||||
if (data.length > 0) {
|
||||
pos = joinPoint + scale.invert(5);
|
||||
for (let i = joinPointIndex; i < data.length; ++i) {
|
||||
data[i].textx = pos;
|
||||
pos += scale.invert(data[i].textWidth + 10);
|
||||
}
|
||||
pos = joinPoint - scale.invert(5);
|
||||
for (let i = joinPointIndex - 1; i >= 0; --i) {
|
||||
data[i].textx = pos;
|
||||
pos -= scale.invert(data[i].textWidth + 10);
|
||||
}
|
||||
}
|
||||
|
||||
labels
|
||||
.attr(
|
||||
"x",
|
||||
d =>
|
||||
scale(d.textx) +
|
||||
scaleOffset +
|
||||
(d.effect > 0 ? -d.textWidth / 2 : d.textWidth / 2)
|
||||
)
|
||||
.attr("text-anchor", "middle"); //d => d.effect > 0 ? 'end' : 'start');
|
||||
|
||||
// Now that we know the text widths we further filter by what fits on the screen
|
||||
filteredData = filter(filteredData, d => {
|
||||
return (
|
||||
scale(d.textx) + scaleOffset > this.props.labelMargin &&
|
||||
scale(d.textx) + scaleOffset < width - this.props.labelMargin
|
||||
);
|
||||
});
|
||||
this.filteredData2 = filteredData;
|
||||
|
||||
// Build an array with one extra feature added
|
||||
let filteredDataPlusOne = filteredData.slice();
|
||||
let ind = findIndex(data, filteredData[0]) - 1;
|
||||
if (ind >= 0) filteredDataPlusOne.unshift(data[ind]);
|
||||
|
||||
let labelBacking = this.mainGroup
|
||||
.selectAll(".force-bar-labelBacking")
|
||||
.data(filteredData);
|
||||
labelBacking
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("class", "force-bar-labelBacking")
|
||||
.attr("stroke", "none")
|
||||
.attr("opacity", 0.2)
|
||||
.merge(labelBacking)
|
||||
.attr("d", d => {
|
||||
return lineFunction([
|
||||
[
|
||||
scale(d.x) + scale(Math.abs(d.effect)) + scaleOffset,
|
||||
23 + topOffset
|
||||
],
|
||||
[
|
||||
(d.effect > 0 ? scale(d.textx) : scale(d.textx) + d.textWidth) +
|
||||
scaleOffset +
|
||||
5,
|
||||
33 + topOffset
|
||||
],
|
||||
[
|
||||
(d.effect > 0 ? scale(d.textx) : scale(d.textx) + d.textWidth) +
|
||||
scaleOffset +
|
||||
5,
|
||||
54 + topOffset
|
||||
],
|
||||
[
|
||||
(d.effect > 0 ? scale(d.textx) - d.textWidth : scale(d.textx)) +
|
||||
scaleOffset -
|
||||
5,
|
||||
54 + topOffset
|
||||
],
|
||||
[
|
||||
(d.effect > 0 ? scale(d.textx) - d.textWidth : scale(d.textx)) +
|
||||
scaleOffset -
|
||||
5,
|
||||
33 + topOffset
|
||||
],
|
||||
[scale(d.x) + scaleOffset, 23 + topOffset]
|
||||
]);
|
||||
})
|
||||
.attr("fill", d => `url(#linear-backgrad-${d.effect > 0 ? 0 : 1})`);
|
||||
labelBacking.exit().remove();
|
||||
|
||||
let labelDividers = this.mainGroup
|
||||
.selectAll(".force-bar-labelDividers")
|
||||
.data(filteredData.slice(0, -1));
|
||||
labelDividers
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("class", "force-bar-labelDividers")
|
||||
.attr("height", "21px")
|
||||
.attr("width", "1px")
|
||||
.attr("y", 33 + topOffset)
|
||||
.merge(labelDividers)
|
||||
.attr(
|
||||
"x",
|
||||
d =>
|
||||
(d.effect > 0 ? scale(d.textx) : scale(d.textx) + d.textWidth) +
|
||||
scaleOffset +
|
||||
4.5
|
||||
)
|
||||
.attr("fill", d => `url(#linear-grad-${d.effect > 0 ? 0 : 1})`);
|
||||
labelDividers.exit().remove();
|
||||
|
||||
let labelLinks = this.mainGroup
|
||||
.selectAll(".force-bar-labelLinks")
|
||||
.data(filteredData.slice(0, -1));
|
||||
labelLinks
|
||||
.enter()
|
||||
.append("line")
|
||||
.attr("class", "force-bar-labelLinks")
|
||||
.attr("y1", 23 + topOffset)
|
||||
.attr("y2", 33 + topOffset)
|
||||
.attr("stroke-opacity", 0.5)
|
||||
.attr("stroke-width", 1)
|
||||
.merge(labelLinks)
|
||||
.attr("x1", d => scale(d.x) + scale(Math.abs(d.effect)) + scaleOffset)
|
||||
.attr(
|
||||
"x2",
|
||||
d =>
|
||||
(d.effect > 0 ? scale(d.textx) : scale(d.textx) + d.textWidth) +
|
||||
scaleOffset +
|
||||
5
|
||||
)
|
||||
.attr("stroke", d => (d.effect > 0 ? this.colors[0] : this.colors[1]));
|
||||
labelLinks.exit().remove();
|
||||
|
||||
let blockDividers = this.mainGroup
|
||||
.selectAll(".force-bar-blockDividers")
|
||||
.data(data.slice(0, -1));
|
||||
blockDividers
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("class", "force-bar-blockDividers")
|
||||
.attr("stroke-width", 2)
|
||||
.attr("fill", "none")
|
||||
.merge(blockDividers)
|
||||
.attr("d", d => {
|
||||
let pos = scale(d.x) + scale(Math.abs(d.effect)) + scaleOffset;
|
||||
return lineFunction([
|
||||
[pos, 6 + topOffset],
|
||||
[pos + (d.effect < 0 ? -4 : 4), 14.5 + topOffset],
|
||||
[pos, 23 + topOffset]
|
||||
]);
|
||||
})
|
||||
.attr("stroke", (d, i) => {
|
||||
if (joinPointIndex === i + 1 || Math.abs(d.effect) < 1e-8)
|
||||
return "#rgba(0,0,0,0)";
|
||||
else if (d.effect > 0) return this.brighterColors[0];
|
||||
else return this.brighterColors[1];
|
||||
});
|
||||
blockDividers.exit().remove();
|
||||
|
||||
this.joinPointLine
|
||||
.attr("x1", scale(joinPoint) + scaleOffset)
|
||||
.attr("x2", scale(joinPoint) + scaleOffset)
|
||||
.attr("y1", 0 + topOffset)
|
||||
.attr("y2", 6 + topOffset)
|
||||
.attr("stroke", "#F2F2F2")
|
||||
.attr("stroke-width", 1)
|
||||
.attr("opacity", 1);
|
||||
|
||||
this.joinPointLabelOutline
|
||||
.attr("x", scale(joinPoint) + scaleOffset)
|
||||
.attr("y", -5 + topOffset)
|
||||
.attr("color", "#fff")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("stroke", "#fff")
|
||||
.attr("stroke-width", 6)
|
||||
.text(format(",.2f")(this.invLinkFunction(joinPoint - totalNegEffects)))
|
||||
.attr("opacity", 1);
|
||||
console.log(
|
||||
"joinPoint",
|
||||
joinPoint,
|
||||
scaleOffset,
|
||||
topOffset,
|
||||
totalNegEffects
|
||||
);
|
||||
this.joinPointLabel
|
||||
.attr("x", scale(joinPoint) + scaleOffset)
|
||||
.attr("y", -5 + topOffset)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("fill", "#000")
|
||||
.text(format(",.2f")(this.invLinkFunction(joinPoint - totalNegEffects)))
|
||||
.attr("opacity", 1);
|
||||
|
||||
this.joinPointTitle
|
||||
.attr("x", scale(joinPoint) + scaleOffset)
|
||||
.attr("y", -22 + topOffset)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("font-size", "12")
|
||||
.attr("fill", "#000")
|
||||
.text(this.props.outNames[0])
|
||||
.attr("opacity", 0.5);
|
||||
|
||||
if (!this.props.hideBars) {
|
||||
this.joinPointTitleLeft
|
||||
.attr("x", scale(joinPoint) + scaleOffset - 16)
|
||||
.attr("y", -38 + topOffset)
|
||||
.attr("text-anchor", "end")
|
||||
.attr("font-size", "13")
|
||||
.attr("fill", this.colors[0])
|
||||
.text("higher")
|
||||
.attr("opacity", 1.0);
|
||||
this.joinPointTitleRight
|
||||
.attr("x", scale(joinPoint) + scaleOffset + 16)
|
||||
.attr("y", -38 + topOffset)
|
||||
.attr("text-anchor", "start")
|
||||
.attr("font-size", "13")
|
||||
.attr("fill", this.colors[1])
|
||||
.text("lower")
|
||||
.attr("opacity", 1.0);
|
||||
|
||||
this.joinPointTitleLeftArrow
|
||||
.attr("x", scale(joinPoint) + scaleOffset + 7)
|
||||
.attr("y", -42 + topOffset)
|
||||
.attr("text-anchor", "end")
|
||||
.attr("font-size", "13")
|
||||
.attr("fill", this.colors[0])
|
||||
.text("→")
|
||||
.attr("opacity", 1.0);
|
||||
this.joinPointTitleRightArrow
|
||||
.attr("x", scale(joinPoint) + scaleOffset - 7)
|
||||
.attr("y", -36 + topOffset)
|
||||
.attr("text-anchor", "start")
|
||||
.attr("font-size", "13")
|
||||
.attr("fill", this.colors[1])
|
||||
.text("←")
|
||||
.attr("opacity", 1.0);
|
||||
}
|
||||
if (!this.props.hideBaseValueLabel) {
|
||||
this.baseValueTitle
|
||||
.attr("x", this.scaleCentered(0))
|
||||
.attr("y", -22 + topOffset)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("font-size", "12")
|
||||
.attr("fill", "#000")
|
||||
.text("base value")
|
||||
.attr("opacity", 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("resize", this.redraw);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<svg
|
||||
ref={x => (this.svg = select(x))}
|
||||
style={{
|
||||
userSelect: "none",
|
||||
display: "block",
|
||||
fontFamily: "arial",
|
||||
sansSerif: true
|
||||
}}
|
||||
>
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
.force-bar-axis path {
|
||||
fill: none;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.force-bar-axis paths {
|
||||
display: none;
|
||||
}
|
||||
.tick line {
|
||||
stroke: #000;
|
||||
stroke-width: 1px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.tick text {
|
||||
fill: #000;
|
||||
opacity: 0.5;
|
||||
font-size: 12px;
|
||||
padding: 0px;
|
||||
}`
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AdditiveForceVisualizer.defaultProps = {
|
||||
plot_cmap: "RdBu"
|
||||
};
|
||||
|
||||
export default AdditiveForceVisualizer;
|
||||
@@ -0,0 +1,113 @@
|
||||
import React from "react";
|
||||
import { scaleLinear } from "d3-scale";
|
||||
import { format } from "d3-format";
|
||||
import { sortBy, reverse, max, map } from "lodash";
|
||||
import colors from "./color-set";
|
||||
|
||||
class SimpleListVisualizer extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.width = 100;
|
||||
window.lastSimpleListInstance = this;
|
||||
this.effectFormat = format(".2");
|
||||
}
|
||||
|
||||
render() {
|
||||
//Verify custom color map
|
||||
let plot_colors=undefined;
|
||||
if (typeof this.props.plot_cmap === "string")
|
||||
{
|
||||
if (!(this.props.plot_cmap in colors.colors))
|
||||
{
|
||||
console.log("Invalid color map name, reverting to default.");
|
||||
plot_colors=colors.colors.RdBu;
|
||||
}
|
||||
else
|
||||
{
|
||||
plot_colors = colors.colors[this.props.plot_cmap]
|
||||
}
|
||||
}
|
||||
else if (Array.isArray(this.props.plot_cmap)){
|
||||
plot_colors = this.props.plot_cmap
|
||||
}
|
||||
|
||||
console.log(this.props.features, this.props.features);
|
||||
this.scale = scaleLinear()
|
||||
.domain([0, max(map(this.props.features, x => Math.abs(x.effect)))])
|
||||
.range([0, this.width]);
|
||||
|
||||
// build the rows of the plot
|
||||
let sortedFeatureInds = reverse(
|
||||
sortBy(Object.keys(this.props.features), k =>
|
||||
Math.abs(this.props.features[k].effect)
|
||||
)
|
||||
);
|
||||
let rows = sortedFeatureInds.map(k => {
|
||||
let x = this.props.features[k];
|
||||
let name = this.props.featureNames[k];
|
||||
let style = {
|
||||
width: this.scale(Math.abs(x.effect)),
|
||||
height: "20px",
|
||||
background:
|
||||
x.effect < 0
|
||||
? plot_colors[0]
|
||||
: plot_colors[1],
|
||||
display: "inline-block"
|
||||
};
|
||||
let beforeLabel;
|
||||
let afterLabel;
|
||||
let beforeLabelStyle = {
|
||||
lineHeight: "20px",
|
||||
display: "inline-block",
|
||||
width: this.width + 40,
|
||||
verticalAlign: "top",
|
||||
marginRight: "5px",
|
||||
textAlign: "right"
|
||||
};
|
||||
let afterLabelStyle = {
|
||||
lineHeight: "20px",
|
||||
display: "inline-block",
|
||||
width: this.width + 40,
|
||||
verticalAlign: "top",
|
||||
marginLeft: "5px"
|
||||
};
|
||||
if (x.effect < 0) {
|
||||
afterLabel = <span style={afterLabelStyle}>{name}</span>;
|
||||
beforeLabelStyle.width =
|
||||
40 + this.width - this.scale(Math.abs(x.effect));
|
||||
beforeLabelStyle.textAlign = "right";
|
||||
beforeLabelStyle.color = "#999";
|
||||
beforeLabelStyle.fontSize = "13px";
|
||||
beforeLabel = (
|
||||
<span style={beforeLabelStyle}>{this.effectFormat(x.effect)}</span>
|
||||
);
|
||||
} else {
|
||||
beforeLabelStyle.textAlign = "right";
|
||||
beforeLabel = <span style={beforeLabelStyle}>{name}</span>;
|
||||
afterLabelStyle.width = 40;
|
||||
afterLabelStyle.textAlign = "left";
|
||||
afterLabelStyle.color = "#999";
|
||||
afterLabelStyle.fontSize = "13px";
|
||||
afterLabel = (
|
||||
<span style={afterLabelStyle}>{this.effectFormat(x.effect)}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={k} style={{ marginTop: "2px" }}>
|
||||
{beforeLabel}
|
||||
<div style={style} />
|
||||
{afterLabel}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return <span>{rows}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
SimpleListVisualizer.defaultProps = {
|
||||
plot_cmap: "RdBu"
|
||||
};
|
||||
|
||||
export default SimpleListVisualizer;
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
colors: {
|
||||
RdBu: ["rgb(255, 13, 87)", "rgb(30, 136, 229)"],
|
||||
GnPR: ["rgb(24, 196, 93)", "rgb(124, 82, 255)"],
|
||||
CyPU: ["#0099C6", "#990099"],
|
||||
PkYg: ["#DD4477", "#66AA00"],
|
||||
DrDb: ["#B82E2E", "#316395"],
|
||||
LpLb: ["#994499", "#22AA99"],
|
||||
YlDp: ["#AAAA11", "#6633CC"],
|
||||
OrId: ["#E67300", "#3E0099"]
|
||||
},
|
||||
gray: "#777"
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import SimpleListVisualizer from "./SimpleListVisualizer";
|
||||
import AdditiveForceVisualizer from "./AdditiveForceVisualizer";
|
||||
import AdditiveForceArrayVisualizer from "./AdditiveForceArrayVisualizer";
|
||||
|
||||
export {
|
||||
SimpleListVisualizer,
|
||||
AdditiveForceVisualizer,
|
||||
AdditiveForceArrayVisualizer
|
||||
};
|
||||
|
||||
export default {
|
||||
SimpleListVisualizer,
|
||||
AdditiveForceVisualizer,
|
||||
AdditiveForceArrayVisualizer
|
||||
};
|
||||
Reference in New Issue
Block a user