diff --git a/src/App.vue b/src/App.vue index 1b556bd9..141684ee 100644 --- a/src/App.vue +++ b/src/App.vue @@ -31,7 +31,7 @@ store.fetchNetwork( - + diff --git a/src/components/AlertBanner.vue b/src/components/AlertBanner.vue index 5f612962..25873509 100644 --- a/src/components/AlertBanner.vue +++ b/src/components/AlertBanner.vue @@ -27,7 +27,7 @@ watchEffect(async () => { const buttonHref = ref(loadError.value.href); const buttonText = ref(''); watchEffect(async () => { - if (workspace.value !== null && network.value !== null) { + if (workspace.value !== null) { buttonHref.value = `./?workspace=${workspace.value}&network=${network.value}`; buttonText.value = 'Go To Network'; } else if (loadError.value.message === 'There was a network issue when getting data') { diff --git a/src/components/ContextMenu.vue b/src/components/ContextMenu.vue index 9605efd3..dfdd39e9 100644 --- a/src/components/ContextMenu.vue +++ b/src/components/ContextMenu.vue @@ -10,24 +10,20 @@ const { } = storeToRefs(store); function pinSelectedNodes() { - if (network.value !== null) { - network.value.nodes - .filter((node) => selectedNodes.value.includes(node._id)) - .forEach((node) => { - node.fx = node.x; - node.fy = node.y; - }); - } + network.value.nodes + .filter((node) => selectedNodes.value.includes(node._id)) + .forEach((node) => { + node.fx = node.x; + node.fy = node.y; + }); } function unPinSelectedNodes() { - if (network.value !== null) { - network.value.nodes - .filter((node) => selectedNodes.value.includes(node._id)) - .forEach((node) => { - delete node.fx; - delete node.fy; - }); - } + network.value.nodes + .filter((node) => selectedNodes.value.includes(node._id)) + .forEach((node) => { + delete node.fx; + delete node.fy; + }); } diff --git a/src/components/ControlPanel.vue b/src/components/ControlPanel.vue index 9bba90da..78834183 100644 --- a/src/components/ControlPanel.vue +++ b/src/components/ControlPanel.vue @@ -31,20 +31,17 @@ const searchErrors = ref([]); const showMenu = ref(false); const multiVariableList = computed(() => { - if (network.value !== null) { - // Loop through all nodes, flatten the 2d array, and turn it into a set - const allVars: Set = new Set(); - network.value.nodes.forEach((node) => Object.keys(node).forEach((key) => allVars.add(key))); + // Loop through all nodes, flatten the 2d array, and turn it into a set + const allVars: Set = new Set(); + network.value.nodes.forEach((node) => Object.keys(node).forEach((key) => allVars.add(key))); - internalFieldNames.forEach((field) => allVars.delete(field)); - allVars.delete('vx'); - allVars.delete('vy'); - allVars.delete('x'); - allVars.delete('y'); - allVars.delete('index'); - return allVars; - } - return new Set(); + internalFieldNames.forEach((field) => allVars.delete(field)); + allVars.delete('vx'); + allVars.delete('vy'); + allVars.delete('x'); + allVars.delete('y'); + allVars.delete('index'); + return allVars; }); const markerSize = computed({ @@ -52,21 +49,17 @@ const markerSize = computed({ return store.markerSize || 0; }, set(value: number) { - store.setMarkerSize({ markerSize: value, updateProv: false }); + store.setMarkerSize(value, false); }, }); const autocompleteItems = computed(() => { - if (network.value !== null && labelVariable.value !== undefined) { + if (labelVariable.value !== undefined) { return network.value.nodes.map((node) => (node[labelVariable.value || ''])); } return []; }); function exportNetwork() { - if (network.value === null) { - return; - } - const networkToExport = { nodes: network.value.nodes.map((node) => { const newNode = { ...node }; @@ -94,26 +87,24 @@ function exportNetwork() { function search() { searchErrors.value = []; - if (network.value !== null) { - const nodeIDsToSelect = network.value.nodes - .filter((node) => (labelVariable.value !== undefined ? node[labelVariable.value] === searchTerm.value : false)) - .map((node) => node._id); + const nodeIDsToSelect = network.value.nodes + .filter((node) => (labelVariable.value !== undefined ? node[labelVariable.value] === searchTerm.value : false)) + .map((node) => node._id); - if (nodeIDsToSelect.length > 0) { - selectedNodes.value.push(...nodeIDsToSelect); - } else { - searchErrors.value.push('Enter a valid node to search'); - } + if (nodeIDsToSelect.length > 0) { + selectedNodes.value.push(...nodeIDsToSelect); + } else { + searchErrors.value.push('Enter a valid node to search'); } } function updateSliderProv(value: number, type: 'markerSize' | 'fontSize' | 'edgeLength') { if (type === 'markerSize') { - store.setMarkerSize({ markerSize: value, updateProv: true }); + store.setMarkerSize(value, true); } else if (type === 'fontSize') { fontSize.value = value; } else if (type === 'edgeLength') { - store.setEdgeLength({ edgeLength: value, updateProv: true }); + store.setEdgeLength(value, true); } } diff --git a/src/components/LegendChart.vue b/src/components/LegendChart.vue index b3aa0e8b..b1d93cbb 100644 --- a/src/components/LegendChart.vue +++ b/src/components/LegendChart.vue @@ -55,15 +55,9 @@ function isQuantitative(varName: string, type: 'node' | 'edge') { return columnTypes.value[varName] === 'number'; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let nodesOrEdges: any[]; - - if (network.value !== null) { - nodesOrEdges = type === 'node' ? network.value.nodes : network.value.edges; - const uniqueValues = [...new Set(nodesOrEdges.map((element) => parseFloat(element[varName])))]; - return uniqueValues.length > 5; - } - return false; + const nodesOrEdges = type === 'node' ? network.value.nodes : network.value.edges; + const uniqueValues = [...new Set(nodesOrEdges.map((element) => parseFloat(element[varName])))]; + return uniqueValues.length > 5; } function dragStart(event: DragEvent) { @@ -129,10 +123,6 @@ onMounted(() => { let xScale: ScaleLinear | ScaleBand; let yScale: ScaleLinear; - if (network.value === null) { - return; - } - // Process data for bars/histogram if (props.mappedTo === 'size' && nodeSizeScale.value !== null) { // node size xScale = scaleLinear() diff --git a/src/components/LegendPanel.vue b/src/components/LegendPanel.vue index 0eff6a87..155f45b8 100644 --- a/src/components/LegendPanel.vue +++ b/src/components/LegendPanel.vue @@ -33,36 +33,30 @@ function cleanVariableList(list: Set): Set { } const cleanedNodeVariables = computed(() => { - if (network.value !== null) { - // Loop through all nodes, flatten the 2d array, and turn it into a set - const allVars: Set = new Set(); - network.value.nodes.forEach((node: Node) => Object.keys(node).forEach((key) => allVars.add(key))); - - internalFieldNames.forEach((field) => allVars.delete(field)); - allVars.delete('vx'); - allVars.delete('vy'); - allVars.delete('x'); - allVars.delete('y'); - allVars.delete('index'); - return cleanVariableList(allVars); - } - return new Set(); + // Loop through all nodes, flatten the 2d array, and turn it into a set + const allVars: Set = new Set(); + network.value.nodes.forEach((node: Node) => Object.keys(node).forEach((key) => allVars.add(key))); + + internalFieldNames.forEach((field) => allVars.delete(field)); + allVars.delete('vx'); + allVars.delete('vy'); + allVars.delete('x'); + allVars.delete('y'); + allVars.delete('index'); + return cleanVariableList(allVars); }); const cleanedEdgeVariables = computed(() => { - if (network.value !== null) { - // Loop through all edges, flatten the 2d array, and turn it into a set - const allVars: Set = new Set(); - network.value.edges.map((edge: Edge) => Object.keys(edge).forEach((key) => allVars.add(key))); - - internalFieldNames.forEach((field) => allVars.delete(field)); - allVars.delete('source'); - allVars.delete('target'); - allVars.delete('index'); - - return cleanVariableList(allVars); - } - return new Set(); + // Loop through all edges, flatten the 2d array, and turn it into a set + const allVars: Set = new Set(); + network.value.edges.map((edge: Edge) => Object.keys(edge).forEach((key) => allVars.add(key))); + + internalFieldNames.forEach((field) => allVars.delete(field)); + allVars.delete('source'); + allVars.delete('target'); + allVars.delete('index'); + + return cleanVariableList(allVars); }); const attributeLayout = ref(false); diff --git a/src/components/MultiLink.vue b/src/components/MultiLink.vue index 87abd881..52c31ef9 100644 --- a/src/components/MultiLink.vue +++ b/src/components/MultiLink.vue @@ -170,16 +170,14 @@ function dragNode(node: Node, event: MouseEvent) { const dx = eventX - nodeX; const dy = eventY - nodeY; - if (network.value !== null) { - network.value.nodes - .filter((innerNode) => selectedNodes.value.includes(innerNode._id) && innerNode._id !== node._id) - .forEach((innerNode) => { - innerNode.x = (innerNode.x || 0) + dx; - innerNode.y = (innerNode.y || 0) + dy; - innerNode.fx = (innerNode.fx || innerNode.x || 0) + dx; - innerNode.fy = (innerNode.fy || innerNode.y || 0) + dy; - }); - } + network.value.nodes + .filter((innerNode) => selectedNodes.value.includes(innerNode._id) && innerNode._id !== node._id) + .forEach((innerNode) => { + innerNode.x = (innerNode.x || 0) + dx; + innerNode.y = (innerNode.y || 0) + dy; + innerNode.fx = (innerNode.fx || innerNode.x || 0) + dx; + innerNode.fy = (innerNode.fy || innerNode.y || 0) + dy; + }); } node.x = eventX; @@ -240,33 +238,30 @@ function hideTooltip() { } function arcPath(edge: Edge): string { - if (network.value !== null) { - const fromNode = network.value.nodes.find((node) => node._id === edge._from); - const toNode = network.value.nodes.find((node) => node._id === edge._to); + const fromNode = network.value.nodes.find((node) => node._id === edge._from); + const toNode = network.value.nodes.find((node) => node._id === edge._to); - if (fromNode === undefined || toNode === undefined) { - throw new Error('Couldn\'t find the source or target for a edge, didn\'t draw arc.'); - } + if (fromNode === undefined || toNode === undefined) { + throw new Error('Couldn\'t find the source or target for a edge, didn\'t draw arc.'); + } - if (fromNode.x === undefined || fromNode.y === undefined || toNode.x === undefined || toNode.y === undefined) { - throw new Error('_from or _to node didn\'t have an x or a y position.'); - } + if (fromNode.x === undefined || fromNode.y === undefined || toNode.x === undefined || toNode.y === undefined) { + throw new Error('_from or _to node didn\'t have an x or a y position.'); + } - const x1 = fromNode.x + calculateNodeSize(fromNode) / 2; - const y1 = fromNode.y + calculateNodeSize(fromNode) / 2; - const x2 = toNode.x + calculateNodeSize(toNode) / 2; - const y2 = toNode.y + calculateNodeSize(toNode) / 2; + const x1 = fromNode.x + calculateNodeSize(fromNode) / 2; + const y1 = fromNode.y + calculateNodeSize(fromNode) / 2; + const x2 = toNode.x + calculateNodeSize(toNode) / 2; + const y2 = toNode.y + calculateNodeSize(toNode) / 2; - const dx = x2 - x1; - const dy = y2 - y1; - const dr = Math.sqrt(dx * dx + dy * dy); - const sweep = 1; - const xRotation = 0; - const largeArc = 0; + const dx = x2 - x1; + const dy = y2 - y1; + const dr = Math.sqrt(dx * dx + dy * dy); + const sweep = 1; + const xRotation = 0; + const largeArc = 0; - return (`M ${x1}, ${y1} A ${dr}, ${dr} ${xRotation}, ${largeArc}, ${sweep} ${x2},${y2}`); - } - return ''; + return (`M ${x1}, ${y1} A ${dr}, ${dr} ${xRotation}, ${largeArc}, ${sweep} ${x2},${y2}`); } function isSelected(nodeID: string): boolean { @@ -274,20 +269,17 @@ function isSelected(nodeID: string): boolean { } const oneHop = computed(() => { - if (network.value !== null) { - const inNodes = network.value.edges.map((edge) => (selectedNodes.value.includes(edge._to) ? edge._from : null)); - const outNodes = network.value.edges.map((edge) => (selectedNodes.value.includes(edge._from) ? edge._to : null)); + const inNodes = network.value.edges.map((edge) => (selectedNodes.value.includes(edge._to) ? edge._from : null)); + const outNodes = network.value.edges.map((edge) => (selectedNodes.value.includes(edge._from) ? edge._to : null)); - const oneHopNodeIDs: Set = new Set([...outNodes, ...inNodes]); + const oneHopNodeIDs: Set = new Set([...outNodes, ...inNodes]); - // Remove null if it exists - if (oneHopNodeIDs.has(null)) { - oneHopNodeIDs.delete(null); - } - - return oneHopNodeIDs; + // Remove null if it exists + if (oneHopNodeIDs.has(null)) { + oneHopNodeIDs.delete(null); } - return new Set(); + + return oneHopNodeIDs; }); function nodeGroupClass(node: Node): string { if (selectedNodes.value.length > 0) { @@ -358,10 +350,10 @@ function edgeStyle(edge: Edge): string { ); return ` - stroke: ${useCalculatedColorValue ? edgeColorScale.value(calculatedColorValue) : '#888888'}; - stroke-width: ${(edgeWidth > 20 || edgeWidth < 1) ? 0 : edgeWidth}px; - opacity: 0.7; - `; + stroke: ${useCalculatedColorValue ? edgeColorScale.value(calculatedColorValue) : '#888888'}; + stroke-width: ${(edgeWidth > 20 || edgeWidth < 1) ? 0 : edgeWidth}px; + opacity: 0.7; + `; } function glyphFill(node: Node, glyphVar: string) { @@ -440,15 +432,13 @@ function rectSelectDrag(event: MouseEvent) { // Find which nodes are in the box let nodesInRect: Node[] = []; - if (network.value !== null) { - nodesInRect = network.value.nodes.filter((node) => { - const nodeSize = calculateNodeSize(node) / 2; - return (node.x || 0) + nodeSize > boxX1 - && (node.x || 0) + nodeSize < boxX2 - && (node.y || 0) + nodeSize > boxY1 - && (node.y || 0) + nodeSize < boxY2; - }); - } + nodesInRect = network.value.nodes.filter((node) => { + const nodeSize = calculateNodeSize(node) / 2; + return (node.x || 0) + nodeSize > boxX1 + && (node.x || 0) + nodeSize < boxX2 + && (node.y || 0) + nodeSize > boxY1 + && (node.y || 0) + nodeSize < boxY2; + }); // Select the nodes inside the box if there are any nodesInRect.forEach((node) => selectedNodes.value.push(node._id)); @@ -497,23 +487,16 @@ function generateNodePositions(nodes: Node[]) { } }); } -if (network.value !== null) { - generateNodePositions(network.value.nodes); -} +generateNodePositions(network.value.nodes); -const simulationEdges = computed(() => { - if (network.value !== null) { - return network.value.edges.map((edge: Edge) => { - const newEdge: SimulationEdge = { - ...structuredClone(edge), - source: edge._from, - target: edge._to, - }; - return newEdge; - }); - } - return null; -}); +const simulationEdges = computed(() => network.value.edges.map((edge: Edge) => { + const newEdge: SimulationEdge = { + ...structuredClone(edge), + source: edge._from, + target: edge._to, + }; + return newEdge; +})); watch(attributeRanges, () => { if (simulationEdges.value !== null && layoutVars.value.x === null && layoutVars.value.y === null) { const simEdges = simulationEdges.value.filter((edge: Edge) => { @@ -579,7 +562,7 @@ function makePositionScale(axis: 'x' | 'y', type: ColumnType, range: AttributeRa let maxValue = range.max - 1; // subtract 1, because of the + 1 on the legend chart scale // Check IQR for outliers - if (network.value !== null && varName !== null) { + if (varName !== null) { const values = network.value.nodes.map((node) => node[varName]).sort((a, b) => a - b); let q1; @@ -632,12 +615,12 @@ function makePositionScale(axis: 'x' | 'y', type: ColumnType, range: AttributeRa if (varName !== null) { // Set node size smaller - store.setMarkerSize({ markerSize: 10, updateProv: true }); + store.setMarkerSize(10, true); // Clear the label variable labelVariable.value = undefined; - if (network.value !== null && columnTypes.value !== null) { + if (columnTypes.value !== null) { const otherAxisPadding = axis === 'x' ? 80 : 60; if (type === 'number') { @@ -801,7 +784,7 @@ const minimumY = svgEdgePadding; const maximumX = svgDimensions.value.width - svgEdgePadding; const maximumY = svgDimensions.value.height - svgEdgePadding; onMounted(() => { - if (network.value !== null && simulationEdges.value !== null) { + if (simulationEdges.value !== null) { // Make the simulation simulation.value = forceSimulation(network.value.nodes) .on('tick', () => { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 6fa30831..524c75f2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,7 +1,7 @@ // Get the url querystring variables export function getUrlVars() { const url = new URL(window.location.href); - const vars: { [key: string]: string } = {}; + const vars: { [key: string]: string | undefined } = {}; url.searchParams.forEach((value: string, key: string) => { vars[key] = value; diff --git a/src/store/index.ts b/src/store/index.ts index e1b9366a..c6581595 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,407 +1,450 @@ import { defineStore } from 'pinia'; -import { forceCollide } from 'd3-force'; -import { ColumnTypes, NetworkSpec } from 'multinet'; +import { forceCollide, Simulation } from 'd3-force'; +import { ColumnTypes, NetworkSpec, UserSpec } from 'multinet'; import { scaleLinear, scaleOrdinal, scaleSequential } from 'd3-scale'; import { interpolateBlues, interpolateReds, schemeCategory10 } from 'd3-scale-chromatic'; -import { initProvenance } from '@visdesignlab/trrack'; +import { initProvenance, Provenance } from '@visdesignlab/trrack'; import api from '@/api'; import { - Edge, Node, State, NestedVariables, ProvenanceEventTypes, AttributeRange, + Edge, Node, State, NestedVariables, ProvenanceEventTypes, AttributeRanges, LoadError, Network, SimulationEdge, AttributeRange, } from '@/types'; import { undoRedoKeyHandler, updateProvenanceState } from '@/lib/provenanceUtils'; import { isInternalField } from '@/lib/typeUtils'; import { applyForceToSimulation } from '@/lib/d3ForceUtils'; import oauthClient from '@/oauth'; - -export const useStore = defineStore('store', { - state: (): State => ({ - workspaceName: null, - networkName: null, - network: null, - columnTypes: null, - selectedNodes: [], - loadError: { - message: '', - href: '', - }, - simulation: null, - displayCharts: false, - markerSize: 50, - fontSize: 12, - labelVariable: undefined, - selectNeighbors: true, - nestedVariables: { - bar: [], - glyph: [], - }, - edgeVariables: { - width: '', - color: '', - }, - nodeSizeVariable: '', - nodeColorVariable: '', - attributeRanges: {}, - nodeBarColorScale: scaleOrdinal(schemeCategory10), - nodeGlyphColorScale: scaleOrdinal(schemeCategory10), - provenance: null, - directionalEdges: false, - controlsWidth: 256, - simulationRunning: false, - showProvenanceVis: false, - rightClickMenu: { - show: false, - top: 0, - left: 0, - }, - userInfo: null, - edgeLength: 10, - svgDimensions: { - height: 0, - width: 0, - }, - layoutVars: { - x: null, - y: null, - }, - }), - - getters: { - nodeColorScale(state) { - if (state.columnTypes !== null && Object.keys(state.columnTypes).length > 0 && state.columnTypes[state.nodeColorVariable] === 'number') { - const minValue = state.attributeRanges[state.nodeColorVariable].currentMin || state.attributeRanges[state.nodeColorVariable].min; - const maxValue = state.attributeRanges[state.nodeColorVariable].currentMax || state.attributeRanges[state.nodeColorVariable].max; - - return scaleSequential(interpolateBlues) - .domain([minValue, maxValue]); - } - - return state.nodeGlyphColorScale; - }, - - edgeColorScale(state) { - if (state.columnTypes !== null && Object.keys(state.columnTypes).length > 0 && state.columnTypes[state.edgeVariables.color] === 'number') { - const minValue = state.attributeRanges[state.edgeVariables.color].currentMin || state.attributeRanges[state.edgeVariables.color].min; - const maxValue = state.attributeRanges[state.edgeVariables.color].currentMax || state.attributeRanges[state.edgeVariables.color].max; - - return scaleSequential(interpolateReds) - .domain([minValue, maxValue]); - } - - return state.nodeGlyphColorScale; - }, - - nodeSizeScale(state) { - if (state.columnTypes !== null && Object.keys(state.columnTypes).length > 0 && state.columnTypes[state.nodeSizeVariable]) { - const minValue = state.attributeRanges[state.nodeSizeVariable].currentMin || state.attributeRanges[state.nodeSizeVariable].min; - const maxValue = state.attributeRanges[state.nodeSizeVariable].currentMax || state.attributeRanges[state.nodeSizeVariable].max; - - return scaleLinear() - .domain([minValue, maxValue]) - .range([10, 40]); - } - return scaleLinear(); - }, - - edgeWidthScale(state) { - if (state.columnTypes !== null && Object.keys(state.columnTypes).length > 0 && state.columnTypes[state.edgeVariables.width] === 'number') { - const minValue = state.attributeRanges[state.edgeVariables.width].currentMin || state.attributeRanges[state.edgeVariables.width].min; - const maxValue = state.attributeRanges[state.edgeVariables.width].currentMax || state.attributeRanges[state.edgeVariables.width].max; - - return scaleLinear().domain([minValue, maxValue]).range([1, 20]); - } - return scaleLinear(); - }, - }, - - actions: { - async fetchNetwork(workspaceName: string, networkName: string) { - this.workspaceName = workspaceName; - this.networkName = networkName; - - let network: NetworkSpec | undefined; - - // Get all table names - try { - network = await api.network(workspaceName, networkName); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - if (error.status === 404) { - if (workspaceName === undefined || networkName === undefined) { - this.loadError = { - message: 'Workspace and/or network were not defined in the url', - href: 'https://multinet.app', - }; - } else { - this.loadError = { - message: error.statusText, - href: 'https://multinet.app', - }; - } - } else if (error.status === 401) { - this.loadError = { - message: 'You are not authorized to view this workspace', - href: 'https://multinet.app', - }; - } else { - this.loadError = { - message: 'An unexpected error ocurred', - href: 'https://multinet.app', - }; - } - } finally { - if (this.loadError.message === '' && typeof network === 'undefined') { - // Catches CORS errors, issues when DB/API are down, etc. - this.loadError = { - message: 'There was a network issue when getting data', - href: `./?workspace=${workspaceName}&network=${networkName}`, - }; - } - } - - if (network === undefined) { - return; - } - - // Check network size - if (network.node_count > 300) { - this.loadError = { - message: 'The network you are loading is too large', - href: 'https://multinet.app', - }; - } - - if (this.loadError.message !== '') { - return; - } - - // Generate all node table promises - const nodes = await api.nodes(workspaceName, networkName, { offset: 0, limit: 300 }); - - // Generate and resolve edge table promise and extract rows - const edges = await api.edges(workspaceName, networkName, { offset: 0, limit: 1000 }); - - // Build the network object and set it as the network in the store - const networkElements = { - nodes: nodes.results as Node[], - edges: edges.results as Edge[], - }; - this.network = networkElements; - - const networkTables = await api.networkTables(workspaceName, networkName); - // Get the network metadata promises - const metadataPromises: Promise[] = []; - networkTables.forEach((table) => { - metadataPromises.push(api.columnTypes(workspaceName, table.name)); - }); - - // Resolve network metadata promises - const resolvedMetadataPromises = await Promise.all(metadataPromises); - - // Combine all network metadata - const columnTypes: ColumnTypes = {}; - resolvedMetadataPromises.forEach((types) => { - Object.assign(columnTypes, types); - }); - - this.columnTypes = columnTypes; - - // Guess the best label variable and set it +import { computed, ref } from 'vue'; + +export const useStore = defineStore('store', () => { + const workspaceName = ref(''); + const networkName = ref(''); + const network = ref({ nodes: [], edges: [] }); + const columnTypes = ref(null); + const selectedNodes = ref([]); + const loadError = ref({ + message: '', + href: '', + }); + const simulation = ref | null>(null); + const displayCharts = ref(false); + const markerSize = ref(50); + const fontSize = ref(12); + const labelVariable = ref(undefined); + const selectNeighbors = ref(true); + const nestedVariables = ref({ + bar: [], + glyph: [], + }); + const edgeVariables = ref({ + width: '', + color: '', + }); + const nodeSizeVariable = ref(''); + const nodeColorVariable = ref(''); + const attributeRanges = ref({}); + const nodeBarColorScale = ref(scaleOrdinal(schemeCategory10)); + const nodeGlyphColorScale = ref(scaleOrdinal(schemeCategory10)); + const provenance = ref | null>(null); + const directionalEdges = ref(false); + const controlsWidth = ref(256); + const simulationRunning = ref(false); + const showProvenanceVis = ref(false); + const rightClickMenu = ref({ + show: false, + top: 0, + left: 0, + }); + const userInfo = ref(null); + const edgeLength = ref(10); + const svgDimensions = ref({ + height: 0, + width: 0, + }); + const layoutVars = ref<{ x: string | null; y: string | null }>({ + x: null, + y: null, + }); + + const nodeColorScale = computed(() => { + if (columnTypes.value !== null && Object.keys(columnTypes.value).length > 0 && columnTypes.value[nodeColorVariable.value] === 'number') { + const minValue = attributeRanges.value[nodeColorVariable.value].currentMin || attributeRanges.value[nodeColorVariable.value].min; + const maxValue = attributeRanges.value[nodeColorVariable.value].currentMax || attributeRanges.value[nodeColorVariable.value].max; + + return scaleSequential(interpolateBlues) + .domain([minValue, maxValue]); + } + + return nodeGlyphColorScale; + }); + + const edgeColorScale = computed(() => { + if (columnTypes.value !== null && Object.keys(columnTypes.value).length > 0 && columnTypes.value[edgeVariables.value.color] === 'number') { + const minValue = attributeRanges.value[edgeVariables.value.color].currentMin || attributeRanges.value[edgeVariables.value.color].min; + const maxValue = attributeRanges.value[edgeVariables.value.color].currentMax || attributeRanges.value[edgeVariables.value.color].max; + + return scaleSequential(interpolateReds) + .domain([minValue, maxValue]); + } + + return scaleSequential(interpolateReds); + }); + + const nodeSizeScale = computed(() => { + if (columnTypes.value !== null && Object.keys(columnTypes.value).length > 0 && columnTypes.value[nodeSizeVariable.value]) { + const minValue = attributeRanges.value[nodeSizeVariable.value].currentMin || attributeRanges.value[nodeSizeVariable.value].min; + const maxValue = attributeRanges.value[nodeSizeVariable.value].currentMax || attributeRanges.value[nodeSizeVariable.value].max; + + return scaleLinear() + .domain([minValue, maxValue]) + .range([10, 40]); + } + return scaleLinear(); + }); + + const edgeWidthScale = computed(() => { + if (columnTypes.value !== null && Object.keys(columnTypes.value).length > 0 && columnTypes.value[edgeVariables.value.width] === 'number') { + const minValue = attributeRanges.value[edgeVariables.value.width].currentMin || attributeRanges.value[edgeVariables.value.width].min; + const maxValue = attributeRanges.value[edgeVariables.value.width].currentMax || attributeRanges.value[edgeVariables.value.width].max; + + return scaleLinear().domain([minValue, maxValue]).range([1, 20]); + } + return scaleLinear(); + }); + + function guessLabel() { + if (columnTypes.value !== null) { + // Guess the best label variable and set it const allVars: Set = new Set(); - networkElements.nodes.map((node: Node) => Object.keys(node).forEach((key) => allVars.add(key))); - - this.guessLabel(); - }, - - async fetchUserInfo() { - const info = await api.userInfo(); - this.userInfo = info; - }, - - async logout() { - // Perform the server logout. - oauthClient.logout(); - this.userInfo = null; - }, - - releaseNodes() { - if (this.network !== null) { - this.network.nodes.forEach((n: Node) => { - n.fx = null; - n.fy = null; - }); - this.startSimulation(); - } - }, - - startSimulation() { - if (this.simulation !== null) { - this.simulation.alpha(0.2); - this.simulation.restart(); - this.simulationRunning = true; - } - }, - - stopSimulation() { - if (this.simulation !== null) { - this.simulation.stop(); - this.simulationRunning = false; - } - }, - - setMarkerSize(payload: { markerSize: number; updateProv: boolean }) { - const { markerSize, updateProv } = payload; - this.markerSize = markerSize; - - // Apply force to simulation and restart it - applyForceToSimulation( - this.simulation, - 'collision', - forceCollide((markerSize / 2) * 1.5), - ); - - if (this.provenance !== null && updateProv) { - updateProvenanceState(this.$state, 'Set Marker Size'); - } - }, - - setNestedVariables(nestedVariables: NestedVariables) { - const newNestedVars = { - ...nestedVariables, - bar: [...new Set(nestedVariables.bar)], - glyph: [...new Set(nestedVariables.glyph)], + network.value.nodes.forEach((node: Node) => Object.keys(node).forEach((key) => allVars.add(key))); + + // Remove _key from the search + allVars.delete('_key'); + const bestLabelVar = [...allVars] + .find((colName) => !isInternalField(colName) && columnTypes.value?.[colName] === 'label'); + + // Use the label variable we found or _key if we didn't find one + labelVariable.value = bestLabelVar || '_key'; + } + } + + async function fetchNetwork(workspaceNameInput: string | undefined, networkNameInput: string | undefined) { + if (workspaceNameInput === undefined || networkNameInput === undefined) { + loadError.value = { + message: 'Workspace and/or network were not defined in the url', + href: 'https://multinet.app', }; - - // Allow only 2 variables for the glyphs - newNestedVars.glyph.length = Math.min(2, newNestedVars.glyph.length); - - this.nestedVariables = newNestedVars; - }, - - addAttributeRange(attributeRange: AttributeRange) { - this.attributeRanges = { ...this.attributeRanges, [attributeRange.attr]: attributeRange }; - }, - - setEdgeLength(payload: { edgeLength: number; updateProv: boolean }) { - const { edgeLength, updateProv } = payload; - this.edgeLength = edgeLength; - - // Apply force to simulation and restart it - applyForceToSimulation( - this.simulation, - 'edge', - undefined, - edgeLength * 10, - ); - this.startSimulation(); - - if (this.provenance !== null && updateProv) { - updateProvenanceState(this.$state, 'Set Edge Length'); - } - }, - - goToProvenanceNode(node: string) { - if (this.provenance !== null) { - this.provenance.goToNode(node); + return; + } + + workspaceName.value = workspaceNameInput; + networkName.value = networkNameInput; + + let networkRequest: NetworkSpec | undefined; + + // Get all table names + try { + networkRequest = await api.network(workspaceName.value, networkName.value); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (error.status === 404) { + loadError.value = { + message: error.statusText, + href: 'https://multinet.app', + }; + } else if (error.status === 401) { + loadError.value = { + message: 'You are not authorized to view this workspace', + href: 'https://multinet.app', + }; + } else { + loadError.value = { + message: 'An unexpected error ocurred', + href: 'https://multinet.app', + }; } - }, - - createProvenance() { - const storeState = this.$state; - - const stateForProv = JSON.parse(JSON.stringify(this)); - stateForProv.selectedNodes = []; - - this.provenance = initProvenance( - stateForProv, - { loadFromUrl: false }, - ); - - // Add a global observer to watch the state and update the tracked elements in the store - // enables undo/redo + navigating around provenance graph - this.provenance.addGlobalObserver( - () => { - const provenanceState = this.provenance.state; - - const { selectedNodes } = provenanceState; - - // If the sets are not equal (happens when provenance is updated through provenance vis), - // update the store's selectedNodes to match the provenance state - if (selectedNodes.sort().toString() !== storeState.selectedNodes.sort().toString()) { - storeState.selectedNodes = selectedNodes; - } - - // Iterate through vars with primitive data types - [ - 'displayCharts', - 'markerSize', - 'fontSize', - 'labelVariable', - 'nodeSizeVariable', - 'nodeColorVariable', - 'selectNeighbors', - 'directionalEdges', - 'edgeLength', - ].forEach((primitiveVariable) => { - // If not modified, don't update - if (provenanceState[primitiveVariable] === storeState[primitiveVariable]) { - return; - } - - if (primitiveVariable === 'markerSize') { - this.setMarkerSize({ markerSize: provenanceState[primitiveVariable], updateProv: false }); - } else if (primitiveVariable === 'edgeLength') { - this.setEdgeLength({ edgeLength: provenanceState[primitiveVariable], updateProv: false }); - } else if (storeState[primitiveVariable] !== provenanceState[primitiveVariable]) { - storeState[primitiveVariable] = provenanceState[primitiveVariable]; - } - }); - }, - ); - - storeState.provenance.done(); - - // Add keydown listener for undo/redo - document.addEventListener('keydown', (event) => undoRedoKeyHandler(event, storeState)); - }, - - guessLabel() { - if (this.network !== null && this.columnTypes !== null) { - // Guess the best label variable and set it - const allVars: Set = new Set(); - this.network.nodes.forEach((node: Node) => Object.keys(node).forEach((key) => allVars.add(key))); - - // Remove _key from the search - allVars.delete('_key'); - const bestLabelVar = [...allVars] - .find((colName) => !isInternalField(colName) && this.columnTypes?.[colName] === 'label'); - - // Use the label variable we found or _key if we didn't find one - this.labelVariable = bestLabelVar || '_key'; + return; + } finally { + if (loadError.value.message === '' && typeof networkRequest === 'undefined') { + // Catches CORS errors, issues when DB/API are down, etc. + loadError.value = { + message: 'There was a network issue when getting data', + href: `./?workspace=${workspaceName}&network=${networkName}`, + }; } - }, + } - applyVariableLayout(payload: { varName: string | null; axis: 'x' | 'y'}) { - const { - varName, axis, - } = payload; - const otherAxis = axis === 'x' ? 'y' : 'x'; + if (network === undefined) { + return; + } - const updatedLayoutVars = { [axis]: varName, [otherAxis]: this.layoutVars[otherAxis] } as { - x: string | null; - y: string | null; + // Check network size + if (networkRequest.node_count > 300) { + loadError.value = { + message: 'The network you are loading is too large', + href: 'https://multinet.app', }; - this.layoutVars = updatedLayoutVars; - - // Reapply the layout if there is still a variable - if (varName === null && this.layoutVars[otherAxis] !== null) { - // Set marker size to 11 to trigger re-render (will get reset to 10 in dispatch again) - this.markerSize = 11; - - this.applyVariableLayout({ varName: this.layoutVars[otherAxis], axis: otherAxis }); - } else if (varName === null && this.layoutVars[otherAxis] === null) { - // If both null, release - this.releaseNodes(); - } - }, - }, + } + + if (loadError.value.message !== '') { + return; + } + + // Generate all node table promises + const nodes = await api.nodes(workspaceName.value, networkName.value, { offset: 0, limit: 300 }); + + // Generate and resolve edge table promise and extract rows + const edges = await api.edges(workspaceName.value, networkName.value, { offset: 0, limit: 1000 }); + + // Build the network object and set it as the network in the store + const networkElements = { + nodes: nodes.results as Node[], + edges: edges.results as Edge[], + }; + network.value = networkElements; + + const networkTables = await api.networkTables(workspaceName.value, networkName.value); + // Get the network metadata promises + const metadataPromises: Promise[] = []; + networkTables.forEach((table) => { + metadataPromises.push(api.columnTypes(workspaceName.value, table.name)); + }); + + // Resolve network metadata promises + const resolvedMetadataPromises = await Promise.all(metadataPromises); + + // Combine all network metadata + const columnTypesFromRequests: ColumnTypes = {}; + resolvedMetadataPromises.forEach((types) => { + Object.assign(columnTypesFromRequests, types); + }); + + columnTypes.value = columnTypesFromRequests; + + // Guess the best label variable and set it + const allVars: Set = new Set(); + networkElements.nodes.map((node: Node) => Object.keys(node).forEach((key) => allVars.add(key))); + + guessLabel(); + } + + async function fetchUserInfo() { + const info = await api.userInfo(); + userInfo.value = info; + } + + async function logout() { + // Perform the server logout. + oauthClient.logout(); + userInfo.value = null; + } + + function startSimulation() { + if (simulation.value !== null) { + simulation.value.alpha(0.2); + simulation.value.restart(); + simulationRunning.value = true; + } + } + + function stopSimulation() { + if (simulation.value !== null) { + simulation.value.stop(); + simulationRunning.value = false; + } + } + + function releaseNodes() { + network.value.nodes.forEach((n: Node) => { + n.fx = null; + n.fy = null; + }); + startSimulation(); + } + + function setMarkerSize(markerSizeInput: number, updateProv: boolean) { + markerSize.value = markerSizeInput; + + // Apply force to simulation and restart it + applyForceToSimulation( + simulation.value, + 'collision', + forceCollide((markerSize.value / 2) * 1.5), + ); + + if (provenance.value !== null && updateProv) { + // updateProvenanceState(this.$state, 'Set Marker Size'); + } + } + + function setNestedVariables(nestedVariablesInput: NestedVariables) { + const newNestedVars = { + ...nestedVariables.value, + bar: [...new Set(nestedVariablesInput.bar)], + glyph: [...new Set(nestedVariablesInput.glyph)], + }; + + // Allow only 2 variables for the glyphs + newNestedVars.glyph.length = Math.min(2, newNestedVars.glyph.length); + + nestedVariables.value = newNestedVars; + } + + function addAttributeRange(attributeRange: AttributeRange) { + attributeRanges.value = { ...attributeRanges.value, [attributeRange.attr]: attributeRange }; + } + + function setEdgeLength(edgeLengthInput: number, updateProv: boolean) { + edgeLength.value = edgeLengthInput; + + // Apply force to simulation and restart it + applyForceToSimulation( + simulation.value, + 'edge', + undefined, + edgeLength.value * 10, + ); + startSimulation(); + + if (provenance.value !== null && updateProv) { + // updateProvenanceState(this.$state, 'Set Edge Length'); + } + } + + function goToProvenanceNode(node: string) { + if (provenance.value !== null) { + provenance.value.goToNode(node); + } + } + + function createProvenance() { + // const storeState = this.$state; + + // const stateForProv = JSON.parse(JSON.stringify(this)); + // stateForProv.selectedNodes = []; + + // provenance.value = initProvenance( + // stateForProv, + // { loadFromUrl: false }, + // ); + + // // Add a global observer to watch the state and update the tracked elements in the store + // // enables undo/redo + navigating around provenance graph + // provenance.value.addGlobalObserver( + // () => { + // const provenanceState = provenance.value.state; + + // const { selectedNodes } = provenanceState; + + // // If the sets are not equal (happens when provenance is updated through provenance vis), + // // update the store's selectedNodes to match the provenance state + // if (selectedNodes.sort().toString() !== storeState.selectedNodes.sort().toString()) { + // storeState.selectedNodes = selectedNodes; + // } + + // // Iterate through vars with primitive data types + // [ + // 'displayCharts', + // 'markerSize', + // 'fontSize', + // 'labelVariable', + // 'nodeSizeVariable', + // 'nodeColorVariable', + // 'selectNeighbors', + // 'directionalEdges', + // 'edgeLength', + // ].forEach((primitiveVariable) => { + // // If not modified, don't update + // if (provenanceState[primitiveVariable] === storeState[primitiveVariable]) { + // return; + // } + + // if (primitiveVariable === 'markerSize') { + // setMarkerSize(provenanceState[primitiveVariable], false); + // } else if (primitiveVariable === 'edgeLength') { + // setEdgeLength({ edgeLength: provenanceState[primitiveVariable], updateProv: false }); + // } else if (storeState[primitiveVariable] !== provenanceState[primitiveVariable]) { + // storeState[primitiveVariable] = provenanceState[primitiveVariable]; + // } + // }); + // }, + // ); + + // storeState.provenance.done(); + + // // Add keydown listener for undo/redo + // document.addEventListener('keydown', (event) => undoRedoKeyHandler(event, storeState)); + } + + function applyVariableLayout(payload: { varName: string | null; axis: 'x' | 'y'}) { + const { + varName, axis, + } = payload; + const otherAxis = axis === 'x' ? 'y' : 'x'; + + const updatedLayoutVars = { [axis]: varName, [otherAxis]: layoutVars.value[otherAxis] } as { + x: string | null; + y: string | null; + }; + layoutVars.value = updatedLayoutVars; + + // Reapply the layout if there is still a variable + if (varName === null && layoutVars.value[otherAxis] !== null) { + // Set marker size to 11 to trigger re-render (will get reset to 10 in dispatch again) + markerSize.value = 11; + + applyVariableLayout({ varName: layoutVars.value[otherAxis], axis: otherAxis }); + } else if (varName === null && layoutVars.value[otherAxis] === null) { + // If both null, release + releaseNodes(); + } + } + + return { + workspaceName, + networkName, + network, + columnTypes, + selectedNodes, + loadError, + simulation, + displayCharts, + markerSize, + fontSize, + labelVariable, + selectNeighbors, + nestedVariables, + edgeVariables, + nodeSizeVariable, + nodeColorVariable, + attributeRanges, + nodeBarColorScale, + nodeGlyphColorScale, + provenance, + directionalEdges, + controlsWidth, + simulationRunning, + showProvenanceVis, + rightClickMenu, + userInfo, + edgeLength, + svgDimensions, + layoutVars, + nodeColorScale, + edgeColorScale, + nodeSizeScale, + edgeWidthScale, + fetchNetwork, + fetchUserInfo, + logout, + startSimulation, + stopSimulation, + releaseNodes, + setMarkerSize, + setNestedVariables, + addAttributeRange, + setEdgeLength, + goToProvenanceNode, + createProvenance, + guessLabel, + applyVariableLayout, + }; });