From 4448daf867279048e6394f89aaeeb2372c48f98c Mon Sep 17 00:00:00 2001 From: james hadfield Date: Thu, 16 Jul 2020 16:15:15 +1200 Subject: [PATCH 1/8] wip: prototype node states graph --- scripts/get-data.sh | 8 + src/components/main/index.js | 2 + src/components/map/map.js | 7 + src/components/map/mapHelpersLatLong.js | 4 +- src/components/states/states.js | 250 ++++++++++++++++++++++++ 5 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 src/components/states/states.js diff --git a/scripts/get-data.sh b/scripts/get-data.sh index 73dbb84fc..38d757cfa 100755 --- a/scripts/get-data.sh +++ b/scripts/get-data.sh @@ -75,4 +75,12 @@ do curl http://data.nextstrain.org/"${i}" --compressed -o data/"${i}" done +staging_files=( + "testing_states.json" \ +) +for i in "${staging_files[@]}" +do + curl http://staging.nextstrain.org/"${i}" --compressed -o data/"${i}" +done + echo "The local data directory ./data now contains up-to-date datasets from http://data.nextstrain.org" diff --git a/src/components/main/index.js b/src/components/main/index.js index 8aa27ff41..bcf1e93e6 100644 --- a/src/components/main/index.js +++ b/src/components/main/index.js @@ -6,6 +6,7 @@ import SidebarToggle from "../framework/sidebar-toggle"; import Info from "../info/info"; import Tree from "../tree"; import Map from "../map/map"; +import States from "../states/states"; import { controlsHiddenWidth } from "../../util/globals"; import Footer from "../framework/footer"; import DownloadModal from "../download/downloadModal"; @@ -141,6 +142,7 @@ class Main extends React.Component { } {this.props.displayNarrative || this.props.showOnlyPanels ? null : } {this.props.panelsToDisplay.includes("tree") ? : null} + {this.props.panelsToDisplay.includes("map") ? : null} {this.props.panelsToDisplay.includes("entropy") ? ( diff --git a/src/components/map/map.js b/src/components/map/map.js index 3ec2b2c5b..30753399a 100644 --- a/src/components/map/map.js +++ b/src/components/map/map.js @@ -205,6 +205,9 @@ class Map extends React.Component { // const initialVisibilityVersion = this.props.visibilityVersion === 1; /* see tree reducer, we set this to 1 after tree comes back */ // const newVisibilityVersion = this.props.visibilityVersion !== prevProps.visibilityVersion; + // following is temporary and only for prototyping + if (!this.props.metadata.geoResolutions.filter((x) => x.key === this.props.geoResolution)[0].demes) return; + if (mapIsDrawn && allDataPresent && demesTransmissionsNotComputed) { timerStart("drawDemesAndTransmissions"); /* data structures to feed to d3 latLongs = { tips: [{}, {}], transmissions: [{}, {}] } */ @@ -365,6 +368,10 @@ class Map extends React.Component { */ maybeUpdateDemesAndTransmissions(nextProps) { if (!this.state.map || !this.props.treeLoaded || !this.state.d3elems) { return; } + + // following is temporary and only for prototyping + if (!nextProps.metadata.geoResolutions.filter((x) => x.key === nextProps.geoResolution)[0].demes) return; + const visibilityChange = nextProps.visibilityVersion !== this.props.visibilityVersion; const haveData = nextProps.nodes && nextProps.visibility && nextProps.geoResolution && nextProps.nodeColors; diff --git a/src/components/map/mapHelpersLatLong.js b/src/components/map/mapHelpersLatLong.js index 8a213e18b..9119bd9e1 100644 --- a/src/components/map/mapHelpersLatLong.js +++ b/src/components/map/mapHelpersLatLong.js @@ -49,7 +49,7 @@ const maybeGetTransmissionPair = (latOrig, longOrig, latDest, longDest, map) => * Traverses the tips of the tree to create a dict of * location(deme) -> list of visible tips at that location */ -const getVisibleNodesPerLocation = (nodes, visibility, geoResolution) => { +export const getVisibleNodesPerLocation = (nodes, visibility, geoResolution) => { const locationToVisibleNodes = {}; nodes.forEach((n, i) => { if (n.children) return; /* only consider terminal nodes */ @@ -73,7 +73,7 @@ const getVisibleNodesPerLocation = (nodes, visibility, geoResolution) => { * @param {array} currentArcs only used if updating. Array of current arcs. * @returns {array} arcs for display */ -const createOrUpdateArcs = (visibleNodes, legendValues, colorBy, nodeColors, currentArcs=undefined) => { +export const createOrUpdateArcs = (visibleNodes, legendValues, colorBy, nodeColors, currentArcs=undefined) => { const colorByIsGenotype = isColorByGenotype(colorBy); const legendValueToArcIdx = {}; const undefinedArcIdx = legendValues.length; /* the arc which is grey to represent undefined values on tips */ diff --git a/src/components/states/states.js b/src/components/states/states.js new file mode 100644 index 000000000..3dbf5ff2e --- /dev/null +++ b/src/components/states/states.js @@ -0,0 +1,250 @@ +import React from "react"; +import { connect } from "react-redux"; +import { withTranslation } from "react-i18next"; +import { select } from "d3-selection"; +import { interpolateNumber } from "d3-interpolate"; +import ErrorBoundary from "../../util/errorBoundry"; +import Legend from "../tree/legend/legend"; +import Card from "../framework/card"; +import { getVisibleNodesPerLocation, createOrUpdateArcs } from "../map/mapHelpersLatLong"; +import { getAverageColorFromNodes } from "../../util/colorHelpers"; +import { drawDemesAndTransmissions } from "../map/mapHelpers"; +import { getTraitFromNode } from "../../util/treeMiscHelpers"; +import { bezier } from "../map/transmissionBezier"; +import { NODE_NOT_VISIBLE } from "../../util/globals"; + +/** + * This is a prototype. + * There are numerous calls into functions and use of data structures designed + * for the component. These are unnecessarily complex for this use case, + * but are employed to simplify the creation of a prototype without needing + * to refactor shared functions or duplicate code. + */ + +@connect((state) => { + return { + branchLengthsToDisplay: state.controls.branchLengthsToDisplay, + absoluteDateMin: state.controls.absoluteDateMin, + absoluteDateMax: state.controls.absoluteDateMax, + nodes: state.tree.nodes, + nodeColors: state.tree.nodeColors, + visibility: state.tree.visibility, + metadata: state.metadata, + geoResolution: state.controls.geoResolution, + dateMinNumeric: state.controls.dateMinNumeric, + dateMaxNumeric: state.controls.dateMaxNumeric, + colorBy: state.controls.colorScale.colorBy, + pieChart: ( + !state.controls.colorScale.continuous && // continuous color scale = no pie chart + state.controls.geoResolution !== state.controls.colorScale.colorBy // geo circles match colorby == no pie chart + ), + legendValues: state.controls.colorScale.legendValues, + showTransmissionLines: state.controls.showTransmissionLines + }; +}) +class States extends React.Component { + constructor(props) { + super(props); + this.svgDomRef = null; + } + redraw(props) { + // prototype. Recreate data every update & redraw. + const {demeData, demeIndices, transmissionData, transmissionIndices} = setUpDataStructures(props); // eslint-disable-line + // console.log("redraw()"); + // console.log("demeData", demeData); + // console.log("demeIndices", demeIndices); + // console.log("transmissionData", transmissionData); + // console.log("transmissionIndices", transmissionIndices); + renderDemes({svgDomRef: this.svgDomRef, demeData, transmissionData, ...props}); + } + componentDidMount() { + // console.log("\n\n----------CDM-------------"); + this.redraw(this.props); + } + componentWillReceiveProps(nextProps) { + // console.log("\n\n----------CWRP-------------"); + this.redraw(nextProps); + } + + render() { + const { t } = this.props; + return ( + + {this.props.legend && ( + + + + )} + {this.svgDomRef = c;}} + /> + + ); + } + +} + +function setUpDataStructures(props) { + const locationToVisibleNodes = getVisibleNodesPerLocation(props.nodes, props.visibility, props.geoResolution); + const demeData = []; + const demeIndices = []; // not useful since we never use triplicate for + + const getCoord = coordFactory(props.width, props.height, Object.keys(locationToVisibleNodes).length); + + Object.entries(locationToVisibleNodes).forEach(([location, visibleNodes], index) => { + const deme = { + name: location, + count: visibleNodes.length, + coords: getCoord(index) + }; + if (props.pieChart) { + /* create the arcs for the pie chart. NB `demeDataIdx` is the index of the deme in `demeData` where this will be inserted */ + deme.arcs = createOrUpdateArcs(visibleNodes, props.legendValues, props.colorBy, props.nodeColors); + /* create back links between the arcs & which index of `demeData` they (will be) stored at */ + deme.arcs.forEach((a) => {a.demeDataIdx = index;}); + } else { + /* average out the constituent colours for a blended-colour circle */ + deme.color = getAverageColorFromNodes(visibleNodes, props.nodeColors); + } + demeData.push(deme); + demeIndices[location] = [index]; + }); + + const {transmissionData, transmissionIndices} = setUpTransmissions( + props.showTransmissionLines, + props.nodes, + props.visibility, + props.geoResolution, + demeData, + demeIndices, + props.nodeColors + ); + + return {demeData, demeIndices, transmissionData, transmissionIndices}; +} + + +function renderDemes({svgDomRef, demeData, transmissionData, nodes, dateMinNumeric, dateMaxNumeric, pieChart, geoResolution, dispatch}) { + const svg = select(svgDomRef); + svg.selectAll("*").remove(); + const g = svg.append("g").attr("id", "StateDemes"); + + drawDemesAndTransmissions( + demeData, + transmissionData, + g, + null, // not used in fn! + nodes, + dateMinNumeric, + dateMaxNumeric, + pieChart, + geoResolution, + dispatch + ); + + // draw text labels over each deme + g.selectAll("demeLabels") + .data(demeData) + .enter() + .append("text") + .attr("x", (d) => d.coords.x + 10) + .attr("y", (d) => d.coords.y) + .text((d) => d.name) + .attr("class", "tipLabel") + .style("font-size", "12px"); +} + + +function setUpTransmissions(showTransmissionLines, nodes, visibility, geoResolution, demeData, demeIndices, nodeColors) { + /* similar to the 's setupTransmissionData */ + const transmissionData = []; /* edges, animation paths */ + const transmissionIndices = {}; /* map of transmission id to array of indices. Only used for updating? */ + const demeToDemeCounts = {}; /* Used to ompute the "extend" so that curves don't sit on top of each other */ + + if (!showTransmissionLines) return {transmissionData, transmissionIndices}; + + /* loop through nodes and compare each with its own children to get A->B transmissions */ + nodes.forEach((n) => { + const nodeDeme = getTraitFromNode(n, geoResolution); + if (n.children) { + n.children.forEach((child) => { + const childDeme = getTraitFromNode(child, geoResolution); + if (nodeDeme && childDeme && nodeDeme !== childDeme) { + + // Keep track of how many we've seen from A->B in order to get a curve's "extend" + if ([nodeDeme, childDeme] in demeToDemeCounts) { + demeToDemeCounts[[nodeDeme, childDeme]] += 1; + } else { + demeToDemeCounts[[nodeDeme, childDeme]] = 1; + } + const extend = demeToDemeCounts[[nodeDeme, childDeme]]; + + // compute a bezier curve + // logic following the 's maybeConstructTransmissionEvent + // console.log(`TRANSMISSION! ${nodeDeme} -> ${childDeme}, ${extend}`); + const nodeCoords = demeData[demeIndices[nodeDeme]].coords; + const childCoords = demeData[demeIndices[childDeme]].coords; + const bezierCurve = bezier(nodeCoords, childCoords, extend); + /* set up interpolator with origin and destination numdates */ + const nodeDate = getTraitFromNode(n, "num_date"); + const childDate = getTraitFromNode(child, "num_date"); + const interpolator = interpolateNumber(nodeDate, childDate); + /* make a bezierDates array as long as bezierCurve */ + const bezierDates = bezierCurve.map((d, i) => { + return interpolator(i / (bezierCurve.length - 1)); + }); + + // following data structure same as in + const transmission = { + id: n.arrayIdx.toString() + "-" + child.arrayIdx.toString(), + originNode: n, + destinationNode: child, + bezierCurve, + bezierDates, + originName: nodeDeme, + destinationName: childDeme, + originCoords: nodeCoords, // after interchange + destinationCoords: childCoords, // after interchange + originNumDate: nodeDate, + destinationNumDate: childDate, + color: nodeColors[n.arrayIdx], // colour given by *origin* node + visible: visibility[child.arrayIdx] !== NODE_NOT_VISIBLE ? "visible" : "hidden", // transmission visible if child is visible + extend: extend + }; + transmissionData.push(transmission); + } + }); + } + }); + + transmissionData.forEach((transmission, index) => { + if (!transmissionIndices[transmission.id]) { + transmissionIndices[transmission.id] = [index]; + } else { + transmissionIndices[transmission.id].push(index); + } + }); + + return {transmissionData, transmissionIndices}; +} + +function coordFactory(width, height, n) { + const x0 = width/2; + const y0 = height/2; + const t = 2 * Math.PI / (n+1); + const r = Math.min(width, height) * 0.40; + return (index) => { + return { + x: x0+r*Math.cos(t*index), + y: y0+r*Math.sin(t*index) + }; + }; +} + + +const WithTranslation = withTranslation()(States); +export default WithTranslation; From ede63f46a2309face1ec1331c080aaf7c4b486e4 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Fri, 17 Jul 2020 13:53:59 +1200 Subject: [PATCH 2/8] [node-states-viz] UI to toggle between map and state-graph viz Includes setting & loading state from a URL query "showStateGraph" when the user could have chosen either for a particular resolution. --- src/actions/recomputeReduxState.js | 17 +++++++- src/actions/types.js | 1 + src/components/controls/controls.js | 2 + src/components/controls/geo-resolution.js | 19 +++++++-- .../controls/map-display-type-toggle.js | 39 +++++++++++++++++++ src/components/main/index.js | 11 ++++-- src/components/map/map.js | 6 --- src/middleware/changeURL.js | 10 ++++- src/reducers/controls.js | 8 +++- src/util/spatialResolutionHelpers.js | 16 ++++++++ 10 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 src/components/controls/map-display-type-toggle.js create mode 100644 src/util/spatialResolutionHelpers.js diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 70baccd48..0a3aff8d7 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -16,7 +16,7 @@ import { computeMatrixFromRawData } from "../util/processFrequencies"; import { applyInViewNodesToTree } from "../actions/tree"; import { isColorByGenotype, decodeColorByGenotype } from "../util/getGenotype"; import { getTraitFromNode, getDivFromNode } from "../util/treeMiscHelpers"; - +import { getMapTypesAvailable } from "../util/spatialResolutionHelpers"; export const doesColorByHaveConfidence = (controlsState, colorBy) => controlsState.coloringsPresentOnTreeWithConfidence.has(colorBy); @@ -67,6 +67,9 @@ const modifyStateViaURLQuery = (state, query) => { if (query.r) { state["geoResolution"] = query.r; } + if (Object.hasOwnProperty.call(query, "showStateMap")) { + state.mapDisplayType = "states"; + } if (query.p && state.canTogglePanelLayout && (query.p === "full" || query.p === "grid")) { state["panelLayout"] = query.p; } @@ -459,6 +462,18 @@ const checkAndCorrectErrorsInState = (state, metadata, query, tree, viewingNarra console.error("Error detected. Setting geoResolution to ", state.geoResolution); delete query.r; // no-op if query.r doesn't exist } + + /* only once the geo-res has been set can we decide on the mapDisplayType and mapDisplayTypesAvailable */ + const { mapDisplayType, mapDisplayTypesAvailable } = getMapTypesAvailable({ + currentMapDisplayType: state.mapDisplayType, + currentMapDisplayTypesAvailable: state.mapDisplayTypesAvailable, + newGeoResolution: state.geoResolution, + geoResolutions: metadata.geoResolutions + }); + state.mapDisplayType = mapDisplayType; + state.mapDisplayTypesAvailable = mapDisplayTypesAvailable; + if (state.mapDisplayTypesAvailable.length === 1 || state.mapDisplayType === "geo") delete query.showStateMap; + } else { console.warn("JSONs did not include `geoResolutions`"); } diff --git a/src/actions/types.js b/src/actions/types.js index f33109774..f3d87cf61 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -15,6 +15,7 @@ export const CHANGE_DATES_VISIBILITY_THICKNESS = "CHANGE_DATES_VISIBILITY_THICKN export const CHANGE_ABSOLUTE_DATE_MIN = "CHANGE_ABSOLUTE_DATE_MIN"; export const CHANGE_ABSOLUTE_DATE_MAX = "CHANGE_ABSOLUTE_DATE_MAX"; export const CHANGE_GEO_RESOLUTION = "CHANGE_GEO_RESOLUTION"; +export const CHANGE_MAP_DISPLAY_TYPE = "CHANGE_MAP_DISPLAY_TYPE"; export const CHANGE_LANGUAGE = "CHANGE_LANGUAGE"; export const CLEAN_START = "CLEAN_START"; export const APPLY_FILTER = "APPLY_FILTER"; diff --git a/src/components/controls/controls.js b/src/components/controls/controls.js index 79a43e662..447d34bc6 100644 --- a/src/components/controls/controls.js +++ b/src/components/controls/controls.js @@ -16,6 +16,7 @@ import MapAnimationControls from "./map-animation"; import PanelToggles from "./panel-toggles"; import SearchStrains from "./search"; import ToggleTangle from "./toggle-tangle"; +import ToggleMapDisplayType from "./map-display-type-toggle"; import Language from "./language"; import { SidebarHeader, ControlsContainer } from "./styles"; @@ -45,6 +46,7 @@ function Controls({mapOn, frequenciesOn}) { {t("sidebar:Map Options")} + diff --git a/src/components/controls/geo-resolution.js b/src/components/controls/geo-resolution.js index dbe6fb855..a6bcbe0b0 100644 --- a/src/components/controls/geo-resolution.js +++ b/src/components/controls/geo-resolution.js @@ -2,7 +2,7 @@ import React from "react"; import { connect } from "react-redux"; import Select from "react-select/lib/Select"; import { withTranslation } from "react-i18next"; - +import { getMapTypesAvailable } from "../../util/spatialResolutionHelpers"; import { controlsWidth } from "../../util/globals"; import { CHANGE_GEO_RESOLUTION } from "../../actions/types"; import { analyticsControlsEvent } from "../../util/googleAnalytics"; @@ -11,7 +11,9 @@ import { SidebarSubtitle } from "./styles"; @connect((state) => { return { metadata: state.metadata, - geoResolution: state.controls.geoResolution + geoResolution: state.controls.geoResolution, + mapDisplayType: state.controls.mapDisplayType, + mapDisplayTypesAvailable: state.controls.mapDisplayTypesAvailable }; }) class GeoResolution extends React.Component { @@ -21,9 +23,18 @@ class GeoResolution extends React.Component { []; } - changeGeoResolution(resolution) { + changeGeoResolution(geoResolution) { analyticsControlsEvent("change-geo-resolution"); - this.props.dispatch({ type: CHANGE_GEO_RESOLUTION, data: resolution }); + this.props.dispatch({ + type: CHANGE_GEO_RESOLUTION, + geoResolution, + ...getMapTypesAvailable({ + currentMapDisplayType: this.props.mapDisplayType, + currentMapDisplayTypesAvailable: this.props.mapDisplayTypesAvailable, + newGeoResolution: geoResolution, + geoResolutions: this.props.metadata.geoResolutions + }) + }); } render() { diff --git a/src/components/controls/map-display-type-toggle.js b/src/components/controls/map-display-type-toggle.js new file mode 100644 index 000000000..2917568f3 --- /dev/null +++ b/src/components/controls/map-display-type-toggle.js @@ -0,0 +1,39 @@ +import React from "react"; +import { connect } from "react-redux"; +import { withTranslation } from "react-i18next"; + +import Toggle from "./toggle"; +import { controlsWidth } from "../../util/globals"; +import { CHANGE_MAP_DISPLAY_TYPE } from "../../actions/types"; + +@connect((state) => { + return { + mapDisplayType: state.controls.mapDisplayType, + mapDisplayTypesAvailable: state.controls.mapDisplayTypesAvailable + }; +}) +class ToggleMapDisplayType extends React.Component { + render() { + const { t } = this.props; + + if (this.props.mapDisplayTypesAvailable.length !== 2) return null; + return ( +
+ { + this.props.dispatch({ + type: CHANGE_MAP_DISPLAY_TYPE, + mapDisplayTypesAvailable: this.props.mapDisplayTypesAvailable, + mapDisplayType: this.props.mapDisplayType === "geo" ? "states" : "geo" + }); + }} + label={t("sidebar:Show state layout view")} + /> +
+ ); + } +} + +export default withTranslation()(ToggleMapDisplayType); diff --git a/src/components/main/index.js b/src/components/main/index.js index bcf1e93e6..40803ce61 100644 --- a/src/components/main/index.js +++ b/src/components/main/index.js @@ -39,7 +39,8 @@ const Frequencies = lazy(() => import("../frequencies")); metadataLoaded: state.metadata.loaded, treeLoaded: state.tree.loaded, sidebarOpen: state.controls.sidebarOpen, - showOnlyPanels: state.controls.showOnlyPanels + showOnlyPanels: state.controls.showOnlyPanels, + mapDisplayType: state.controls.mapDisplayType })) class Main extends React.Component { constructor(props) { @@ -142,8 +143,12 @@ class Main extends React.Component { } {this.props.displayNarrative || this.props.showOnlyPanels ? null : } {this.props.panelsToDisplay.includes("tree") ? : null} - - {this.props.panelsToDisplay.includes("map") ? : null} + {this.props.panelsToDisplay.includes("map") ? + this.props.mapDisplayType === "geo" ? + : + : + null + } {this.props.panelsToDisplay.includes("entropy") ? ( diff --git a/src/components/map/map.js b/src/components/map/map.js index 30753399a..4df299317 100644 --- a/src/components/map/map.js +++ b/src/components/map/map.js @@ -205,9 +205,6 @@ class Map extends React.Component { // const initialVisibilityVersion = this.props.visibilityVersion === 1; /* see tree reducer, we set this to 1 after tree comes back */ // const newVisibilityVersion = this.props.visibilityVersion !== prevProps.visibilityVersion; - // following is temporary and only for prototyping - if (!this.props.metadata.geoResolutions.filter((x) => x.key === this.props.geoResolution)[0].demes) return; - if (mapIsDrawn && allDataPresent && demesTransmissionsNotComputed) { timerStart("drawDemesAndTransmissions"); /* data structures to feed to d3 latLongs = { tips: [{}, {}], transmissions: [{}, {}] } */ @@ -369,9 +366,6 @@ class Map extends React.Component { maybeUpdateDemesAndTransmissions(nextProps) { if (!this.state.map || !this.props.treeLoaded || !this.state.d3elems) { return; } - // following is temporary and only for prototyping - if (!nextProps.metadata.geoResolutions.filter((x) => x.key === nextProps.geoResolution)[0].demes) return; - const visibilityChange = nextProps.visibilityVersion !== this.props.visibilityVersion; const haveData = nextProps.nodes && nextProps.visibility && nextProps.geoResolution && nextProps.nodeColors; diff --git a/src/middleware/changeURL.js b/src/middleware/changeURL.js index d697be20a..1e3d21d1d 100644 --- a/src/middleware/changeURL.js +++ b/src/middleware/changeURL.js @@ -72,7 +72,15 @@ export const changeURLMiddleware = (store) => (next) => (action) => { break; } case types.CHANGE_GEO_RESOLUTION: { - query.r = action.data === state.controls.defaults.geoResolution ? undefined : action.data; + query.r = action.geoResolution === state.controls.defaults.geoResolution ? undefined : action.geoResolution; + } + // fall through -- we also want to manipulate the `showStateMap` query when we change resolutions + case types.CHANGE_MAP_DISPLAY_TYPE: { + if (action.mapDisplayTypesAvailable.length !== 1 && action.mapDisplayType === "states") { + query.showStateMap = null; // `null` places it in the URL query without a value + } else { + query.showStateMap = undefined; + } break; } case types.TOGGLE_TRANSMISSION_LINES: { diff --git a/src/reducers/controls.js b/src/reducers/controls.js index 9b1cbb9be..510551bc9 100644 --- a/src/reducers/controls.js +++ b/src/reducers/controls.js @@ -66,6 +66,8 @@ export const getDefaultControlsState = () => { filters: {}, showDownload: false, quickdraw: false, // if true, components may skip expensive computes. + mapDisplayType: "geo", + mapDisplayTypesAvailable: [], mapAnimationDurationInMilliseconds: 30000, // in milliseconds mapAnimationStartDate: null, // Null so it can pull the absoluteDateMin as the default mapAnimationCumulative: false, @@ -214,8 +216,12 @@ const Controls = (state = getDefaultControlsState(), action) => { } case types.CHANGE_GEO_RESOLUTION: return Object.assign({}, state, { - geoResolution: action.data + geoResolution: action.geoResolution, + mapDisplayType: action.mapDisplayType, + mapDisplayTypesAvailable: action.mapDisplayTypesAvailable }); + case types.CHANGE_MAP_DISPLAY_TYPE: + return {...state, mapDisplayType: action.mapDisplayType}; case types.APPLY_FILTER: { // values arrive as array const filters = Object.assign({}, state.filters, {}); diff --git a/src/util/spatialResolutionHelpers.js b/src/util/spatialResolutionHelpers.js new file mode 100644 index 000000000..67c16b555 --- /dev/null +++ b/src/util/spatialResolutionHelpers.js @@ -0,0 +1,16 @@ + + +export const getMapTypesAvailable = ({currentMapDisplayType, currentMapDisplayTypesAvailable, newGeoResolution, geoResolutions}) => { + const geoResJsonData = geoResolutions.filter((x) => x.key === newGeoResolution)[0]; + const geoAvailable = !!geoResJsonData.demes; // the presense of demes (i.e. lat/long mappings defined) + const mapDisplayTypesAvailable = ["states"]; // can always display statemap, even if lat-longs present + if (geoAvailable) mapDisplayTypesAvailable.push("geo"); + /* If we're going from a resolution with only states to one with geo as well, we switch to geo representation + If we're going from a resolution with both to another resolution with both, we keep the old selection */ + const mapDisplayType = mapDisplayTypesAvailable.length === 1 + ? mapDisplayTypesAvailable[0] + : currentMapDisplayTypesAvailable.length === 1 // note: length can be zero (e.g. on load) + ? "geo" + : currentMapDisplayType; + return { mapDisplayType, mapDisplayTypesAvailable }; +}; From c1aa8ac4043df9be564b23684f9e9ba00b477a62 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Mon, 20 Jul 2020 14:22:40 +1200 Subject: [PATCH 3/8] [node-states-viz] use interactive force layout --- package-lock.json | 21 ++- package.json | 8 +- src/components/map/mapHelpers.js | 4 +- src/components/states/states.js | 309 +++++++++++++++++++++++++------ 4 files changed, 274 insertions(+), 68 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9cedd986c..c2a96036b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5400,9 +5400,9 @@ "integrity": "sha512-vwKx+lAqB1UuCeklr6Jh1bvC4SZgbSqbkGBLClItFBIYH4vqDJCA7qfoy14lXmJdnBOdxndAMxjCbImJYW7e6g==" }, "d3-drag": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.3.tgz", - "integrity": "sha512-8S3HWCAg+ilzjJsNtWW1Mutl74Nmzhb9yU6igspilaJzeZVFktmY6oO9xOh5TDk+BM2KrNFjttZNoJJmDnkjkg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz", + "integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==", "requires": { "d3-dispatch": "1", "d3-selection": "1" @@ -5413,6 +5413,16 @@ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.5.tgz", "integrity": "sha512-Ct1O//ly5y5lFM9YTdu+ygq7LleSgSE4oj7vUt9tPLHUi8VCV7QoizGpdWRWAwCO9LdYzIrQDg97+hGVdsSGPQ==" }, + "d3-force": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-2.0.1.tgz", + "integrity": "sha512-zh73/N6+MElRojiUG7vmn+3vltaKon7iD5vB/7r9nUaBeftXMzRo5IWEG63DLBCto4/8vr9i3m9lwr1OTJNiCg==", + "requires": { + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, "d3-format": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.3.2.tgz", @@ -5431,6 +5441,11 @@ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.7.tgz", "integrity": "sha512-q0cW1RpvA5c5ma2rch62mX8AYaiLX0+bdaSM2wxSU9tXjU4DNvkx9qiUvjkuWCj3p22UO/hlPivujqMiR9PDzA==" }, + "d3-quadtree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", + "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==" + }, "d3-scale": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-1.0.7.tgz", diff --git a/package.json b/package.json index 26f2407cb..8296986b1 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,10 @@ "@babel/core": "^7.3.4", "@babel/plugin-proposal-class-properties": "^7.3.4", "@babel/plugin-proposal-decorators": "^7.3.0", - "@babel/plugin-transform-runtime": "^7.8.3", - "@babel/preset-react": "^7.0.0", "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-runtime": "^7.8.3", "@babel/preset-env": "^7.9.6", + "@babel/preset-react": "^7.0.0", "@hot-loader/react-dom": "^16.13.0", "argparse": "^1.0.10", "awesomplete": "^1.1.2", @@ -65,7 +65,9 @@ "d3-brush": "^1.0.4", "d3-collection": "^1.0.4", "d3-color": "^1.0.3", + "d3-drag": "^1.2.5", "d3-ease": "^1.0.3", + "d3-force": "^2.0.1", "d3-format": "^1.3.0", "d3-interpolate": "^1.1.5", "d3-scale": "^1.0.5", @@ -118,9 +120,9 @@ "styled-components": "^4.0.3", "typeface-lato": "^0.0.75", "webpack": "^4.30.0", + "webpack-bundle-analyzer": "^3.3.2", "webpack-chunk-hash": "^0.6.0", "webpack-cli": "^3.1.2", - "webpack-bundle-analyzer": "^3.3.2", "webpack-dev-middleware": "^3.1.3", "webpack-hot-middleware": "^2.24.3", "whatwg-fetch": "^0.10.1", diff --git a/src/components/map/mapHelpers.js b/src/components/map/mapHelpers.js index 15a46f2ea..308804b03 100644 --- a/src/components/map/mapHelpers.js +++ b/src/components/map/mapHelpers.js @@ -13,7 +13,7 @@ export const pathStringGenerator = line() .y((d) => { return d.y; }) .curve(curveBasis); -const extractLineSegmentForAnimationEffect = ( +export const extractLineSegmentForAnimationEffect = ( numDateMin, numDateMax, originCoords, @@ -117,7 +117,7 @@ const extractLineSegmentForAnimationEffect = ( return curve; }; -const createArcsFromDemes = (demeData) => { +export const createArcsFromDemes = (demeData) => { const individualArcs = []; demeData.forEach((demeInfo) => { demeInfo.arcs.forEach((slice) => { diff --git a/src/components/states/states.js b/src/components/states/states.js index 3dbf5ff2e..3432480c7 100644 --- a/src/components/states/states.js +++ b/src/components/states/states.js @@ -1,17 +1,22 @@ import React from "react"; import { connect } from "react-redux"; import { withTranslation } from "react-i18next"; -import { select } from "d3-selection"; +import _max from "lodash/max"; +import { select, event as d3event } from "d3-selection"; import { interpolateNumber } from "d3-interpolate"; +import { forceSimulation, forceManyBody } from "d3-force"; +import { drag as d3drag } from "d3-drag"; +import { arc } from "d3-shape"; import ErrorBoundary from "../../util/errorBoundry"; import Legend from "../tree/legend/legend"; import Card from "../framework/card"; import { getVisibleNodesPerLocation, createOrUpdateArcs } from "../map/mapHelpersLatLong"; import { getAverageColorFromNodes } from "../../util/colorHelpers"; -import { drawDemesAndTransmissions } from "../map/mapHelpers"; +import { pathStringGenerator, extractLineSegmentForAnimationEffect } from "../map/mapHelpers"; import { getTraitFromNode } from "../../util/treeMiscHelpers"; import { bezier } from "../map/transmissionBezier"; -import { NODE_NOT_VISIBLE } from "../../util/globals"; +import { NODE_NOT_VISIBLE, demeCountMultiplier, demeCountMinimum } from "../../util/globals"; +import { updateTipRadii } from "../../actions/tree"; /** * This is a prototype. @@ -21,6 +26,17 @@ import { NODE_NOT_VISIBLE } from "../../util/globals"; * to refactor shared functions or duplicate code. */ +/** Known to-do list before release: + * improve physics, especially related to SVG boundary + * improve initial layout + * don't recreate the d3 chart each time there's a prop change - react according to what's changed + * wrap with error boundary + * cancel subscriptions (also not done well for tree + map) + * decide on JSON format + * test json with geo-res none of which have lat-longs + * allow genotype to be a geo-res! + */ + @connect((state) => { return { branchLengthsToDisplay: state.controls.branchLengthsToDisplay, @@ -46,16 +62,20 @@ class States extends React.Component { constructor(props) { super(props); this.svgDomRef = null; + this.simulation = null; // not in `this.state` as we want no updates to occur } redraw(props) { // prototype. Recreate data every update & redraw. - const {demeData, demeIndices, transmissionData, transmissionIndices} = setUpDataStructures(props); // eslint-disable-line - // console.log("redraw()"); + const {demeData, demeIndices, transmissionData, transmissionIndices, demeMultiplier} = setUpDataStructures(props); // eslint-disable-line + console.log("redraw()"); // console.log("demeData", demeData); // console.log("demeIndices", demeIndices); // console.log("transmissionData", transmissionData); // console.log("transmissionIndices", transmissionIndices); - renderDemes({svgDomRef: this.svgDomRef, demeData, transmissionData, ...props}); + if (this.simulation) this.simulation.stop(); + const svg = select(this.svgDomRef); + svg.selectAll("*").remove(); + this.simulation = drawDemesAndTransmissions({svg, demeData, transmissionData, demeMultiplier, ...props}); } componentDidMount() { // console.log("\n\n----------CDM-------------"); @@ -92,20 +112,27 @@ function setUpDataStructures(props) { const locationToVisibleNodes = getVisibleNodesPerLocation(props.nodes, props.visibility, props.geoResolution); const demeData = []; const demeIndices = []; // not useful since we never use triplicate for - - const getCoord = coordFactory(props.width, props.height, Object.keys(locationToVisibleNodes).length); + const visibleTips = props.nodes[0].tipCount; + const demeMultiplier = + demeCountMultiplier / + Math.sqrt(_max([Math.sqrt(visibleTips * props.nodes.length), demeCountMinimum])); Object.entries(locationToVisibleNodes).forEach(([location, visibleNodes], index) => { const deme = { name: location, - count: visibleNodes.length, - coords: getCoord(index) + count: visibleNodes.length }; + deme.x = props.width/2; + deme.y = props.height/2; if (props.pieChart) { /* create the arcs for the pie chart. NB `demeDataIdx` is the index of the deme in `demeData` where this will be inserted */ deme.arcs = createOrUpdateArcs(visibleNodes, props.legendValues, props.colorBy, props.nodeColors); /* create back links between the arcs & which index of `demeData` they (will be) stored at */ - deme.arcs.forEach((a) => {a.demeDataIdx = index;}); + deme.arcs.forEach((a) => { + a.demeDataIdx = index; + a.outerRadius = Math.sqrt(deme.count)*demeMultiplier; + a.parentDeme = deme; + }); } else { /* average out the constituent colours for a blended-colour circle */ deme.color = getAverageColorFromNodes(visibleNodes, props.nodeColors); @@ -124,41 +151,9 @@ function setUpDataStructures(props) { props.nodeColors ); - return {demeData, demeIndices, transmissionData, transmissionIndices}; -} - - -function renderDemes({svgDomRef, demeData, transmissionData, nodes, dateMinNumeric, dateMaxNumeric, pieChart, geoResolution, dispatch}) { - const svg = select(svgDomRef); - svg.selectAll("*").remove(); - const g = svg.append("g").attr("id", "StateDemes"); - - drawDemesAndTransmissions( - demeData, - transmissionData, - g, - null, // not used in fn! - nodes, - dateMinNumeric, - dateMaxNumeric, - pieChart, - geoResolution, - dispatch - ); - - // draw text labels over each deme - g.selectAll("demeLabels") - .data(demeData) - .enter() - .append("text") - .attr("x", (d) => d.coords.x + 10) - .attr("y", (d) => d.coords.y) - .text((d) => d.name) - .attr("class", "tipLabel") - .style("font-size", "12px"); + return {demeData, demeIndices, transmissionData, transmissionIndices, demeMultiplier}; } - function setUpTransmissions(showTransmissionLines, nodes, visibility, geoResolution, demeData, demeIndices, nodeColors) { /* similar to the 's setupTransmissionData */ const transmissionData = []; /* edges, animation paths */ @@ -186,8 +181,8 @@ function setUpTransmissions(showTransmissionLines, nodes, visibility, geoResolut // compute a bezier curve // logic following the 's maybeConstructTransmissionEvent // console.log(`TRANSMISSION! ${nodeDeme} -> ${childDeme}, ${extend}`); - const nodeCoords = demeData[demeIndices[nodeDeme]].coords; - const childCoords = demeData[demeIndices[childDeme]].coords; + const nodeCoords = {x: demeData[demeIndices[nodeDeme]].x, y: demeData[demeIndices[nodeDeme]].y}; + const childCoords = {x: demeData[demeIndices[childDeme]].x, y: demeData[demeIndices[childDeme]].y}; const bezierCurve = bezier(nodeCoords, childCoords, extend); /* set up interpolator with origin and destination numdates */ const nodeDate = getTraitFromNode(n, "num_date"); @@ -198,17 +193,19 @@ function setUpTransmissions(showTransmissionLines, nodes, visibility, geoResolut return interpolator(i / (bezierCurve.length - 1)); }); - // following data structure same as in + // following similar data structure same as in , should be able to cut down const transmission = { id: n.arrayIdx.toString() + "-" + child.arrayIdx.toString(), originNode: n, destinationNode: child, bezierCurve, bezierDates, + originDeme: demeData[demeIndices[nodeDeme]], + destinationDeme: demeData[demeIndices[childDeme]], originName: nodeDeme, destinationName: childDeme, - originCoords: nodeCoords, // after interchange - destinationCoords: childCoords, // after interchange + originCoords: nodeCoords, + destinationCoords: childCoords, originNumDate: nodeDate, destinationNumDate: childDate, color: nodeColors[n.arrayIdx], // colour given by *origin* node @@ -232,19 +229,211 @@ function setUpTransmissions(showTransmissionLines, nodes, visibility, geoResolut return {transmissionData, transmissionIndices}; } -function coordFactory(width, height, n) { - const x0 = width/2; - const y0 = height/2; - const t = 2 * Math.PI / (n+1); - const r = Math.min(width, height) * 0.40; - return (index) => { - return { - x: x0+r*Math.cos(t*index), - y: y0+r*Math.sin(t*index) - }; - }; + +function updateTransmissionPositions(transmissionData) { + transmissionData.forEach((transmission) => { + // recomputing the entire curve isn't the smartest way to do it, but it is the simplest + transmission.bezierCurve = bezier( + {x: transmission.originDeme.x, y: transmission.originDeme.y}, + {x: transmission.destinationDeme.x, y: transmission.destinationDeme.y}, + transmission.extend + ); + }); } +function drawDemesAndTransmissions({ + svg, + demeData, + transmissionData, + demeMultiplier, + dateMinNumeric, + dateMaxNumeric, + pieChart, /* bool */ + geoResolution, + dispatch +}) { + const width = +svg.attr("width"); + const height = +svg.attr("height"); + const simulation = forceSimulation() + // .force("link", forceLink().id((d) => d.id)) + .force("charge", forceManyBody().strength(-10)); // must parameterise strength + // .force("center", forceCenter(width / 2, height / 2)); // mean of all nodes is in center of SVG + + /* To do -- de-duplicate as much as possible via d3.call etc */ + let demes; + if (pieChart) { + demes = svg.append("g") + .attr("class", "state_nodes") + .selectAll("circle") + .data(demeData) + .enter() + .append("g") + .attr("class", "pie") + .selectAll("arc") + .call(d3drag() + .on("start", dragstarted) + .on("drag", dragged) + .on("end", dragended) + ) + .data((deme) => deme.arcs) + .enter() + .append("path") + .attr("d", (d) => arc()(d)) + /* following calls are (almost) the same for pie charts & circles */ + .style("stroke", "none") + .style("fill-opacity", 0.65) + .style("fill", (d) => { return d.color; }) + .style("pointer-events", "all") + .attr("transform", (d) => + "translate(" + demeData[d.demeDataIdx].x + "," + demeData[d.demeDataIdx].y + ")" + ) + .on("mouseover", (d) => { dispatch(updateTipRadii({geoFilter: [geoResolution, demeData[d.demeDataIdx].name]})); }) + .on("mouseout", () => { dispatch(updateTipRadii()); }) + .call(d3drag() + .on("start", dragstartedPie) + .on("drag", draggedPie) + .on("end", dragendedPie) + ); + } else { + demes = svg.append("g") + .attr("class", "demes_circles") + .selectAll("circle") + .data(demeData) + .enter() + .append("circle") + .attr("r", (d) => { return demeMultiplier * Math.sqrt(d.count); }) + /* following calls are (almost) the same for pie charts & circles */ + .style("stroke", "none") + .style("fill-opacity", 0.65) + .style("fill", (d) => { return d.color || "black"; }) + .style("stroke-opacity", 0.85) + .style("stroke", (d) => { return d.color || "black"; }) + .style("pointer-events", "all") + .attr("transform", (d) => "translate(" + d.x + "," + d.y + ")") + .on("mouseover", (d) => { dispatch(updateTipRadii({geoFilter: [geoResolution, d.name]})); }) + .on("mouseout", () => { dispatch(updateTipRadii()); }) + .call(d3drag() + .on("start", dragstarted) + .on("drag", dragged) + .on("end", dragended) + ); + } + + const labels = svg.append("g") + .attr("class", "labels") + .selectAll("text") + .data(demeData) + .enter() + .append("text") + .attr("x", (d) => d.x + 10) + .attr("y", (d) => d.y) + .text((d) => d.name) + .attr("class", "tipLabel") + .style("font-size", "12px"); + + const transmissions = svg.append("g") + .attr("class", "transmissions") + .selectAll("transmissions") + .data(transmissionData) + .enter() + .append("path") /* instead of appending a geodesic path from the leaflet plugin data, we now draw a line directly between two points */ + .attr("d", (d) => renderBezier(d, dateMinNumeric, dateMaxNumeric)) + .attr("fill", "none") + .attr("stroke-opacity", 0.6) + .attr("stroke-linecap", "round") + .attr("stroke", (d) => { return d.color; }) + .attr("stroke-width", 1); + + simulation + .nodes(demeData) // will initialise index, x, y, vx & vy on objects in `demeData` + .on("tick", () => { + if (pieChart) { + demes + // to do -- stop arcs going outside visible SVG (Loop over `demeData` instead of using chained d3 call?) + .attr("transform", (d) => "translate(" + d.parentDeme.x + "," + d.parentDeme.y + ")"); + } else { + demes + .each((d) => { // stop the simulation pushing things outside the visible SVG + const pad = 20; + if (d.x(width-pad)) d.x=width-pad; + if (d.y(width-pad)) d.y=height-pad; + }) + .attr("transform", (d) => "translate(" + d.x + "," + d.y + ")"); + } + labels + .attr("x", (d) => d.x) + .attr("y", (d) => d.y); + updateTransmissionPositions(transmissionData); + transmissions + .attr("d", (d) => renderBezier(d, dateMinNumeric, dateMaxNumeric)); + }); + + + function dragstarted(d) { + if (!d3event.active) { + simulation.alphaTarget(0.3).restart(); + } + d.fx = d.x; + d.fy = d.y; + } + + function dragged(d) { + d.fx = d3event.x; + d.fy = d3event.y; + } + + function dragended(d) { + if (!d3event.active) { + simulation.alphaTarget(0); + } + d.fx = null; + d.fy = null; + } + + /* pie chart drag functions are subtly different. Combine function with above! */ + function dragstartedPie(d) { + if (!d3event.active) { + simulation.alphaTarget(0.3).restart(); + } + d.parentDeme.fx = d.parentDeme.x; + d.parentDeme.fy = d.parentDeme.y; + } + + function draggedPie(d) { + d.parentDeme.fx = d3event.x; + d.parentDeme.fy = d3event.y; + } + + function dragendedPie(d) { + if (!d3event.active) { + simulation.alphaTarget(0); + } + d.parentDeme.fx = null; + d.parentDeme.fy = null; + } + + return simulation; +} + +/* function to generate the path (the "d" attr) */ +function renderBezier(d, numDateMin, numDateMax) { + return pathStringGenerator( + extractLineSegmentForAnimationEffect( + numDateMin, + numDateMax, + d.originCoords, + d.destinationCoords, + d.originNumDate, + d.destinationNumDate, + d.visible, + d.bezierCurve, + d.bezierDates + ) + ); +} + const WithTranslation = withTranslation()(States); export default WithTranslation; From 3ece62e2a3259dbb9980944cb936c0eac3573626 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Mon, 20 Jul 2020 15:27:29 +1200 Subject: [PATCH 4/8] [node-states-viz] allow selected genotype to be a (spatial) resolution Previously it didn't make sense for a selected genotype to be a geo-resolution as there's no logical mapping between a nucleotide/aa and a lat-long. However for a force-directed state-map we do want to be able to visualise these. This changes the behavior so that when a genotype is selected a new option appears in the available geo-resolutions. If the color-by changes to a non-genotype, then this option is removed. If the selected geo-res is the genotype, and the color-by changes, we either update the geo res to the new genotype color-by or switch to the default geo res. This necessitated refactoring `getTraitFromNode` to handle genotype traits, and allowed us to get rid of the `getTipColorAttribute` function. --- src/actions/recomputeReduxState.js | 3 +++ src/components/map/mapHelpersLatLong.js | 3 ++- src/components/states/states.js | 7 ++++--- src/components/tree/infoPanels/hover.js | 3 +-- src/reducers/controls.js | 13 ++++++++++--- src/reducers/metadata.js | 8 ++++++++ src/util/colorHelpers.js | 14 ++------------ src/util/tipRadiusHelpers.js | 4 ++-- src/util/treeMiscHelpers.js | 6 ++++-- 9 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 0a3aff8d7..1844b5dd7 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -449,6 +449,9 @@ const checkAndCorrectErrorsInState = (state, metadata, query, tree, viewingNarra /* geoResolutions */ if (metadata.geoResolutions) { + if (isColorByGenotype(state.colorBy)) { // already error corrected above + metadata.geoResolutions.push({key: state.colorBy, title: state.colorBy, isGenotype: true}); + } const availableGeoResultions = metadata.geoResolutions.map((i) => i.key); if (availableGeoResultions.indexOf(state["geoResolution"]) === -1) { /* fallbacks: JSON defined default, then hardocded default, then any available */ diff --git a/src/components/map/mapHelpersLatLong.js b/src/components/map/mapHelpersLatLong.js index 9119bd9e1..dc8818a0a 100644 --- a/src/components/map/mapHelpersLatLong.js +++ b/src/components/map/mapHelpersLatLong.js @@ -51,9 +51,10 @@ const maybeGetTransmissionPair = (latOrig, longOrig, latDest, longDest, map) => */ export const getVisibleNodesPerLocation = (nodes, visibility, geoResolution) => { const locationToVisibleNodes = {}; + const genotype = isColorByGenotype(geoResolution); nodes.forEach((n, i) => { if (n.children) return; /* only consider terminal nodes */ - const location = getTraitFromNode(n, geoResolution); + const location = getTraitFromNode(n, geoResolution, {genotype}); if (!location) return; /* ignore undefined locations */ if (!locationToVisibleNodes[location]) locationToVisibleNodes[location]=[]; if (visibility[i] !== NODE_NOT_VISIBLE) { diff --git a/src/components/states/states.js b/src/components/states/states.js index 3432480c7..88f829063 100644 --- a/src/components/states/states.js +++ b/src/components/states/states.js @@ -17,6 +17,7 @@ import { getTraitFromNode } from "../../util/treeMiscHelpers"; import { bezier } from "../map/transmissionBezier"; import { NODE_NOT_VISIBLE, demeCountMultiplier, demeCountMinimum } from "../../util/globals"; import { updateTipRadii } from "../../actions/tree"; +import { isColorByGenotype } from "../../util/getGenotype"; /** * This is a prototype. @@ -34,7 +35,6 @@ import { updateTipRadii } from "../../actions/tree"; * cancel subscriptions (also not done well for tree + map) * decide on JSON format * test json with geo-res none of which have lat-longs - * allow genotype to be a geo-res! */ @connect((state) => { @@ -163,11 +163,12 @@ function setUpTransmissions(showTransmissionLines, nodes, visibility, geoResolut if (!showTransmissionLines) return {transmissionData, transmissionIndices}; /* loop through nodes and compare each with its own children to get A->B transmissions */ + const genotype = isColorByGenotype(geoResolution); nodes.forEach((n) => { - const nodeDeme = getTraitFromNode(n, geoResolution); + const nodeDeme = getTraitFromNode(n, geoResolution, {genotype}); if (n.children) { n.children.forEach((child) => { - const childDeme = getTraitFromNode(child, geoResolution); + const childDeme = getTraitFromNode(child, geoResolution, {genotype}); if (nodeDeme && childDeme && nodeDeme !== childDeme) { // Keep track of how many we've seen from A->B in order to get a curve's "extend" diff --git a/src/components/tree/infoPanels/hover.js b/src/components/tree/infoPanels/hover.js index 5dbd61c47..4b299a71a 100644 --- a/src/components/tree/infoPanels/hover.js +++ b/src/components/tree/infoPanels/hover.js @@ -1,7 +1,6 @@ import React from "react"; import { infoPanelStyles } from "../../../globalStyles"; import { numericToCalendar } from "../../../util/dateHelpers"; -import { getTipColorAttribute } from "../../../util/colorHelpers"; import { isColorByGenotype, decodeColorByGenotype } from "../../../util/getGenotype"; import { getTraitFromNode, getDivFromNode, getVaccineFromNode, getFullAuthorInfoFromNode } from "../../../util/treeMiscHelpers"; import { isValueValid } from "../../../util/globals"; @@ -87,7 +86,7 @@ const ColorBy = ({node, colorBy, colorByConfidence, colorScale, colorings}) => { const name = genotype.aa ? `Amino Acid at ${genotype.gene} site ${genotype.positions.join(", ")}:` : `Nucleotide at pos ${genotype.positions.join(", ")}:`; - return ; + return ; } /* handle author as a special case */ if (colorBy === "author") { diff --git a/src/reducers/controls.js b/src/reducers/controls.js index 510551bc9..7cdf9d6b6 100644 --- a/src/reducers/controls.js +++ b/src/reducers/controls.js @@ -10,6 +10,7 @@ import { defaultGeoResolution, import * as types from "../actions/types"; import { calcBrowserDimensionsInitialState } from "./browserDimensions"; import { doesColorByHaveConfidence } from "../actions/recomputeReduxState"; +import { isColorByGenotype } from "../util/getGenotype"; /* defaultState is a fn so that we can re-create it at any time, e.g. if we want to revert things (e.g. on dataset change) @@ -207,12 +208,18 @@ const Controls = (state = getDefaultControlsState(), action) => { action.panelsToDisplay.indexOf("map") !== -1 }); case types.NEW_COLORS: { - const newState = Object.assign({}, state, { + const updatedState = { colorBy: action.colorBy, colorScale: action.colorScale, colorByConfidence: doesColorByHaveConfidence(state, action.colorBy) - }); - return newState; + }; + /* genotype resolutions have to be modified on colorBy change */ + if (isColorByGenotype(state.geoResolution)) { + updatedState.geoResolution = isColorByGenotype(action.colorBy) ? + action.colorBy : + state.defaults.geoResolution; + } + return Object.assign({}, state, updatedState); } case types.CHANGE_GEO_RESOLUTION: return Object.assign({}, state, { diff --git a/src/reducers/metadata.js b/src/reducers/metadata.js index e283116cf..c4c1562b4 100644 --- a/src/reducers/metadata.js +++ b/src/reducers/metadata.js @@ -1,5 +1,6 @@ import { colorOptions } from "../util/globals"; import * as types from "../actions/types"; +import { isColorByGenotype } from "../util/getGenotype"; /* The metdata reducer holds data that is * (a) mostly derived from the dataset JSON @@ -30,6 +31,13 @@ const Metadata = (state = { return Object.assign({}, state, {buildUrl}); } return state; + case types.NEW_COLORS: { + const geoResolutions = state.geoResolutions.filter((r) => !r.isGenotype); + if (isColorByGenotype(action.colorBy)) { + geoResolutions.push({key: action.colorBy, title: action.colorBy, isGenotype: true}); + } + return {...state, geoResolutions}; + } default: return state; } diff --git a/src/util/colorHelpers.js b/src/util/colorHelpers.js index e59520ff1..bd4f83461 100644 --- a/src/util/colorHelpers.js +++ b/src/util/colorHelpers.js @@ -51,22 +51,12 @@ export const getExtraVals = (nodes, nodesToo, colorBy, providedVals) => { return valsInTree.filter((x) => providedVals.indexOf(x) === -1); }; - -/* a getter for the value of the colour attribute of the node provided for the currently set colour -note this is not the colour HEX */ -export const getTipColorAttribute = (node, colorScale) => { - if (isColorByGenotype(colorScale.colorBy) && colorScale.genotype) { - return node.currentGt; - } - return getTraitFromNode(node, colorScale.colorBy); -}; - /* generates and returns an array of colours (HEXs) for the nodes under the given colorScale */ /* takes around 2ms on a 2000 tip tree */ export const calcNodeColor = (tree, colorScale) => { if (tree && tree.nodes && colorScale && colorScale.colorBy) { - const nodeColorAttr = tree.nodes.map((n) => getTipColorAttribute(n, colorScale)); - // console.log(nodeColorAttr.map((n) => colorScale.scale(n))) + const genotype = isColorByGenotype(colorScale.colorBy); + const nodeColorAttr = tree.nodes.map((n) => getTraitFromNode(n, colorScale.colorBy, {genotype})); return nodeColorAttr.map((n) => colorScale.scale(n)); } return null; diff --git a/src/util/tipRadiusHelpers.js b/src/util/tipRadiusHelpers.js index db1033b89..e4fe7160e 100644 --- a/src/util/tipRadiusHelpers.js +++ b/src/util/tipRadiusHelpers.js @@ -1,6 +1,6 @@ import { tipRadius, tipRadiusOnLegendMatch } from "./globals"; -import { getTipColorAttribute } from "./colorHelpers"; import { getTraitFromNode } from "../util/treeMiscHelpers"; +import { isColorByGenotype } from "../util/getGenotype"; /** * equates a single tip and a legend element @@ -13,7 +13,7 @@ import { getTraitFromNode } from "../util/treeMiscHelpers"; * @returns bool */ const determineLegendMatch = (selectedLegendItem, node, colorScale) => { - const nodeAttr = getTipColorAttribute(node, colorScale); + const nodeAttr = getTraitFromNode(node, colorScale.colorBy, {genotype: isColorByGenotype(colorScale.colorBy)}); if (colorScale.continuous) { return (nodeAttr <= colorScale.legendBounds[selectedLegendItem][1]) && (nodeAttr >= colorScale.legendBounds[selectedLegendItem][0]); diff --git a/src/util/treeMiscHelpers.js b/src/util/treeMiscHelpers.js index bb6a21219..8c82b81ff 100644 --- a/src/util/treeMiscHelpers.js +++ b/src/util/treeMiscHelpers.js @@ -25,10 +25,10 @@ james hadfield, nov 2019. * NOTE: do not use this for "div", "vaccine" or other traits set on `node_attrs` * which don't share the same structure as traits. See the JSON spec for more details. */ -export const getTraitFromNode = (node, trait, {entropy=false, confidence=false}={}) => { +export const getTraitFromNode = (node, trait, {entropy=false, confidence=false, genotype=false}={}) => { if (!node.node_attrs) return undefined; - if (!entropy && !confidence) { + if (!entropy && !confidence && !genotype) { if (!node.node_attrs[trait]) return undefined; const value = node.node_attrs[trait].value; if (!isValueValid(value)) return undefined; @@ -39,6 +39,8 @@ export const getTraitFromNode = (node, trait, {entropy=false, confidence=false}= } else if (confidence) { if (node.node_attrs[trait]) return node.node_attrs[trait].confidence; return undefined; + } else if (genotype) { + return node.currentGt; } return undefined; }; From a83d010de74dfc45f9a944b090722260be850de9 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Mon, 20 Jul 2020 15:50:42 +1200 Subject: [PATCH 5/8] [node-states-viz] catch errors with ErrorBoundary --- src/components/main/index.js | 12 ++++++++---- src/components/states/states.js | 1 - 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/main/index.js b/src/components/main/index.js index 40803ce61..d6fbbf928 100644 --- a/src/components/main/index.js +++ b/src/components/main/index.js @@ -144,10 +144,14 @@ class Main extends React.Component { {this.props.displayNarrative || this.props.showOnlyPanels ? null : } {this.props.panelsToDisplay.includes("tree") ? : null} {this.props.panelsToDisplay.includes("map") ? - this.props.mapDisplayType === "geo" ? - : - : - null + + { + this.props.mapDisplayType === "geo" ? + : + + } + + : null } {this.props.panelsToDisplay.includes("entropy") ? ( diff --git a/src/components/states/states.js b/src/components/states/states.js index 88f829063..4bc915515 100644 --- a/src/components/states/states.js +++ b/src/components/states/states.js @@ -31,7 +31,6 @@ import { isColorByGenotype } from "../../util/getGenotype"; * improve physics, especially related to SVG boundary * improve initial layout * don't recreate the d3 chart each time there's a prop change - react according to what's changed - * wrap with error boundary * cancel subscriptions (also not done well for tree + map) * decide on JSON format * test json with geo-res none of which have lat-longs From d8cb9951e82d66926b142460552f50735f688524 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Wed, 22 Jul 2020 18:33:41 +1200 Subject: [PATCH 6/8] [nodes-states-viz] keep app state and viz in sync This commit turns a proof-of-principle implementation into a working feature with acceptable performance. As props change (e.g. color-by, visibility) the visualization updates as needed. There are further performance improvements possible. The main outstanding task is to improve the physics of the simulation. --- src/components/states/states.js | 626 ++++++++++++++++++-------------- 1 file changed, 348 insertions(+), 278 deletions(-) diff --git a/src/components/states/states.js b/src/components/states/states.js index 4bc915515..2ac92a69d 100644 --- a/src/components/states/states.js +++ b/src/components/states/states.js @@ -4,55 +4,42 @@ import { withTranslation } from "react-i18next"; import _max from "lodash/max"; import { select, event as d3event } from "d3-selection"; import { interpolateNumber } from "d3-interpolate"; -import { forceSimulation, forceManyBody } from "d3-force"; +import { forceSimulation, forceManyBody, forceCenter } from "d3-force"; // eslint-disable-line import { drag as d3drag } from "d3-drag"; import { arc } from "d3-shape"; import ErrorBoundary from "../../util/errorBoundry"; import Legend from "../tree/legend/legend"; import Card from "../framework/card"; -import { getVisibleNodesPerLocation, createOrUpdateArcs } from "../map/mapHelpersLatLong"; -import { getAverageColorFromNodes } from "../../util/colorHelpers"; +import { createOrUpdateArcs } from "../map/mapHelpersLatLong"; import { pathStringGenerator, extractLineSegmentForAnimationEffect } from "../map/mapHelpers"; -import { getTraitFromNode } from "../../util/treeMiscHelpers"; import { bezier } from "../map/transmissionBezier"; +import { getAverageColorFromNodes } from "../../util/colorHelpers"; +import { getTraitFromNode } from "../../util/treeMiscHelpers"; import { NODE_NOT_VISIBLE, demeCountMultiplier, demeCountMinimum } from "../../util/globals"; -import { updateTipRadii } from "../../actions/tree"; +import { updateTipRadii } from "../../actions/tree"; // eslint-disable-line import { isColorByGenotype } from "../../util/getGenotype"; -/** - * This is a prototype. - * There are numerous calls into functions and use of data structures designed - * for the component. These are unnecessarily complex for this use case, - * but are employed to simplify the creation of a prototype without needing - * to refactor shared functions or duplicate code. - */ - /** Known to-do list before release: * improve physics, especially related to SVG boundary * improve initial layout - * don't recreate the d3 chart each time there's a prop change - react according to what's changed - * cancel subscriptions (also not done well for tree + map) * decide on JSON format * test json with geo-res none of which have lat-longs + * better setting of deme sizes (applies to the map as well) + * better label positioning + * make line width (and "extend") a function of line count + * reinstate onhover behavior to highlight nodes in tree (this is a big performance slow-down) */ @connect((state) => { return { - branchLengthsToDisplay: state.controls.branchLengthsToDisplay, - absoluteDateMin: state.controls.absoluteDateMin, - absoluteDateMax: state.controls.absoluteDateMax, nodes: state.tree.nodes, nodeColors: state.tree.nodeColors, visibility: state.tree.visibility, - metadata: state.metadata, geoResolution: state.controls.geoResolution, dateMinNumeric: state.controls.dateMinNumeric, dateMaxNumeric: state.controls.dateMaxNumeric, colorBy: state.controls.colorScale.colorBy, - pieChart: ( - !state.controls.colorScale.continuous && // continuous color scale = no pie chart - state.controls.geoResolution !== state.controls.colorScale.colorBy // geo circles match colorby == no pie chart - ), + continuousColorScale: state.controls.colorScale.continuous, legendValues: state.controls.colorScale.legendValues, showTransmissionLines: state.controls.showTransmissionLines }; @@ -60,31 +47,80 @@ import { isColorByGenotype } from "../../util/getGenotype"; class States extends React.Component { constructor(props) { super(props); + // we store data as properties of the object ("class") rather than in `this.state` because + // we don't want react's lifecycle's to run when it's updated as we manage this ourselves this.svgDomRef = null; - this.simulation = null; // not in `this.state` as we want no updates to occur + this.data = {}; + this.groups = {}; + this.selections = {}; } - redraw(props) { - // prototype. Recreate data every update & redraw. - const {demeData, demeIndices, transmissionData, transmissionIndices, demeMultiplier} = setUpDataStructures(props); // eslint-disable-line - console.log("redraw()"); - // console.log("demeData", demeData); - // console.log("demeIndices", demeIndices); - // console.log("transmissionData", transmissionData); - // console.log("transmissionIndices", transmissionIndices); - if (this.simulation) this.simulation.stop(); - const svg = select(this.svgDomRef); - svg.selectAll("*").remove(); - this.simulation = drawDemesAndTransmissions({svg, demeData, transmissionData, demeMultiplier, ...props}); + recomputeData(props, recompute={everything: true}) { + /* We only want to recompute data as needed (it's expensive!) */ + this.data.demeMultiplier = computeDemeMultiplier(props.nodes); + + if (recompute.everything) { + this.data.demes = computeDemeData(props, {demeMultiplier: this.data.demeMultiplier}); + } else if (recompute.colors) { + this.data.demes = computeDemeData(props, { + demeMultiplier: this.data.demeMultiplier, + existingCoords: this.data.demes.map((d) => ({x: d.x, y: d.y})) + }); + } else if (recompute.visibility) { + updateArcData(this.data.demes, props, {demeMultiplier: this.data.demeMultiplier}); + } + + /* performance improvements are possible here (e.g. colors doesn't need to recreate the beziers!) */ + if (recompute.everything || recompute.colors || recompute.transmissionToggle) { + this.data.transmissions = computeTransmissions(props, this.data); + } + + this.simulation.nodes(this.data.demes).stop(); + } + renderData(props, recompute={everything: true}) { + /* Labels only render when we update everything. Possible improvement: when demes are not visible + (i.e. due to visibility) we may want to remove the label and perhaps update the coordinates. + Note that we also re-render when we recompute the color as the data bind needs updating */ + if (recompute.everything || recompute.colors) { + this.selections.labels = renderLabels({g: this.groups.labels, demes: this.data.demes}); + } + /* We remove & rerender demes on ∆colorBy & ∆geoRes, otherwise we update their attrs */ + if (recompute.everything || recompute.colors) { + this.selections.demes = renderDemes({g: this.groups.demes, demes: this.data.demes, geoResolution: props.geoResolution, dispatch: props.dispatch, demeMultiplier: this.demeMultiplier, drag: this.drag}); + } else if (recompute.visibility) { + this.selections.demes = renderUpdatesToExistingDemes({selection: this.selections.demes}); + } + /* we handle transmissions differently -- the visibility is computed here, not in the data construction. + Note that it would be more performant to update existing DOM elements rather than destroying & recreating */ + if (recompute.everything || recompute.colors || recompute.visibility || recompute.transmissionToggle) { + this.selections.transmissions = renderTransmissions({g: this.groups.transmissions, transmissions: this.data.transmissions, visibility: props.visibility, dateMinNumeric: props.dateMinNumeric, dateMaxNumeric: props.dateMaxNumeric}); + } } componentDidMount() { - // console.log("\n\n----------CDM-------------"); - this.redraw(this.props); + this.groups = setUpSvg(this.svgDomRef); + this.simulation = forceSimulation() + // .force("link", forceLink().id((d) => d.id)) + // .force("charge", forceManyBody().strength(-10)); // must parameterise strength + .force("center", forceCenter(this.props.width / 2, this.props.height / 2)) // mean of all nodes is in center of SVG + .on("tick", this.onTick); + this.drag = setUpDragFunctions(this.simulation); + this.recomputeData(this.props); + this.renderData(this.props); } componentWillReceiveProps(nextProps) { - // console.log("\n\n----------CWRP-------------"); - this.redraw(nextProps); + const recompute = compareProps(this.props, nextProps); + this.recomputeData(nextProps, recompute); + this.renderData(nextProps, recompute); + } + onTick = () => { + this.selections.demes + .attr("transform", (d) => "translate(" + d.parent.x + "," + d.parent.y + ")"); + this.selections.labels + .attr("x", (d) => d.x) + .attr("y", (d) => d.y); + updateTransmissionCoordinates(this.data.transmissions); + this.selections.transmissions + .attr("d", (d) => renderBezier(d, this.props.visibility, this.props.dateMinNumeric, this.props.dateMaxNumeric)); } - render() { const { t } = this.props; return ( @@ -105,84 +141,169 @@ class States extends React.Component { ); } + componentWillUnmount() { + const svg = select(this.svgDomRef); + svg.selectAll("*").remove(); + } } -function setUpDataStructures(props) { - const locationToVisibleNodes = getVisibleNodesPerLocation(props.nodes, props.visibility, props.geoResolution); - const demeData = []; - const demeIndices = []; // not useful since we never use triplicate for - const visibleTips = props.nodes[0].tipCount; +function computeDemeMultiplier(nodes) { + const visibleTips = nodes[0].tipCount; const demeMultiplier = demeCountMultiplier / - Math.sqrt(_max([Math.sqrt(visibleTips * props.nodes.length), demeCountMinimum])); + Math.sqrt(_max([Math.sqrt(visibleTips * nodes.length), demeCountMinimum])); + return demeMultiplier; +} - Object.entries(locationToVisibleNodes).forEach(([location, visibleNodes], index) => { +/** + * Compute demes, including co-ordinates, for _all_ demes regardless of their visibility + */ +function computeDemeData(props, {demeMultiplier, existingCoords}) { + const {locationToAllNodes, locationToVisibleNodes} = getNodesPerLocation(props.nodes, props.visibility, props.geoResolution); + const nDemes = Object.keys(locationToAllNodes).length; + const demes = new Array(nDemes); // similar to `demeData` in the component + /* coordinates defined per-deme (i.e. per geo-resolution value) */ + let coords; + if (existingCoords) { + if (existingCoords.length !== nDemes) { + console.warn("WARNING: provided coords length mismatch"); + coords = undefined; + } else { + coords = existingCoords; + } + } + if (!coords) { + coords = computeCoordinates(props.width, props.height, nDemes); + } + /* create data structure for each deme, each containing an array of arcs */ + Object.entries(locationToAllNodes).forEach(([location, visibleNodes], index) => { const deme = { name: location, - count: visibleNodes.length + count: locationToVisibleNodes[location].length, + ...coords[index] // sets `x` & `y` }; - deme.x = props.width/2; - deme.y = props.height/2; - if (props.pieChart) { - /* create the arcs for the pie chart. NB `demeDataIdx` is the index of the deme in `demeData` where this will be inserted */ + if (props.geoResolution===props.colorBy || props.continuousColorScale) { + deme.arcs = [{innerRadius: 0, startAngle: 0, endAngle: 2*Math.PI, color: getAverageColorFromNodes(visibleNodes, props.nodeColors)}]; + } else { deme.arcs = createOrUpdateArcs(visibleNodes, props.legendValues, props.colorBy, props.nodeColors); - /* create back links between the arcs & which index of `demeData` they (will be) stored at */ - deme.arcs.forEach((a) => { - a.demeDataIdx = index; - a.outerRadius = Math.sqrt(deme.count)*demeMultiplier; - a.parentDeme = deme; - }); + } + deme.arcs.forEach((a) => { + a.outerRadius = Math.sqrt(deme.count)*demeMultiplier; + a.parent = deme; + }); + demes[index]=deme; + }); + return demes; +} + +/** + * Given an array of demes ("node states"), update the constituent arcs. This is used when the + * visibility of nodes on the tree has changed etc. + * Side effect: Updates the `demes` data structure in place. + */ +function updateArcData(demes, props, {demeMultiplier}) { + const {locationToAllNodes, locationToVisibleNodes} = getNodesPerLocation(props.nodes, props.visibility, props.geoResolution); + const nDemes = Object.keys(locationToAllNodes).length; + if (nDemes !== demes.length) { + console.warn("Can't update arcs if length differs"); + return; + } + demes.forEach((deme) => { + const visibleNodes = locationToVisibleNodes[deme.name]; + deme.count = visibleNodes.length; + if (props.geoResolution===props.colorBy) { + deme.arcs[0].color = visibleNodes.length ? props.nodeColors[visibleNodes[0].arrayIdx] : ""; + } else if (props.continuousColorScale) { + deme.arcs[0].color = getAverageColorFromNodes(visibleNodes, props.nodeColors); } else { - /* average out the constituent colours for a blended-colour circle */ - deme.color = getAverageColorFromNodes(visibleNodes, props.nodeColors); + deme.arcs = createOrUpdateArcs(visibleNodes, props.legendValues, props.colorBy, props.nodeColors, deme.arcs); } - demeData.push(deme); - demeIndices[location] = [index]; + deme.arcs.forEach((a) => { + a.outerRadius = Math.sqrt(deme.count)*demeMultiplier; + a.parent = deme; + }); }); +} - const {transmissionData, transmissionIndices} = setUpTransmissions( - props.showTransmissionLines, - props.nodes, - props.visibility, - props.geoResolution, - demeData, - demeIndices, - props.nodeColors - ); +/** + * Traverses the tips of the tree to create a dict of + * location (aka deme) -> list of tips at that location + * This is similar to the `getVisibleNodesPerLocation` function used by the + */ +function getNodesPerLocation(nodes, visibility, geoResolution) { + const locationToAllNodes = {}; + const locationToVisibleNodes = {}; + const genotype = isColorByGenotype(geoResolution); + nodes.forEach((n, i) => { + if (n.children) return; /* only consider terminal nodes */ + const location = getTraitFromNode(n, geoResolution, {genotype}); + if (!location) return; /* ignore undefined locations */ + if (!locationToAllNodes[location]) locationToAllNodes[location]=[]; + locationToAllNodes[location].push(n); + if (!locationToVisibleNodes[location]) locationToVisibleNodes[location]=[]; + if (visibility[i] !== NODE_NOT_VISIBLE) { + locationToVisibleNodes[location].push(n); + } + }); + return {locationToAllNodes, locationToVisibleNodes}; +} - return {demeData, demeIndices, transmissionData, transmissionIndices, demeMultiplier}; +/** + * Compute `n` coordinates where demes will be located. + * Each coordinate is an object with `x` and `y` properties. + */ +function computeCoordinates(width, height, n) { + const x0 = width/2; + const y0 = height/2; + const t = 2*Math.PI/(n+1); + const r = Math.min(width, height) * 0.4; + return Array.from(Array(10).keys()) + .map((index) => ({ + x: x0 + r*Math.cos(t*index), + y: y0 + r*Math.sin(t*index) + })); } -function setUpTransmissions(showTransmissionLines, nodes, visibility, geoResolution, demeData, demeIndices, nodeColors) { - /* similar to the 's setupTransmissionData */ - const transmissionData = []; /* edges, animation paths */ - const transmissionIndices = {}; /* map of transmission id to array of indices. Only used for updating? */ - const demeToDemeCounts = {}; /* Used to ompute the "extend" so that curves don't sit on top of each other */ +/** + * Compute an array of transmissions (the data underlying the bezier curves) + * Note: approach is similar to the 's `setupTransmissionData` & + * `maybeConstructTransmissionEvent` functions. + * Note that this computes all transmissions regardless of visibility. The rendering + * code will handle the visibility. + */ +function computeTransmissions(props, data) { + const transmissionData = []; + const demeToDemeCounts = {}; /* Used to compute the "extend" so that curves don't sit on top of each other */ + const {showTransmissionLines, nodes, geoResolution, nodeColors} = props; + if (!showTransmissionLines) return transmissionData; + const genotype = isColorByGenotype(geoResolution); - if (!showTransmissionLines) return {transmissionData, transmissionIndices}; + /* construct a (temporary) mapping of deme (state) name -> data structure */ + const getDemeFromName = {}; + data.demes.forEach((d) => {getDemeFromName[d.name] = d;}); - /* loop through nodes and compare each with its own children to get A->B transmissions */ - const genotype = isColorByGenotype(geoResolution); + /* loop through (phylogeny) nodes and compare each with its own children to get A->B transmissions */ nodes.forEach((n) => { - const nodeDeme = getTraitFromNode(n, geoResolution, {genotype}); + const parentDemeValue = getTraitFromNode(n, geoResolution, {genotype}); if (n.children) { n.children.forEach((child) => { - const childDeme = getTraitFromNode(child, geoResolution, {genotype}); - if (nodeDeme && childDeme && nodeDeme !== childDeme) { + const childDemeValue = getTraitFromNode(child, geoResolution, {genotype}); + if (parentDemeValue && childDemeValue && parentDemeValue !== childDemeValue) { // Keep track of how many we've seen from A->B in order to get a curve's "extend" - if ([nodeDeme, childDeme] in demeToDemeCounts) { - demeToDemeCounts[[nodeDeme, childDeme]] += 1; + if ([parentDemeValue, childDemeValue] in demeToDemeCounts) { + demeToDemeCounts[[parentDemeValue, childDemeValue]] += 2; } else { - demeToDemeCounts[[nodeDeme, childDeme]] = 1; + demeToDemeCounts[[parentDemeValue, childDemeValue]] = 1; } - const extend = demeToDemeCounts[[nodeDeme, childDeme]]; + const extend = demeToDemeCounts[[parentDemeValue, childDemeValue]]; + + const parentDeme = getDemeFromName[parentDemeValue]; + const childDeme = getDemeFromName[childDemeValue]; // compute a bezier curve - // logic following the 's maybeConstructTransmissionEvent - // console.log(`TRANSMISSION! ${nodeDeme} -> ${childDeme}, ${extend}`); - const nodeCoords = {x: demeData[demeIndices[nodeDeme]].x, y: demeData[demeIndices[nodeDeme]].y}; - const childCoords = {x: demeData[demeIndices[childDeme]].x, y: demeData[demeIndices[childDeme]].y}; + const nodeCoords = {x: parentDeme.x, y: parentDeme.y}; + const childCoords = {x: childDeme.x, y: childDeme.y}; const bezierCurve = bezier(nodeCoords, childCoords, extend); /* set up interpolator with origin and destination numdates */ const nodeDate = getTraitFromNode(n, "num_date"); @@ -195,21 +316,17 @@ function setUpTransmissions(showTransmissionLines, nodes, visibility, geoResolut // following similar data structure same as in , should be able to cut down const transmission = { - id: n.arrayIdx.toString() + "-" + child.arrayIdx.toString(), - originNode: n, + // originNode: n, destinationNode: child, bezierCurve, bezierDates, - originDeme: demeData[demeIndices[nodeDeme]], - destinationDeme: demeData[demeIndices[childDeme]], - originName: nodeDeme, - destinationName: childDeme, - originCoords: nodeCoords, - destinationCoords: childCoords, + originDeme: parentDeme, + destinationDeme: childDeme, + originName: parentDemeValue, + destinationName: childDemeValue, originNumDate: nodeDate, destinationNumDate: childDate, color: nodeColors[n.arrayIdx], // colour given by *origin* node - visible: visibility[child.arrayIdx] !== NODE_NOT_VISIBLE ? "visible" : "hidden", // transmission visible if child is visible extend: extend }; transmissionData.push(transmission); @@ -217,21 +334,14 @@ function setUpTransmissions(showTransmissionLines, nodes, visibility, geoResolut }); } }); - - transmissionData.forEach((transmission, index) => { - if (!transmissionIndices[transmission.id]) { - transmissionIndices[transmission.id] = [index]; - } else { - transmissionIndices[transmission.id].push(index); - } - }); - - return {transmissionData, transmissionIndices}; + return transmissionData; } - -function updateTransmissionPositions(transmissionData) { - transmissionData.forEach((transmission) => { +/** + * update `transmissions` (array) in-place to reflext changes in corresponding deme coords + */ +function updateTransmissionCoordinates(transmissions) { + transmissions.forEach((transmission) => { // recomputing the entire curve isn't the smartest way to do it, but it is the simplest transmission.bezierCurve = bezier( {x: transmission.originDeme.x, y: transmission.originDeme.y}, @@ -241,89 +351,67 @@ function updateTransmissionPositions(transmissionData) { }); } +function setUpSvg(svgDomRef) { + const svg = select(svgDomRef); + return { + svg, + demes: svg.append("g").attr("class", "nodes"), + labels: svg.append("g").attr("class", "labels"), + transmissions: svg.append("g").attr("class", "transmissions") + }; +} -function drawDemesAndTransmissions({ - svg, - demeData, - transmissionData, - demeMultiplier, - dateMinNumeric, - dateMaxNumeric, - pieChart, /* bool */ - geoResolution, - dispatch -}) { - const width = +svg.attr("width"); - const height = +svg.attr("height"); - const simulation = forceSimulation() - // .force("link", forceLink().id((d) => d.id)) - .force("charge", forceManyBody().strength(-10)); // must parameterise strength - // .force("center", forceCenter(width / 2, height / 2)); // mean of all nodes is in center of SVG - - /* To do -- de-duplicate as much as possible via d3.call etc */ - let demes; - if (pieChart) { - demes = svg.append("g") - .attr("class", "state_nodes") - .selectAll("circle") - .data(demeData) +/** + * Given a SVG group selection (`g`), render the demes (the "circles"). Each deme is always + * made up of arcs (i.e. a deme is always a pie chart) to simplify the code. + */ +function renderDemes({g, demes, geoResolution, dispatch, drag}) { // eslint-disable-line + g.selectAll("*").remove(); + const generateArc = arc(); + return g.selectAll("circle") + .data(demes) + .enter() + .append("g") + .attr("class", "pie") + .selectAll("arc") + .data((deme) => deme.arcs) .enter() - .append("g") - .attr("class", "pie") - .selectAll("arc") + .append("path") + .attr("d", generateArc) + .style("stroke", "none") + .style("fill-opacity", 0.65) + .style("fill", (d) => d.color) + .style("pointer-events", "all") + .attr("transform", (d) => `translate(${d.parent.x},${d.parent.y})`) + // .on("mouseover", (d) => { dispatch(updateTipRadii({geoFilter: [geoResolution, d.parent.name]})); }) + // .on("mouseout", () => { dispatch(updateTipRadii()); }) .call(d3drag() - .on("start", dragstarted) - .on("drag", dragged) - .on("end", dragended) - ) - .data((deme) => deme.arcs) - .enter() - .append("path") - .attr("d", (d) => arc()(d)) - /* following calls are (almost) the same for pie charts & circles */ - .style("stroke", "none") - .style("fill-opacity", 0.65) - .style("fill", (d) => { return d.color; }) - .style("pointer-events", "all") - .attr("transform", (d) => - "translate(" + demeData[d.demeDataIdx].x + "," + demeData[d.demeDataIdx].y + ")" - ) - .on("mouseover", (d) => { dispatch(updateTipRadii({geoFilter: [geoResolution, demeData[d.demeDataIdx].name]})); }) - .on("mouseout", () => { dispatch(updateTipRadii()); }) - .call(d3drag() - .on("start", dragstartedPie) - .on("drag", draggedPie) - .on("end", dragendedPie) - ); - } else { - demes = svg.append("g") - .attr("class", "demes_circles") - .selectAll("circle") - .data(demeData) - .enter() - .append("circle") - .attr("r", (d) => { return demeMultiplier * Math.sqrt(d.count); }) - /* following calls are (almost) the same for pie charts & circles */ - .style("stroke", "none") - .style("fill-opacity", 0.65) - .style("fill", (d) => { return d.color || "black"; }) - .style("stroke-opacity", 0.85) - .style("stroke", (d) => { return d.color || "black"; }) - .style("pointer-events", "all") - .attr("transform", (d) => "translate(" + d.x + "," + d.y + ")") - .on("mouseover", (d) => { dispatch(updateTipRadii({geoFilter: [geoResolution, d.name]})); }) - .on("mouseout", () => { dispatch(updateTipRadii()); }) - .call(d3drag() - .on("start", dragstarted) - .on("drag", dragged) - .on("end", dragended) - ); - } + .on("start", drag.dragstarted) + .on("drag", drag.dragged) + .on("end", drag.dragended) + ); +} + +/** + * Given an existing d3 selection, whose bound data has been updated in-place, + * update all of the attrs which may have changed. See `updateArcData` + * for the properties of arcs which may have changed. + */ +function renderUpdatesToExistingDemes({selection}) { + const generateArc = arc(); + return selection + .attr("d", generateArc) + .style("fill", (d) => d.color); +} - const labels = svg.append("g") - .attr("class", "labels") - .selectAll("text") - .data(demeData) +/** + * Given a SVG group selection (`g`), render labels corresponding + * to the `demes` + */ +function renderLabels({g, demes}) { + g.selectAll("*").remove(); + return g.selectAll("text") + .data(demes) .enter() .append("text") .attr("x", (d) => d.x + 10) @@ -331,109 +419,91 @@ function drawDemesAndTransmissions({ .text((d) => d.name) .attr("class", "tipLabel") .style("font-size", "12px"); +} - const transmissions = svg.append("g") - .attr("class", "transmissions") - .selectAll("transmissions") - .data(transmissionData) +/** + * Given a SVG group selection (`g`), render the transmissions ("curved lines"). + * Note that it is during rendering that we decide if a line is visible, or what segements of + * the line are visibility according to the current temporal slice. + */ +function renderTransmissions({g, transmissions, visibility, dateMinNumeric, dateMaxNumeric}) { + g.selectAll("*").remove(); + return g.selectAll("transmissions") + .data(transmissions) .enter() - .append("path") /* instead of appending a geodesic path from the leaflet plugin data, we now draw a line directly between two points */ - .attr("d", (d) => renderBezier(d, dateMinNumeric, dateMaxNumeric)) - .attr("fill", "none") - .attr("stroke-opacity", 0.6) - .attr("stroke-linecap", "round") - .attr("stroke", (d) => { return d.color; }) - .attr("stroke-width", 1); + .append("path") /* instead of appending a geodesic path from the leaflet plugin data, we now draw a line directly between two points */ + .attr("d", (d) => renderBezier(d, visibility, dateMinNumeric, dateMaxNumeric)) + .attr("fill", "none") + .attr("stroke-opacity", 0.6) + .attr("stroke-linecap", "round") + .attr("stroke", (d) => d.color) + .attr("stroke-width", 2); +} - simulation - .nodes(demeData) // will initialise index, x, y, vx & vy on objects in `demeData` - .on("tick", () => { - if (pieChart) { - demes - // to do -- stop arcs going outside visible SVG (Loop over `demeData` instead of using chained d3 call?) - .attr("transform", (d) => "translate(" + d.parentDeme.x + "," + d.parentDeme.y + ")"); - } else { - demes - .each((d) => { // stop the simulation pushing things outside the visible SVG - const pad = 20; - if (d.x(width-pad)) d.x=width-pad; - if (d.y(width-pad)) d.y=height-pad; - }) - .attr("transform", (d) => "translate(" + d.x + "," + d.y + ")"); +function setUpDragFunctions(simulation) { + return { + dragstarted: (d) => { + if (!d3event.active) { + simulation.alphaTarget(0.3).restart(); } - labels - .attr("x", (d) => d.x) - .attr("y", (d) => d.y); - updateTransmissionPositions(transmissionData); - transmissions - .attr("d", (d) => renderBezier(d, dateMinNumeric, dateMaxNumeric)); - }); - - - function dragstarted(d) { - if (!d3event.active) { - simulation.alphaTarget(0.3).restart(); - } - d.fx = d.x; - d.fy = d.y; - } - - function dragged(d) { - d.fx = d3event.x; - d.fy = d3event.y; - } - - function dragended(d) { - if (!d3event.active) { - simulation.alphaTarget(0); - } - d.fx = null; - d.fy = null; - } - - /* pie chart drag functions are subtly different. Combine function with above! */ - function dragstartedPie(d) { - if (!d3event.active) { - simulation.alphaTarget(0.3).restart(); - } - d.parentDeme.fx = d.parentDeme.x; - d.parentDeme.fy = d.parentDeme.y; - } - - function draggedPie(d) { - d.parentDeme.fx = d3event.x; - d.parentDeme.fy = d3event.y; - } - - function dragendedPie(d) { - if (!d3event.active) { - simulation.alphaTarget(0); + d.parent.fx = d.parent.x; + d.parent.fy = d.parent.y; + }, + dragged: (d) => { + d.parent.fx = d3event.x; + d.parent.fy = d3event.y; + }, + dragended: (d) => { + if (!d3event.active) { + simulation.alphaTarget(0); + } + d.parent.fx = null; + d.parent.fy = null; } - d.parentDeme.fx = null; - d.parentDeme.fy = null; - } - - return simulation; + }; } -/* function to generate the path (the "d" attr) */ -function renderBezier(d, numDateMin, numDateMax) { +/** + * Produce a SVG path ("d" attr) from a datum given temporal & visibility constraints + */ +function renderBezier(d, visibility, numDateMin, numDateMax) { return pathStringGenerator( extractLineSegmentForAnimationEffect( numDateMin, numDateMax, - d.originCoords, - d.destinationCoords, + {x: d.originDeme.x, y: d.originDeme.y}, + {x: d.destinationDeme.x, y: d.destinationDeme.y}, d.originNumDate, d.destinationNumDate, - d.visible, + visibility[d.destinationNode.arrayIdx] !== NODE_NOT_VISIBLE ? "visible" : "hidden", d.bezierCurve, d.bezierDates ) ); } +/** + * When props change, the data structures behind the data visualisation, and the d3-rendered + * visualisation itself, must change. For performance and usability reasons, we don't want + * to recompute & rerender everything on every prop change. This function identifies how we + * should update the data structures & viz. + */ +function compareProps(oldProps, newProps) { + const recompute = { + everything: false, + colors: false, + visibility: false + }; + if (oldProps.geoResolution !== newProps.geoResolution) { + recompute.everything = true; + } else if (oldProps.colorBy !== newProps.colorBy) { + recompute.colors = true; + } else { + recompute.visibility = true; + } + recompute.transmissionToggle = oldProps.showTransmissionLines !== newProps.showTransmissionLines; + return recompute; +} + const WithTranslation = withTranslation()(States); export default WithTranslation; From a4edb1eb3b740a8fb891a03fdaf6931832dced85 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Fri, 24 Jul 2020 09:57:23 +1200 Subject: [PATCH 7/8] [nodes-states-viz] Improve deme layout Sets up d3 forces (and a custom force) to better position demes. Working reasonably well, but the performance when there are large numbers of transitions is unacceptable. --- src/components/states/states.js | 155 ++++++++++++++++++++++++++------ 1 file changed, 126 insertions(+), 29 deletions(-) diff --git a/src/components/states/states.js b/src/components/states/states.js index 2ac92a69d..42539307a 100644 --- a/src/components/states/states.js +++ b/src/components/states/states.js @@ -4,7 +4,7 @@ import { withTranslation } from "react-i18next"; import _max from "lodash/max"; import { select, event as d3event } from "d3-selection"; import { interpolateNumber } from "d3-interpolate"; -import { forceSimulation, forceManyBody, forceCenter } from "d3-force"; // eslint-disable-line +import { forceSimulation, forceManyBody, forceCenter, forceLink, forceCollide, forceRadial } from "d3-force"; // eslint-disable-line import { drag as d3drag } from "d3-drag"; import { arc } from "d3-shape"; import ErrorBoundary from "../../util/errorBoundry"; @@ -19,15 +19,17 @@ import { NODE_NOT_VISIBLE, demeCountMultiplier, demeCountMinimum } from "../../u import { updateTipRadii } from "../../actions/tree"; // eslint-disable-line import { isColorByGenotype } from "../../util/getGenotype"; -/** Known to-do list before release: - * improve physics, especially related to SVG boundary - * improve initial layout - * decide on JSON format - * test json with geo-res none of which have lat-longs - * better setting of deme sizes (applies to the map as well) - * better label positioning - * make line width (and "extend") a function of line count - * reinstate onhover behavior to highlight nodes in tree (this is a big performance slow-down) +/** TODO LIST (before release) + * test json with geo-res none of which have lat-longs, as well as jsons without any geo-res. + * handle browser resizing + * performance when there are large numbers of transitions is unacceptable + */ + +/** TODO LIST (potentially post release) + * - better setting of deme sizes (applies to the map as well) + * - decide on JSON format (do we rename geo resolutions to spatial resolutions?) + * - make line width (and "extend") a function of line count + * - reinstate onhover behavior to highlight nodes in tree (this is a big performance slow-down) */ @connect((state) => { @@ -53,6 +55,8 @@ class States extends React.Component { this.data = {}; this.groups = {}; this.selections = {}; + this.simulation = forceSimulation().stop(); + this.drag = setUpDragFunctions(this.simulation); } recomputeData(props, recompute={everything: true}) { /* We only want to recompute data as needed (it's expensive!) */ @@ -74,14 +78,15 @@ class States extends React.Component { this.data.transmissions = computeTransmissions(props, this.data); } - this.simulation.nodes(this.data.demes).stop(); + this.simulation.nodes(this.data.demes); + } renderData(props, recompute={everything: true}) { /* Labels only render when we update everything. Possible improvement: when demes are not visible (i.e. due to visibility) we may want to remove the label and perhaps update the coordinates. Note that we also re-render when we recompute the color as the data bind needs updating */ if (recompute.everything || recompute.colors) { - this.selections.labels = renderLabels({g: this.groups.labels, demes: this.data.demes}); + this.selections.labels = renderLabels({g: this.groups.labels, demes: this.data.demes, width: this.props.width}); } /* We remove & rerender demes on ∆colorBy & ∆geoRes, otherwise we update their attrs */ if (recompute.everything || recompute.colors) { @@ -97,26 +102,52 @@ class States extends React.Component { } componentDidMount() { this.groups = setUpSvg(this.svgDomRef); - this.simulation = forceSimulation() - // .force("link", forceLink().id((d) => d.id)) - // .force("charge", forceManyBody().strength(-10)); // must parameterise strength - .force("center", forceCenter(this.props.width / 2, this.props.height / 2)) // mean of all nodes is in center of SVG - .on("tick", this.onTick); - this.drag = setUpDragFunctions(this.simulation); this.recomputeData(this.props); this.renderData(this.props); + this.setUpAndRunSimulation(); // must run after data is bound } componentWillReceiveProps(nextProps) { const recompute = compareProps(this.props, nextProps); this.recomputeData(nextProps, recompute); this.renderData(nextProps, recompute); + if (recompute.everything) this.setUpAndRunSimulation(); + } + setUpAndRunSimulation() { + // Ideally we want to recompute forces whenever viz changes (as links will have changed). Due to potentical cost, + // we currently only recompute & restart when the geo res changes + // TODO: handle window size changes + + this.simulation + .force("distribute", // distribute points over the SVG & don't let them live outside it. + forceDistribute(this.props.width, this.props.height) + .strength(0.2) + ) + .force("collision", // don't let demes overlap + forceCollide() + .radius((n) => n.arcs.length ? n.arcs[0].outerRadius+this.props.width/50 : 0) + .strength(0.2) + ) + .force("center", + forceCenter(this.props.width / 2, this.props.height / 2) // mean of all nodes is in center of SVG + ); + if (this.data.transmissions && this.data.transmissions.length) { + this.simulation.force("link", + forceLink(this.data.transmissions) + .id((d) => d.name) + .strength(this.data.transmissions.length > 500 ? 0.005 : 0.1) + ); + } + this.simulation.alpha(1) // reheat if necessary + .alphaDecay(0.05) + .on("tick", this.onTick) + .tick(100) // don't need to animate the burn in + .restart(); } onTick = () => { this.selections.demes .attr("transform", (d) => "translate(" + d.parent.x + "," + d.parent.y + ")"); this.selections.labels - .attr("x", (d) => d.x) - .attr("y", (d) => d.y); + .call((sel) => setLabelPosition(sel, this.props.width)); updateTransmissionCoordinates(this.data.transmissions); this.selections.transmissions .attr("d", (d) => renderBezier(d, this.props.visibility, this.props.dateMinNumeric, this.props.dateMaxNumeric)); @@ -142,6 +173,7 @@ class States extends React.Component { } componentWillUnmount() { + this.simulation.stop(); const svg = select(this.svgDomRef); svg.selectAll("*").remove(); } @@ -257,7 +289,7 @@ function computeCoordinates(width, height, n) { const y0 = height/2; const t = 2*Math.PI/(n+1); const r = Math.min(width, height) * 0.4; - return Array.from(Array(10).keys()) + return Array.from(Array(n).keys()) .map((index) => ({ x: x0 + r*Math.cos(t*index), y: y0 + r*Math.sin(t*index) @@ -314,7 +346,6 @@ function computeTransmissions(props, data) { return interpolator(i / (bezierCurve.length - 1)); }); - // following similar data structure same as in , should be able to cut down const transmission = { // originNode: n, destinationNode: child, @@ -327,7 +358,9 @@ function computeTransmissions(props, data) { originNumDate: nodeDate, destinationNumDate: childDate, color: nodeColors[n.arrayIdx], // colour given by *origin* node - extend: extend + extend: extend, + source: parentDemeValue, + target: childDemeValue }; transmissionData.push(transmission); } @@ -351,13 +384,17 @@ function updateTransmissionCoordinates(transmissions) { }); } +/** + * Create d3 selections representing groups in the SVG to hold demes, transmissions etc. + * @param {} svgDomRef React reference to DOM. + */ function setUpSvg(svgDomRef) { const svg = select(svgDomRef); return { svg, + transmissions: svg.append("g").attr("class", "transmissions"), demes: svg.append("g").attr("class", "nodes"), - labels: svg.append("g").attr("class", "labels"), - transmissions: svg.append("g").attr("class", "transmissions") + labels: svg.append("g").attr("class", "labels") }; } @@ -406,21 +443,33 @@ function renderUpdatesToExistingDemes({selection}) { /** * Given a SVG group selection (`g`), render labels corresponding - * to the `demes` + * to the `demes`. You could imagine a force to find the best + * positioning taking into account surrounding demes & lines, but + * for now it's overkill. */ -function renderLabels({g, demes}) { +function renderLabels({g, demes, width}) { g.selectAll("*").remove(); return g.selectAll("text") .data(demes) .enter() .append("text") - .attr("x", (d) => d.x + 10) - .attr("y", (d) => d.y) + .call((sel) => setLabelPosition(sel, width)) + .style("pointer-events", "none") .text((d) => d.name) .attr("class", "tipLabel") .style("font-size", "12px"); } +/** + * label positioning fn intended to be called by d3's `call` method + */ +function setLabelPosition(selection, svgWidth) { + selection + .attr("x", (d) => d.x*2 d.y) + .attr("text-anchor", (d) => d.x*2 { + const r = n.arcs.length ? n.arcs[0].outerRadius + 10 : 10; + n.x = Math.max(r, Math.min(svgWidth - r, n.x)); + n.y = Math.max(r, Math.min(svgHeight - r, n.y)); + if (n.xmaxX) maxX=n.x; + if (n.ymaxY) maxY=n.y; + }); + /* push them to be better distributed */ + const scaleX = availableWidth/(maxX-minX); + const scaleY = availableHeight/(maxY-minY); + const offsetX = padFrac*svgWidth - minX; // -ve values indicate a leftward shift is desired + const offsetY = padFrac*svgHeight - minY; + nodes.forEach((n) => { + n.vx -= (n.x - (n.x+offsetX)*scaleX) * strength * alpha; + n.vy -= (n.y - (n.y+offsetY)*scaleY) * strength * alpha; + }); + } + + force.initialize = function _initialize(_) { + nodes = _; + }; + + force.strength = function _strength(_) { + strength = _; + return force; + }; + + return force; +} + + const WithTranslation = withTranslation()(States); export default WithTranslation; From f8be8704b246d9263402b8cf05703ac984f6f5b2 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Fri, 24 Jul 2020 12:02:05 +1200 Subject: [PATCH 8/8] [nodes-states-viz] refer to component as a network --- src/actions/recomputeReduxState.js | 6 +++--- src/components/controls/geo-resolution.js | 2 +- src/components/controls/map-display-type-toggle.js | 6 +++--- src/components/main/index.js | 2 +- src/components/{states/states.js => network/network.js} | 0 src/middleware/changeURL.js | 8 ++++---- src/util/spatialResolutionHelpers.js | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) rename src/components/{states/states.js => network/network.js} (100%) diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 1844b5dd7..92516f3a8 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -67,8 +67,8 @@ const modifyStateViaURLQuery = (state, query) => { if (query.r) { state["geoResolution"] = query.r; } - if (Object.hasOwnProperty.call(query, "showStateMap")) { - state.mapDisplayType = "states"; + if (Object.hasOwnProperty.call(query, "showNetwork")) { + state.mapDisplayType = "network"; } if (query.p && state.canTogglePanelLayout && (query.p === "full" || query.p === "grid")) { state["panelLayout"] = query.p; @@ -475,7 +475,7 @@ const checkAndCorrectErrorsInState = (state, metadata, query, tree, viewingNarra }); state.mapDisplayType = mapDisplayType; state.mapDisplayTypesAvailable = mapDisplayTypesAvailable; - if (state.mapDisplayTypesAvailable.length === 1 || state.mapDisplayType === "geo") delete query.showStateMap; + if (state.mapDisplayTypesAvailable.length === 1 || state.mapDisplayType === "geo") delete query.showNetwork; } else { console.warn("JSONs did not include `geoResolutions`"); diff --git a/src/components/controls/geo-resolution.js b/src/components/controls/geo-resolution.js index a6bcbe0b0..911750817 100644 --- a/src/components/controls/geo-resolution.js +++ b/src/components/controls/geo-resolution.js @@ -43,7 +43,7 @@ class GeoResolution extends React.Component { return ( <> - {t("sidebar:Geographic resolution")} + {t("sidebar:Spatial resolution")}