Skip to content

Commit

Permalink
Node tooltips and URL history building for Network Viewer
Browse files Browse the repository at this point in the history
  • Loading branch information
aschonfeld committed Jan 25, 2021
1 parent 6b4af64 commit 1e921c7
Show file tree
Hide file tree
Showing 8 changed files with 296 additions and 66 deletions.
2 changes: 0 additions & 2 deletions dtale/query.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from six import PY3

import dtale.global_state as global_state


Expand Down
62 changes: 62 additions & 0 deletions static/__tests__/network/NetworkUrlParams-test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { shallow } from "enzyme";
import React from "react";

import { expect, it } from "@jest/globals";

import actions from "../../actions/dtale";
import NetworkUrlParams from "../../network/NetworkUrlParams";

describe("NetworkUrlParams", () => {
const { onpopstate } = window;
const { history } = global;
let result, propagateState, getParamsSpy;
const params = { to: "to", from: "from", group: "group", weight: "weight" };

beforeAll(() => {
delete window.onpopstate;
window.onpopstate = jest.fn();
});

beforeEach(() => {
propagateState = jest.fn();
getParamsSpy = jest.spyOn(actions, "getParams");
result = shallow(<NetworkUrlParams params={undefined} propagateState={propagateState} />);
});

afterEach(() => {
getParamsSpy.mockRestore();
});

afterAll(() => {
window.onpopstate = onpopstate;
Object.defineProperty(global, "history", history);
});

it("renders successfully", () => {
expect(result.html()).toBeNull();
});

it("correctly updates history", () => {
const pushState = jest.fn();
Object.defineProperty(global.history, "pushState", {
value: pushState,
});
getParamsSpy.mockReturnValue({});
result.setProps({ params });
expect(pushState).toHaveBeenLastCalledWith({}, "", "?to=to&from=from&group=group&weight=weight");
window.onpopstate();
expect(propagateState).toHaveBeenLastCalledWith({
to: null,
from: null,
group: null,
weight: null,
});
pushState.mockClear();
result.setProps({ params });
expect(pushState).not.toHaveBeenCalled();
getParamsSpy.mockReturnValue(params);
result.setProps({ params: { ...params, test: "blah" } });
expect(pushState).not.toHaveBeenCalled();
result.unmount();
});
});
31 changes: 31 additions & 0 deletions static/__tests__/network/networkUtils-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import _ from "lodash";
import vis from "vis-network/dist/vis-network";

import { expect, it } from "@jest/globals";

import * as networkUtils from "../../network/networkUtils";

describe("NetworkDisplay test", () => {
it("neighborhoodHighlight", () => {
const networkData = require("./data.json");
const nodesDataset = new vis.DataSet(networkData.nodes);
const allNodes = nodesDataset.get({ returnType: "Object" });
const node1 = networkData.nodes[0].id;
const node2 = networkData.nodes[1].id;
const node3 = networkData.nodes[2].id;
const network = {
getConnectedNodes: nodeId => (nodeId === node1 ? [node2] : [node3]),
update: () => undefined,
body: { data: { nodes: nodesDataset } },
};
expect(allNodes[node3].color).toBeUndefined();
let output = networkUtils.neighborhoodHighlight({ allNodes, highlightActive: false }, network, { nodes: [node1] });
expect(_.has(allNodes[node1], "color")).toBe(true);
expect(allNodes[node3].color).toBe("rgba(150,150,150,0.75)");

expect(output).toBe(true);
output = networkUtils.neighborhoodHighlight({ allNodes, highlightActive: true }, network, { nodes: [] });
expect(output).toBe(false);
expect(allNodes[node3].color).toBeUndefined();
});
});
51 changes: 51 additions & 0 deletions static/__tests__/popups/analysis/filters/GeoFilters-test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { mount } from "enzyme";
import React from "react";

import { expect, it } from "@jest/globals";

import GeoFilters from "../../../../popups/analysis/filters/GeoFilters";

