diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index c0968736..2a362816 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -139,3 +139,20 @@ HTTPS connection. ```bash ./build/ratel -tls_crt example.crt -tls_key example.key ``` + +## Serving under a URL prefix + +When hosting Ratel behind a reverse proxy under a subpath (e.g. `https://example.com/ratel/`), set +the `-url-prefix` flag (or the `RATEL_URL_PREFIX` environment variable) so the UI and its static +assets are served under that prefix: + +```bash +./build/ratel -url-prefix /ratel +# or +RATEL_URL_PREFIX=/ratel ./build/ratel +``` + +With a prefix set, all routes move under the prefix (`/ratel/`, `/ratel/static/...`), requests to +the bare prefix (`/ratel`) redirect to `/ratel/`, asset URLs in the served `index.html` are +rewritten to include the prefix, and any path outside the prefix returns a 404 pointing at the +prefix. The flag value is normalized to have a leading slash and no trailing slash. diff --git a/client/config/jest/babelTransform.js b/client/config/jest/babelTransform.js new file mode 100644 index 00000000..1529ff91 --- /dev/null +++ b/client/config/jest/babelTransform.js @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Custom babel-jest transformer with an explicit config. +// +// The babel config in package.json ("babel" key) is file-relative, so it is +// never applied to files inside node_modules. ESM-only packages allowed +// through transformIgnorePatterns (e.g. react-leaflet) therefore reached the +// test runtime untransformed. This transformer applies the presets to every +// file Jest transforms, regardless of location. +const babelJest = require('babel-jest') + +module.exports = babelJest.createTransformer({ + presets: [ + [require.resolve('@babel/preset-env'), { targets: { node: 'current' } }], + require.resolve('@babel/preset-react'), + ], + plugins: [ + [ + require.resolve('@babel/plugin-proposal-class-properties'), + { loose: true }, + ], + ], + babelrc: false, + configFile: false, +}) diff --git a/client/config/jest/fileTransform.js b/client/config/jest/fileTransform.js index 01ef824a..391a4190 100644 --- a/client/config/jest/fileTransform.js +++ b/client/config/jest/fileTransform.js @@ -4,7 +4,14 @@ */ const path = require('path') -const camelcase = require('camelcase') + +// camelcase >= 7 is ESM-only and can no longer be require()d here. +const pascalCase = (str) => + str + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') // This is a custom Jest transformer turning file imports into filenames. // http://facebook.github.io/jest/docs/en/webpack.html @@ -16,9 +23,7 @@ module.exports = { if (filename.match(/\.svg$/)) { // Based on how SVGR generates a component name: // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 - const pascalCaseFilename = camelcase(path.parse(filename).name, { - pascalCase: true, - }) + const pascalCaseFilename = pascalCase(path.parse(filename).name) const componentName = `Svg${pascalCaseFilename}` return `const React = require('react'); module.exports = { diff --git a/client/config/jest/nodeShims/assert.js b/client/config/jest/nodeShims/assert.js new file mode 100644 index 00000000..085a1e39 --- /dev/null +++ b/client/config/jest/nodeShims/assert.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('assert') diff --git a/client/config/jest/nodeShims/async_hooks.js b/client/config/jest/nodeShims/async_hooks.js new file mode 100644 index 00000000..f27fcb6a --- /dev/null +++ b/client/config/jest/nodeShims/async_hooks.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('async_hooks') diff --git a/client/config/jest/nodeShims/buffer.js b/client/config/jest/nodeShims/buffer.js new file mode 100644 index 00000000..518f53e3 --- /dev/null +++ b/client/config/jest/nodeShims/buffer.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('buffer') diff --git a/client/config/jest/nodeShims/console.js b/client/config/jest/nodeShims/console.js new file mode 100644 index 00000000..66805964 --- /dev/null +++ b/client/config/jest/nodeShims/console.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('console') diff --git a/client/config/jest/nodeShims/crypto.js b/client/config/jest/nodeShims/crypto.js new file mode 100644 index 00000000..cfe16055 --- /dev/null +++ b/client/config/jest/nodeShims/crypto.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('crypto') diff --git a/client/config/jest/nodeShims/diagnostics_channel.js b/client/config/jest/nodeShims/diagnostics_channel.js new file mode 100644 index 00000000..0e317d95 --- /dev/null +++ b/client/config/jest/nodeShims/diagnostics_channel.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('diagnostics_channel') diff --git a/client/config/jest/nodeShims/dns.js b/client/config/jest/nodeShims/dns.js new file mode 100644 index 00000000..91b92d39 --- /dev/null +++ b/client/config/jest/nodeShims/dns.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('dns') diff --git a/client/config/jest/nodeShims/events.js b/client/config/jest/nodeShims/events.js new file mode 100644 index 00000000..7cd57ce9 --- /dev/null +++ b/client/config/jest/nodeShims/events.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('events') diff --git a/client/config/jest/nodeShims/fs.js b/client/config/jest/nodeShims/fs.js new file mode 100644 index 00000000..f5a86a1b --- /dev/null +++ b/client/config/jest/nodeShims/fs.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('fs') diff --git a/client/config/jest/nodeShims/fs/promises.js b/client/config/jest/nodeShims/fs/promises.js new file mode 100644 index 00000000..df437700 --- /dev/null +++ b/client/config/jest/nodeShims/fs/promises.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('fs/promises') diff --git a/client/config/jest/nodeShims/http.js b/client/config/jest/nodeShims/http.js new file mode 100644 index 00000000..5ad7390d --- /dev/null +++ b/client/config/jest/nodeShims/http.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('http') diff --git a/client/config/jest/nodeShims/http2.js b/client/config/jest/nodeShims/http2.js new file mode 100644 index 00000000..737d57f3 --- /dev/null +++ b/client/config/jest/nodeShims/http2.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('http2') diff --git a/client/config/jest/nodeShims/https.js b/client/config/jest/nodeShims/https.js new file mode 100644 index 00000000..f4385332 --- /dev/null +++ b/client/config/jest/nodeShims/https.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('https') diff --git a/client/config/jest/nodeShims/net.js b/client/config/jest/nodeShims/net.js new file mode 100644 index 00000000..f81c980b --- /dev/null +++ b/client/config/jest/nodeShims/net.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('net') diff --git a/client/config/jest/nodeShims/os.js b/client/config/jest/nodeShims/os.js new file mode 100644 index 00000000..cb9051eb --- /dev/null +++ b/client/config/jest/nodeShims/os.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('os') diff --git a/client/config/jest/nodeShims/path.js b/client/config/jest/nodeShims/path.js new file mode 100644 index 00000000..8d718257 --- /dev/null +++ b/client/config/jest/nodeShims/path.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('path') diff --git a/client/config/jest/nodeShims/perf_hooks.js b/client/config/jest/nodeShims/perf_hooks.js new file mode 100644 index 00000000..41b93be3 --- /dev/null +++ b/client/config/jest/nodeShims/perf_hooks.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('perf_hooks') diff --git a/client/config/jest/nodeShims/process.js b/client/config/jest/nodeShims/process.js new file mode 100644 index 00000000..27d951e0 --- /dev/null +++ b/client/config/jest/nodeShims/process.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('process') diff --git a/client/config/jest/nodeShims/querystring.js b/client/config/jest/nodeShims/querystring.js new file mode 100644 index 00000000..0236105c --- /dev/null +++ b/client/config/jest/nodeShims/querystring.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('querystring') diff --git a/client/config/jest/nodeShims/stream.js b/client/config/jest/nodeShims/stream.js new file mode 100644 index 00000000..4304b9f3 --- /dev/null +++ b/client/config/jest/nodeShims/stream.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('stream') diff --git a/client/config/jest/nodeShims/stream/web.js b/client/config/jest/nodeShims/stream/web.js new file mode 100644 index 00000000..56e0f218 --- /dev/null +++ b/client/config/jest/nodeShims/stream/web.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('stream/web') diff --git a/client/config/jest/nodeShims/string_decoder.js b/client/config/jest/nodeShims/string_decoder.js new file mode 100644 index 00000000..6104d33f --- /dev/null +++ b/client/config/jest/nodeShims/string_decoder.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('string_decoder') diff --git a/client/config/jest/nodeShims/timers.js b/client/config/jest/nodeShims/timers.js new file mode 100644 index 00000000..bcb29577 --- /dev/null +++ b/client/config/jest/nodeShims/timers.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('timers') diff --git a/client/config/jest/nodeShims/timers/promises.js b/client/config/jest/nodeShims/timers/promises.js new file mode 100644 index 00000000..a665ffd2 --- /dev/null +++ b/client/config/jest/nodeShims/timers/promises.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('timers/promises') diff --git a/client/config/jest/nodeShims/tls.js b/client/config/jest/nodeShims/tls.js new file mode 100644 index 00000000..2824ec36 --- /dev/null +++ b/client/config/jest/nodeShims/tls.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('tls') diff --git a/client/config/jest/nodeShims/tty.js b/client/config/jest/nodeShims/tty.js new file mode 100644 index 00000000..30b5ef73 --- /dev/null +++ b/client/config/jest/nodeShims/tty.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('tty') diff --git a/client/config/jest/nodeShims/url.js b/client/config/jest/nodeShims/url.js new file mode 100644 index 00000000..9498116c --- /dev/null +++ b/client/config/jest/nodeShims/url.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('url') diff --git a/client/config/jest/nodeShims/util.js b/client/config/jest/nodeShims/util.js new file mode 100644 index 00000000..b8405103 --- /dev/null +++ b/client/config/jest/nodeShims/util.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('util') diff --git a/client/config/jest/nodeShims/util/types.js b/client/config/jest/nodeShims/util/types.js new file mode 100644 index 00000000..13b6deb9 --- /dev/null +++ b/client/config/jest/nodeShims/util/types.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('util/types') diff --git a/client/config/jest/nodeShims/worker_threads.js b/client/config/jest/nodeShims/worker_threads.js new file mode 100644 index 00000000..871c4421 --- /dev/null +++ b/client/config/jest/nodeShims/worker_threads.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('worker_threads') diff --git a/client/config/jest/nodeShims/zlib.js b/client/config/jest/nodeShims/zlib.js new file mode 100644 index 00000000..2e936bd0 --- /dev/null +++ b/client/config/jest/nodeShims/zlib.js @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Jest 26 can't resolve `node:`-prefixed core modules (used by newer +// dependencies such as cheerio); moduleNameMapper points them here. +module.exports = require('zlib') diff --git a/client/config/jest/sigmaMock.js b/client/config/jest/sigmaMock.js new file mode 100644 index 00000000..0d7089f8 --- /dev/null +++ b/client/config/jest/sigmaMock.js @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Stand-in for `sigma`, `sigma/rendering` and `@sigma/edge-curve` under +// Jest: sigma's CJS bundle references WebGL2RenderingContext at module +// load, which doesn't exist in jsdom. +class MockSigma { + on() { + return this + } + refresh() {} + kill() {} + getNodeAttribute() {} + getEdgeAttribute() {} + getCustomBBox() { + return null + } + setCustomBBox() {} + getBBox() { + return { x: [0, 1], y: [0, 1] } + } + viewportToGraph() { + return { x: 0, y: 0 } + } +} + +class MockProgram {} + +module.exports = { + __esModule: true, + default: MockSigma, + Sigma: MockSigma, + EdgeArrowProgram: MockProgram, + EdgeCurveProgram: MockProgram, + EdgeCurvedArrowProgram: MockProgram, +} diff --git a/client/config/jest/testPolyfills.js b/client/config/jest/testPolyfills.js new file mode 100644 index 00000000..8b11872c --- /dev/null +++ b/client/config/jest/testPolyfills.js @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Test-only polyfills. jsdom 16 doesn't expose TextEncoder/TextDecoder, +// web streams, Blob or MessageChannel, which newer dependencies +// (e.g. undici via cheerio/enzyme) require. This file is referenced only +// from Jest's setupFiles, never from webpack, so requiring node core +// modules here is safe. + +const util = require('util') +if (typeof global.TextEncoder === 'undefined') { + global.TextEncoder = util.TextEncoder +} +if (typeof global.TextDecoder === 'undefined') { + global.TextDecoder = util.TextDecoder +} + +const streamWeb = require('stream/web') +for (const name of [ + 'ReadableStream', + 'WritableStream', + 'TransformStream', + 'ByteLengthQueuingStrategy', + 'CountQueuingStrategy', +]) { + if (typeof global[name] === 'undefined' && streamWeb[name]) { + global[name] = streamWeb[name] + } +} + +const buffer = require('buffer') +if (typeof global.Blob === 'undefined' && buffer.Blob) { + global.Blob = buffer.Blob +} + +const workerThreads = require('worker_threads') +for (const name of ['MessageChannel', 'MessagePort']) { + if (typeof global[name] === 'undefined' && workerThreads[name]) { + global[name] = workerThreads[name] + } +} diff --git a/client/package-lock.json b/client/package-lock.json index d238f7f4..52d2417f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@babel/node": "^7.20.2", "@fortawesome/fontawesome-free": "^6.2.0", + "@sigma/edge-curve": "^3.1.0", "bootstrap": "4.6.2", "browserslist": "^4.21.4", "classnames": "^2.3.2", @@ -18,8 +19,12 @@ "codemirror-graphql": "^0.15.2", "core-js": "^3.26.0", "crypto-js": "^4.2.0", - "d3": "^5.16.0", "dgraph-js-http": "^21.3.1", + "graphology": "^0.26.0", + "graphology-communities-louvain": "^2.0.2", + "graphology-layout": "^0.6.1", + "graphology-layout-forceatlas2": "^0.10.1", + "graphology-metrics": "^2.4.0", "graphql": "^15.8.0", "immer": "^9.0.16", "jquery": "^3.6.1", @@ -49,6 +54,7 @@ "redux-persist": "^6.0.0", "redux-thunk": "^2.4.2", "screenfull": "^5.2.0", + "sigma": "^3.0.3", "use-interval": "^1.4.0", "uuid": "^3.4.0", "web-vitals": "^3.0.4" @@ -5290,6 +5296,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@sigma/edge-curve": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigma/edge-curve/-/edge-curve-3.1.0.tgz", + "integrity": "sha512-OFWkfAXEsm+X8l1K4K49cC0psB0gQ+gqxKA08HG5piNPdzrDZ5gG9Gza6htZ5AirOVwd/4/uq/gPpD5En+H+3Q==", + "license": "MIT", + "peerDependencies": { + "sigma": ">=3.0.0-beta.10" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.47", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", @@ -6512,6 +6527,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@yomguithereal/helpers": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@yomguithereal/helpers/-/helpers-1.1.1.tgz", + "integrity": "sha512-UYvAq/XCA7xoh1juWDYsq3W0WywOB+pz8cgVnE1b45ZfdMhBvHDrgmSFG3jXeZSr2tMTYLGHFHON+ekG05Jebg==", + "license": "MIT" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -8956,319 +8977,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/d3": { - "version": "5.16.0", - "resolved": "https://registry.npmjs.org/d3/-/d3-5.16.0.tgz", - "integrity": "sha512-4PL5hHaHwX4m7Zr1UapXW23apo6pexCgdetdJ5kTmADpG/7T9Gkxw0M0tf/pjoB63ezCCm0u5UaFYy2aMt0Mcw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1", - "d3-axis": "1", - "d3-brush": "1", - "d3-chord": "1", - "d3-collection": "1", - "d3-color": "1", - "d3-contour": "1", - "d3-dispatch": "1", - "d3-drag": "1", - "d3-dsv": "1", - "d3-ease": "1", - "d3-fetch": "1", - "d3-force": "1", - "d3-format": "1", - "d3-geo": "1", - "d3-hierarchy": "1", - "d3-interpolate": "1", - "d3-path": "1", - "d3-polygon": "1", - "d3-quadtree": "1", - "d3-random": "1", - "d3-scale": "2", - "d3-scale-chromatic": "1", - "d3-selection": "1", - "d3-shape": "1", - "d3-time": "1", - "d3-time-format": "2", - "d3-timer": "1", - "d3-transition": "1", - "d3-voronoi": "1", - "d3-zoom": "1" - } - }, - "node_modules/d3-array": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", - "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-axis": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-1.0.12.tgz", - "integrity": "sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-brush": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.1.6.tgz", - "integrity": "sha512-7RW+w7HfMCPyZLifTz/UnJmI5kdkXtpCbombUSs8xniAyo0vIbrDzDwUJB6eJOgl9u5DQOt2TQlYumxzD1SvYA==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dispatch": "1", - "d3-drag": "1", - "d3-interpolate": "1", - "d3-selection": "1", - "d3-transition": "1" - } - }, - "node_modules/d3-chord": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-1.0.6.tgz", - "integrity": "sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1", - "d3-path": "1" - } - }, - "node_modules/d3-collection": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", - "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-color": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", - "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-contour": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-1.3.2.tgz", - "integrity": "sha512-hoPp4K/rJCu0ladiH6zmJUEz6+u3lgR+GSm/QdM2BBvDraU39Vr7YdDCicJcxP1z8i9B/2dJLgDC1NcvlF8WCg==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^1.1.1" - } - }, - "node_modules/d3-dispatch": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", - "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-drag": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz", - "integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dispatch": "1", - "d3-selection": "1" - } - }, - "node_modules/d3-dsv": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.2.0.tgz", - "integrity": "sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==", - "license": "BSD-3-Clause", - "dependencies": { - "commander": "2", - "iconv-lite": "0.4", - "rw": "1" - }, - "bin": { - "csv2json": "bin/dsv2json", - "csv2tsv": "bin/dsv2dsv", - "dsv2dsv": "bin/dsv2dsv", - "dsv2json": "bin/dsv2json", - "json2csv": "bin/json2dsv", - "json2dsv": "bin/json2dsv", - "json2tsv": "bin/json2dsv", - "tsv2csv": "bin/dsv2dsv", - "tsv2json": "bin/dsv2json" - } - }, - "node_modules/d3-dsv/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, - "node_modules/d3-ease": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.7.tgz", - "integrity": "sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-fetch": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-1.2.0.tgz", - "integrity": "sha512-yC78NBVcd2zFAyR/HnUiBS7Lf6inSCoWcSxFfw8FYL7ydiqe80SazNwoffcqOfs95XaLo7yebsmQqDKSsXUtvA==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dsv": "1" - } - }, - "node_modules/d3-force": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", - "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-collection": "1", - "d3-dispatch": "1", - "d3-quadtree": "1", - "d3-timer": "1" - } - }, - "node_modules/d3-format": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", - "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-geo": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", - "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1" - } - }, - "node_modules/d3-hierarchy": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", - "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-interpolate": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz", - "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-color": "1" - } - }, - "node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-polygon": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.6.tgz", - "integrity": "sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-quadtree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", - "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-random": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.2.tgz", - "integrity": "sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-scale": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz", - "integrity": "sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^1.2.0", - "d3-collection": "1", - "d3-format": "1", - "d3-interpolate": "1", - "d3-time": "1", - "d3-time-format": "2" - } - }, - "node_modules/d3-scale-chromatic": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz", - "integrity": "sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-color": "1", - "d3-interpolate": "1" - } - }, - "node_modules/d3-selection": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz", - "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "1" - } - }, - "node_modules/d3-time": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", - "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-time-format": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", - "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-time": "1" - } - }, - "node_modules/d3-timer": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", - "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-transition": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.3.2.tgz", - "integrity": "sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-color": "1", - "d3-dispatch": "1", - "d3-ease": "1", - "d3-interpolate": "1", - "d3-selection": "^1.1.0", - "d3-timer": "1" - } - }, - "node_modules/d3-voronoi": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", - "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-zoom": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-1.8.3.tgz", - "integrity": "sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dispatch": "1", - "d3-drag": "1", - "d3-interpolate": "1", - "d3-selection": "1", - "d3-transition": "1" - } - }, "node_modules/data-urls": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", @@ -10394,7 +10102,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -11506,6 +11213,111 @@ "dev": true, "license": "ISC" }, + "node_modules/graphology": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", + "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-communities-louvain": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz", + "integrity": "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==", + "license": "MIT", + "dependencies": { + "graphology-indices": "^0.17.0", + "graphology-utils": "^2.4.4", + "mnemonist": "^0.39.0", + "pandemonium": "^2.4.1" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-indices": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz", + "integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.4.2", + "mnemonist": "^0.39.0" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, + "node_modules/graphology-layout": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/graphology-layout/-/graphology-layout-0.6.1.tgz", + "integrity": "sha512-m9aMvbd0uDPffUCFPng5ibRkb2pmfNvdKjQWeZrf71RS1aOoat5874+DcyNfMeCT4aQguKC7Lj9eCbqZj/h8Ag==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.3.0", + "pandemonium": "^2.4.0" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-layout-forceatlas2": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/graphology-layout-forceatlas2/-/graphology-layout-forceatlas2-0.10.1.tgz", + "integrity": "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.1.0" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-metrics": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/graphology-metrics/-/graphology-metrics-2.4.0.tgz", + "integrity": "sha512-7WOfOP+mFLCaTJx55Qg4eY+211vr1/b3D/R3biz3SXGhAaCVcWYkfabnmO4O4WBNWANEHtVnFrGgJ0kj6MM6xw==", + "license": "MIT", + "dependencies": { + "graphology-indices": "^0.17.0", + "graphology-shortest-path": "^2.0.0", + "graphology-utils": "^2.4.4", + "mnemonist": "^0.39.0", + "pandemonium": "2.4.1" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, + "node_modules/graphology-shortest-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/graphology-shortest-path/-/graphology-shortest-path-2.1.0.tgz", + "integrity": "sha512-KbT9CTkP/u72vGEJzyRr24xFC7usI9Es3LMmCPHGwQ1KTsoZjxwA9lMKxfU0syvT/w+7fZUdB/Hu2wWYcJBm6Q==", + "license": "MIT", + "dependencies": { + "@yomguithereal/helpers": "^1.1.1", + "graphology-indices": "^0.17.0", + "graphology-utils": "^2.4.3", + "mnemonist": "^0.39.0" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, "node_modules/graphql": { "version": "15.10.1", "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.10.1.tgz", @@ -12308,6 +12120,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" @@ -15602,6 +15415,15 @@ "license": "MIT", "optional": true }, + "node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -16055,6 +15877,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -16287,6 +16115,15 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pandemonium": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz", + "integrity": "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==", + "license": "MIT", + "dependencies": { + "mnemonist": "^0.39.2" + } + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -19463,12 +19300,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" - }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -19555,6 +19386,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, "license": "MIT" }, "node_modules/sane": { @@ -20229,6 +20061,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sigma": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.3.tgz", + "integrity": "sha512-5H0zFlx6/NTQpqBg4Rm569ZOpnBOXMaS25UQThIWMU3XyzI5AhmorK/gnl87BvJBLhQd0tW4C0LIp3enWzMoNw==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "graphology-utils": "^2.5.2" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", diff --git a/client/package.json b/client/package.json index 87f3d26e..a7b634a3 100644 --- a/client/package.json +++ b/client/package.json @@ -21,6 +21,7 @@ "prettier": "prettier --write \"src/**/*.{js,jsx,mjs,json,scss}\"", "lint": "trunk fmt", "test": "node scripts/test.js", + "test:e2e": "node scripts/test.js --testPathIgnorePatterns=never-ignore-anything --testPathPattern=src/e2etests", "test:watch": "node scripts/test.js --watch", "precommit": "lint-staged", "deepClean": "rm -rf yarn-error.log node_modules yarn.lock package-lock.json", @@ -65,22 +66,33 @@ "browserslist": [">2%", "last 3 versions", "Firefox ESR", "not ie < 9"], "jest": { "collectCoverageFrom": ["src/**/*.{js,jsx,mjs}"], - "setupFiles": ["/config/polyfills.js"], + "setupFiles": [ + "/config/polyfills.js", + "/config/jest/testPolyfills.js" + ], "testEnvironment": "jsdom", "testMatch": [ "/src/**/__tests__/**/*.{js,jsx,mjs}", "/src/**/?(*.)(spec|test).{js,jsx,mjs}" ], "testResultsProcessor": "jest-teamcity", + "testPathIgnorePatterns": ["/src/e2etests/"], "transform": { - "^.+\\.(js|jsx|mjs)$": "/node_modules/babel-jest", + "^.+\\.(js|jsx|mjs)$": "/config/jest/babelTransform.js", "^.+\\.css$": "/config/jest/cssTransform.js", "^(?!.*\\.(js|jsx|mjs|css|json)$)": "/config/jest/fileTransform.js" }, - "transformIgnorePatterns": ["[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$"], + "transformIgnorePatterns": [ + "[/\\\\]node_modules[/\\\\](?!(react-leaflet|@react-leaflet)[/\\\\]).+\\.(js|jsx|mjs)$" + ], "modulePaths": ["./src/"], "moduleNameMapper": { - "^react-native$": "react-native-web" + "^react-native$": "react-native-web", + "^node:(.*)$": "/config/jest/nodeShims/$1.js", + "^cheerio/lib/utils$": "/node_modules/cheerio/dist/commonjs/utils.js", + "^sigma$": "/config/jest/sigmaMock.js", + "^sigma/rendering$": "/config/jest/sigmaMock.js", + "^@sigma/edge-curve$": "/config/jest/sigmaMock.js" }, "moduleFileExtensions": [ "web.js", @@ -175,6 +187,7 @@ "dependencies": { "@babel/node": "^7.20.2", "@fortawesome/fontawesome-free": "^6.2.0", + "@sigma/edge-curve": "^3.1.0", "bootstrap": "4.6.2", "browserslist": "^4.21.4", "classnames": "^2.3.2", @@ -182,8 +195,12 @@ "codemirror-graphql": "^0.15.2", "core-js": "^3.26.0", "crypto-js": "^4.2.0", - "d3": "^5.16.0", "dgraph-js-http": "^21.3.1", + "graphology": "^0.26.0", + "graphology-communities-louvain": "^2.0.2", + "graphology-layout": "^0.6.1", + "graphology-layout-forceatlas2": "^0.10.1", + "graphology-metrics": "^2.4.0", "graphql": "^15.8.0", "immer": "^9.0.16", "jquery": "^3.6.1", @@ -213,6 +230,7 @@ "redux-persist": "^6.0.0", "redux-thunk": "^2.4.2", "screenfull": "^5.2.0", + "sigma": "^3.0.3", "use-interval": "^1.4.0", "uuid": "^3.4.0", "web-vitals": "^3.0.4" diff --git a/client/scripts/graph-smoke.mjs b/client/scripts/graph-smoke.mjs new file mode 100644 index 00000000..2f28d161 --- /dev/null +++ b/client/scripts/graph-smoke.mjs @@ -0,0 +1,199 @@ +// Smoke test: drive the real Ratel UI (with the new SigmaGraph renderer) +// against the local Dgraph, run a query, and verify the WebGL graph renders. +import puppeteer from 'puppeteer' + +const RATEL = 'http://localhost:3000' +const DGRAPH = 'http://localhost:8080' + +const die = async (msg) => { + console.error('FAIL:', msg) + process.exit(1) +} + +// 0. Login to Dgraph (ACL is enabled on this cluster). +const loginRes = await fetch(`${DGRAPH}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userid: 'groot', password: 'password' }), +}).then((r) => r.json()) +const accessJwt = loginRes.data && loginRes.data.accessJWT +if (!accessJwt) + await die('dgraph login failed: ' + JSON.stringify(loginRes).slice(0, 300)) +console.log('dgraph login ok') + +// 1. Seed a small graph (namespaced predicate to avoid clobbering user data). +const stamp = `smoke${Date.now() % 99991}` +const rdf = ` + _:a <${stamp}_name> "Alice" . + _:b <${stamp}_name> "Bob" . + _:c <${stamp}_name> "Carol" . + _:a <${stamp}_friend> _:b . + _:a <${stamp}_friend> _:c . + _:b <${stamp}_friend> _:c . +` +const mutateRes = await fetch(`${DGRAPH}/mutate?commitNow=true`, { + method: 'POST', + headers: { + 'Content-Type': 'application/rdf', + 'X-Dgraph-AccessToken': accessJwt, + }, + body: `{ set { ${rdf} } }`, +}).then((r) => r.json()) +if (!mutateRes.data || mutateRes.data.code !== 'Success') { + await die('mutation failed: ' + JSON.stringify(mutateRes).slice(0, 300)) +} +console.log('seeded test data') + +const browser = await puppeteer.launch({ + headless: 'new', + executablePath: + process.env.CHROME_BIN || + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + defaultViewport: { width: 1280, height: 1024 }, +}) +const page = await browser.newPage() + +const pageErrors = [] +const consoleErrors = [] +page.on('pageerror', (err) => pageErrors.push(String(err))) +page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()) +}) +page.on('response', async (res) => { + if (/login|admin/.test(res.url())) { + let body = '' + try { + body = (await res.text()).slice(0, 300) + } catch {} + console.log('NET', res.status(), res.url(), body) + } +}) + +const waitGone = async (sel, timeout = 20000) => { + const start = Date.now() + while (await page.$(sel)) { + if (Date.now() - start > timeout) + await die('timeout waiting for ' + sel + ' to disappear') + await new Promise((r) => setTimeout(r, 200)) + } +} + +await page.goto(`${RATEL}?addr=${DGRAPH}`, { waitUntil: 'networkidle2' }) +await page.waitForSelector('.editor-panel .CodeMirror-cursors', { + timeout: 20000, +}) +console.log('app loaded') + +// 2. Login through the UI (ACL cluster). +if (!(await page.$('.modal.server-connection #serverUrlInput'))) { + await page.click('.sidebar-menu a[href="#connection"]') +} +await page.waitForSelector('#useridInput', { timeout: 10000 }) +await page.click('#useridInput', { clickCount: 3 }) +await page.keyboard.type('groot') +await page.click('#passwordInput', { clickCount: 3 }) +await page.keyboard.type('password') +const loginClicked = await page.$$eval( + '.modal.server-connection .modal-body button.btn.btn-primary', + (btns) => { + const b = btns.find((x) => x.textContent === 'Login') + if (b) b.click() + return !!b + }, +) +if (!loginClicked) await die('no Login button found') +const spinner = + '.modal.server-connection .modal-body button.btn-primary .fa-spinner.fa-pulse' +await page.waitForSelector(spinner, { timeout: 10000 }).catch(() => {}) +await waitGone(spinner) +console.log('ui login done') + +// Back to console tab. +await page.click(".sidebar-menu a[href='#']") +await page.waitForSelector('.editor-panel .CodeMirror-cursors', { + timeout: 10000, +}) +await page.click('.editor-panel .CodeMirror') + +const query = `{ q(func: has(${stamp}_name)) { uid ${stamp}_name ${stamp}_friend { uid ${stamp}_name } } }` +await page.evaluate((q) => { + document.querySelector('.editor-panel .CodeMirror').CodeMirror.setValue(q) +}, query) +await new Promise((r) => setTimeout(r, 1000)) +await page.$$eval('.editor-panel button', (btns) => { + const run = btns.find((b) => b.textContent.trim() === 'Run') + if (run) run.click() +}) +console.log('query submitted') + +// 3. The graph container + sigma canvases must appear. +try { + await page.waitForSelector('.graph-container .sigma-graph-outer canvas', { + timeout: 20000, + }) +} catch { + await page.screenshot({ path: '/tmp/sigma-fail.png' }) + console.error('pageErrors:', JSON.stringify(pageErrors, null, 2)) + console.error( + 'consoleErrors:', + JSON.stringify(consoleErrors.slice(0, 10), null, 2), + ) + const frameText = await page.evaluate(() => { + const f = document.querySelector('.frame-item') || document.body + return f.innerText.slice(0, 600) + }) + console.error('frame text:', frameText) + await die('graph canvas never appeared') +} +// Give the FA2 layout a moment to spread the nodes. +await new Promise((r) => setTimeout(r, 4000)) + +const info = await page.evaluate(() => { + const canvases = document.querySelectorAll('.sigma-graph-outer canvas') + const panel = document.querySelector('.graph-stats') + let webgl = false + for (const c of canvases) { + if (c.getContext('webgl2') || c.getContext('webgl')) webgl = true + } + return { + canvasCount: canvases.length, + webgl, + panelText: panel ? panel.innerText.replace(/\n/g, ' ').slice(0, 200) : null, + } +}) +console.log('render info:', JSON.stringify(info)) + +if (info.canvasCount < 2) + await die('expected sigma canvas layers, got ' + info.canvasCount) +if (!info.webgl) await die('no WebGL context on sigma canvases') +if (!/3 nodes(.|\n)*3 edges/.test(info.panelText || '')) + await die('graph stats mismatch: ' + info.panelText) + +// Exercise the new toolbar: search for a node and zoom-to-fit. +await page.$eval('.graph-search input', (el) => { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value', + ).set + setter.call(el, 'Alice') + el.dispatchEvent(new Event('input', { bubbles: true })) +}) +await page.focus('.graph-search input') +await page.keyboard.press('Enter') +await new Promise((r) => setTimeout(r, 800)) +await page.$eval('.graph-control-btn', (b) => b.click()) +await new Promise((r) => setTimeout(r, 800)) +console.log('search + zoomToFit exercised') + +// 4. Screenshot for the human. +await page.screenshot({ path: '/tmp/sigma-graph.png' }) + +if (pageErrors.length) await die('page errors: ' + pageErrors.join(' | ')) +const realConsoleErrors = consoleErrors.filter( + (e) => !/favicon|manifest|ERR_BLOCKED|sourcemap|404/i.test(e), +) +if (realConsoleErrors.length) + console.warn('console errors (non-fatal):', realConsoleErrors.slice(0, 5)) + +console.log('PASS: sigma graph rendered 3 nodes / 3 edges via WebGL') +await browser.close() diff --git a/client/src/actions/query.js b/client/src/actions/query.js index 50fbb4af..fe8767ad 100644 --- a/client/src/actions/query.js +++ b/client/src/actions/query.js @@ -9,6 +9,10 @@ export const UPDATE_QUERY_AND_ACTION = 'query/UPDATE_QUERY_AND_ACTION' export const UPDATE_QUERY_VARS = 'query/UPDATE_QUERY_VARS' export const UPDATE_READ_ONLY = 'query/UPDATE_READ_ONLY' export const UPDATE_BEST_EFFORT = 'query/UPDATE_BEST_EFFORT' +export const ADD_TAB = 'query/ADD_TAB' +export const CLOSE_TAB = 'query/CLOSE_TAB' +export const SWITCH_TAB = 'query/SWITCH_TAB' +export const RENAME_TAB = 'query/RENAME_TAB' export function updateQuery(query) { return { @@ -50,3 +54,23 @@ export const updateQueryVars = (newVars) => ({ type: UPDATE_QUERY_VARS, newVars, }) + +export const addTab = () => ({ + type: ADD_TAB, +}) + +export const closeTab = (id) => ({ + type: CLOSE_TAB, + id, +}) + +export const switchTab = (id) => ({ + type: SWITCH_TAB, + id, +}) + +export const renameTab = (id, name) => ({ + type: RENAME_TAB, + id, + name, +}) diff --git a/client/src/actions/ui.js b/client/src/actions/ui.js index 42d9c66e..d606205b 100644 --- a/client/src/actions/ui.js +++ b/client/src/actions/ui.js @@ -5,6 +5,7 @@ export const SET_PANEL_SIZE = 'ui/SET_PANEL_SIZE' export const SET_PANEL_MINIMIZED = 'ui/SET_PANEL_MINIMIZED' +export const SET_THEME = 'ui/SET_THEME' export const CLICK_SIDEBAR_URL = 'mainframe/CLICK_SIDEBAR_URL' @@ -29,3 +30,10 @@ export function setPanelMinimized(minimized) { minimized, } } + +export function setTheme(theme) { + return { + type: SET_THEME, + theme, + } +} diff --git a/client/src/assets/css/EditorPanel.scss b/client/src/assets/css/EditorPanel.scss index 6c8ca26f..07ebf7f0 100644 --- a/client/src/assets/css/EditorPanel.scss +++ b/client/src/assets/css/EditorPanel.scss @@ -12,12 +12,21 @@ .header { border-bottom: 1px solid #d2d2d2; background-color: #fff; + + // Flex (not floats) so a crowded toolbar wraps into clean rows instead + // of collapsing the float container and leaving an empty band. + display: flex; + flex-wrap: wrap; + align-items: stretch; } .actions { - float: left; + display: flex; + align-items: stretch; &.right { - float: right; + // Push the run/clear/etc. group to the right; on a narrow pane it + // wraps to its own row rather than overflowing. + margin-left: auto; } } diff --git a/client/src/assets/css/Graph.scss b/client/src/assets/css/Graph.scss index 6de7c1d9..e218051e 100644 --- a/client/src/assets/css/Graph.scss +++ b/client/src/assets/css/Graph.scss @@ -124,6 +124,35 @@ &:active { transform: scale(0.95); } + + &.active { + background: #4e79a7; + border-color: #4e79a7; + color: #fff; + + &:hover { + background: #3f6690; + color: #fff; + } + } +} + +.graph-layout-select { + height: 36px; + border: 1px solid #ddd; + border-radius: 6px; + background: rgba(255, 255, 255, 0.95); + color: #555; + cursor: pointer; + padding: 0 6px; + font-size: 13px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + + &:hover { + background: #fff; + color: #333; + border-color: #bbb; + } } .graph-stats { @@ -138,3 +167,93 @@ z-index: 10; pointer-events: none; } + +.graph-path-banner { + position: absolute; + top: 56px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 10px; + max-width: 80%; + font-size: 13px; + color: #333; + background: rgba(255, 255, 255, 0.97); + border: 1px solid #4e79a7; + padding: 6px 12px; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + z-index: 20; + + .graph-path-banner-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .graph-path-banner-clear { + flex: none; + border: none; + background: #4e79a7; + color: #fff; + font-size: 12px; + padding: 3px 10px; + border-radius: 4px; + cursor: pointer; + + &:hover { + background: #3f6690; + } + } +} + +.graph-timeline { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 10px; + width: 60%; + max-width: 520px; + font-size: 12px; + color: #333; + background: rgba(255, 255, 255, 0.97); + border: 1px solid #ddd; + padding: 6px 12px; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + z-index: 15; + + &__play { + flex: none; + width: 28px; + height: 28px; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; + color: #4e79a7; + cursor: pointer; + font-size: 12px; + line-height: 1; + + &:hover { + border-color: #4e79a7; + } + } + + &__slider { + flex: 1; + min-width: 0; + } + + &__label { + flex: none; + min-width: 92px; + text-align: right; + color: #555; + font-variant-numeric: tabular-nums; + } +} diff --git a/client/src/assets/css/NodeProperties.scss b/client/src/assets/css/NodeProperties.scss index 7058d1c6..a480f2cd 100644 --- a/client/src/assets/css/NodeProperties.scss +++ b/client/src/assets/css/NodeProperties.scss @@ -39,3 +39,54 @@ margin-right: 2px; } } + +.node-properties { + .value-cell { + position: relative; + + .value-text { + word-break: break-word; + } + + input, + select { + width: calc(100% - 52px); + font-size: 13px; + padding: 1px 4px; + } + + .row-actions { + float: right; + white-space: nowrap; + opacity: 0.35; + transition: opacity 100ms; + } + + &:hover .row-actions { + opacity: 1; + } + + .row-action { + border: none; + background: none; + padding: 0 3px; + cursor: pointer; + color: #666; + font-size: 12px; + + &:hover { + color: #111; + } + + &--danger { + color: #c00; + font-weight: 600; + } + + &:disabled { + color: #ccc; + cursor: default; + } + } + } +} diff --git a/client/src/assets/css/theme-dark.scss b/client/src/assets/css/theme-dark.scss new file mode 100644 index 00000000..1759f53a --- /dev/null +++ b/client/src/assets/css/theme-dark.scss @@ -0,0 +1,484 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Dark theme. Activated by data-theme='dark' on (see containers/App.js). +// Scoped, targeted overrides of the app's major surfaces — not a full +// Bootstrap retheme. Keep the palette in the custom properties below. + +[data-theme="dark"] { + --bg: #1e1e24; + --bg-raised: #26262e; + --bg-elevated: #2f2f3a; + --text: #d8d8de; + --text-muted: #9a9aa5; + --border: #3d3d49; + --accent: #4c8eda; + + color-scheme: dark; + + body { + background-color: var(--bg); + color: var(--text); + } + + // Bootstrap reboot hardcodes pre { color: #212529 }. + pre { + color: var(--text); + } + + // ---------------------------------------------------------------- layout + + .main-content { + background-color: var(--bg); + color: var(--text); + } + + .click-capture { + background: #000; + } + + // ------------------------------------------------------------- side bar + // The sidebar is already dark; only fix the hover/active accents so they + // blend with the dark page background. + + .sidebar-menu li { + border-bottom-color: #3a3a44; + } + + // -------------------------------------------------------- editor panel + + .editor-panel { + border-color: var(--border); + + .header { + background-color: var(--bg-raised); + border-bottom-color: var(--border); + } + + .action { + border-color: var(--border); + color: #5a5a66; + + &.actionable { + background-color: var(--bg-elevated); + color: var(--text); + } + + &.actionable:hover { + background: #383846; + } + } + } + + // Multi-tab strip above the editor (EditorTabs). The light defaults leave a + // bright bar in dark mode; recolor the strip, tabs and active tab so the + // active tab blends into the editor header (--bg-raised) below it. + .editor-tabs { + background-color: var(--bg); + border-bottom-color: var(--border); + + .editor-tab { + color: var(--text-muted); + border-bottom-color: var(--border); + + &:hover { + background: var(--bg-elevated); + color: var(--text); + } + + &.active { + background: var(--bg-raised); + border-color: var(--border); + border-bottom-color: var(--bg-raised); + color: var(--text); + } + + .editor-tab-close { + color: var(--text-muted); + + &:hover { + color: var(--text); + } + } + + .editor-tab-rename-input { + background: var(--bg-elevated); + border-color: var(--border); + color: var(--text); + } + } + + .editor-tab-add { + color: var(--text-muted); + + &:hover { + color: var(--text); + } + } + } + + .editor-radio { + background: var(--bg-raised); + } + + .btn-dgraph { + background-color: var(--bg-elevated); + border-color: var(--border); + color: var(--text-muted); + } + + // CodeMirror chrome around the dark 'material-darker' theme. Editor.scss + // sets .editor-panel-scoped gutter/cursor colors that outrank the CM theme + // (equal specificity, later order), so re-assert them here. + .cm-s-material-darker.CodeMirror { + background-color: #1c1c22; + } + + .cm-s-material-darker .CodeMirror-gutters { + background: #1c1c22; + border-right: 1px solid var(--border); + } + + .cm-s-material-darker .CodeMirror-linenumber { + color: #6a6a76; + } + + .cm-s-material-darker .CodeMirror-cursor { + background: rgba(216, 216, 222, 0.67); + } + + // Editor.scss deliberately paints .cm-invalidchar black: the graphql-ish + // mode flags plenty of valid DQL (dotted predicates, numeric args) as + // "invalid", and black-on-white makes those tokens read as normal text. + // Mirror that intent on dark - normal text color, not invisible black. + .cm-invalidchar { + color: var(--text); + } + + // ---------------------------------------------------- query variables bar + + .query-vars-editor { + background-color: var(--bg-raised); + border-top-color: var(--border); + + .btn { + background-color: var(--bg-elevated); + color: var(--text); + } + + .vars { + border-top-color: var(--border); + + .var:hover { + background-color: var(--bg-elevated); + } + } + } + + // ---------------------------------------------------------------- frames + + .frame-item { + background-color: var(--bg-raised); + border-color: var(--border); + + .frame-header { + background-color: var(--bg-raised); + border-bottom-color: var(--border); + + &:hover { + background: var(--bg-elevated); + } + + .query-icon { + color: var(--text-muted); + } + } + + .body .toolbar { + svg { + fill: #80808c; + } + + .active { + color: #fff; + + svg { + fill: #fff; + } + } + } + } + + .frame-item.collapsed .frame-header.active { + background-color: #2a3a51; + } + + .footer, + .partial-graph-footer { + background: var(--bg-raised); + border-top-color: var(--border); + } + + // ------------------------------------------------------- movable panels + + .vertical-panel-layout .separator, + .horizontal-panel-layout .separator { + background-color: var(--bg-elevated); + border-color: var(--border); + } + + .panel-layout .toolbar button.active { + background-color: var(--bg-elevated); + color: var(--text); + } + + // ------------------------------------------------------------ graph area + + .graph-container { + background-color: var(--bg); + background-image: radial-gradient(circle, #34343e 1px, transparent 1px); + } + + .graph { + background-color: var(--bg); + } + + .graph-search { + background: rgba(38, 38, 46, 0.95); + border-color: var(--border); + + input { + color: var(--text); + + &::placeholder { + color: var(--text-muted); + } + } + } + + .graph-control-btn { + background: rgba(38, 38, 46, 0.95); + border-color: var(--border); + color: var(--text-muted); + + &:hover { + background: var(--bg-elevated); + border-color: #4a4a58; + color: var(--text); + } + } + + .partial-render-info, + .graph-stats { + background: rgba(30, 30, 36, 0.9); + color: var(--text-muted); + } + + .vis-tooltip { + background-color: #34342a; + border-color: #56563e; + color: var(--text); + } + + // ------------------------------------------------- entity selector strip + + .entity-selector { + background: var(--bg-raised); + border-top-color: var(--border); + + .toggle { + background-color: var(--bg-raised); + } + } + + // ----------------------------------------------------- history strip + + .Previous-query-pre { + background-color: var(--bg-raised); + } + + // --------------------------------------------------- bootstrap surfaces + + .modal-content { + background-color: var(--bg-raised); + color: var(--text); + } + + .modal-header, + .modal-footer { + border-color: var(--border); + } + + .close { + color: var(--text); + text-shadow: none; + } + + .modal.server-connection .modal-body .url-input-box { + background-color: var(--bg-elevated); + } + + .modal.server-connection .modal-body .col-history { + box-shadow: 1px 0 var(--border); + } + + .list-group-item { + background-color: var(--bg-raised); + border-color: var(--border); + color: var(--text); + + &.active { + background-color: var(--accent); + border-color: var(--accent); + color: #fff; + } + } + + .dropdown-menu { + background-color: var(--bg-elevated); + border-color: var(--border); + } + + .dropdown-item { + color: var(--text); + + &:hover, + &:focus { + background-color: #383846; + color: #fff; + } + } + + .dropdown-divider { + border-top-color: var(--border); + } + + .form-control { + background-color: var(--bg); + border-color: var(--border); + color: var(--text); + + &:focus { + background-color: var(--bg); + border-color: var(--accent); + color: var(--text); + } + + &::placeholder { + color: var(--text-muted); + } + + &:disabled, + &[readonly] { + background-color: var(--bg-elevated); + } + } + + .input-group-text { + background-color: var(--bg-elevated); + border-color: var(--border); + color: var(--text-muted); + } + + .table { + color: var(--text); + + th, + td, + thead th { + border-color: var(--border); + } + } + + .table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(255, 255, 255, 0.04); + } + + .table-hover tbody tr:hover { + background-color: rgba(255, 255, 255, 0.07); + color: var(--text); + } + + .card { + background-color: var(--bg-raised); + border-color: var(--border); + } + + .nav-tabs { + border-bottom-color: var(--border); + + .nav-link:hover, + .nav-link:focus { + border-color: var(--border) var(--border) var(--border); + } + + .nav-link.active { + background-color: var(--bg-raised); + border-color: var(--border) var(--border) var(--bg-raised); + color: var(--text); + } + } + + .alert-secondary { + background-color: var(--bg-elevated); + border-color: var(--border); + color: var(--text); + } + + .popover { + background-color: var(--bg-elevated); + border-color: var(--border); + + .popover-body { + color: var(--text); + } + } + + .text-muted { + color: var(--text-muted) !important; + } + + .btn-light, + .btn-outline-secondary { + background-color: var(--bg-elevated); + border-color: var(--border); + color: var(--text); + } + + // react-bootstrap renders variant='default' as an unstyled .btn-default; + // Bootstrap 4 leaves it with near-black text, invisible on the dark modal + // (the connection modal's "Connected" status and "Return to Ratel" button). + .btn-default { + color: var(--text); + + &:hover, + &:focus { + background-color: var(--bg-elevated); + color: var(--text); + } + } + + // ----------------------------------------------- JSON / response viewer + // FrameCodeTab uses highlight.js with a light theme; keep the tokens + // readable on a dark surface. + + .hljs { + color: var(--text); + } + + .hljs-attr, + .hljs-attribute { + color: #8ab4f8; + } + + .hljs-string { + color: #9ccc8c; + } + + .hljs-number, + .hljs-literal { + color: #d8a657; + } +} diff --git a/client/src/components/AiQueryModal.js b/client/src/components/AiQueryModal.js new file mode 100644 index 00000000..e130f9f4 --- /dev/null +++ b/client/src/components/AiQueryModal.js @@ -0,0 +1,176 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react' +import Button from 'react-bootstrap/Button' +import Form from 'react-bootstrap/Form' +import Modal from 'react-bootstrap/Modal' + +import { executeQuery } from 'lib/helpers' +import { + PROVIDERS, + PROVIDER_IDS, + generateDql, + loadAiSettings, + saveAiSettings, + schemaSummary, +} from 'lib/nl2dql' + +// "Generate query with AI": natural language -> DQL, bring-your-own-key. +// The key is kept in localStorage and requests go straight from the +// browser to the model API. +export default function AiQueryModal({ show, onHide, onInsert }) { + const [settings, setSettings] = React.useState(loadAiSettings) + const [request, setRequest] = React.useState('') + const [generated, setGenerated] = React.useState('') + const [busy, setBusy] = React.useState(false) + const [error, setError] = React.useState(null) + + const provider = settings.provider + const providerDef = PROVIDERS[provider] + const current = settings[provider] + + const updateSettings = (change) => { + const next = { ...settings, ...change } + setSettings(next) + saveAiSettings(next) + } + + const updateProviderSettings = (change) => + updateSettings({ [provider]: { ...current, ...change } }) + + const handleGenerate = async () => { + setBusy(true) + setError(null) + setGenerated('') + try { + let schemaText = '' + try { + const schemaResponse = await executeQuery('schema {}', { + action: 'query', + }) + schemaText = schemaSummary(schemaResponse) + } catch { + // Schema is optional context; generate without it. + } + + const dql = await generateDql({ + provider, + apiKey: current.apiKey, + model: current.model, + schemaText, + request, + }) + setGenerated(dql) + } catch (e) { + setError(e.message) + } finally { + setBusy(false) + } + } + + const handleInsert = () => { + onInsert(generated) + onHide() + } + + return ( + + + Generate query with AI + + + + Provider + updateSettings({ provider: e.target.value })} + > + {PROVIDER_IDS.map((id) => ( + + ))} + + + + + {providerDef.label} API key + updateProviderSettings({ apiKey: e.target.value })} + /> + + Stored only in this browser, per provider. Requests go directly to + the model API; your data never passes through the Ratel server. + + + + + Model + updateProviderSettings({ model: e.target.value })} + > + {providerDef.models.map(([value, label]) => ( + + ))} + + + + + Describe the query you want + setRequest(e.target.value)} + disabled={busy} + /> + + The current schema is sent along as context. + + + + {error &&
{error}
} + + {generated && ( + + Generated DQL + setGenerated(e.target.value)} + style={{ fontFamily: 'monospace', fontSize: 13 }} + /> + + )} +
+ + + + + +
+ ) +} diff --git a/client/src/components/D3Graph/D3Graph.scss b/client/src/components/D3Graph/D3Graph.scss deleted file mode 100644 index ecf273ba..00000000 --- a/client/src/components/D3Graph/D3Graph.scss +++ /dev/null @@ -1,34 +0,0 @@ -/* - * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. - * SPDX-License-Identifier: Apache-2.0 - */ - -.graph-outer { - position: relative; - width: 100%; - - canvas { - position: absolute; - width: 100%; - height: 100%; - } - - .graph-minimap { - position: absolute; - bottom: 32px; - left: 12px; - width: 180px; - height: 120px; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - cursor: crosshair; - z-index: 5; - pointer-events: auto; - opacity: 0.9; - transition: opacity 0.2s; - - &:hover { - opacity: 1; - } - } -} diff --git a/client/src/components/D3Graph/index.js b/client/src/components/D3Graph/index.js deleted file mode 100644 index 2866e145..00000000 --- a/client/src/components/D3Graph/index.js +++ /dev/null @@ -1,1181 +0,0 @@ -/* - * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as d3 from 'd3' -import { event as currentEvent } from 'd3-selection' -import debounce from 'lodash.debounce' -import React from 'react' - -import './D3Graph.scss' - -const ARROW_LENGTH = 8 -const ARROW_WIDTH = 4 - -const MIN_NODE_RADIUS = 8 -const MAX_NODE_RADIUS = 40 -const DEFAULT_NODE_RADIUS = 12 -const DOUBLE_CLICK_MS = 250 - -// Performance thresholds -const PERF = { - LARGE_GRAPH: 300, // nodes: disable expensive effects - HUGE_GRAPH: 800, // nodes: aggressive LOD - MAX_VISIBLE_EDGES: 2000, // max edges to render at once - MINIMAP_INTERVAL: 8, // only redraw minimap every N frames - HULL_INTERVAL: 5, // only recompute hulls every N frames -} - -const THEME = { - nodeBorderActive: 3, - nodeBorderDefault: 1.5, - edgeWidthActive: 3.0, - edgeWidthHighlight: 2.0, - edgeWidthDefault: 1.2, - edgeAlphaDefault: 0.35, - edgeAlphaHighlight: 0.9, - edgeAlphaDimmed: 0.06, - nodeDimmedAlpha: 0.12, - labelFont: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', - hullAlpha: 0.07, - hullStrokeAlpha: 0.25, - hullPadding: 30, -} - -function parseHexColor(hex) { - if (!hex || hex[0] !== '#' || hex.length < 7) - return { r: 128, g: 128, b: 128 } - return { - r: parseInt(hex.slice(1, 3), 16), - g: parseInt(hex.slice(3, 5), 16), - b: parseInt(hex.slice(5, 7), 16), - } -} - -function darkenColor(hex, factor = 0.3) { - const { r, g, b } = parseHexColor(hex) - return `rgb(${Math.round(r * (1 - factor))},${Math.round(g * (1 - factor))},${Math.round(b * (1 - factor))})` -} - -function hexToRgba(hex, alpha) { - const { r, g, b } = parseHexColor(hex) - return `rgba(${r},${g},${b},${alpha})` -} - -function roundRect(ctx, x, y, w, h, r) { - const rr = Math.min(r, w / 2, h / 2) - ctx.beginPath() - ctx.moveTo(x + rr, y) - ctx.arcTo(x + w, y, x + w, y + h, rr) - ctx.arcTo(x + w, y + h, x, y + h, rr) - ctx.arcTo(x, y + h, x, y, rr) - ctx.arcTo(x, y, x + w, y, rr) - ctx.closePath() -} - -const fixedPosForce = () => { - const self = { nodes: [] } - const res = function tick() { - for (let i = 0; i < self.nodes.length; i++) { - const n = self.nodes[i] - if (!n._posFixed) continue - n.x = n._posFixed.x - n.y = n._posFixed.y - } - } - res.initialize = (nodes) => (self.nodes = nodes) - res.setNodeCoords = (node, x, y) => { - node._posFixed = { x, y } - node.x = x - node.y = y - } - return res -} - -// ArangoDB-style cluster force — reuses allocations -const forceCluster = (strength = 0.35) => { - let nodes = [] - // Reuse these maps across ticks to avoid GC - const centerX = new Map() - const centerY = new Map() - const counts = new Map() - - function force(alpha) { - // Reset accumulators - centerX.forEach((_, k) => { - centerX.set(k, 0) - centerY.set(k, 0) - counts.set(k, 0) - }) - - for (let i = 0; i < nodes.length; i++) { - const n = nodes[i] - const g = n.group - if (!g) continue - if (!counts.has(g)) { - centerX.set(g, 0) - centerY.set(g, 0) - counts.set(g, 0) - } - centerX.set(g, centerX.get(g) + n.x) - centerY.set(g, centerY.get(g) + n.y) - counts.set(g, counts.get(g) + 1) - } - - // Normalize to centroids - counts.forEach((c, g) => { - if (c > 0) { - centerX.set(g, centerX.get(g) / c) - centerY.set(g, centerY.get(g) / c) - } - }) - - const s = alpha * strength - for (let i = 0; i < nodes.length; i++) { - const n = nodes[i] - if (!n.group || n._posFixed) continue - const cx = centerX.get(n.group) - const cy = centerY.get(n.group) - if (cx === undefined) continue - n.vx += (cx - n.x) * s - n.vy += (cy - n.y) * s - } - } - - force.initialize = (_) => (nodes = _) - force.strength = (s) => { - strength = s - return force - } - return force -} - -export default class D3Graph extends React.Component { - width = 100 - height = 100 - outer = React.createRef() - devicePixelRatio = window.devicePixelRatio || 1 - - state = { - transform: d3.zoomTransform({}), - } - - document = { - nodes: new Map(), - edges: new Map(), - } - - nodeDegrees = new Map() - adjacencyMap = new Map() - groupColors = new Map() - animationFrameId = null - lastFrameTime = 0 - frameCount = 0 - - // Pre-cached per-node render data (avoids per-frame allocation) - nodeRenderCache = new Map() - - // Cached hull paths (recomputed every HULL_INTERVAL frames) - cachedHulls = null - - // Pulse animation state - pulsingNodes = new Map() - - computeNodeDegrees = () => { - this.nodeDegrees.clear() - this.adjacencyMap.clear() - - this.document.nodes.forEach((n) => { - this.nodeDegrees.set(n.id, 0) - this.adjacencyMap.set(n.id, new Set()) - }) - - this.document.edges.forEach((edge) => { - const srcId = - typeof edge.source === 'object' ? edge.source.id : edge.source - const tgtId = - typeof edge.target === 'object' ? edge.target.id : edge.target - this.nodeDegrees.set(srcId, (this.nodeDegrees.get(srcId) || 0) + 1) - this.nodeDegrees.set(tgtId, (this.nodeDegrees.get(tgtId) || 0) + 1) - if (this.adjacencyMap.has(srcId)) this.adjacencyMap.get(srcId).add(tgtId) - if (this.adjacencyMap.has(tgtId)) this.adjacencyMap.get(tgtId).add(srcId) - }) - - this.maxDegree = 1 - this.nodeDegrees.forEach((deg) => { - if (deg > this.maxDegree) this.maxDegree = deg - }) - - this.groupColors.clear() - this.document.nodes.forEach((n) => { - if (n.group && n.color && !this.groupColors.has(n.group)) { - this.groupColors.set(n.group, n.color) - } - }) - - // Pre-cache radius and colors per node (avoids recalc every frame) - this.nodeRenderCache.clear() - this.document.nodes.forEach((n) => { - const degree = this.nodeDegrees.get(n.id) || 0 - let radius = DEFAULT_NODE_RADIUS - if (this.maxDegree > 1) { - const t = Math.sqrt(degree / this.maxDegree) - radius = MIN_NODE_RADIUS + t * (MAX_NODE_RADIUS - MIN_NODE_RADIUS) - } - const color = n.color || '#848484' - this.nodeRenderCache.set(n.id, { - radius, - degree, - color, - colorDark: darkenColor(color, 0.25), - }) - }) - - this.cachedHulls = null // invalidate hull cache - } - - getNodeRadius = (node) => { - const cached = this.nodeRenderCache.get(node.id) - return cached ? cached.radius : DEFAULT_NODE_RADIUS - } - - isInNeighborhood = (nodeId) => { - const hoveredNode = this.props.activeNode - if (!hoveredNode || !this._isHovering) return true - if (nodeId === hoveredNode.id) return true - const neighbors = this.adjacencyMap.get(hoveredNode.id) - return neighbors && neighbors.has(nodeId) - } - - isEdgeInNeighborhood = (edge) => { - const hoveredNode = this.props.activeNode - if (!hoveredNode || !this._isHovering) return true - const srcId = typeof edge.source === 'object' ? edge.source.id : edge.source - const tgtId = typeof edge.target === 'object' ? edge.target.id : edge.target - return srcId === hoveredNode.id || tgtId === hoveredNode.id - } - - getWorldViewport = () => { - const k = this.state.transform.k - const tx = this.state.transform.x - const ty = this.state.transform.y - return { - x0: -tx / k, - y0: -ty / k, - x1: (this.width - tx) / k, - y1: (this.height - ty) / k, - } - } - - isInViewport = (x, y, pad = 60) => { - const vp = this._viewport - if (!vp) return true - return ( - x >= vp.x0 - pad && - x <= vp.x1 + pad && - y >= vp.y0 - pad && - y <= vp.y1 + pad - ) - } - - // Hull computation — cached and throttled - computeHulls = () => { - const nodeCount = this.document.nodes.size - if (nodeCount > PERF.HUGE_GRAPH || nodeCount < 4) { - this.cachedHulls = [] - return - } - - const byGroup = new Map() - this.document.nodes.forEach((n) => { - if (!n.group) return - if (!byGroup.has(n.group)) byGroup.set(n.group, []) - byGroup.get(n.group).push([n.x, n.y]) - }) - - const hulls = [] - byGroup.forEach((points, group) => { - if (points.length < 3) return - const hull = d3.polygonHull(points) - if (!hull) return - hulls.push({ hull, color: this.groupColors.get(group) || '#888888' }) - }) - this.cachedHulls = hulls - } - - drawHulls = (context) => { - if (!this.cachedHulls || !this.cachedHulls.length) return - - context.save() - context.lineJoin = 'round' - context.lineWidth = 1.5 - - for (let h = 0; h < this.cachedHulls.length; h++) { - const { hull, color } = this.cachedHulls[h] - context.fillStyle = hexToRgba(color, THEME.hullAlpha) - context.strokeStyle = hexToRgba(color, THEME.hullStrokeAlpha) - - const pad = THEME.hullPadding - context.beginPath() - for (let i = 0; i < hull.length; i++) { - const p0 = hull[(i - 1 + hull.length) % hull.length] - const p1 = hull[i] - const p2 = hull[(i + 1) % hull.length] - const v1x = p1[0] - p0[0], - v1y = p1[1] - p0[1] - const v2x = p2[0] - p1[0], - v2y = p2[1] - p1[1] - const len1 = Math.hypot(v1x, v1y) || 1 - const len2 = Math.hypot(v2x, v2y) || 1 - const pInX = p1[0] - (v1x / len1) * pad, - pInY = p1[1] - (v1y / len1) * pad - const pOutX = p1[0] + (v2x / len2) * pad, - pOutY = p1[1] + (v2y / len2) * pad - if (i === 0) context.moveTo(pInX, pInY) - else context.lineTo(pInX, pInY) - context.quadraticCurveTo(p1[0], p1[1], pOutX, pOutY) - } - context.closePath() - context.fill() - context.stroke() - } - context.restore() - } - - // Minimap — throttled, simple rendering - drawMinimap = () => { - if (!this.minimapCanvas || !this.document.nodes.size) return - // Only redraw minimap every N frames - if (this.frameCount % PERF.MINIMAP_INTERVAL !== 0) return - - const mmw = this.minimapCanvas.width - const mmh = this.minimapCanvas.height - const mmctx = this.minimapContext - - mmctx.clearRect(0, 0, mmw, mmh) - - mmctx.fillStyle = 'rgba(20, 22, 30, 0.85)' - roundRect(mmctx, 0, 0, mmw, mmh, 6) - mmctx.fill() - - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity - this.document.nodes.forEach((n) => { - if (n.x < minX) minX = n.x - if (n.y < minY) minY = n.y - if (n.x > maxX) maxX = n.x - if (n.y > maxY) maxY = n.y - }) - - const pad = 10 - const gw = maxX - minX || 1 - const gh = maxY - minY || 1 - const sx = (mmw - pad * 2) / gw - const sy = (mmh - pad * 2) / gh - const s = Math.min(sx, sy) - const ox = (mmw - s * gw) / 2 - const oy = (mmh - s * gh) / 2 - - mmctx.save() - mmctx.translate(ox - minX * s, oy - minY * s) - mmctx.scale(s, s) - - // Skip edges in minimap for large graphs - if (this.document.edges.size < 500) { - mmctx.strokeStyle = 'rgba(255,255,255,0.1)' - mmctx.lineWidth = 1 / s - mmctx.beginPath() - this.document.edges.forEach((edge) => { - if (!edge.source.x) return - mmctx.moveTo(edge.source.x, edge.source.y) - mmctx.lineTo(edge.target.x, edge.target.y) - }) - mmctx.stroke() - } - - // Batch node drawing by color for fewer state changes - const dotSize = Math.max(1.5, 2 / s) - mmctx.globalAlpha = 0.8 - // Simple single-pass: just draw all nodes as one color for speed - mmctx.fillStyle = 'rgba(180,190,200,0.9)' - mmctx.beginPath() - this.document.nodes.forEach((n) => { - mmctx.moveTo(n.x + dotSize, n.y) - mmctx.arc(n.x, n.y, dotSize, 0, Math.PI * 2) - }) - mmctx.fill() - mmctx.globalAlpha = 1 - - // Viewport rectangle - const vp = this.getWorldViewport() - mmctx.strokeStyle = 'rgba(80,166,255,0.9)' - mmctx.lineWidth = 2 / s - mmctx.fillStyle = 'rgba(80,166,255,0.08)' - mmctx.strokeRect(vp.x0, vp.y0, vp.x1 - vp.x0, vp.y1 - vp.y0) - mmctx.fillRect(vp.x0, vp.y0, vp.x1 - vp.x0, vp.y1 - vp.y0) - - mmctx.restore() - } - - labelEdge = (context, edge) => { - const zoom = this.state.transform.k * this.devicePixelRatio - if (this.document.edges.size > 200 && zoom < 1.5) return - if (this.document.edges.size > 40 && zoom < 1.0) return - if (zoom < 0.6) return - - const srcR = this.getNodeRadius(edge.source) - const tgtR = this.getNodeRadius(edge.target) - if (edge.arc.distance < srcR + tgtR + 40) return - - const fontSize = 11 - context.font = `500 ${fontSize}px ${THEME.labelFont}` - context.textAlign = 'center' - context.textBaseline = 'middle' - - const maxWidth = 120 - const bgPadding = 4 - let { width } = context.measureText(edge.label) - width = Math.min(width, maxWidth) - - const { centerX: cx, centerY: cy } = edge.arc - const rw = width + 2 * bgPadding - const rh = fontSize + bgPadding - - context.globalAlpha = 0.88 - context.fillStyle = '#ffffff' - roundRect(context, cx - rw / 2, cy - rh / 2, rw, rh, 3) - context.fill() - context.globalAlpha = 1 - - context.fillStyle = '#333333' - context.fillText(edge.label, cx, cy, maxWidth) - } - - labelNode = (context, node) => { - const zoom = this.state.transform.k * this.devicePixelRatio - const nodeCount = this.document.nodes.size - const cached = this.nodeRenderCache.get(node.id) - const degree = cached ? cached.degree : 0 - const radius = cached ? cached.radius : DEFAULT_NODE_RADIUS - - if (nodeCount > 500 && zoom < 2.0 && degree < 3) return - if (nodeCount > 200 && zoom < 1.2 && degree < 2) return - if (nodeCount > 50 && zoom < 0.8) return - if (zoom < 0.4) return - if (nodeCount > 100 && degree < 2 && zoom < 1.5) return - - const label = node.label || '' - if (!label) return - - const fontSize = Math.max(10, Math.min(14, radius * 0.9)) - context.font = `600 ${fontSize}px ${THEME.labelFont}` - context.textAlign = 'center' - context.textBaseline = 'middle' - - const maxWidth = radius * 4 - let { width: textWidth } = context.measureText(label) - textWidth = Math.min(textWidth, maxWidth) - - const bgPadding = 3 - const textY = node.y + radius + fontSize / 2 + 4 - const rw = textWidth + 2 * bgPadding - const rh = fontSize + bgPadding - - context.globalAlpha = 0.88 - context.fillStyle = '#2A2C34' - roundRect(context, node.x - rw / 2, textY - rh / 2, rw, rh, rh / 2) - context.fill() - context.globalAlpha = 1 - - context.fillStyle = '#FFFFFF' - context.fillText(label, node.x, textY, maxWidth) - } - - _drawAll = () => { - const context = this.canvasContext - if (!context) return - - const { highlightPredicate } = this.props - this._isHovering = !!this.props.hoveredNode - this.frameCount++ - - context.save() - const { devicePixelRatio: dpr } = this - context.clearRect(0, 0, this.width * dpr, this.height * dpr) - context.translate( - this.state.transform.x * dpr, - this.state.transform.y * dpr, - ) - context.scale(this.state.transform.k * dpr, this.state.transform.k * dpr) - - this._viewport = this.getWorldViewport() - - const zoom = this.state.transform.k * dpr - const isZoomedOut = zoom < 0.3 - const nodeCount = this.document.nodes.size - const isLargeGraph = nodeCount > PERF.LARGE_GRAPH - const isHugeGraph = nodeCount > PERF.HUGE_GRAPH - - // --- Hulls (throttled) --- - if (!isZoomedOut && !isHugeGraph) { - if (this.frameCount % PERF.HULL_INTERVAL === 0 || !this.cachedHulls) { - this.computeHulls() - } - this.drawHulls(context) - } - - // --- Arc helpers (defined once, not per-edge) --- - const addArrow = (arc, target, vx, vy, nodeRadius) => { - const baseOffset = nodeRadius + ARROW_LENGTH - arc.arrowBase0 = target.x - vx * baseOffset - arc.arrowBase1 = target.y - vy * baseOffset - arc.arrowEnd0 = target.x - nodeRadius * vx - arc.arrowEnd1 = target.y - nodeRadius * vy - arc.arrowPt10 = arc.arrowBase0 + ARROW_WIDTH * vy - arc.arrowPt11 = arc.arrowBase1 - ARROW_WIDTH * vx - arc.arrowPt20 = arc.arrowBase0 - ARROW_WIDTH * vy - arc.arrowPt21 = arc.arrowBase1 + ARROW_WIDTH * vx - arc.hasArrow = true - } - - const getArc = (edge) => { - const dx = edge.target.x - edge.source.x - const dy = edge.target.y - edge.source.y - const l = Math.sqrt(dx * dx + dy * dy) - - const srcR = this.getNodeRadius(edge.source) - const tgtR = this.getNodeRadius(edge.target) - - const arc = { - radius: -1, - distance: l, - centerX: edge.source.x + dx / 2, - centerY: edge.source.y + dy / 2, - hasArrow: false, - } - - if ( - !edge.siblingCount || - edge.siblingCount < 2 || - (edge.siblingCount % 2 && edge.siblingIndex === 0) || - l < srcR + tgtR - ) { - if (l > srcR + tgtR + 2 * ARROW_LENGTH) { - addArrow(arc, edge.target, dx / l, dy / l, tgtR) - } - return arc - } - - const LR = 4 - let offset - if (edge.siblingCount % 2) { - offset = LR * (1 + Math.ceil(edge.siblingIndex / 2) * 2) - } else { - offset = LR * (1 + Math.floor(edge.siblingIndex / 2) * 2) - } - if (edge.siblingCount * LR > (0.9 * l) / 2) { - offset = (offset * 0.9 * l) / 2 / edge.siblingCount / LR - } - - const norm0 = edge.siblingIndex % 2 ? dy / l : -dy / l - const norm1 = edge.siblingIndex % 2 ? -dx / l : dx / l - const R = (offset * offset + (l * l) / 4) / 2 / offset - const h2 = (R * offset) / (R - offset) - - arc.radius = R - arc.centerX = edge.source.x + dx / 2 + norm0 * offset - arc.centerY = edge.source.y + dy / 2 + norm1 * offset - arc.controlX = edge.source.x + dx / 2 + norm0 * (offset + h2) - arc.controlY = edge.source.y + dy / 2 + norm1 * (offset + h2) - - if (l > srcR + tgtR + 2 * ARROW_LENGTH) { - const rotateDir = edge.siblingIndex % 2 ? +1 : -1 - const alpha = Math.asin(Math.min(1, l / 2 / R)) - const theta = Math.asin(Math.min(1, tgtR / 2 / R)) - const ra = rotateDir * (alpha - theta) - const cosA = Math.cos(ra), - sinA = Math.sin(ra) - const ndx = dx / l, - ndy = dy / l - addArrow( - arc, - edge.target, - ndx * cosA - ndy * sinA, - ndx * sinA + ndy * cosA, - tgtR, - ) - } - - return arc - } - - // --- Edges --- - context.lineCap = 'round' - let edgesDrawn = 0 - this.document.edges.forEach((edge) => { - // Hard cap on visible edges for huge graphs - if (isHugeGraph && edgesDrawn >= PERF.MAX_VISIBLE_EDGES) return - - // Viewport culling - if ( - !this.isInViewport(edge.source.x, edge.source.y, 100) && - !this.isInViewport(edge.target.x, edge.target.y, 100) - ) - return - - edgesDrawn++ - const arc = (edge.arc = getArc(edge)) - - const isHighlighted = edge.predicate === highlightPredicate - const isActive = edge === this.props.activeEdge - const inNeighborhood = this.isEdgeInNeighborhood(edge) - - context.strokeStyle = edge.color - if (this._isHovering && !inNeighborhood) { - context.globalAlpha = THEME.edgeAlphaDimmed - context.lineWidth = 0.5 - } else if (isActive) { - context.globalAlpha = THEME.edgeAlphaHighlight - context.lineWidth = THEME.edgeWidthActive - } else if (isHighlighted || (this._isHovering && inNeighborhood)) { - context.globalAlpha = THEME.edgeAlphaHighlight - context.lineWidth = THEME.edgeWidthHighlight - } else { - context.globalAlpha = THEME.edgeAlphaDefault - context.lineWidth = THEME.edgeWidthDefault - } - - context.beginPath() - context.moveTo(edge.source.x, edge.source.y) - if (arc.radius <= 0) { - context.lineTo(edge.target.x, edge.target.y) - } else { - context.arcTo( - arc.controlX, - arc.controlY, - edge.target.x, - edge.target.y, - arc.radius, - ) - } - context.stroke() - - // Arrowhead — skip for huge graphs when zoomed out - if (arc.hasArrow && !(isHugeGraph && isZoomedOut)) { - context.fillStyle = edge.color - context.beginPath() - context.moveTo(arc.arrowEnd0, arc.arrowEnd1) - context.lineTo(arc.arrowPt10, arc.arrowPt11) - context.lineTo(arc.arrowPt20, arc.arrowPt21) - context.closePath() - context.fill() - } - - context.globalAlpha = 1 - if (!isLargeGraph && (!this._isHovering || inNeighborhood)) { - this.labelEdge(context, edge) - } - }) - - // --- Nodes --- - this.document.nodes.forEach((d) => { - if (!this.isInViewport(d.x, d.y, 50)) return - - const cached = this.nodeRenderCache.get(d.id) - const radius = cached ? cached.radius : DEFAULT_NODE_RADIUS - const color = cached ? cached.color : '#848484' - const isActive = d === this.props.activeNode - const inNeighborhood = this.isInNeighborhood(d.id) - const dimmed = this._isHovering && !inNeighborhood - - if (dimmed) context.globalAlpha = THEME.nodeDimmedAlpha - - // LOD: dots at very low zoom - if (isZoomedOut) { - context.fillStyle = color - context.beginPath() - context.arc(d.x, d.y, Math.max(2, radius * 0.3), 0, 2 * Math.PI) - context.fill() - context.globalAlpha = 1 - return - } - - // Solid fill - context.fillStyle = color - context.beginPath() - context.arc(d.x, d.y, radius, 0, 2 * Math.PI, true) - context.fill() - - // Border - context.strokeStyle = cached ? cached.colorDark : darkenColor(color, 0.25) - context.lineWidth = isActive - ? THEME.nodeBorderActive - : THEME.nodeBorderDefault - context.stroke() - - // Active glow - if (isActive && !dimmed) { - context.strokeStyle = color - context.globalAlpha = 0.4 - context.lineWidth = 4 - context.beginPath() - context.arc(d.x, d.y, radius + 4, 0, 2 * Math.PI, true) - context.stroke() - context.globalAlpha = dimmed ? THEME.nodeDimmedAlpha : 1 - } - - // Search pulse - const pulse = this.pulsingNodes.get(d.id) - if (pulse && pulse > 0) { - context.strokeStyle = '#50A6FF' - context.globalAlpha = 0.6 * pulse - context.lineWidth = 8 * pulse - context.beginPath() - context.arc(d.x, d.y, radius + 10 * (1 - pulse), 0, 2 * Math.PI) - context.stroke() - context.globalAlpha = 1 - } - - // Expanded dot - if (d.expanded) { - context.fillStyle = '#ffffff' - context.beginPath() - context.arc(d.x + radius * 0.55, d.y - radius * 0.55, 3, 0, 2 * Math.PI) - context.fill() - } - - context.globalAlpha = 1 - if (!dimmed) this.labelNode(context, d) - }) - - context.restore() - this.drawMinimap() - } - - startAnimationLoop = () => { - const animate = (timestamp) => { - const delta = timestamp - (this.lastFrameTime || timestamp) - this.lastFrameTime = timestamp - - let hasPulse = false - this.pulsingNodes.forEach((val, key) => { - const newVal = val - delta * 0.002 - if (newVal <= 0) this.pulsingNodes.delete(key) - else { - this.pulsingNodes.set(key, newVal) - hasPulse = true - } - }) - - if (hasPulse) this._drawAll() - - this.animationFrameId = requestAnimationFrame(animate) - } - this.animationFrameId = requestAnimationFrame(animate) - } - - drawGraph = debounce(this._drawAll, 5, { leading: true, trailing: true }) - - createForces = () => { - this.d3simulation - .alphaTarget(0) - .alphaMin(0.005) - .alphaDecay(0.05) - .velocityDecay(0.5) - .force( - 'link', - d3 - .forceLink() - .distance((d) => { - const srcDeg = - this.nodeDegrees.get( - typeof d.source === 'object' ? d.source.id : d.source, - ) || 1 - const tgtDeg = - this.nodeDegrees.get( - typeof d.target === 'object' ? d.target.id : d.target, - ) || 1 - const srcG = typeof d.source === 'object' ? d.source.group : null - const tgtG = typeof d.target === 'object' ? d.target.group : null - const cross = srcG && tgtG && srcG !== tgtG - return (cross ? 100 : 60) + Math.sqrt(srcDeg + tgtDeg) * 15 - }) - .strength((d) => { - const srcG = typeof d.source === 'object' ? d.source.group : null - const tgtG = typeof d.target === 'object' ? d.target.group : null - return srcG === tgtG ? 0.4 : 0.15 - }) - .id((d) => d.id), - ) - .force( - 'charge', - d3 - .forceManyBody() - .strength((d) => { - const degree = this.nodeDegrees.get(d.id) || 0 - return -200 - degree * 30 - }) - .distanceMax(500) - .theta(0.9), - ) - .force( - 'collision', - d3 - .forceCollide() - .radius((d) => this.getNodeRadius(d) + 8) - .strength(0.8), - ) - .force('cluster', forceCluster(0.35)) - .force('fixedPosForce', fixedPosForce()) - - this.fixedPosForce = this.d3simulation.force('fixedPosForce') - this.edgesForce = this.d3simulation.force('link') - } - - componentDidMount() { - this.d3simulation = d3.forceSimulation().on('tick', this.drawGraph) - this.createForces() - - this.graphCanvas = d3 - .select(this.outer.current) - .append('canvas') - .attr('width', this.width) - .attr('height', this.height) - .node() - - this.minimapCanvas = document.createElement('canvas') - this.minimapCanvas.className = 'graph-minimap' - this.minimapCanvas.width = 180 - this.minimapCanvas.height = 120 - this.outer.current.appendChild(this.minimapCanvas) - this.minimapContext = this.minimapCanvas.getContext('2d') - this.minimapCanvas.addEventListener('click', this.onMinimapClick) - - this.zoomBehavior = d3 - .zoom() - .scaleExtent([(1 / 8) * this.devicePixelRatio, 6 * this.devicePixelRatio]) - .on('zoom', this.onZoom) - - d3.select(this.graphCanvas) - .on('click', this.onClick) - .on('dblclick', this.onDoubleClick) - .on('mousemove', this.onMouseMove) - .call( - d3 - .drag() - .subject(this.dragsubject) - .on('start', this.dragstarted) - .on('drag', this.dragged), - ) - .call(this.zoomBehavior) - - this.onResize() - this.updateDocument(this.props.nodes, this.props.edges) - this.startAnimationLoop() - this.resizeObserver = window.setInterval(this.onResize, 1000) - } - - componentWillUnmount() { - clearInterval(this.resizeObserver) - if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId) - if (this.minimapCanvas) - this.minimapCanvas.removeEventListener('click', this.onMinimapClick) - } - - onMinimapClick = (e) => { - if (!this.document.nodes.size) return - const rect = this.minimapCanvas.getBoundingClientRect() - const px = e.clientX - rect.left, - py = e.clientY - rect.top - const mmw = this.minimapCanvas.width, - mmh = this.minimapCanvas.height - - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity - this.document.nodes.forEach((n) => { - if (n.x < minX) minX = n.x - if (n.y < minY) minY = n.y - if (n.x > maxX) maxX = n.x - if (n.y > maxY) maxY = n.y - }) - - const pad = 10, - gw = maxX - minX || 1, - gh = maxY - minY || 1 - const s = Math.min((mmw - pad * 2) / gw, (mmh - pad * 2) / gh) - const ox = (mmw - s * gw) / 2, - oy = (mmh - s * gh) / 2 - const wx = (px - ox + minX * s) / s, - wy = (py - oy + minY * s) / s - - const k = this.state.transform.k - d3.select(this.graphCanvas) - .transition() - .duration(300) - .call( - this.zoomBehavior.transform, - d3.zoomIdentity - .translate(this.width / 2 - wx * k, this.height / 2 - wy * k) - .scale(k), - ) - } - - getD3EventCoords = (event) => this.state.transform.invert([event.x, event.y]) - - findNodeAtPos = (x, y) => { - let minNode, - minD = 1e10 - this.document.nodes.forEach((n) => { - const r = this.getNodeRadius(n) - const d = (n.x - x) * (n.x - x) + (n.y - y) * (n.y - y) - if (d < r * r && d < minD) { - minNode = n - minD = d - } - }) - return minNode - } - - findEdgeAtPos = (x, y) => { - let minEdge, - minD = 1e10 - this.document.edges.forEach((edge) => { - if (!edge.arc) return - const { centerX: cx, centerY: cy } = edge.arc - const d = (cx - x) * (cx - x) + (cy - y) * (cy - y) - if (d < minD) { - minEdge = edge - minD = d - } - }) - return minD > 225 ? undefined : minEdge - } - - onMouseMove = () => { - const { offsetX: x, offsetY: y } = currentEvent - const pt = this.getD3EventCoords({ x, y }) - const node = this.findNodeAtPos(...pt) - this.props.onNodeHovered(node) - if (this.graphCanvas) - this.graphCanvas.style.cursor = node ? 'pointer' : 'default' - if (!node) this.props.onEdgeHovered(this.findEdgeAtPos(...pt)) - this.drawGraph() - } - - onClick = () => { - const { offsetX: x, offsetY: y } = currentEvent - const pt = this.getD3EventCoords({ x, y }) - const node = this.findNodeAtPos(...pt) - if (node) { - currentEvent.stopImmediatePropagation() - return this.props.onNodeSelected(node) - } - const edge = this.findEdgeAtPos(...pt) - if (edge) { - currentEvent.stopImmediatePropagation() - return this.props.onEdgeSelected(edge) - } - } - - onDoubleClick = () => { - const { offsetX: x, offsetY: y } = currentEvent - const pt = this.getD3EventCoords({ x, y }) - const node = this.findNodeAtPos(...pt) - if (node) { - currentEvent.stopImmediatePropagation() - return this.props.onNodeDoubleClicked(node) - } - } - - dragsubject = () => { - const { offsetX: x, offsetY: y } = currentEvent.sourceEvent - const pt = this.getD3EventCoords({ x, y }) - const node = this.findNodeAtPos(...pt) - this.props.onNodeSelected(node) - return node - } - - dragstarted = () => { - if (!currentEvent.active) - setTimeout(() => this.d3simulation.alpha(0.5).restart(), DOUBLE_CLICK_MS) - } - - dragged = () => { - const { offsetX: x, offsetY: y } = currentEvent.sourceEvent - const pt = this.getD3EventCoords({ x, y }) - this.fixedPosForce.setNodeCoords(currentEvent.subject, ...pt) - this.drawGraph() - this.d3simulation.alpha(Math.max(0.12, this.d3simulation.alpha())) - } - - _updateZoom = (transform) => { - if (this.state.transform.toString() !== transform.toString()) - this.setState({ transform }) - } - updateZoom = debounce(this._updateZoom, 2, { leading: true, trailing: true }) - onZoom = () => this.updateZoom(currentEvent.transform) - - zoomToFit = () => { - if (!this.graphCanvas || !this.document.nodes.size) return - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity - this.document.nodes.forEach((n) => { - const r = this.getNodeRadius(n) + 20 - if (n.x - r < minX) minX = n.x - r - if (n.y - r < minY) minY = n.y - r - if (n.x + r > maxX) maxX = n.x + r - if (n.y + r > maxY) maxY = n.y + r - }) - const p = 40, - gw = maxX - minX + p * 2, - gh = maxY - minY + p * 2 - const scale = Math.min(this.width / gw, this.height / gh, 2) * 0.9 - const transform = d3.zoomIdentity - .translate(this.width / 2, this.height / 2) - .scale(scale) - .translate(-(minX + maxX) / 2, -(minY + maxY) / 2) - d3.select(this.graphCanvas) - .transition() - .duration(500) - .call(this.zoomBehavior.transform, transform) - } - - focusNode = (node) => { - if (!this.graphCanvas || !node) return - const k = 2.2 - d3.select(this.graphCanvas) - .transition() - .duration(500) - .ease(d3.easeCubicOut) - .call( - this.zoomBehavior.transform, - d3.zoomIdentity - .translate(this.width / 2 - node.x * k, this.height / 2 - node.y * k) - .scale(k), - ) - this.pulsingNodes.set(node.id, 1.0) - } - - searchNode = (query) => { - if (!query) return null - const q = query.toLowerCase().trim() - let found = null - this.document.nodes.forEach((n) => { - if (found) return - const name = (n.name || n.label || '').toLowerCase() - const uid = (n.uid || n.id || '').toLowerCase() - if (name.includes(q) || uid === q) found = n - }) - return found - } - - onResize = () => { - let resized = false - if (this.outer.current) { - const el = this.outer.current - resized |= this.width !== el.offsetWidth - resized |= this.height !== el.offsetHeight - this.width = el.offsetWidth - this.height = el.offsetHeight - } - if (!resized) return - - this.zoomBehavior.scaleTo(d3.select(this.graphCanvas), 1) - this.zoomBehavior.translateTo(d3.select(this.graphCanvas), 0, 0) - - const { width, height } = this - this.d3simulation - .force('x', d3.forceX(0).strength((0.02 * height) / width)) - .force('y', d3.forceY(0).strength((0.02 * width) / height)) - - d3.select(this.graphCanvas) - .attr('width', this.width * this.devicePixelRatio) - .attr('height', this.height * this.devicePixelRatio) - - this.canvasContext = this.graphCanvas.getContext('2d') - this._drawAll() - } - - updateDocument = (nodes, edges) => { - if (!this.d3simulation || !nodes || !edges) return - - const newNodesReceived = - this.document.nodesLength !== nodes.size || - this.document.edgesLength !== edges.size - - this.document = { - edges, - edgesLength: edges.size, - nodes, - nodesLength: nodes.size, - } - this.computeNodeDegrees() - - if (newNodesReceived) { - this.d3simulation - .force( - 'link', - d3 - .forceLink() - .distance((d) => { - const srcDeg = - this.nodeDegrees.get( - typeof d.source === 'object' ? d.source.id : d.source, - ) || 1 - const tgtDeg = - this.nodeDegrees.get( - typeof d.target === 'object' ? d.target.id : d.target, - ) || 1 - const srcG = typeof d.source === 'object' ? d.source.group : null - const tgtG = typeof d.target === 'object' ? d.target.group : null - return ( - (srcG && tgtG && srcG !== tgtG ? 100 : 60) + - Math.sqrt(srcDeg + tgtDeg) * 15 - ) - }) - .strength((d) => { - const srcG = typeof d.source === 'object' ? d.source.group : null - const tgtG = typeof d.target === 'object' ? d.target.group : null - return srcG === tgtG ? 0.4 : 0.15 - }) - .id((d) => d.id), - ) - .force( - 'charge', - d3 - .forceManyBody() - .strength((d) => -200 - (this.nodeDegrees.get(d.id) || 0) * 30) - .distanceMax(500) - .theta(0.9), - ) - .force( - 'collision', - d3 - .forceCollide() - .radius((d) => this.getNodeRadius(d) + 8) - .strength(0.8), - ) - .force('cluster', forceCluster(0.35)) - - this.edgesForce = this.d3simulation.force('link') - this.d3simulation.alpha(0.5).restart() - } - - this.d3simulation.nodes(Array.from(nodes.values())) - this.edgesForce.links(Array.from(edges.values())) - } - - render() { - this.updateDocument(this.props.nodes, this.props.edges) - this.onResize() - this.drawGraph() - return
- } -} diff --git a/client/src/components/EditorPanel.js b/client/src/components/EditorPanel.js index ddcb2cee..cbba7c75 100644 --- a/client/src/components/EditorPanel.js +++ b/client/src/components/EditorPanel.js @@ -18,8 +18,12 @@ import { updateReadOnly, } from 'actions/query' +import AiQueryModal from 'components/AiQueryModal' +import EditorTabs from 'components/EditorTabs' import QueryVarsEditor from 'components/QueryVarsEditor' +import RunHistoryPanel from 'components/RunHistoryPanel' import Editor from 'containers/Editor' +import { formatDql } from 'lib/formatDql' import '../assets/css/EditorPanel.scss' @@ -32,6 +36,8 @@ export default function EditorPanel() { const setReadOnly = (value) => dispatch(updateReadOnly(value)) const setBestEffort = (value) => dispatch(updateBestEffort(value)) + const [aiModalOpen, setAiModalOpen] = React.useState(false) + const onClearQuery = () => { dispatch(updateQuery('')) dispatch(updateQueryVars([])) @@ -39,6 +45,13 @@ export default function EditorPanel() { const onUpdateQuery = (query) => dispatch(updateQuery(query)) const onUpdateAction = (action) => dispatch(updateAction(action)) + const onFormatQuery = () => { + if (query.trim() === '') { + return + } + dispatch(updateQuery(formatDql(query))) + } + const onRunCurrentQuery = () => dispatch( runQuery(query, action, { @@ -92,6 +105,7 @@ export default function EditorPanel() { return (
+
{renderRadioBtn('query', 'Query', action, onUpdateAction)} @@ -101,6 +115,22 @@ export default function EditorPanel() { {queryOptions}
+ + +
) diff --git a/client/src/components/EditorTabs.js b/client/src/components/EditorTabs.js new file mode 100644 index 00000000..5c03f105 --- /dev/null +++ b/client/src/components/EditorTabs.js @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import classnames from 'classnames' +import React, { useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { addTab, closeTab, renameTab, switchTab } from 'actions/query' + +import './EditorTabs.scss' + +export default function EditorTabs() { + const dispatch = useDispatch() + const { tabs = [], activeTabId } = useSelector((state) => state.query) + const [editingId, setEditingId] = useState(null) + const [draftName, setDraftName] = useState('') + const renameInputRef = useRef(null) + + useEffect(() => { + if (editingId !== null && renameInputRef.current) { + renameInputRef.current.focus() + renameInputRef.current.select() + } + }, [editingId]) + + const startRename = (tab) => { + setEditingId(tab.id) + setDraftName(tab.name) + } + + const commitRename = () => { + if (editingId !== null) { + dispatch(renameTab(editingId, draftName)) + setEditingId(null) + } + } + + const onInputKeyDown = (e) => { + e.stopPropagation() + if (e.key === 'Enter') { + commitRename() + } else if (e.key === 'Escape') { + setEditingId(null) + } + } + + return ( +
+ {tabs.map((tab) => ( +
dispatch(switchTab(tab.id))} + onDoubleClick={() => startRename(tab)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + dispatch(switchTab(tab.id)) + } + }} + > + {editingId === tab.id ? ( + setDraftName(e.target.value)} + onBlur={commitRename} + onKeyDown={onInputKeyDown} + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + /> + ) : ( + + {tab.name} + + )} + {tabs.length > 1 && ( + + )} +
+ ))} + +
+ ) +} diff --git a/client/src/components/EditorTabs.scss b/client/src/components/EditorTabs.scss new file mode 100644 index 00000000..49dbfc82 --- /dev/null +++ b/client/src/components/EditorTabs.scss @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.editor-tabs { + display: flex; + align-items: flex-end; + overflow-x: auto; + padding: 3px 4px 0; + background-color: #f3f3f3; + border-bottom: 1px solid #d2d2d2; + + .editor-tab { + display: inline-flex; + align-items: center; + max-width: 180px; + margin-right: 2px; + margin-bottom: -1px; + padding: 3px 8px; + border: 1px solid transparent; + border-bottom: 1px solid #d2d2d2; + border-radius: 3px 3px 0 0; + background: transparent; + color: #8a8a8a; + font-size: 12px; + line-height: 18px; + white-space: nowrap; + cursor: pointer; + user-select: none; + outline: none; + + &:hover { + background: #ececec; + color: #555; + } + + &.active { + background: #fff; + border-color: #d2d2d2; + border-bottom-color: #fff; + color: #333; + } + + .editor-tab-name { + overflow: hidden; + text-overflow: ellipsis; + } + + .editor-tab-close { + visibility: hidden; + margin-left: 6px; + padding: 0 2px; + border: none; + background: transparent; + color: #8a8a8a; + font-size: 12px; + line-height: 1; + cursor: pointer; + + &:hover { + color: #333; + } + } + + &:hover .editor-tab-close, + &.active .editor-tab-close { + visibility: visible; + } + + .editor-tab-rename-input { + width: 100px; + padding: 0 2px; + border: 1px solid #d2d2d2; + border-radius: 2px; + font-size: 12px; + line-height: 16px; + color: #333; + outline: none; + } + } + + .editor-tab-add { + flex: none; + margin-bottom: 1px; + padding: 1px 8px 3px; + border: none; + background: transparent; + color: #8a8a8a; + font-size: 14px; + line-height: 1; + cursor: pointer; + + &:hover { + color: #333; + } + } +} diff --git a/client/src/components/EntitySelector.js b/client/src/components/EntitySelector.js index c56c3ea8..f1d2df90 100644 --- a/client/src/components/EntitySelector.js +++ b/client/src/components/EntitySelector.js @@ -15,7 +15,12 @@ export default class EntitySelector extends React.Component { state = { expanded: false } render() { - const { graphLabels, onPredicateHovered } = this.props + const { + graphLabels, + onPredicateHovered, + hiddenPredicates, + onPredicateToggled, + } = this.props const { expanded } = this.state return ( @@ -26,16 +31,30 @@ export default class EntitySelector extends React.Component { > ▲ - {graphLabels.map((label) => ( -
) } diff --git a/client/src/components/FrameItem.js b/client/src/components/FrameItem.js index a3731f45..1733750c 100644 --- a/client/src/components/FrameItem.js +++ b/client/src/components/FrameItem.js @@ -115,6 +115,7 @@ export default function FrameItem({ > { + const pad = (value) => String(value).padStart(2, '0') + const now = new Date() + const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad( + now.getDate(), + )}-${pad(now.getHours())}${pad(now.getMinutes())}` + return `ratel-results-${timestamp}.csv` +} export default function FrameBodyToolbar({ frame, @@ -20,6 +32,14 @@ export default function FrameBodyToolbar({ const isError = tabResult.error || (tabResult.response && tabResult.response.error) + const onSelectTab = (tab) => { + if (tab === ACTION_DOWNLOAD_CSV) { + downloadCSV(tabResult.response.data, getCsvFilename()) + return + } + setActiveTab(tab) + } + const toolbarBtn = (id, icon, label) => ( { + if (!isQueryFrame || !tabResult.response?.data) { + return null + } + return toolbarBtn( + ACTION_DOWNLOAD_CSV, + , + 'Download CSV', + ) + } + return ( {visualTab()} {toolbarBtn(TAB_JSON, , 'JSON')} {toolbarBtn(TAB_QUERY, , 'Request')} {toolbarBtn(TAB_GEO, , 'Geo')} + {downloadCsvTab()} ) } diff --git a/client/src/components/FrameLayout/FrameHeader.js b/client/src/components/FrameLayout/FrameHeader.js index 05fad75f..0092788b 100644 --- a/client/src/components/FrameLayout/FrameHeader.js +++ b/client/src/components/FrameLayout/FrameHeader.js @@ -11,41 +11,23 @@ import { useDispatch } from 'react-redux' import { discardFrame, setActiveFrame } from 'actions/frames' import { updateQueryAndAction, updateQueryVars } from 'actions/query' +import { latencyBarSegments, latencyTooltip, timeToText } from 'lib/latency' +import LatencyModal from './LatencyModal' import QueryPreview from './QueryPreview' import SharingSettings from './SharingSettings' import './FrameHeader.scss' -function timeToText(ns) { - if (ns === null || ns === undefined) { - return '' - } - if (ns < 1e4) { - return ns.toFixed(0) + 'ns' - } - const ms = ns / 1e6 - if (ms < 1000) { - return ms.toFixed(0) + 'ms' - } - const s = ms / 1000 - if (s <= 60) { - return s.toFixed(1) + 's' - } - const secondsOnly = Math.round(s) % 60 - - return `${Math.floor(s / 60)}m${secondsOnly.toLocaleString('en', { - minimumIntegerDigits: 2, - })}s` -} - export default function FrameHeader({ collapsed, frame, + tabResult, isActive, isFullscreen, onToggleFullscreen, }) { const dispatch = useDispatch() + const [showLatency, setShowLatency] = React.useState(false) const selectFrame = () => { dispatch(updateQueryAndAction(frame.query, frame.action)) if (frame.action === 'query') { @@ -54,40 +36,40 @@ export default function FrameHeader({ dispatch(setActiveFrame(frame.id)) } - function drawLatency(serverNs, networkNs) { - if ( - serverNs === undefined || - networkNs === undefined || - serverNs === null || - networkNs === null - ) { + function drawLatency(result) { + if (!result) { + return null + } + const segments = latencyBarSegments( + result.serverLatency, + result.networkLatencyNs, + ) + if (!segments.length) { return null } - const ratio = serverNs / (serverNs + networkNs) - const title = `Alpha Latency: ${timeToText(serverNs)} (${( - ratio * 100 - ).toFixed(0)}%)\nNetwork Latency: ${timeToText(networkNs)} (${( - (1 - ratio) * 100 - ).toFixed(0)}%)\nTotal Latency: ${timeToText(serverNs + networkNs)}` + const totalNs = segments.reduce((sum, s) => sum + s.ns, 0) - const flexStyles = { - server: { flexGrow: 1000 * ratio }, - network: { flexGrow: 1000 * (1 - ratio) }, - } return ( -
+
{ + e.stopPropagation() + setShowLatency(true) + }} + >
-
-
+ {segments.map((s) => ( +
+ ))}
-
- {timeToText(serverNs)} -
-
- {timeToText(networkNs)} -
+
{timeToText(totalNs)}
) @@ -109,7 +91,7 @@ export default function FrameHeader({ /> ) : null} - {drawLatency(frame.serverLatencyNs, frame.networkLatencyNs)} + {drawLatency(tabResult)}
{collapsed ? null : ( @@ -143,6 +125,10 @@ export default function FrameHeader({ ) : null}
+ + {showLatency && tabResult && ( + setShowLatency(false)} /> + )}
) } diff --git a/client/src/components/FrameLayout/FrameHeader.scss b/client/src/components/FrameLayout/FrameHeader.scss index 37a6265c..b2bf1a0e 100644 --- a/client/src/components/FrameLayout/FrameHeader.scss +++ b/client/src/components/FrameLayout/FrameHeader.scss @@ -54,15 +54,26 @@ display: flex; flex-direction: row; - .server-bar { + .latency-seg { flex-basis: 2px; height: 2px; background-color: $serverColor; } - .network-bar { - flex-basis: 2px; - height: 2px; + // Server phases, in pipeline order. + .latency-seg--parsing { + background-color: color.adjust(#5cb85c, $saturation: -30%); + } + .latency-seg--processing { + background-color: $serverColor; + } + .latency-seg--encoding { + background-color: color.adjust(#d9534f, $saturation: -30%); + } + .latency-seg--assign_timestamp { + background-color: color.adjust(#9b59b6, $saturation: -30%); + } + .latency-seg--network { background-color: $networkColor; } } @@ -97,7 +108,7 @@ *, & { - cursor: default; + cursor: pointer; } } diff --git a/client/src/components/FrameLayout/FrameSession.js b/client/src/components/FrameLayout/FrameSession.js index d337e851..bf3038fa 100644 --- a/client/src/components/FrameLayout/FrameSession.js +++ b/client/src/components/FrameLayout/FrameSession.js @@ -42,6 +42,21 @@ export default function FrameSession({ frame, tabResult }) { dispatch(setPanelMinimized(minimized)) const [hoveredPredicate, setHoveredPredicate] = React.useState(null) + const [hiddenPredicates, setHiddenPredicates] = React.useState( + () => new Set(), + ) + + const togglePredicateHidden = (pred) => { + setHiddenPredicates((prev) => { + const next = new Set(prev) + if (next.has(pred)) { + next.delete(pred) + } else { + next.add(pred) + } + return next + }) + } // TODO: updating graphUpdateHack will force Graphcontainer > D3Graph // to re-render, and before render it will refresh nodes/edges dataset. @@ -123,10 +138,13 @@ export default function FrameSession({ frame, tabResult }) { panelHeight={panelHeight} panelWidth={panelWidth} remainingNodes={graph.remainingNodes} + hiddenPredicates={hiddenPredicates} /> ) diff --git a/client/src/components/FrameLayout/LatencyModal.js b/client/src/components/FrameLayout/LatencyModal.js new file mode 100644 index 00000000..686f1d76 --- /dev/null +++ b/client/src/components/FrameLayout/LatencyModal.js @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react' +import Modal from 'react-bootstrap/Modal' + +import { latencyBarSegments, numUidSegments, timeToText } from 'lib/latency' + +import './LatencyModal.scss' + +// Phase colors mirror the inline latency bar (.latency-seg--* in +// FrameHeader.scss). +const PHASE_COLOR = { + parsing: '#74b074', + processing: '#e0a960', + encoding: '#cc6b68', + assign_timestamp: '#a974c0', + network: '#5b8bb5', +} + +// Segment keys for server phases carry an "_ns" suffix (parsing_ns, ...); +// network does not. Normalize before looking up the color. +const colorFor = (key) => PHASE_COLOR[key.replace(/_ns$/, '')] || '#5b8bb5' + +export default function LatencyModal({ result, onHide }) { + const segments = latencyBarSegments( + result.serverLatency, + result.networkLatencyNs, + ) + const totalNs = segments.reduce((sum, s) => sum + s.ns, 0) + + // Lay the phases out as a timeline/waterfall: each bar starts where the + // previous phase ended, so the row reads as a sequence over the total + // duration rather than five independent bars from zero. + let elapsedNs = 0 + const timeline = segments.map((s) => { + const offset = totalNs > 0 ? elapsedNs / totalNs : 0 + elapsedNs += s.ns + return { ...s, offset } + }) + + const { segments: uidSegments, total: uidTotal } = numUidSegments( + result.response && result.response.data, + ) + + return ( + + + Query latency breakdown + + + {segments.length === 0 ? ( +

+ No latency data for this query. +

+ ) : ( +
+
Latency
+ {timeline.map((s) => ( +
+ {s.label} + + + + {s.text} + + {(s.ratio * 100).toFixed(0)}% + +
+ ))} +
+ Total + + + {timeToText(totalNs)} + + +
+
+ )} + + {uidSegments.length > 0 && ( +
+
+ Num UIDs + + total: {uidTotal.toLocaleString()} + +
+ {uidSegments.map((s) => ( +
+ + {s.key} + + + + + + {s.count.toLocaleString()} + +
+ ))} +
+ )} +
+
+ ) +} diff --git a/client/src/components/FrameLayout/LatencyModal.scss b/client/src/components/FrameLayout/LatencyModal.scss new file mode 100644 index 00000000..dbe329b7 --- /dev/null +++ b/client/src/components/FrameLayout/LatencyModal.scss @@ -0,0 +1,131 @@ +.latency-modal { + .latency-modal__section { + margin-bottom: 18px; + + &:last-child { + margin-bottom: 0; + } + } + + .latency-modal__section-title { + display: flex; + justify-content: space-between; + align-items: baseline; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.05em; + color: #999; + margin-bottom: 10px; + } + + .latency-modal__section-total { + text-transform: none; + letter-spacing: 0; + font-size: 13px; + color: #666; + } + + .latency-modal__row { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 0; + + &--total { + margin-top: 6px; + padding-top: 10px; + border-top: 1px solid #eee; + font-weight: 600; + } + } + + .latency-modal__label { + flex: none; + width: 150px; + text-align: right; + color: #444; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .latency-modal__track { + position: relative; + flex: 1; + height: 22px; + background: #f3f4f6; + border-radius: 3px; + display: flex; + align-items: center; + + &--rule { + background: none; + border-bottom: 1px dashed #ccc; + height: 0; + } + } + + .latency-modal__bar { + height: 22px; + border-radius: 3px; + min-width: 2px; + } + + // Timeline/waterfall bar: positioned at its start offset within the track. + .latency-modal__bar--timeline { + position: absolute; + top: 0; + } + + .latency-modal__value { + flex: none; + width: 64px; + text-align: right; + font-size: 13px; + color: #444; + font-variant-numeric: tabular-nums; + } + + .latency-modal__pct { + flex: none; + width: 48px; + text-align: right; + color: #888; + font-variant-numeric: tabular-nums; + } + + .latency-modal__empty { + color: #888; + } +} + +// Dark mode: the modal chrome is themed centrally (theme-dark.scss), but the +// bar tracks and text inside default to light. Recolor them so the empty +// tracks read as dark surfaces and the labels stay legible. Variables are +// inherited from the [data-theme="dark"] root. +[data-theme="dark"] .latency-modal { + .latency-modal__track { + background: var(--bg-elevated); + } + + .latency-modal__track--rule { + background: none; + border-bottom-color: var(--border); + } + + .latency-modal__row--total { + border-top-color: var(--border); + } + + .latency-modal__label, + .latency-modal__value { + color: var(--text); + } + + .latency-modal__pct, + .latency-modal__section-title, + .latency-modal__section-total, + .latency-modal__empty { + color: var(--text-muted); + } +} diff --git a/client/src/components/GraphContainer.js b/client/src/components/GraphContainer.js index 583b0736..6aa921d4 100644 --- a/client/src/components/GraphContainer.js +++ b/client/src/components/GraphContainer.js @@ -9,11 +9,43 @@ import EdgeProperties from 'components/EdgeProperties' import NodeProperties from 'components/NodeProperties' import PartialRenderInfo from 'components/PartialRenderInfo' -import D3Graph from 'components/D3Graph' +import GraphFilterPanel from 'components/GraphFilterPanel' +import GraphStatsPanel from 'components/GraphStatsPanel' +import GraphStylePanel from 'components/GraphStylePanel' import MovablePanel from 'components/MovablePanel' +import SigmaGraph from 'components/SigmaGraph' +import { buildGraph } from 'components/SigmaGraph/buildGraph' + +import { + EMPTY_FILTER, + collectAttributeKeys, + nodeMatchesFilter, +} from '../lib/graphFilter' +import { summarizeGraph, topByAttribute } from '../lib/graphStats' +import { loadStyleRules, saveStyleRules } from '../lib/graphStyles' +import { timelineRange } from '../lib/graphTimeline' + +import { downloadJSON, downloadPNG } from '../lib/exportGraph' import '../assets/css/Graph.scss' +const LAYOUTS = [ + ['force', 'Force'], + ['circular', 'Circular'], + ['circlepack', 'Packed'], +] + +const COLOR_BY = [ + ['group', 'Color: Predicate'], + ['community', 'Color: Community'], +] + +const SIZE_BY = [ + ['degree', 'Size: Degree'], + ['betweenness', 'Size: Centrality'], + ['uniform', 'Size: Uniform'], +] + export default ({ graphUpdateHack, edgesDataset, @@ -28,6 +60,7 @@ export default ({ panelHeight, panelWidth, remainingNodes, + hiddenPredicates, }) => { const [selectedNode, setSelectedNode] = React.useState(null) const [hoveredNode, setHoveredNode] = React.useState(null) @@ -38,6 +71,112 @@ export default ({ const [searchQuery, setSearchQuery] = React.useState('') const [searchFocused, setSearchFocused] = React.useState(false) + const [layout, setLayout] = React.useState('force') + const [colorBy, setColorBy] = React.useState('group') + const [sizeBy, setSizeBy] = React.useState('degree') + const [styleRules, setStyleRules] = React.useState(loadStyleRules) + const [stylePanelOpen, setStylePanelOpen] = React.useState(false) + + // Find-path: pick two nodes, highlight the shortest route between them. + const [pathMode, setPathMode] = React.useState(false) + const [pathSource, setPathSource] = React.useState(null) + const [pathResult, setPathResult] = React.useState(null) + const [pathMessage, setPathMessage] = React.useState(null) + + // Faceted filtering: hide nodes outside a degree range / attribute predicate. + const [filter, setFilter] = React.useState(EMPTY_FILTER) + const [filterPanelOpen, setFilterPanelOpen] = React.useState(false) + + // Read-only graph statistics panel. + const [statsPanelOpen, setStatsPanelOpen] = React.useState(false) + + // Timeline: scrub/animate the graph by node timestamps. + const [timeEnabled, setTimeEnabled] = React.useState(false) + const [timeCutoff, setTimeCutoff] = React.useState(null) + const [playing, setPlaying] = React.useState(false) + + const handleStyleChange = (rules) => { + setStyleRules(rules) + saveStyleRules(rules) + } + + const styleGroups = React.useMemo(() => { + const groups = new Map() + nodesDataset.forEach((node) => { + if (node.group && !groups.has(node.group)) { + groups.set(node.group, node.color || '#cccccc') + } + }) + return Array.from(groups, ([group, color]) => ({ group, color })).sort( + (a, b) => a.group.localeCompare(b.group), + ) + // graphUpdateHack changes when the (mutable) dataset Maps change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodesDataset, graphUpdateHack]) + + const attributeKeys = React.useMemo( + () => collectAttributeKeys(nodesDataset), + // eslint-disable-next-line react-hooks/exhaustive-deps + [nodesDataset, graphUpdateHack], + ) + + const timeRange = React.useMemo( + () => timelineRange(nodesDataset), + // eslint-disable-next-line react-hooks/exhaustive-deps + [nodesDataset, graphUpdateHack], + ) + + // Node degree from the edge dataset, tolerating either uid-string or + // resolved-object endpoints, used to preview how many nodes the filter hides. + const hiddenCount = React.useMemo(() => { + const endpointId = (x) => (x && typeof x === 'object' ? x.id || x.uid : x) + const degree = new Map() + edgesDataset.forEach((edge) => { + ;[endpointId(edge.source), endpointId(edge.target)].forEach((id) => { + degree.set(id, (degree.get(id) || 0) + 1) + }) + }) + let hidden = 0 + nodesDataset.forEach((node) => { + const id = node.id || node.uid + if (!nodeMatchesFilter(node, degree.get(id) || 0, filter)) { + hidden++ + } + }) + return hidden + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodesDataset, edgesDataset, filter, graphUpdateHack]) + + // Snapshot of the currently visible graph for the stats panel. Building + // a second graphology graph (rather than reaching into the Sigma instance) + // keeps the renderer unaware of the stats consumer and avoids the cost of + // remounting it whenever the panel opens. + const statsGraph = React.useMemo(() => { + if (!statsPanelOpen) { + return null + } + return buildGraph(nodesDataset, edgesDataset) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodesDataset, edgesDataset, graphUpdateHack, statsPanelOpen]) + + const statsSummary = React.useMemo( + () => (statsGraph ? summarizeGraph(statsGraph) : null), + [statsGraph], + ) + + const topByDegree = React.useMemo( + () => (statsGraph ? topByAttribute(statsGraph, 'degree', 5) : []), + [statsGraph], + ) + + const topByBetweenness = React.useMemo( + () => + statsGraph && statsGraph.order > 0 && statsGraph.order <= 1500 + ? topByAttribute(statsGraph, '_betweenness', 5) + : [], + [statsGraph], + ) + const graphRef = React.useRef(null) const onEdgeSelected = (edge) => { @@ -45,10 +184,109 @@ export default ({ setSelectedEdge(edge) } const onNodeSelected = (node) => { + if (pathMode && node) { + handlePathPick(node) + return + } setSelectedEdge(null) setSelectedNode(node) } + const clearPath = () => { + setPathSource(null) + setPathResult(null) + setPathMessage(null) + } + + const togglePathMode = () => { + clearPath() + setPathMode((on) => !on) + } + + const nodeKey = (node) => node.id || node.uid + const nodeName = (node) => node.label || node.uid || node.id + + const handlePathPick = (node) => { + if (!pathSource) { + setPathResult(null) + setPathSource(node) + setPathMessage(`From “${nodeName(node)}” — now pick a target`) + return + } + if (nodeKey(node) === nodeKey(pathSource)) { + return + } + const result = graphRef.current?.findPathBetween( + nodeKey(pathSource), + nodeKey(node), + ) + if (result) { + setPathResult(result) + setPathMessage( + `${result.hops} hop${result.hops === 1 ? '' : 's'} from ` + + `“${nodeName(pathSource)}” to “${nodeName(node)}”`, + ) + } else { + setPathResult(null) + setPathMessage('No path between those nodes in the current graph') + } + setPathSource(null) + } + + const toggleTimeline = () => { + setPlaying(false) + setTimeEnabled((on) => { + const next = !on + if (next && timeRange.available) { + setTimeCutoff(timeRange.max) + } + return next + }) + } + + const togglePlay = () => { + if (!timeRange.available) return + if (!playing && (timeCutoff == null || timeCutoff >= timeRange.max)) { + setTimeCutoff(timeRange.min) + } + setPlaying((p) => !p) + } + + // Advance the scrubber while playing, ~7s end to end, stopping at the end. + React.useEffect(() => { + if (!playing || !timeRange.available) return undefined + const step = Math.max(1, (timeRange.max - timeRange.min) / 120) + const id = window.setInterval(() => { + setTimeCutoff((prev) => { + const base = prev == null ? timeRange.min : prev + const next = base + step + if (next >= timeRange.max) { + setPlaying(false) + return timeRange.max + } + return next + }) + }, 60) + return () => window.clearInterval(id) + }, [playing, timeRange]) + + // Reset the scrubber when the dataset's time span changes underneath it. + React.useEffect(() => { + if (timeEnabled && timeRange.available) { + setTimeCutoff((prev) => + prev == null || prev < timeRange.min || prev > timeRange.max + ? timeRange.max + : prev, + ) + } + }, [timeEnabled, timeRange]) + + const formatTime = (ms) => { + const date = new Date(ms) + const intraday = timeRange.max - timeRange.min < 2 * 24 * 3600 * 1000 + return intraday ? date.toLocaleString() : date.toLocaleDateString() + } + const activeNode = hoveredNode || selectedNode const activeEdge = !hoveredNode ? hoveredEdge || selectedEdge : null @@ -91,7 +329,7 @@ export default ({ return (
- {/* Graph toolbar: search + controls */} @@ -132,22 +379,284 @@ export default ({ />
+ + + + + + + + + + {timeRange.available && ( + + )}
+ {stylePanelOpen && ( + setStylePanelOpen(false)} + /> + )} + + {filterPanelOpen && ( + setFilterPanelOpen(false)} + /> + )} + + {statsPanelOpen && ( + setStatsPanelOpen(false)} + /> + )} + {/* Node/edge count indicator */}
{nodesDataset.size} nodes · {edgesDataset.size} edges {remainingNodes > 0 && ` · ${remainingNodes} hidden`}
+ {pathMode && ( +
+ + {pathMessage || 'Pick a source node, then a target'} + + {pathResult && ( + + )} +
+ )} + + {timeEnabled && timeRange.available && ( +
+ + { + setPlaying(false) + setTimeCutoff(Number(e.target.value)) + }} + /> + + {formatTime(timeCutoff == null ? timeRange.max : timeCutoff)} + +
+ )} + {!remainingNodes ? null : ( onChange({ ...filter, ...patch }) + const op = FILTER_OPERATORS.find(([value]) => value === filter.op) + const opNeedsValue = op ? op[2] : true + + return ( +
+
+ Filter + +
+ +
Connectivity (degree)
+
+ set({ degreeMin: e.target.value })} + /> + + set({ degreeMax: e.target.value })} + /> +
+ +
Attribute
+
+ + +
+ {opNeedsValue && ( +
+ set({ value: e.target.value })} + /> +
+ )} + +
+ + {filterActive(filter) ? `${hiddenCount} hidden` : 'No filter applied'} + + +
+
+ ) +} diff --git a/client/src/components/GraphFilterPanel.scss b/client/src/components/GraphFilterPanel.scss new file mode 100644 index 00000000..0352d58c --- /dev/null +++ b/client/src/components/GraphFilterPanel.scss @@ -0,0 +1,103 @@ +.graph-filter-panel { + position: absolute; + top: 52px; + left: 12px; + z-index: 11; + width: 240px; + + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + padding: 8px 10px; + font-size: 13px; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + margin-bottom: 6px; + } + + &__close { + border: none; + background: none; + font-size: 16px; + line-height: 1; + cursor: pointer; + color: #888; + + &:hover { + color: #333; + } + } + + &__section { + color: #888; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 8px 0 4px; + } + + &__row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; + + input, + select { + flex: 1; + min-width: 0; + height: 28px; + border: 1px solid #ccc; + border-radius: 4px; + padding: 0 6px; + font-size: 13px; + } + + input[type="number"] { + width: 60px; + } + } + + &__dash { + flex: none; + color: #888; + } + + &__footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; + padding-top: 6px; + border-top: 1px solid #eee; + } + + &__count { + color: #888; + font-size: 12px; + } + + &__clear { + border: none; + background: #4e79a7; + color: #fff; + font-size: 12px; + padding: 3px 10px; + border-radius: 4px; + cursor: pointer; + + &:hover { + background: #3f6690; + } + + &:disabled { + background: #ccc; + cursor: default; + } + } +} diff --git a/client/src/components/GraphStatsPanel.js b/client/src/components/GraphStatsPanel.js new file mode 100644 index 00000000..8132ee5e --- /dev/null +++ b/client/src/components/GraphStatsPanel.js @@ -0,0 +1,188 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react' + +import './GraphStatsPanel.scss' + +const formatNumber = (n, digits = 2) => + Number.isFinite(n) ? n.toFixed(digits) : '—' + +const formatPercent = (n) => + Number.isFinite(n) ? `${(n * 100).toFixed(1)}%` : '—' + +// Computes, from a stats summary, the visible histogram bar heights so the +// panel can render them without keeping the full data array in JSX. +function histogramBars(histogram) { + if (!histogram || histogram.length === 0) { + return [] + } + let max = 0 + for (const bin of histogram) { + if (bin.count > max) max = bin.count + } + if (max === 0) { + return histogram.map((bin) => ({ ...bin, heightPct: 0 })) + } + return histogram.map((bin) => ({ + ...bin, + heightPct: (bin.count / max) * 100, + })) +} + +// Read-only panel that summarises the currently visible graph: counts, +// degree statistics, connected components, density, reciprocity, a +// degree-distribution histogram, and the top hubs by degree and betweenness. +export default function GraphStatsPanel({ + summary, + topByDegree, + topByBetweenness, + onClose, +}) { + if (!summary) { + return ( +
+
+ Graph statistics + +
+
No graph loaded.
+
+ ) + } + + const bars = histogramBars(summary.degreeHistogram) + const totalHistogramNodes = bars.reduce((acc, b) => acc + b.count, 0) + + return ( +
+
+ Graph statistics + +
+ +
Overview
+
+
Nodes
+
{summary.nodes}
+
Edges
+
{summary.edges}
+
Density
+
{formatPercent(summary.density)}
+
Reciprocity
+
{formatPercent(summary.reciprocity)}
+
+ +
Connected components
+
+
Components
+
{summary.components.count}
+
Largest
+
{summary.components.largest}
+
+ +
Degree
+
+
Average
+
{formatNumber(summary.degree.avg)}
+
Median
+
{formatNumber(summary.degree.median)}
+
Min / Max
+
+ {summary.degree.min} / {summary.degree.max} +
+
+ + {bars.length > 0 && ( + <> +
+ Degree distribution ({totalHistogramNodes} nodes) +
+
+ {bars.map((bin) => ( +
+
+
+ {bin.count} +
+
+ ))} +
+
+ {bars[0].range[0]} + {bars[bars.length - 1].range[1]} +
+ + )} + +
Top by degree
+
    + {topByDegree.length === 0 ? ( +
  1. + ) : ( + topByDegree.map((entry) => ( +
  2. + + {entry.label} + + + {entry.value} + +
  3. + )) + )} +
+ + {topByBetweenness && ( + <> +
Top by betweenness
+
    + {topByBetweenness.length === 0 ? ( +
  1. + ) : ( + topByBetweenness.map((entry) => ( +
  2. + + {entry.label} + + + {formatNumber(entry.value, 3)} + +
  3. + )) + )} +
+ + )} +
+ ) +} diff --git a/client/src/components/GraphStatsPanel.scss b/client/src/components/GraphStatsPanel.scss new file mode 100644 index 00000000..68b3e500 --- /dev/null +++ b/client/src/components/GraphStatsPanel.scss @@ -0,0 +1,223 @@ +.graph-stats-panel { + position: absolute; + top: 52px; + right: 12px; + z-index: 11; + width: 260px; + max-height: calc(100% - 64px); + overflow-y: auto; + + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + padding: 8px 10px; + font-size: 13px; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + margin-bottom: 6px; + } + + &__close { + border: none; + background: none; + font-size: 16px; + line-height: 1; + cursor: pointer; + color: #888; + + &:hover { + color: #333; + } + } + + &__empty { + color: #888; + font-size: 12px; + padding: 6px 0; + } + + &__section { + color: #888; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 10px 0 4px; + } + + &__kv { + display: grid; + grid-template-columns: 1fr auto; + gap: 2px 8px; + margin: 0; + font-variant-numeric: tabular-nums; + + dt { + color: #555; + } + + dd { + margin: 0; + color: #222; + font-weight: 500; + text-align: right; + } + } + + &__histogram { + display: flex; + align-items: flex-end; + gap: 2px; + height: 60px; + padding: 4px 0 0; + border-bottom: 1px solid #eee; + } + + &__histogram-bar { + flex: 1; + min-width: 0; + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: stretch; + height: 100%; + } + + &__histogram-fill { + background: #4e79a7; + border-radius: 2px 2px 0 0; + min-height: 2px; + transition: height 0.2s ease; + } + + &__histogram-count { + position: absolute; + top: -16px; + left: 0; + right: 0; + text-align: center; + font-size: 10px; + color: #777; + font-variant-numeric: tabular-nums; + } + + &__histogram-axis { + display: flex; + justify-content: space-between; + font-size: 10px; + color: #999; + padding: 2px 0 0; + font-variant-numeric: tabular-nums; + } + + &__rank { + list-style: none; + margin: 0; + padding: 0; + + li { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + padding: 2px 0; + border-bottom: 1px dotted #eee; + font-variant-numeric: tabular-nums; + + &:last-child { + border-bottom: none; + } + } + } + + &__rank-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #333; + } + + &__rank-value { + flex: none; + color: #4e79a7; + font-weight: 500; + } + + &__rank-empty { + color: #999; + justify-content: center; + } +} + +// Inherit the project's dark theme when the user opts in. +[data-theme="dark"] .graph-stats-panel { + background: #2a2a2a; + border-color: #444; + color: #ddd; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); + + &__section { + color: #aaa; + } + + &__close { + color: #aaa; + + &:hover { + color: #fff; + } + } + + &__kv { + dt { + color: #ccc; + } + + dd { + color: #fff; + } + } + + &__histogram { + border-bottom-color: #444; + } + + &__histogram-fill { + background: #6a9fd1; + } + + &__histogram-count { + color: #aaa; + } + + &__histogram-axis { + color: #888; + } + + &__rank { + li { + border-bottom-color: #444; + } + } + + &__rank-label { + color: #ddd; + } + + &__rank-value { + color: #6a9fd1; + } + + &__rank-empty { + color: #777; + } + + &__empty { + color: #aaa; + } +} diff --git a/client/src/components/GraphStylePanel.js b/client/src/components/GraphStylePanel.js new file mode 100644 index 00000000..8a356827 --- /dev/null +++ b/client/src/components/GraphStylePanel.js @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react' + +import { MAX_NODE_SIZE, MIN_NODE_SIZE, updateRule } from '../lib/graphStyles' + +import './GraphStylePanel.scss' + +// Per-group style overrides (color / node size), Neo4j Bloom style. +// Groups come from the predicates that introduced each node. +export default function GraphStylePanel({ + groups, + styleRules, + onChange, + onClose, +}) { + const handleColor = (group, color) => + onChange(updateRule(styleRules, group, { color })) + + const handleSize = (group, size) => + onChange(updateRule(styleRules, group, { size: Number(size) })) + + const handleReset = (group) => { + const next = { ...styleRules } + delete next[group] + onChange(next) + } + + return ( +
+
+ Graph styles + +
+ {groups.length === 0 ? ( +
No groups in this graph
+ ) : ( + groups.map(({ group, color }) => { + const rule = styleRules[group] || {} + return ( +
+ + {group} + + handleColor(group, e.target.value)} + /> + handleSize(group, e.target.value)} + /> + +
+ ) + }) + )} +
+ ) +} diff --git a/client/src/components/GraphStylePanel.scss b/client/src/components/GraphStylePanel.scss new file mode 100644 index 00000000..9123adca --- /dev/null +++ b/client/src/components/GraphStylePanel.scss @@ -0,0 +1,84 @@ +.graph-style-panel { + position: absolute; + top: 52px; + right: 12px; + z-index: 11; + width: 260px; + max-height: 60%; + overflow-y: auto; + + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + padding: 8px 10px; + font-size: 13px; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + margin-bottom: 6px; + } + + &__close { + border: none; + background: none; + font-size: 16px; + line-height: 1; + cursor: pointer; + color: #888; + + &:hover { + color: #333; + } + } + + &__empty { + color: #888; + padding: 6px 0; + } + + &__row { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 0; + + input[type="color"] { + width: 26px; + height: 22px; + padding: 0; + border: 1px solid #ccc; + border-radius: 3px; + flex: none; + } + + input[type="range"] { + flex: 1; + min-width: 60px; + } + } + + &__name { + flex: none; + width: 90px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__reset { + border: none; + background: none; + cursor: pointer; + color: #666; + flex: none; + + &:disabled { + color: #ccc; + cursor: default; + } + } +} diff --git a/client/src/components/Label.js b/client/src/components/Label.js index 32ec4106..a558b8c7 100644 --- a/client/src/components/Label.js +++ b/client/src/components/Label.js @@ -26,12 +26,15 @@ function getRGBComponents(color) { } } -export default ({ color, pred, label, ...domProps }) => ( +export default ({ color, pred, label, hidden, ...domProps }) => (