chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:52 +08:00
commit 9d4c7d16ba
528 changed files with 740585 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
!build/index.js
!visualizers
+3
View File
@@ -0,0 +1,3 @@
buildjs:
npm install && npm run build && npm run test && \
cp build/bundle.js ../shap/plots/resources/bundle.js
+22
View File
@@ -0,0 +1,22 @@
<p align="center">
<img src="https://raw.githubusercontent.com/shap/shap/master/docs/artwork/shap_header.png" width="800" />
</p>
---
**SHAP (SHapley Additive exPlanations)** is a game theoretic approach to explain the output of any machine learning model. It connects optimal credit allocation with local explanations using the classic Shapley values from game theory and their related extensions (see [papers](https://github.com/shap/shap#citations) for details and citations).
## Install
Shap can be installed from [npm](https://www.npmjs.com/package/shapjs):
<pre>
npm i shapjs
</pre>
Further details at https://github.com/shap/shap
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { targets: { node: 'current' } }]
],
};
+19
View File
@@ -0,0 +1,19 @@
## Starting the dev server (for local development)
<pre>
npm run dev
</pre>
## Building the package
<pre>
npm run build
</pre>
## Publishing the package to npm
A new shapjs version can be published to [npm](https://www.npmjs.com/package/shapjs):
<pre>
npm run pub
</pre>
+24
View File
@@ -0,0 +1,24 @@
import React from "react";
import * as ReactDOM from "react-dom/client";
import {
SimpleListVisualizer,
AdditiveForceVisualizer,
AdditiveForceArrayVisualizer
} from "./visualizers";
// Save some globals for the inline scripts to access
window.SHAP = {
SimpleListVisualizer,
AdditiveForceVisualizer,
AdditiveForceArrayVisualizer,
React: React,
ReactDOM: ReactDOM,
// Provide backward compatibility for render method
ReactDom: {
render: (element, container) => {
const root = ReactDOM.createRoot(container);
root.render(element);
return root;
}
}
};
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
moduleNameMapper: {
// Fix Jest import of D3. See https://github.com/jestjs/jest/issues/12036
'^d3$': '<rootDir>/node_modules/d3/dist/d3.min.js',
"^d3-(.*)$": `<rootDir>/node_modules/d3-$1/dist/d3-$1`,
},
testEnvironment: 'jsdom'
};
+10386
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "shapjs",
"version": "0.35.0",
"description": "Web based visualizations of SHAP explanations.",
"main": "build/index.js",
"files": [
"build/index.js",
"visualizers"
],
"scripts": {
"test": "jest --verbose ./tests",
"dev": "webpack-dev-server --mode development --open",
"build": "webpack --mode production",
"pub": "npm run build && npm publish"
},
"author": "Scott Lundberg",
"repository": "https://github.com/shap/shap",
"license": "MIT",
"dependencies": {
"d3": "^7",
"lodash": "^4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@babel/core": "^7.9.0",
"@babel/preset-env": "^7.22.9",
"@babel/preset-react": "^7.22.5",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"babel-jest": "^30.0.2",
"babel-loader": "^10.0.0",
"jest": "^30.0.3",
"jest-environment-jsdom": "^30.0.2",
"react-test-renderer": "^19.0.0",
"webpack": "^5.88.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.0.2"
}
}
+25
View File
@@ -0,0 +1,25 @@
import { range } from "lodash";
let seed = 1;
function random() {
var x = Math.sin(seed++) * 10000;
return x - Math.floor(x);
}
export default function() {
return {
outNames: ["Probability of flower"],
baseValue: 0.2,
link: "identity",
features: [
{ name: "F1", effect: 0.0, value: 1 },
{ name: "F2", effect: -0.6, value: 1 },
{ name: "F3", effect: -0.2, value: 2 },
{ name: "F4", effect: 0, value: 0 }
]
// range(20).map(i => ({
// name: 'value_'+i,
// effect: random()-0.5
// }))
};
}
+4
View File
@@ -0,0 +1,4 @@
import randExp from './random-explanation';
document.querySelector('#visualizer').draw(randExp());
document.querySelector('#visualizer2').draw(randExp());
@@ -0,0 +1,136 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`AdditiveForceVisualizer renders correctly 1`] = `
<div>
<svg
style="user-select: none; display: block; font-family: arial;"
>
<style>
.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;
}
</style>
<g>
<g
class="force-bar-axis"
transform="translate(0,35)"
/>
</g>
<g />
<text />
<line />
<text />
<text />
<text />
<text />
<text />
<text />
<text />
<text
fill="#fff"
font-size="12"
stroke="#fff"
stroke-linejoin="round"
stroke-width="4"
text-anchor="middle"
x="10"
y="20"
/>
<text
fill="#0f0"
font-size="12"
text-anchor="middle"
x="10"
y="20"
/>
<lineargradient
id="linear-grad-0"
x1="0%"
x2="0%"
y1="0%"
y2="100%"
>
<stop
offset="0%"
stop-color="rgb(255, 13, 87)"
stop-opacity="0.6"
/>
<stop
offset="100%"
stop-color="rgb(255, 13, 87)"
stop-opacity="0"
/>
</lineargradient>
<lineargradient
id="linear-backgrad-0"
x1="0%"
x2="0%"
y1="0%"
y2="100%"
>
<stop
offset="0%"
stop-color="rgb(255, 13, 87)"
stop-opacity="0.5"
/>
<stop
offset="100%"
stop-color="rgb(255, 13, 87)"
stop-opacity="0"
/>
</lineargradient>
<lineargradient
id="linear-grad-1"
x1="0%"
x2="0%"
y1="0%"
y2="100%"
>
<stop
offset="0%"
stop-color="rgb(30, 136, 229)"
stop-opacity="0.6"
/>
<stop
offset="100%"
stop-color="rgb(30, 136, 229)"
stop-opacity="0"
/>
</lineargradient>
<lineargradient
id="linear-backgrad-1"
x1="0%"
x2="0%"
y1="0%"
y2="100%"
>
<stop
offset="0%"
stop-color="rgb(30, 136, 229)"
stop-opacity="0.5"
/>
<stop
offset="100%"
stop-color="rgb(30, 136, 229)"
stop-opacity="0"
/>
</lineargradient>
</svg>
</div>
`;
+31
View File
@@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react';
import { AdditiveForceVisualizer } from "../visualizers";
import React from 'react';
describe('AdditiveForceVisualizer', () => {
it('renders correctly', () => {
// Render the component
const { container } = render(
<AdditiveForceVisualizer
baseValue={0.0}
link={"identity"}
featureNames={{
"0": "Blue",
"1": "Red",
"2": "Green",
"3": "Orange"
}}
outNames={["color rating"]}
features={{
"0": { value: 1.0, effect: 1.0 },
"1": { value: 0.0, effect: 0.5 },
"2": { value: 2.0, effect: -2.5 },
"3": { value: 2.0, effect: -0.5 }
}}
/>
);
// Compare with snapshot
expect(container).toMatchSnapshot();
});
});
@@ -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;
+13
View File
@@ -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"
};
+15
View File
@@ -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
};
+54
View File
@@ -0,0 +1,54 @@
var path = require("path");
// To build use `webpack --mode production` then copy bundle.js to resources
var buildDir = path.resolve(__dirname, "build");
var moduleConfig = {
rules: [
{
test: /\.css$/,
use: ["style-loader", "css-loader"]
},
{
test: /\.js[x]?$/,
exclude: /(node_modules)/,
loader: "babel-loader",
options: {
presets: ["@babel/preset-env", "@babel/preset-react"]
}
}
]
};
var resolveConfig = {
extensions: ["*", ".js", ".jsx", ".json"]
};
module.exports = [
{
entry: {
bundle: "./index.jsx",
test_bundle: "./test_bundle.js"
},
output: {
path: buildDir,
filename: "[name].js"
},
module: moduleConfig,
resolve: resolveConfig,
devServer: {
static: buildDir
}
},
{
entry: {
index: "./visualizers/index.jsx"
},
output: {
path: buildDir,
filename: "[name].js",
libraryTarget: "commonjs2"
},
module: moduleConfig,
resolve: resolveConfig
}
];