diff --git a/package.json b/package.json index 27397da1c..edac5d7ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neodash", - "version": "2.0.4", + "version": "2.0.5", "description": "NeoDash - Neo4j Dashboard Builder", "neo4jDesktop": { "apiVersion": "^1.2.0" @@ -64,13 +64,16 @@ }, "dependencies": { "@babel/runtime": "^7.14.6", + "@emotion/react": "^11.7.1", + "@emotion/styled": "^11.6.0", "@material-ui/core": "^4.12.3", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", "@material-ui/styles": "^4.11.4", "@material-ui/system": "^4.12.1", "@material-ui/utils": "^4.11.2", - "@mui/x-data-grid": "^4.0.1", + "@mui/material": "^5.2.7", + "@mui/x-data-grid": "^5.2.1", "@react-leaflet/core": "1.0.2", "codemirror": "^5.63.3", "cypher-codemirror": "^1.1.7", diff --git a/public/embed-test.html b/public/embed-test.html index 937ad095b..2b2f75355 100644 --- a/public/embed-test.html +++ b/public/embed-test.html @@ -6,7 +6,7 @@ -

I am an iFrame of the page located at http://neodash.graphapp.io/embed-test.html

+

I am an iFrame of the page located at https://neodash.graphapp.io/embed-test.html

I'm embedded directly into a dashboard, and dynamically passed the user-made parameter selections.

I will not refresh when selections are updated, but, I can see variables change.

You can use me to embed external visualizations that are updated together with other charts.