describe("GeoFilters tests", () => {
let result, update;

beforeEach(() => {
update = jest.fn();
const props = {
col: "lat",
columns: [
{ name: "lat", coord: "lat" },
{ name: "lon", coord: "lon" },
{ name: "lat2", coord: "lat" },
{ name: "lat3", coord: "lat" },
{ name: "lon2", coord: "lon" },
],
update,
latCol: { value: "lat" },
lonCol: { value: "lon" },
};
result = mount(<GeoFilters {...props} />);
});

it("renders longitude dropdown", () => {
expect(result.find("FilterSelect").length).toBe(1);
result.find("FilterSelect").prop("selectProps").onChange({ value: "lon2" });
expect(update).toHaveBeenLastCalledWith({ lonCol: { value: "lon2" } });
});

it("renders latitude dropdown", () => {
result.setProps({ col: "lon" });
expect(result.find("FilterSelect").length).toBe(1);
result.find("FilterSelect").prop("selectProps").onChange({ value: "lat2" });
expect(update).toHaveBeenLastCalledWith({ latCol: { value: "lat2" } });
});

it("renders text", () => {
result.setProps({
columns: [
{ name: "lat", coord: "lat" },
{ name: "lon", coord: "lon" },
],
});
expect(result.text()).toBe("Latitude:latLongitude:lon");
});
});
2 changes: 1 addition & 1 deletion static/network/NetworkDisplay.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.groups-legend {
width: 5em;
width: min-content;
position: absolute;
border: 1px solid gray;
border-radius: 0.25rem;
Expand Down
107 changes: 44 additions & 63 deletions static/network/NetworkDisplay.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,39 @@ import { ShortestPath } from "./ShortestPath";
import HierarchyToggle from "./HierarchyToggle";
import * as Constants from "./networkUtils";
import { NetworkAnalysis } from "./NetworkAnalysis";
import NetworkUrlParams from "./NetworkUrlParams";

require("./NetworkDisplay.css");
require("vis-network/styles/vis-network.min.css");

function buildParams({ to, from, weight, group }) {
return {
to: to?.value,
from: from?.value,
group: group?.value,
weight: weight?.value,
};
}

function buildState(props = {}) {
return {
error: null,
loadingData: false,
dtypes: null,
to: props.to ? { value: props.to } : null,
from: props.from ? { value: props.from } : null,
group: props.group ? { value: props.group } : null,
weight: props.weight ? { value: props.weight } : null,
hierarchy: null,
groups: null,
shortestPath: [],
};
}

class ReactNetworkDisplay extends React.Component {
constructor(props) {
super(props);
this.state = {
error: null,
loadingDtypes: true,
loadingData: false,
dtypes: null,
to: props.to ? { value: props.to } : null,
from: props.from ? { value: props.from } : null,
group: props.group ? { value: props.group } : null,
weight: props.weight ? { value: props.weight } : null,
hierarchy: null,
groups: null,
shortestPath: [],
};
this.state = { ...buildState(props), loadingDtypes: true };
this.onClick = this.onClick.bind(this);
this.neighborhoodHighlight = this.neighborhoodHighlight.bind(this);
this.load = this.load.bind(this);
Expand Down Expand Up @@ -110,60 +123,19 @@ class ReactNetworkDisplay extends React.Component {
}

neighborhoodHighlight(params) {
let { allNodes, highlightActive } = this.state;
const network = this.network;
const nodesDataset = network.body.data.nodes;
// if something is selected:
if (params.nodes.length > 0) {
highlightActive = true;
const selectedNodeId = params.nodes[0];

// mark all nodes as hard to read.
_.forEach(allNodes, node => {
node.color = "rgba(200,200,200,0.5)";
if (node.hiddenLabel === undefined) {
node.hiddenLabel = node.label;
node.label = undefined;
}
});
const connectedNodes = network.getConnectedNodes(selectedNodeId);
let allConnectedNodes = [];

// get the second degree nodes
_.forEach(
connectedNodes,
node => (allConnectedNodes = allConnectedNodes.concat(network.getConnectedNodes(node)))
);

// all second degree nodes get a different color and their label back
_.forEach(allConnectedNodes, connectedNode =>
Constants.resetNode(allNodes[connectedNode], "rgba(150,150,150,0.75)")
);

// all first degree nodes get their own color and their label back
_.forEach(connectedNodes, connectedNode => Constants.resetNode(allNodes[connectedNode]));

// the main node gets its own color and its label back.
Constants.resetNode(allNodes[selectedNodeId]);
} else if (highlightActive === true) {
// reset all nodes
_.forEach(allNodes, node => Constants.resetNode(node));
highlightActive = false;
}

nodesDataset.update(_.values(allNodes));
const highlightActive = Constants.neighborhoodHighlight(this.state, this.network, params);
this.setState({ highlightActive, shortestPath: [] });
}

load() {
const { to, from, group, weight } = this.state;
const params = buildParams(this.state);
if (!params.to || !params.from) {
this.network?.destroy();
this.network = null;
this.setState({ ...buildState(), dtypes: this.state.dtypes });
return;
}
this.setState({ loadingData: true });
const params = {
to: to?.value,
from: from?.value,
group: group?.value ?? "",
weight: weight?.value ?? "",
};
fetchJson(buildURLString(`/dtale/network-data/${this.props.dataId}?`, params), data => {
if (data.error) {
this.setState({
Expand All @@ -176,6 +148,7 @@ class ReactNetworkDisplay extends React.Component {
if (this.state.weight) {
_.forEach(edges, edge => (edge.title = `Weight: ${edge.value}`));
}
_.forEach(nodes, node => (node.title = node.label));
const nodesDataset = new vis.DataSet(nodes);
const edgesDataset = new vis.DataSet(edges);
this.draw({ nodes: nodesDataset, edges: edgesDataset });
Expand All @@ -185,7 +158,14 @@ class ReactNetworkDisplay extends React.Component {
groupsMapping = _.map(groups, (nodeId, group) => [group, { ...networkNodes[nodeId]?.options?.color }]);
}
const allNodes = nodesDataset.get({ returnType: "Object" });
this.setState({ loadingData: false, allNodes, highlightActive: false, groups: groupsMapping, error: null });
this.setState({
params,
loadingData: false,
allNodes,
highlightActive: false,
groups: groupsMapping,
error: null,
});
});
}

Expand All @@ -211,6 +191,7 @@ class ReactNetworkDisplay extends React.Component {
const loadDisabled = !(to && from);
return (
<React.Fragment>
<NetworkUrlParams params={this.state.params} propagateState={state => this.setState(state, this.load)} />
<NetworkDescription />
{error}
<BouncerWrapper showBouncer={loadingDtypes}>
Expand Down
66 changes: 66 additions & 0 deletions static/network/NetworkUrlParams.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import PropTypes from "prop-types";
import React from "react";

import actions from "../actions/dtale";
import _ from "lodash";
import querystring from "querystring";

const DATA_PROPS = ["to", "from", "weight", "group"];

export default class NetworkUrlParams extends React.Component {
constructor(props) {
super(props);
this.state = { oldOnPopState: null };
this.callOnChangeFunctions = this.callOnChangeFunctions.bind(this);
}

callOnChangeFunctions() {
const params = actions.getParams();
const newState = {};
_.forEach(DATA_PROPS, key => {
const urlValue = params[key];
newState[key] = urlValue ? { value: urlValue } : null;
});
this.props.propagateState(newState);
}

// Once we're loaded, hook into the history API to spot any changes
componentDidMount() {
if (window.onpopstate) {
this.setState({ oldOnPopState: window.onpopstate });
}

window.onpopstate = event => {
this.callOnChangeFunctions();

// Call any other onpopstate handlers.
if (this.state.oldOnPopState) {
this.state.oldOnPopState.call(window, event);
}
};
}
// Cleanup window.onpopstate.
componentWillUnmount() {
window.onpopstate = this.state.oldOnPopState;
}

componentDidUpdate(prevProps) {
if (_.isEqual(prevProps.params, this.props.params)) {
return;
}
const urlParams = actions.getParams();
const shouldUpdateUrl = _.find(DATA_PROPS, key => this.props.params?.[key] != urlParams[key]);

if (shouldUpdateUrl) {
history.pushState({}, "", `?${querystring.stringify(this.props.params)}`);
}
}

render() {
return null;
}
}
NetworkUrlParams.propTypes = {
params: PropTypes.object,
propagateState: PropTypes.func,
};
Loading

0 comments on commit 1e921c7

Please sign in to comment.