chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
!build/index.js
|
||||
!visualizers
|
||||
@@ -0,0 +1,3 @@
|
||||
buildjs:
|
||||
npm install && npm run build && npm run test && \
|
||||
cp build/bundle.js ../shap/plots/resources/bundle.js
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', { targets: { node: 'current' } }],
|
||||
['@babel/preset-react', { targets: { node: 'current' } }]
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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'
|
||||
};
|
||||
Generated
+10386
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// }))
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
Reference in New Issue
Block a user