diff --git a/public/style.css b/public/style.css index 0f4f851f7..8d5a47acd 100644 --- a/public/style.css +++ b/public/style.css @@ -53,12 +53,12 @@ .MuiChip-root:before { border: none !important; } -.MuiDataGrid-columnsContainer { +/* .MuiDataGrid-columnHeaders { min-height: 32px !important; max-height: 32px !important; line-height: 32px !important; - /* border-top: 1px solid rgba(224, 224, 224, 1); */ } + .MuiDataGrid-columnSeparator{ height: 32px; } @@ -70,6 +70,9 @@ overflow-y: hidden !important; overflow-x: hidden !important; } +.MuiTablePagination-root{ + margin-top: -10px; +} */ .MuiTablePagination-root{ margin-top: -10px; } diff --git a/release-notes.md b/release-notes.md index 98211999f..4894ee571 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,3 +1,21 @@ + +## NeoDash 2.0.5 +Graph report: +- Fixed node position after dragging nodes. +- Added option to 'lock' graph views, storing the current positions of the nodes in the graph. +- Added experimental graph layouts. + +Table: +- Fixed bug where the report freezes for very wide tables. +- Added support for rendering native/custom Neo4j types in the table. + +Parameter select: +- Fixed issue where the dashboard crashes for slow connections. + +Editor: +- Added button to create a debug file from the 'About' screen. + + ## NeoDash 2.0.4 New features: - Added option dashboard setting to let users view reports in a fullscreen pop-up. diff --git a/src/application/Application.tsx b/src/application/Application.tsx index 59f5c1ac9..de5a13ce9 100644 --- a/src/application/Application.tsx +++ b/src/application/Application.tsx @@ -10,7 +10,7 @@ import NeoNotificationModal from '../modal/NotificationModal'; import NeoWelcomeScreenModal from '../modal/WelcomeScreenModal'; import { removeReportRequest } from '../page/PageThunks'; import { connect } from 'react-redux'; -import { applicationGetConnection, applicationGetShareDetails, applicationGetOldDashboard, applicationHasNeo4jDesktopConnection, applicationHasAboutModalOpen, applicationHasCachedDashboard, applicationHasConnectionModalOpen, applicationIsConnected, applicationHasWelcomeScreenOpen } from '../application/ApplicationSelectors'; +import { applicationGetConnection, applicationGetShareDetails, applicationGetOldDashboard, applicationHasNeo4jDesktopConnection, applicationHasAboutModalOpen, applicationHasCachedDashboard, applicationHasConnectionModalOpen, applicationIsConnected, applicationHasWelcomeScreenOpen, applicationGetDebugState } from '../application/ApplicationSelectors'; import { createConnectionThunk, createConnectionFromDesktopIntegrationThunk, setDatabaseFromNeo4jDesktopIntegrationThunk, handleSharedDashboardsThunk, onConfirmLoadSharedDashboardThunk } from '../application/ApplicationThunks'; import { clearDesktopConnectionProperties, clearNotification, resetShareDetails, setAboutModalOpen, setConnected, setConnectionModalOpen, setDashboardToLoadAfterConnecting, setOldDashboard, setStandAloneMode, setWelcomeScreenOpen } from '../application/ApplicationActions'; import { resetDashboardState } from '../dashboard/DashboardActions'; @@ -29,7 +29,7 @@ import { NeoLoadSharedDashboardModal } from '../modal/LoadSharedDashboardModal'; const Application = ({ connection, connected, hasCachedDashboard, oldDashboard, clearOldDashboard, connectionModalOpen, aboutModalOpen, loadDashboard, hasNeo4jDesktopConnection, shareDetails, createConnection, createConnectionFromDesktopIntegration, onResetShareDetails, onConfirmLoadSharedDashboard, - initializeApplication, resetDashboard, onAboutModalOpen, onAboutModalClose, + initializeApplication, resetDashboard, onAboutModalOpen, onAboutModalClose, getDebugState, welcomeScreenOpen, setWelcomeScreenOpen, onConnectionModalOpen, onConnectionModalClose }) => { const [initialized, setInitialized] = React.useState(false); @@ -48,7 +48,8 @@ const Application = ({ connection, connected, hasCachedDashboard, oldDashboard, {(connected) ? : <>} + handleClose={onAboutModalClose} + getDebugState={getDebugState}> ({ aboutModalOpen: applicationHasAboutModalOpen(state), welcomeScreenOpen: applicationHasWelcomeScreenOpen(state), hasCachedDashboard: applicationHasCachedDashboard(state), + getDebugState: () => {return applicationGetDebugState(state)}, hasNeo4jDesktopConnection: applicationHasNeo4jDesktopConnection(state), }); diff --git a/src/application/ApplicationSelectors.tsx b/src/application/ApplicationSelectors.tsx index 98d2e5989..ce5015fc5 100644 --- a/src/application/ApplicationSelectors.tsx +++ b/src/application/ApplicationSelectors.tsx @@ -55,4 +55,16 @@ export const applicationHasCachedDashboard = (state: any) => { return false; } return !_.isEqual(state.dashboard, initialState); +} + +/** + * Deep-copy the current state, and remove the password. + */ +export const applicationGetDebugState = (state: any) => { + const copy = JSON.parse(JSON.stringify(state)); + copy.application.connection.password = "************"; + if(copy.application.desktopConnection){ + copy.application.desktopConnection.password = "************"; + } + return copy; } \ No newline at end of file diff --git a/src/chart/GraphChart.tsx b/src/chart/GraphChart.tsx index b8eb7dede..453103f2b 100644 --- a/src/chart/GraphChart.tsx +++ b/src/chart/GraphChart.tsx @@ -8,10 +8,19 @@ import { categoricalColorSchemes } from '../config/ColorConfig'; import { ChartProps } from './Chart'; import { valueIsArray, valueIsNode, valueIsRelationship, valueIsPath } from '../report/RecordProcessing'; import { NeoGraphItemInspectModal } from '../modal/GraphItemInspectModal'; +import LockIcon from '@material-ui/icons/Lock'; +import LockOpenIcon from '@material-ui/icons/LockOpen'; +import LockTwoToneIcon from '@material-ui/icons/LockTwoTone'; +import { Tooltip } from '@material-ui/core'; const update = (state, mutations) => Object.assign({}, state, mutations) +const layouts = { + "force-directed": undefined, + "tree": "td", + "radial": "radialout" +}; const NeoGraphChart = (props: ChartProps) => { // TODO force graph on page switch @@ -46,11 +55,19 @@ const NeoGraphChart = (props: ChartProps) => { const nodeColorScheme = props.settings && props.settings.nodeColorScheme ? props.settings.nodeColorScheme : "neodash"; const showPropertiesOnHover = props.settings && props.settings.showPropertiesOnHover !== undefined ? props.settings.showPropertiesOnHover : true; const showPropertiesOnClick = props.settings && props.settings.showPropertiesOnClick !== undefined ? props.settings.showPropertiesOnClick : true; + const fixNodeAfterDrag = props.settings && props.settings.fixNodeAfterDrag !== undefined ? props.settings.fixNodeAfterDrag : true; + const layout = props.settings && props.settings.layout !== undefined ? props.settings.layout : "force-directed"; + const lockable = props.settings && props.settings.lockable !== undefined ? props.settings.lockable : true; const selfLoopRotationDegrees = 45; const rightClickToExpandNodes = false; // TODO - this isn't working properly yet, disable it. const defaultNodeColor = "lightgrey"; // Color of nodes without labels const [data, setData] = React.useState({ nodes: [], links: [] }); + if(props.settings.nodePositions == undefined){ + props.settings.nodePositions = {}; + } + var nodePositions = props.settings && props.settings.nodePositions; + const [frozen, setFrozen] = React.useState(props.settings && props.settings.frozen !== undefined ? props.settings.frozen : false); const [extraRecords, setExtraRecords] = React.useState([]); useEffect(() => { @@ -87,6 +104,10 @@ const NeoGraphChart = (props: ChartProps) => { properties: value.properties, lastLabel: value.labels[value.labels.length - 1] }; + if (frozen && nodePositions && nodePositions[value.identity.low]) { + nodes[value.identity.low]["fx"] = nodePositions[value.identity.low][0]; + nodes[value.identity.low]["fy"] = nodePositions[value.identity.low][1]; + } } else if (valueIsRelationship(value)) { if (links[value.start.low + "," + value.end.low] == undefined) { links[value.start.low + "," + value.end.low] = []; @@ -154,12 +175,14 @@ const NeoGraphChart = (props: ChartProps) => { } else { // If we also have edges from the target to the source, adjust curvatures accordingly. const mirroredNodePair = links[link.target + "," + link.source]; - if (!mirroredNodePair){ + if (!mirroredNodePair) { return update(link, { curvature: getCurvature(i, nodePair.length) }); - }else{ - return update(link, { curvature: (link.source > link.target ? 1 : -1) * - getCurvature(link.source > link.target ? i : i + mirroredNodePair.length, - nodePair.length + mirroredNodePair.length) }); + } else { + return update(link, { + curvature: (link.source > link.target ? 1 : -1) * + getCurvature(link.source > link.target ? i : i + mirroredNodePair.length, + nodePair.length + mirroredNodePair.length) + }); } } }); @@ -181,7 +204,7 @@ const NeoGraphChart = (props: ChartProps) => { } const generateTooltip = (value) => { - const tooltip =
{value.labels ? (value.labels.length > 0 ? value.labels.join(", ") : "Node") : value.type}{Object.keys(value.properties).length == 0 ? : Object.keys(value.properties).map((k, i) => )}
(No properties)
{k.toString()}:{(value.properties[k].toString().length <= 30) ? value.properties[k].toString() : value.properties[k].toString().substring(0,40) +"..."}
; + const tooltip =
{value.labels ? (value.labels.length > 0 ? value.labels.join(", ") : "Node") : value.type}{Object.keys(value.properties).length == 0 ? : Object.keys(value.properties).map((k, i) => )}
(No properties)
{k.toString()}:{(value.properties[k].toString().length <= 30) ? value.properties[k].toString() : value.properties[k].toString().substring(0, 40) + "..."}
; return ReactDOMServer.renderToString(tooltip); } @@ -206,7 +229,7 @@ const NeoGraphChart = (props: ChartProps) => { }, []); const showPopup = useCallback(item => { - if(showPropertiesOnClick){ + if (showPropertiesOnClick) { setInspectItem(item); handleOpen(); } @@ -219,68 +242,117 @@ const NeoGraphChart = (props: ChartProps) => { return ( -
- link.width} - linkLabel={link => showPropertiesOnHover ? `
${generateTooltip(link)}
` : ""} - nodeLabel={node => showPropertiesOnHover ? `
${generateTooltip(node)}
` : ""} - nodeVal={node => node.size} - onNodeClick={showPopup} - onLinkClick={showPopup} - onNodeRightClick={handleExpand} - nodeCanvasObjectMode={() => "after"} - nodeCanvasObject={(node, ctx, globalScale) => { - const label = (props.selection && props.selection[node.lastLabel]) ? renderNodeLabel(node) : ""; - const fontSize = nodeLabelFontSize; - ctx.font = `${fontSize}px Sans-Serif`; - ctx.fillStyle = nodeLabelColor; - ctx.textAlign = "center"; - ctx.fillText(label, node.x, node.y + 1); - }} - linkCanvasObjectMode={() => "after"} - linkCanvasObject={(link, ctx, globalScale) => { - const label = link.properties.name || link.type || link.id; - const fontSize = relLabelFontSize; - ctx.font = `${fontSize}px Sans-Serif`; - ctx.fillStyle = relLabelColor; - if (link.target != link.source) { - const lenX = (link.target.x - link.source.x); - const lenY = (link.target.y - link.source.y); - const posX = link.target.x - lenX / 2; - const posY = link.target.y - lenY / 2; - const length = Math.sqrt(lenX * lenX + lenY * lenY) - const angle = Math.atan(lenY / lenX) - ctx.save(); - ctx.translate(posX, posY); - ctx.rotate(angle); - // Mirrors the curvatures when the label is upside down. - const mirror = (link.source.x > link.target.x) ? 1 : -1; + <> +
+ {lockable ? (frozen ? + + { + setFrozen(false); + if (props.settings) { + props.settings.frozen = false; + } + }} style={{ fontSize: "1.3rem", opacity: 0.6, bottom: 12, right: 12, position: "absolute", zIndex: 5 }} color="disabled" fontSize="small"> + + : + + { + if (nodePositions == undefined) { + nodePositions = {}; + } + setFrozen(true); + if (props.settings) { + props.settings.frozen = true; + } + }} style={{ fontSize: "1.3rem", opacity: 0.6, bottom: 12, right: 12, position: "absolute", zIndex: 5 }} color="disabled" fontSize="small"> + + ) : <>} + link.width} + linkLabel={link => showPropertiesOnHover ? `
${generateTooltip(link)}
` : ""} + nodeLabel={node => showPropertiesOnHover ? `
${generateTooltip(node)}
` : ""} + nodeVal={node => node.size} + onNodeClick={showPopup} + onLinkClick={showPopup} + onNodeRightClick={handleExpand} + onNodeDragEnd={node => { + if (fixNodeAfterDrag) { + node.fx = node.x; + node.fy = node.y; + } + if (frozen) { + if (nodePositions == undefined) { + nodePositions = {}; + } + nodePositions["" + node.id] = [node.x, node.y]; + } + }} + nodeCanvasObjectMode={() => "after"} + nodeCanvasObject={(node, ctx, globalScale) => { + const label = (props.selection && props.selection[node.lastLabel]) ? renderNodeLabel(node) : ""; + const fontSize = nodeLabelFontSize; + ctx.font = `${fontSize}px Sans-Serif`; + ctx.fillStyle = nodeLabelColor; ctx.textAlign = "center"; - if (link.curvature) { - ctx.fillText(label, 0, mirror * length * link.curvature * 0.5); + ctx.fillText(label, node.x, node.y + 1); + if (frozen && !node.fx && !node.fy && nodePositions) { + node.fx = node.x; + node.fy = node.y; + nodePositions["" + node.id] = [node.x, node.y]; + } + if (!frozen && node.fx && node.fy && nodePositions && nodePositions[node.id]) { + nodePositions[node.id] = undefined; + node.fx = undefined; + node.fy = undefined; + } + + }} + linkCanvasObjectMode={() => "after"} + linkCanvasObject={(link, ctx, globalScale) => { + const label = link.properties.name || link.type || link.id; + const fontSize = relLabelFontSize; + ctx.font = `${fontSize}px Sans-Serif`; + ctx.fillStyle = relLabelColor; + if (link.target != link.source) { + const lenX = (link.target.x - link.source.x); + const lenY = (link.target.y - link.source.y); + const posX = link.target.x - lenX / 2; + const posY = link.target.y - lenY / 2; + const length = Math.sqrt(lenX * lenX + lenY * lenY) + const angle = Math.atan(lenY / lenX) + ctx.save(); + ctx.translate(posX, posY); + ctx.rotate(angle); + // Mirrors the curvatures when the label is upside down. + const mirror = (link.source.x > link.target.x) ? 1 : -1; + ctx.textAlign = "center"; + if (link.curvature) { + ctx.fillText(label, 0, mirror * length * link.curvature * 0.5); + } else { + ctx.fillText(label, 0, 0); + } + ctx.restore(); } else { - ctx.fillText(label, 0, 0); + ctx.save(); + ctx.translate(link.source.x, link.source.y); + ctx.rotate(Math.PI * selfLoopRotationDegrees / 180); + ctx.textAlign = "center"; + ctx.fillText(label, 0, -18.7 + -37.1 * (link.curvature - 0.5)); + ctx.restore(); } - ctx.restore(); - } else { - ctx.save(); - ctx.translate(link.source.x, link.source.y); - ctx.rotate(Math.PI * selfLoopRotationDegrees / 180); - ctx.textAlign = "center"; - ctx.fillText(label, 0, -18.7 + -37.1 * (link.curvature - 0.5)); - ctx.restore(); - } - }} - graphData={width ? data : { nodes: [], links: [] }} - /> - -
+ }} + graphData={width ? data : { nodes: [], links: [] }} + /> + + +
+ ); } diff --git a/src/chart/SingleValueChart.tsx b/src/chart/SingleValueChart.tsx index 5311a9e46..971f803f7 100644 --- a/src/chart/SingleValueChart.tsx +++ b/src/chart/SingleValueChart.tsx @@ -11,7 +11,7 @@ const NeoSingleValueChart = (props: ChartProps) => { const color = props.settings && props.settings.color ? props.settings.color : "rgba(0, 0, 0, 0.87)"; const textAlign = props.settings && props.settings.textAlign ? props.settings.textAlign : "left"; - const value = (records && records[0] && records[0]["_fields"]) ? records[0]["_fields"][0].toString() : ""; + const value = (records && records[0] && records[0]["_fields"] && records[0]["_fields"][0]) ? records[0]["_fields"][0].toString() : ""; return
{value}
; diff --git a/src/chart/TableChart.tsx b/src/chart/TableChart.tsx index d7e3dc46b..4db2dd1d2 100644 --- a/src/chart/TableChart.tsx +++ b/src/chart/TableChart.tsx @@ -4,7 +4,7 @@ import { Chip } from '@material-ui/core'; import { withStyles } from '@material-ui/core/styles'; import Tooltip from '@material-ui/core/Tooltip'; import { ChartProps } from './Chart'; -import { valueIsArray, valueIsNode, valueIsRelationship, valueIsPath, valueIsObject } from '../report/RecordProcessing'; +import { valueIsNode, valueIsRelationship, getRecordType } from '../report/RecordProcessing'; function addDirection(relationship, start) { relationship.direction = (relationship.start.low == start.identity.low); @@ -22,41 +22,109 @@ const HtmlTooltip = withStyles((theme) => ({ }, }))(Tooltip); -function RenderTableValue(value, key = 0) { - if (value == undefined) { - return ""; - } - if (valueIsArray(value)) { - const mapped = value.map((v, i) => { - return
- {RenderTableValue(v)} - {i < value.length - 1 && !valueIsNode(v) && !valueIsRelationship(v) ? : <>} -
- }); - return mapped; - } else if (valueIsNode(value)) { - return {value.labels.length > 0 ? value.labels.join(", ") : "Node"}{Object.keys(value.properties).length == 0 ? : Object.keys(value.properties).map((k, i) => )}
(No properties)
{k.toString()}:{value.properties[k].toString()}
}> - 0 ? value.labels.join(", ") : "Node"} /> -
- } else if (valueIsRelationship(value)) { - return {value.type}{Object.keys(value.properties).length == 0 ? : Object.keys(value.properties).map((k, i) => )}
(No properties)
{k.toString()}:{value.properties[k].toString()}
}> - -
- } else if (valueIsPath(value)) { - return value.segments.map((segment, i) => { - return RenderTableValue((i < value.segments.length - 1) ? - [segment.start, addDirection(segment.relationship, segment.start)] : - [segment.start, addDirection(segment.relationship, segment.start), segment.end], i) - }); - } else if (valueIsObject(value)) { - return JSON.stringify(value); - } +function RenderNode(value, key = 0) { + return {value.labels.length > 0 ? value.labels.join(", ") : "Node"}{Object.keys(value.properties).length == 0 ? : Object.keys(value.properties).map((k, i) => )}
(No properties)
{k.toString()}:{value.properties[k].toString()}
}> + 0 ? value.labels.join(", ") : "Node"} /> +
+} + +function RenderRelationship(value, key = 0) { + return {value.type}{Object.keys(value.properties).length == 0 ? : Object.keys(value.properties).map((k, i) => )}
(No properties)
{k.toString()}:{value.properties[k].toString()}
}> + +
+} + +function RenderPath(value) { + return value.segments.map((segment, i) => { + return RenderSubValue((i < value.segments.length - 1) ? + [segment.start, addDirection(segment.relationship, segment.start)] : + [segment.start, addDirection(segment.relationship, segment.start), segment.end], i) + }); +} + +function RenderArray(value) { + const mapped = value.map((v, i) => { + return
+ {RenderSubValue(v)} + {i < value.length - 1 && !valueIsNode(v) && !valueIsRelationship(v) ? : <>} +
+ }); + return mapped; +} + +function RenderString(value) { const str = value.toString(); if (str.startsWith("http") || str.startsWith("https")) { return {str}; } return str; } + +const customColumnProperties: any = { + "node": { + type: 'string', + renderCell: (c) => RenderNode(c.value), + }, + "relationship": { + type: 'string', + renderCell: (c) => RenderRelationship(c.value), + }, + "path": { + type: 'string', + renderCell: (c) => RenderPath(c.value), + }, + "object": { + type: 'string', + // valueGetter enables sorting and filtering on string values inside the object + valueGetter: (c) => { return JSON.stringify(c.value) }, + }, + "array": { + type: 'string', + renderCell: (c) => RenderArray(c.value), + }, + "string": { + type: 'string', + renderCell: (c) => RenderString(c.value), + }, + "null": { + type: 'string' + }, + "undefined": { + type: 'string' + } +}; + +function ApplyColumnType(column, value) { + column.type = getRecordType(value); + const columnProperties = customColumnProperties[column.type]; + + if (columnProperties) { + column = { ...column, ...columnProperties } + } + + return column; +} + +function RenderSubValue(value, key = 0) { + + if (value == undefined) { + return ""; + } + + const type = getRecordType(value); + const columnProperties = customColumnProperties[type]; + + if (columnProperties) { + if (columnProperties.renderCell) { + return columnProperties.renderCell({ value: value }); + } else if (columnProperties.valueGetter) { + return columnProperties.valueGetter({ value: value }); + } + } + + return RenderString(value); +} + const NeoTableChart = (props: ChartProps) => { const fullscreen = props.fullscreen ? props.fullscreen : false; @@ -74,15 +142,15 @@ const NeoTableChart = (props: ChartProps) => { } const columns = props.records[0].keys.map((key, i) => { - return { + const value = props.records[0].get(key); + return ApplyColumnType({ field: key, headerName: key, headerClassName: 'table-small-header', - renderCell: (c) => RenderTableValue(c.value), disableColumnSelector: true, - flex: columnWidths ? columnWidths[i] % columnWidths.length : 1, + flex: (columnWidths && i < columnWidths.length) ? columnWidths[i] : 1, disableClickEventBubbling: true - } + }, value) }) const rows = props.records.map((record, rownumber) => { @@ -92,6 +160,7 @@ const NeoTableChart = (props: ChartProps) => { return (
{ +export const NeoAboutModal = ({ open, handleClose, getDebugState }) => { const app = "NeoDash - Neo4j Dashboard Builder"; const email = "niels.dejong@neo4j.com"; - const version = "2.0.4"; + const version = "2.0.5"; + + const downloadDebugFile = () => { + const element = document.createElement("a"); + const state = getDebugState(); + state["version"] = version; + const file = new Blob([JSON.stringify(state, null, 2)], { type: 'text/plain' }); + element.href = URL.createObjectURL(file); + element.download = "neodash-debug-state.json"; + document.body.appendChild(element); // Required for this to work in FireFox + element.click(); + } return (
@@ -45,7 +57,7 @@ export const NeoAboutModal = ({ open, handleClose }) => {

Extending NeoDash

NeoDash is built with React and use-neo4j, - It uses charts to power some of the visualizations.
+ It uses charts to power some of the visualizations, and openstreetmap for the map view.
You can also extend NeoDash with your own visualizations. Check out the developer guide in the project repository.

Contact

@@ -53,7 +65,26 @@ export const NeoAboutModal = ({ open, handleClose }) => { or by e-mail at {email}.

- v{version} +
+ + + + + +
+ + + v{version} +
@@ -62,4 +93,3 @@ export const NeoAboutModal = ({ open, handleClose }) => { export default (NeoAboutModal); - diff --git a/src/modal/WelcomeScreenModal.tsx b/src/modal/WelcomeScreenModal.tsx index dc7fad2c8..a7dba408a 100644 --- a/src/modal/WelcomeScreenModal.tsx +++ b/src/modal/WelcomeScreenModal.tsx @@ -91,7 +91,7 @@ export const NeoWelcomeScreenModal = ({ welcomeScreenOpen, setWelcomeScreenOpen, } - +