From 2e66f1640b69db4cc01c5e8d5a0016fd77600f66 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 09:12:07 -0400 Subject: [PATCH 01/36] fix: repair broken Jest test suite Every component test suite has been failing to run. Four independent breakages, all in test infrastructure - no production code changes: - ESM-only packages were never transpiled: the babel config lives in package.json (file-relative, like .babelrc), so it is not applied to files inside node_modules even when transformIgnorePatterns allows them through. Added config/jest/babelTransform.js (explicit presets) and allowed react-leaflet/@react-leaflet through the transform. - Jest 26 cannot resolve node:-prefixed core modules required by newer transitive deps (cheerio -> parse5/undici). Added shim files under config/jest/nodeShims plus a moduleNameMapper rule. - enzyme 3 requires cheerio/lib/utils, which no longer exists in cheerio 1.x final (the lockfile resolves enzyme's ^1.0.0-rc.3 range to 1.1.2). Mapped to its new location (dist/commonjs/utils.js). - jsdom 16 lacks TextEncoder/TextDecoder, web streams, Blob and MessageChannel globals that undici needs. Added a Jest-only setupFiles polyfill (kept out of config/polyfills.js, which is also a webpack entry). Also split e2e tests out of the default run: they need puppeteer and a live Dgraph cluster, so 14 suites always failed in a plain checkout. 'npm test' now runs unit tests only; 'npm run test:e2e' runs the rest. Result: npm test goes from 16/18 suites failing to 4/4 passing (13 tests). Production build verified unaffected. Co-Authored-By: Claude Fable 5 --- client/config/jest/babelTransform.js | 25 +++++++++++ client/config/jest/fileTransform.js | 13 ++++-- client/config/jest/nodeShims/assert.js | 8 ++++ client/config/jest/nodeShims/async_hooks.js | 8 ++++ client/config/jest/nodeShims/buffer.js | 8 ++++ client/config/jest/nodeShims/console.js | 8 ++++ client/config/jest/nodeShims/crypto.js | 8 ++++ .../jest/nodeShims/diagnostics_channel.js | 8 ++++ client/config/jest/nodeShims/dns.js | 8 ++++ client/config/jest/nodeShims/events.js | 8 ++++ client/config/jest/nodeShims/fs.js | 8 ++++ client/config/jest/nodeShims/fs/promises.js | 8 ++++ client/config/jest/nodeShims/http.js | 8 ++++ client/config/jest/nodeShims/http2.js | 8 ++++ client/config/jest/nodeShims/https.js | 8 ++++ client/config/jest/nodeShims/net.js | 8 ++++ client/config/jest/nodeShims/os.js | 8 ++++ client/config/jest/nodeShims/path.js | 8 ++++ client/config/jest/nodeShims/perf_hooks.js | 8 ++++ client/config/jest/nodeShims/process.js | 8 ++++ client/config/jest/nodeShims/querystring.js | 8 ++++ client/config/jest/nodeShims/stream.js | 8 ++++ client/config/jest/nodeShims/stream/web.js | 8 ++++ .../config/jest/nodeShims/string_decoder.js | 8 ++++ client/config/jest/nodeShims/timers.js | 8 ++++ .../config/jest/nodeShims/timers/promises.js | 8 ++++ client/config/jest/nodeShims/tls.js | 8 ++++ client/config/jest/nodeShims/tty.js | 8 ++++ client/config/jest/nodeShims/url.js | 8 ++++ client/config/jest/nodeShims/util.js | 8 ++++ client/config/jest/nodeShims/util/types.js | 8 ++++ .../config/jest/nodeShims/worker_threads.js | 8 ++++ client/config/jest/nodeShims/zlib.js | 8 ++++ client/config/jest/testPolyfills.js | 43 +++++++++++++++++++ client/package.json | 17 ++++++-- 35 files changed, 338 insertions(+), 8 deletions(-) create mode 100644 client/config/jest/babelTransform.js create mode 100644 client/config/jest/nodeShims/assert.js create mode 100644 client/config/jest/nodeShims/async_hooks.js create mode 100644 client/config/jest/nodeShims/buffer.js create mode 100644 client/config/jest/nodeShims/console.js create mode 100644 client/config/jest/nodeShims/crypto.js create mode 100644 client/config/jest/nodeShims/diagnostics_channel.js create mode 100644 client/config/jest/nodeShims/dns.js create mode 100644 client/config/jest/nodeShims/events.js create mode 100644 client/config/jest/nodeShims/fs.js create mode 100644 client/config/jest/nodeShims/fs/promises.js create mode 100644 client/config/jest/nodeShims/http.js create mode 100644 client/config/jest/nodeShims/http2.js create mode 100644 client/config/jest/nodeShims/https.js create mode 100644 client/config/jest/nodeShims/net.js create mode 100644 client/config/jest/nodeShims/os.js create mode 100644 client/config/jest/nodeShims/path.js create mode 100644 client/config/jest/nodeShims/perf_hooks.js create mode 100644 client/config/jest/nodeShims/process.js create mode 100644 client/config/jest/nodeShims/querystring.js create mode 100644 client/config/jest/nodeShims/stream.js create mode 100644 client/config/jest/nodeShims/stream/web.js create mode 100644 client/config/jest/nodeShims/string_decoder.js create mode 100644 client/config/jest/nodeShims/timers.js create mode 100644 client/config/jest/nodeShims/timers/promises.js create mode 100644 client/config/jest/nodeShims/tls.js create mode 100644 client/config/jest/nodeShims/tty.js create mode 100644 client/config/jest/nodeShims/url.js create mode 100644 client/config/jest/nodeShims/util.js create mode 100644 client/config/jest/nodeShims/util/types.js create mode 100644 client/config/jest/nodeShims/worker_threads.js create mode 100644 client/config/jest/nodeShims/zlib.js create mode 100644 client/config/jest/testPolyfills.js diff --git a/client/config/jest/babelTransform.js b/client/config/jest/babelTransform.js new file mode 100644 index 00000000..d8582377 --- /dev/null +++ b/client/config/jest/babelTransform.js @@ -0,0 +1,25 @@ +/* + * 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/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.json b/client/package.json index 87f3d26e..f753793c 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,30 @@ "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" }, "moduleFileExtensions": [ "web.js", From f7659df864465190d6b6e47cbd297e9620dfba55 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 09:14:42 -0400 Subject: [PATCH 02/36] feat: export graph as PNG image or JSON data Adds two buttons to the graph toolbar (next to zoom-to-fit): - PNG: composites every canvas in the graph container onto a single white-backed canvas and downloads it via toBlob, so it works with the current d3 canvas renderer and any future layered renderer. - JSON: serializes the GraphParser node/edge Maps to plain JSON. Edge endpoints may be uid strings or node objects resolved in place by d3-force - both shapes export as uids, keeping the output free of circular references. lib/exportGraph.js is covered by 8 unit tests. Verified in a real browser against a live Dgraph cluster: both buttons download valid files (the PNG renders the graph, the JSON carries correct uid endpoints). Co-Authored-By: Claude Fable 5 --- client/src/components/GraphContainer.js | 27 +++++ client/src/lib/exportGraph.js | 115 ++++++++++++++++++++ client/src/lib/exportGraph.test.js | 138 ++++++++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 client/src/lib/exportGraph.js create mode 100644 client/src/lib/exportGraph.test.js diff --git a/client/src/components/GraphContainer.js b/client/src/components/GraphContainer.js index 583b0736..2d2d696d 100644 --- a/client/src/components/GraphContainer.js +++ b/client/src/components/GraphContainer.js @@ -12,6 +12,8 @@ import PartialRenderInfo from 'components/PartialRenderInfo' import D3Graph from 'components/D3Graph' import MovablePanel from 'components/MovablePanel' +import { downloadJSON, downloadPNG } from '../lib/exportGraph' + import '../assets/css/Graph.scss' export default ({ @@ -140,6 +142,31 @@ export default ({ + + {/* Node/edge count indicator */} diff --git a/client/src/lib/exportGraph.js b/client/src/lib/exportGraph.js new file mode 100644 index 00000000..6d9eb2c5 --- /dev/null +++ b/client/src/lib/exportGraph.js @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Serializes the GraphParser datasets to a plain JSON structure. + * + * Edge source/target may be either uid strings or node objects (the + * renderer resolves them to objects in place), so both shapes are + * handled. Node objects contain a Set and back-references, which JSON + * cannot represent — only the meaningful fields are exported. + */ +export function graphToJSON(nodesMap, edgesMap) { + const endpointUid = (endpoint) => + endpoint && typeof endpoint === 'object' ? endpoint.id : endpoint + + const nodes = [] + if (nodesMap) { + nodesMap.forEach((node) => + nodes.push({ + uid: node.uid || node.id, + label: node.name || node.label || '', + group: node.group, + attrs: (node.properties && node.properties.attrs) || {}, + facets: (node.properties && node.properties.facets) || {}, + }), + ) + } + + const edges = [] + if (edgesMap) { + edgesMap.forEach((edge) => + edges.push({ + source: endpointUid(edge.source), + target: endpointUid(edge.target), + predicate: edge.predicate, + facets: edge.facets || {}, + }), + ) + } + + return { nodes, edges } +} + +// Composites every canvas inside container (in DOM order) onto a single +// white-backed canvas, so layered renderers export as one image. +export function compositeCanvases(container) { + const canvases = Array.from(container.querySelectorAll('canvas')) + if (!canvases.length) { + return null + } + + const width = Math.max(...canvases.map((c) => c.width)) + const height = Math.max(...canvases.map((c) => c.height)) + if (!width || !height) { + return null + } + + const out = document.createElement('canvas') + out.width = width + out.height = height + const ctx = out.getContext('2d') + if (!ctx) { + return null + } + + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, width, height) + canvases.forEach((c) => ctx.drawImage(c, 0, 0, width, height)) + return out +} + +export function exportFileName(extension, now = new Date()) { + const pad = (n) => String(n).padStart(2, '0') + const stamp = [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + ].join('-') + const time = [pad(now.getHours()), pad(now.getMinutes())].join('') + return `ratel-graph-${stamp}-${time}.${extension}` +} + +export function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} + +export function downloadJSON(nodesMap, edgesMap) { + const json = JSON.stringify(graphToJSON(nodesMap, edgesMap), null, 2) + downloadBlob( + new Blob([json], { type: 'application/json' }), + exportFileName('json'), + ) +} + +export function downloadPNG(container) { + const canvas = compositeCanvases(container) + if (!canvas) { + return false + } + canvas.toBlob((blob) => { + if (blob) { + downloadBlob(blob, exportFileName('png')) + } + }, 'image/png') + return true +} diff --git a/client/src/lib/exportGraph.test.js b/client/src/lib/exportGraph.test.js new file mode 100644 index 00000000..170c78b6 --- /dev/null +++ b/client/src/lib/exportGraph.test.js @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + compositeCanvases, + exportFileName, + graphToJSON, +} from './exportGraph' + +describe('graphToJSON', () => { + const node = (uid, extra = {}) => ({ + id: uid, + uid, + label: `short-${uid}`, + name: `full-${uid}`, + group: 'friend', + properties: { attrs: { name: `full-${uid}` }, facets: { since: 2020 } }, + expansionParents: new Set(['root']), + ...extra, + }) + + it('handles empty/missing datasets', () => { + expect(graphToJSON(null, null)).toEqual({ nodes: [], edges: [] }) + expect(graphToJSON(new Map(), new Map())).toEqual({ nodes: [], edges: [] }) + }) + + it('exports node fields without circular references', () => { + const result = graphToJSON(new Map([['0x1', node('0x1')]]), new Map()) + + expect(result.nodes).toEqual([ + { + uid: '0x1', + label: 'full-0x1', + group: 'friend', + attrs: { name: 'full-0x1' }, + facets: { since: 2020 }, + }, + ]) + // The whole point: must be serializable. + expect(() => JSON.stringify(result)).not.toThrow() + }) + + it('exports edges with uid endpoints when endpoints are strings', () => { + const edges = new Map([ + [ + 'e1', + { source: '0x1', target: '0x2', predicate: 'friend', facets: {} }, + ], + ]) + const result = graphToJSON(new Map(), edges) + expect(result.edges).toEqual([ + { source: '0x1', target: '0x2', predicate: 'friend', facets: {} }, + ]) + }) + + it('exports edges with uid endpoints when endpoints are node objects', () => { + // After rendering, the graph renderer resolves edge endpoints to node + // objects in place — export must not produce circular JSON. + const a = node('0x1') + const b = node('0x2') + const edges = new Map([ + ['e1', { source: a, target: b, predicate: 'friend', facets: {} }], + ]) + + const result = graphToJSON( + new Map([ + ['0x1', a], + ['0x2', b], + ]), + edges, + ) + + expect(result.edges[0].source).toBe('0x1') + expect(result.edges[0].target).toBe('0x2') + expect(() => JSON.stringify(result)).not.toThrow() + }) +}) + +describe('exportFileName', () => { + it('builds a timestamped name', () => { + const fixed = new Date(2026, 5, 12, 9, 5) + expect(exportFileName('png', fixed)).toBe('ratel-graph-2026-06-12-0905.png') + expect(exportFileName('json', fixed)).toBe( + 'ratel-graph-2026-06-12-0905.json', + ) + }) +}) + +describe('compositeCanvases', () => { + it('returns null when the container has no canvases', () => { + const div = document.createElement('div') + expect(compositeCanvases(div)).toBe(null) + }) + + it('returns null for zero-sized canvases', () => { + const div = document.createElement('div') + const canvas = document.createElement('canvas') + canvas.width = 0 + canvas.height = 0 + div.appendChild(canvas) + expect(compositeCanvases(div)).toBe(null) + }) + + it('composites canvases onto a single canvas of the max dimensions', () => { + const div = document.createElement('div') + const sizes = [ + [100, 50], + [200, 40], + ] + const drawn = [] + sizes.forEach(([w, h]) => { + const c = document.createElement('canvas') + c.width = w + c.height = h + div.appendChild(c) + }) + + // jsdom has no real canvas; provide a minimal 2d context. + const ctx = { + fillRect: jest.fn(), + drawImage: jest.fn((img) => drawn.push(img)), + } + jest + .spyOn(window.HTMLCanvasElement.prototype, 'getContext') + .mockReturnValue(ctx) + + const out = compositeCanvases(div) + + expect(out.width).toBe(200) + expect(out.height).toBe(50) + expect(ctx.fillRect).toHaveBeenCalledWith(0, 0, 200, 50) + expect(drawn.length).toBe(2) + + window.HTMLCanvasElement.prototype.getContext.mockRestore() + }) +}) From e56eccb4b0aa7dd7a3f772976a9a8b115048b374 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 09:18:18 -0400 Subject: [PATCH 03/36] feat: replace d3 canvas graph renderer with Sigma.js WebGL renderer Swap the hand-rolled d3-force canvas renderer (~1100 lines of manual hit-testing, arc math and label culling) for sigma.js v3 + graphology: - WebGL rendering scales to tens of thousands of nodes (the canvas renderer struggles past a few hundred); labels get automatic collision/density handling. - ForceAtlas2 layout runs in a web worker, so the UI thread never blocks during layout; it auto-stops after 4s. - Parallel edges between the same node pair fan out as distinct curves (@sigma/edge-curve), preserving the sibling-edge behavior. - Nodes sized by degree (capped); neighbor highlighting on hover; drag-to-pin; double-click to expand/collapse preserved. - Implements the GraphContainer ref API (searchNode/focusNode/ zoomToFit) with the same matching semantics as the d3 renderer. - buildGraph keeps the d3-force contract of resolving edge source/target uids to node objects in place - EdgeProperties and GraphParser.collapseNode depend on that mutation. Covered by 12 unit tests. - sigma is mocked under Jest (jsdom has no WebGL2RenderingContext). - scripts/graph-smoke.mjs: puppeteer smoke test that seeds a local Dgraph, logs in, runs a query through the real UI and asserts the WebGL canvases render and search/zoomToFit work. - Drop the d3 dependency (D3Graph was its only consumer). Verified: 12/12 buildGraph tests, production build, and the browser smoke test against Dgraph v25 with ACL (3 nodes / 3 edges rendered via WebGL, search + zoom-to-fit exercised, zero page errors). Co-Authored-By: Claude Fable 5 --- client/config/jest/sigmaMock.js | 38 + client/package-lock.json | 417 ++---- client/package.json | 34 +- client/scripts/graph-smoke.mjs | 184 +++ client/src/components/D3Graph/D3Graph.scss | 34 - client/src/components/D3Graph/index.js | 1181 ----------------- client/src/components/GraphContainer.js | 4 +- .../src/components/SigmaGraph/SigmaGraph.scss | 9 + .../src/components/SigmaGraph/buildGraph.js | 107 ++ .../components/SigmaGraph/buildGraph.test.js | 190 +++ client/src/components/SigmaGraph/index.js | 269 ++++ 11 files changed, 922 insertions(+), 1545 deletions(-) create mode 100644 client/config/jest/sigmaMock.js create mode 100644 client/scripts/graph-smoke.mjs delete mode 100644 client/src/components/D3Graph/D3Graph.scss delete mode 100644 client/src/components/D3Graph/index.js create mode 100644 client/src/components/SigmaGraph/SigmaGraph.scss create mode 100644 client/src/components/SigmaGraph/buildGraph.js create mode 100644 client/src/components/SigmaGraph/buildGraph.test.js create mode 100644 client/src/components/SigmaGraph/index.js 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/package-lock.json b/client/package-lock.json index d238f7f4..6f734a8b 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,10 @@ "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-layout": "^0.6.1", + "graphology-layout-forceatlas2": "^0.10.1", "graphql": "^15.8.0", "immer": "^9.0.16", "jquery": "^3.6.1", @@ -49,6 +52,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 +5294,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", @@ -8956,319 +8969,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 +10094,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 +11205,52 @@ "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-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-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 +12053,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 +15348,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 +15810,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 +16048,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 +19233,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 +19319,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 +19994,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..ea081e8a 100644 --- a/client/package.json +++ b/client/package.json @@ -62,10 +62,19 @@ "@babel/preset-react" ] }, - "browserslist": [">2%", "last 3 versions", "Firefox ESR", "not ie < 9"], + "browserslist": [ + ">2%", + "last 3 versions", + "Firefox ESR", + "not ie < 9" + ], "jest": { - "collectCoverageFrom": ["src/**/*.{js,jsx,mjs}"], - "setupFiles": ["/config/polyfills.js"], + "collectCoverageFrom": [ + "src/**/*.{js,jsx,mjs}" + ], + "setupFiles": [ + "/config/polyfills.js" + ], "testEnvironment": "jsdom", "testMatch": [ "/src/**/__tests__/**/*.{js,jsx,mjs}", @@ -77,10 +86,17 @@ "^.+\\.css$": "/config/jest/cssTransform.js", "^(?!.*\\.(js|jsx|mjs|css|json)$)": "/config/jest/fileTransform.js" }, - "transformIgnorePatterns": ["[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$"], - "modulePaths": ["./src/"], + "transformIgnorePatterns": [ + "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$" + ], + "modulePaths": [ + "./src/" + ], "moduleNameMapper": { - "^react-native$": "react-native-web" + "^react-native$": "react-native-web", + "^sigma$": "/config/jest/sigmaMock.js", + "^sigma/rendering$": "/config/jest/sigmaMock.js", + "^@sigma/edge-curve$": "/config/jest/sigmaMock.js" }, "moduleFileExtensions": [ "web.js", @@ -175,6 +191,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 +199,10 @@ "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-layout": "^0.6.1", + "graphology-layout-forceatlas2": "^0.10.1", "graphql": "^15.8.0", "immer": "^9.0.16", "jquery": "^3.6.1", @@ -213,6 +232,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..c4e7a596 --- /dev/null +++ b/client/scripts/graph-smoke.mjs @@ -0,0 +1,184 @@ +// 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 (e) {} + 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 (err) { + 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/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/GraphContainer.js b/client/src/components/GraphContainer.js index 583b0736..ffb49ad4 100644 --- a/client/src/components/GraphContainer.js +++ b/client/src/components/GraphContainer.js @@ -9,8 +9,8 @@ import EdgeProperties from 'components/EdgeProperties' import NodeProperties from 'components/NodeProperties' import PartialRenderInfo from 'components/PartialRenderInfo' -import D3Graph from 'components/D3Graph' import MovablePanel from 'components/MovablePanel' +import SigmaGraph from 'components/SigmaGraph' import '../assets/css/Graph.scss' @@ -91,7 +91,7 @@ export default ({ return (
- + endpoint && typeof endpoint === 'object' ? endpoint.id : endpoint + +/** + * Builds a graphology graph out of the GraphParser datasets. + * + * As a side effect, resolves each edge's source/target from uid strings to + * the node objects from nodesMap. The rest of the app depends on this + * (EdgeProperties renders edge.source.label, GraphParser.collapseNode reads + * edge.source.uid) — d3-force used to perform this mutation. + * + * @param nodesMap Map of uid -> node (GraphParser.nodesDataset) + * @param edgesMap Map of key -> edge (GraphParser.edgesDataset) + * @param prevPositions optional Map of uid -> {x, y} to keep already-placed + * nodes where the user left them across expansions/collapses. + */ +export function buildGraph(nodesMap, edgesMap, prevPositions = new Map()) { + const graph = new MultiDirectedGraph() + if (!nodesMap || !edgesMap) { + return graph + } + + let index = 0 + nodesMap.forEach((node, uid) => { + const pos = prevPositions.get(uid) || initialPosition(index, nodesMap.size) + index++ + graph.addNode(uid, { + label: node.label || node.name || String(uid), + color: node.color || '#cccccc', + size: NODE_SIZE, + x: pos.x, + y: pos.y, + originalNode: node, + }) + }) + + edgesMap.forEach((edge, key) => { + const sourceId = endpointId(edge.source) + const targetId = endpointId(edge.target) + if (!graph.hasNode(sourceId) || !graph.hasNode(targetId)) { + return + } + + // See docstring: keep the d3-force contract of object endpoints. + edge.source = nodesMap.get(sourceId) + edge.target = nodesMap.get(targetId) + + const curvature = edgeCurvature(edge.siblingIndex, edge.siblingCount) + graph.addEdgeWithKey(key, sourceId, targetId, { + label: edge.label, + color: edge.color || '#999999', + size: EDGE_SIZE, + type: curvature === 0 ? 'arrow' : 'curvedArrow', + curvature, + originalEdge: edge, + }) + }) + + // Size nodes by connectivity (like Neo4j Bloom), capped so hubs don't + // swallow the viewport. + graph.forEachNode((uid) => { + graph.setNodeAttribute( + uid, + 'size', + Math.min(NODE_MAX_SIZE, NODE_SIZE + graph.degree(uid) * 0.5), + ) + }) + + return graph +} diff --git a/client/src/components/SigmaGraph/buildGraph.test.js b/client/src/components/SigmaGraph/buildGraph.test.js new file mode 100644 index 00000000..aeca36d7 --- /dev/null +++ b/client/src/components/SigmaGraph/buildGraph.test.js @@ -0,0 +1,190 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + buildGraph, + edgeCurvature, + EDGE_SIZE, + NODE_MAX_SIZE, + NODE_SIZE, +} from './buildGraph' + +const makeNode = (uid, overrides = {}) => ({ + id: uid, + uid, + label: `label-${uid}`, + color: '#123456', + properties: { attrs: {}, facets: {} }, + ...overrides, +}) + +const makeEdge = (source, target, overrides = {}) => ({ + source, + target, + label: 'friend', + predicate: 'friend', + color: '#654321', + facets: {}, + ...overrides, +}) + +const mapOf = (entries) => new Map(entries) + +describe('buildGraph', () => { + it('returns an empty graph for missing datasets', () => { + expect(buildGraph(null, null).order).toBe(0) + expect(buildGraph(new Map(), new Map()).order).toBe(0) + }) + + it('adds nodes with label, color and original node reference', () => { + const node = makeNode('0x1') + const graph = buildGraph(mapOf([['0x1', node]]), new Map()) + + expect(graph.order).toBe(1) + const attrs = graph.getNodeAttributes('0x1') + expect(attrs.label).toBe('label-0x1') + expect(attrs.color).toBe('#123456') + expect(attrs.size).toBe(NODE_SIZE) + expect(attrs.originalNode).toBe(node) + expect(typeof attrs.x).toBe('number') + expect(typeof attrs.y).toBe('number') + }) + + it('falls back to name, then uid, for unlabeled nodes', () => { + const noLabel = makeNode('0x1', { label: '', name: 'full name' }) + const nothing = makeNode('0x2', { label: '', name: '' }) + const graph = buildGraph( + mapOf([ + ['0x1', noLabel], + ['0x2', nothing], + ]), + new Map(), + ) + + expect(graph.getNodeAttribute('0x1', 'label')).toBe('full name') + expect(graph.getNodeAttribute('0x2', 'label')).toBe('0x2') + }) + + it('resolves edge source/target uids to node objects', () => { + // GraphParser.collapseNode and EdgeProperties depend on edges holding + // node objects, a mutation d3-force used to perform. + const a = makeNode('0x1') + const b = makeNode('0x2') + const edge = makeEdge('0x1', '0x2') + const nodes = mapOf([ + ['0x1', a], + ['0x2', b], + ]) + const graph = buildGraph(nodes, mapOf([['e1', edge]])) + + expect(graph.size).toBe(1) + expect(edge.source).toBe(a) + expect(edge.target).toBe(b) + expect(graph.getEdgeAttribute('e1', 'originalEdge')).toBe(edge) + expect(graph.getEdgeAttribute('e1', 'label')).toBe('friend') + expect(graph.getEdgeAttribute('e1', 'size')).toBe(EDGE_SIZE) + }) + + it('is stable when edges already hold node objects', () => { + const a = makeNode('0x1') + const b = makeNode('0x2') + const edge = makeEdge(a, b) + const nodes = mapOf([ + ['0x1', a], + ['0x2', b], + ]) + const graph = buildGraph(nodes, mapOf([['e1', edge]])) + + expect(graph.size).toBe(1) + expect(edge.source).toBe(a) + expect(edge.target).toBe(b) + }) + + it('skips edges whose endpoints are not in the node map', () => { + const graph = buildGraph( + mapOf([['0x1', makeNode('0x1')]]), + mapOf([['e1', makeEdge('0x1', '0xmissing')]]), + ) + expect(graph.size).toBe(0) + }) + + it('renders parallel edges as distinct curves', () => { + const a = makeNode('0x1') + const b = makeNode('0x2') + const edges = mapOf([ + ['e1', makeEdge('0x1', '0x2', { siblingIndex: 0, siblingCount: 3 })], + ['e2', makeEdge('0x1', '0x2', { siblingIndex: 1, siblingCount: 3 })], + ['e3', makeEdge('0x1', '0x2', { siblingIndex: 2, siblingCount: 3 })], + ]) + const graph = buildGraph( + mapOf([ + ['0x1', a], + ['0x2', b], + ]), + edges, + ) + + expect(graph.size).toBe(3) + expect(graph.getEdgeAttribute('e1', 'type')).toBe('arrow') + expect(graph.getEdgeAttribute('e2', 'type')).toBe('curvedArrow') + expect(graph.getEdgeAttribute('e3', 'type')).toBe('curvedArrow') + + const curvatures = ['e1', 'e2', 'e3'].map((k) => + graph.getEdgeAttribute(k, 'curvature'), + ) + expect(new Set(curvatures).size).toBe(3) + }) + + it('sizes nodes by degree, capped at NODE_MAX_SIZE', () => { + const hub = makeNode('0xhub') + const nodes = [['0xhub', hub]] + const edges = [] + for (let i = 0; i < 30; i++) { + nodes.push([`0x${i}`, makeNode(`0x${i}`)]) + edges.push([`e${i}`, makeEdge('0xhub', `0x${i}`)]) + } + const graph = buildGraph(mapOf(nodes), mapOf(edges)) + + expect(graph.getNodeAttribute('0xhub', 'size')).toBe(NODE_MAX_SIZE) + expect(graph.getNodeAttribute('0x0', 'size')).toBe(NODE_SIZE + 0.5) + }) + + it('reuses previous positions for already-placed nodes', () => { + const graph = buildGraph( + mapOf([ + ['0x1', makeNode('0x1')], + ['0x2', makeNode('0x2')], + ]), + new Map(), + new Map([['0x1', { x: 42, y: -7 }]]), + ) + + expect(graph.getNodeAttribute('0x1', 'x')).toBe(42) + expect(graph.getNodeAttribute('0x1', 'y')).toBe(-7) + expect(graph.getNodeAttribute('0x2', 'x')).not.toBe(42) + }) +}) + +describe('edgeCurvature', () => { + it('keeps single edges straight', () => { + expect(edgeCurvature(0, 1)).toBe(0) + expect(edgeCurvature(undefined, undefined)).toBe(0) + }) + + it('keeps the first of an odd sibling group straight', () => { + expect(edgeCurvature(0, 3)).toBe(0) + }) + + it('fans siblings out on alternating sides', () => { + const three = [0, 1, 2].map((i) => edgeCurvature(i, 3)) + expect(three[1]).toBeGreaterThan(0) + expect(three[2]).toBeLessThan(0) + + const four = [0, 1, 2, 3].map((i) => edgeCurvature(i, 4)) + expect(new Set(four).size).toBe(4) + expect(four.filter((c) => c > 0).length).toBe(2) + expect(four.filter((c) => c < 0).length).toBe(2) + }) +}) diff --git a/client/src/components/SigmaGraph/index.js b/client/src/components/SigmaGraph/index.js new file mode 100644 index 00000000..c90d9183 --- /dev/null +++ b/client/src/components/SigmaGraph/index.js @@ -0,0 +1,269 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import EdgeCurveProgram, { EdgeCurvedArrowProgram } from '@sigma/edge-curve' +import FA2Layout from 'graphology-layout-forceatlas2/worker' +import React from 'react' +import Sigma from 'sigma' +import { EdgeArrowProgram } from 'sigma/rendering' + +import { buildGraph } from './buildGraph' + +import './SigmaGraph.scss' + +const LAYOUT_MS = 4000 +const DIM_COLOR = '#e4e4e4' + +// WebGL renderer for query results, replacing the d3-force canvas renderer. +// Same contract as the old D3Graph component: nodes/edges are the live Maps +// from GraphParser, callbacks receive the original node/edge objects. +export default class SigmaGraph extends React.Component { + containerRef = React.createRef() + + componentDidMount() { + this.graph = buildGraph(this.props.nodes, this.props.edges) + + this.renderer = new Sigma(this.graph, this.containerRef.current, { + defaultEdgeType: 'arrow', + edgeProgramClasses: { + arrow: EdgeArrowProgram, + curved: EdgeCurveProgram, + curvedArrow: EdgeCurvedArrowProgram, + }, + enableEdgeEvents: true, + renderEdgeLabels: true, + labelDensity: 0.8, + labelGridCellSize: 80, + labelFont: 'sans-serif', + labelSize: 12, + edgeLabelSize: 10, + labelRenderedSizeThreshold: 5, + minCameraRatio: 0.05, + maxCameraRatio: 20, + nodeReducer: this.nodeReducer, + edgeReducer: this.edgeReducer, + }) + + this.bindEvents() + this.startLayout() + + this.datasetSignature = this.signature(this.props) + } + + componentDidUpdate() { + const signature = this.signature(this.props) + if (signature !== this.datasetSignature) { + this.datasetSignature = signature + this.syncGraph() + } else { + // Only selection/highlight props changed. + this.renderer.refresh({ skipIndexation: true }) + } + } + + componentWillUnmount() { + this.stopLayout() + if (this.renderer) { + this.renderer.kill() + } + } + + signature = (props) => + [ + props.nodes ? props.nodes.size : 0, + props.edges ? props.edges.size : 0, + props.graphUpdateHack, + ].join('/') + + // --- public API used via ref by GraphContainer ----------------------- + + zoomToFit = () => { + if (this.renderer) { + this.renderer.getCamera().animatedReset({ duration: 500 }) + } + } + + focusNode = (node) => { + if (!this.renderer || !node) { + return + } + const uid = node.id || node.uid + if (!this.graph.hasNode(uid)) { + return + } + const { x, y } = this.renderer.getNodeDisplayData(uid) + this.renderer.getCamera().animate({ x, y, ratio: 0.35 }, { duration: 500 }) + } + + searchNode = (query) => { + if (!query || !this.props.nodes) { + return null + } + const q = query.toLowerCase().trim() + let found = null + this.props.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 + } + + syncGraph = () => { + // Carry positions over so expanding/collapsing doesn't reshuffle nodes + // the user already arranged. + const prevPositions = new Map() + this.graph.forEachNode((uid, attrs) => + prevPositions.set(uid, { x: attrs.x, y: attrs.y }), + ) + + const next = buildGraph(this.props.nodes, this.props.edges, prevPositions) + this.graph.clear() + this.graph.import(next) + this.startLayout() + } + + startLayout = () => { + this.stopLayout() + if (this.graph.order < 2) { + return + } + + this.layout = new FA2Layout(this.graph, { + settings: { + gravity: 1, + scalingRatio: 12, + slowDown: 5, + strongGravityMode: true, + edgeWeightInfluence: 0, + }, + }) + this.layout.start() + this.layoutTimer = window.setTimeout(this.stopLayout, LAYOUT_MS) + } + + stopLayout = () => { + if (this.layoutTimer) { + window.clearTimeout(this.layoutTimer) + this.layoutTimer = null + } + if (this.layout) { + this.layout.kill() + this.layout = null + } + } + + // --- highlighting --------------------------------------------------- + + hoveredNode = null + + nodeReducer = (uid, attrs) => { + const { activeNode } = this.props + const res = { ...attrs } + + if (activeNode && attrs.originalNode === activeNode) { + res.highlighted = true + } + + if (this.hoveredNode && uid !== this.hoveredNode) { + if (!this.graph.areNeighbors(uid, this.hoveredNode)) { + res.color = DIM_COLOR + res.label = null + } + } + return res + } + + edgeReducer = (key, attrs) => { + const { activeEdge, highlightPredicate } = this.props + const res = { ...attrs } + const edge = attrs.originalEdge + + if (highlightPredicate && edge.predicate === highlightPredicate) { + res.size = attrs.size * 2 + } + if (activeEdge && edge === activeEdge) { + res.size = attrs.size * 2.5 + res.zIndex = 1 + } + if (this.hoveredNode) { + const [source, target] = this.graph.extremities(key) + if (source !== this.hoveredNode && target !== this.hoveredNode) { + res.color = DIM_COLOR + res.label = null + } + } + return res + } + + // --- events ---------------------------------------------------------- + + bindEvents = () => { + const renderer = this.renderer + + renderer.on('enterNode', ({ node }) => { + this.hoveredNode = node + this.props.onNodeHovered(this.originalNode(node)) + renderer.refresh({ skipIndexation: true }) + }) + renderer.on('leaveNode', () => { + this.hoveredNode = null + this.props.onNodeHovered(null) + renderer.refresh({ skipIndexation: true }) + }) + renderer.on('clickNode', ({ node }) => + this.props.onNodeSelected(this.originalNode(node)), + ) + renderer.on('doubleClickNode', (e) => { + e.preventSigmaDefault() + this.props.onNodeDoubleClicked(this.originalNode(e.node)) + }) + + renderer.on('enterEdge', ({ edge }) => + this.props.onEdgeHovered(this.originalEdge(edge)), + ) + renderer.on('leaveEdge', () => this.props.onEdgeHovered(null)) + renderer.on('clickEdge', ({ edge }) => + this.props.onEdgeSelected(this.originalEdge(edge)), + ) + + renderer.on('clickStage', () => this.props.onNodeSelected(null)) + + // Node dragging. + renderer.on('downNode', (e) => { + this.draggedNode = e.node + if (!renderer.getCustomBBox()) { + renderer.setCustomBBox(renderer.getBBox()) + } + }) + renderer.on('moveBody', ({ event }) => { + if (!this.draggedNode) { + return + } + const pos = renderer.viewportToGraph(event) + this.graph.setNodeAttribute(this.draggedNode, 'x', pos.x) + this.graph.setNodeAttribute(this.draggedNode, 'y', pos.y) + + event.preventSigmaDefault() + event.original.preventDefault() + event.original.stopPropagation() + }) + const endDrag = () => (this.draggedNode = null) + renderer.on('upNode', endDrag) + renderer.on('upStage', endDrag) + } + + originalNode = (uid) => this.graph.getNodeAttribute(uid, 'originalNode') + originalEdge = (key) => this.graph.getEdgeAttribute(key, 'originalEdge') + + render() { + return
+ } +} From 6486bd275daf73d1cb9c430ef15688448b0198a6 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 10:27:50 -0400 Subject: [PATCH 04/36] feat: per-phase query latency breakdown in the frame header Dgraph returns a per-phase latency breakdown with every response (extensions.server_latency: parsing/processing/encoding/...), but Ratel discarded it - and the frame header latency bar was dead code: it read frame.serverLatencyNs while the timing lives on frameResults[id][tab], so it never rendered at all. - lib/latency.js: pure helpers turning server_latency into ordered, labelled bar segments (known phases in pipeline order, unknown *_ns fields included future-proof, total_ns excluded) plus tooltip text. 9 unit tests. - frames reducer keeps the raw server_latency on the frame result. - FrameHeader now receives tabResult and renders a multi-segment color-coded bar (parsing/processing/encoding/network) with a per-phase tooltip showing times and percentages. Co-Authored-By: Claude Fable 5 --- client/src/components/FrameItem.js | 1 + .../src/components/FrameLayout/FrameHeader.js | 76 +++++------- .../components/FrameLayout/FrameHeader.scss | 19 ++- client/src/lib/latency.js | 114 ++++++++++++++++++ client/src/lib/latency.test.js | 100 +++++++++++++++ client/src/reducers/frames.js | 6 +- 6 files changed, 263 insertions(+), 53 deletions(-) create mode 100644 client/src/lib/latency.js create mode 100644 client/src/lib/latency.test.js 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({ > sum + s.ns, 0) - const flexStyles = { - server: { flexGrow: 1000 * ratio }, - network: { flexGrow: 1000 * (1 - ratio) }, - } return ( -
+
-
-
+ {segments.map((s) => ( +
+ ))}
-
- {timeToText(serverNs)} -
-
- {timeToText(networkNs)} +
+ {serverNs > 0 ? timeToText(serverNs) : timeToText(totalNs)}
@@ -109,7 +89,7 @@ export default function FrameHeader({ /> ) : null} - {drawLatency(frame.serverLatencyNs, frame.networkLatencyNs)} + {drawLatency(tabResult)}
{collapsed ? null : ( diff --git a/client/src/components/FrameLayout/FrameHeader.scss b/client/src/components/FrameLayout/FrameHeader.scss index 37a6265c..a5936a06 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; } } diff --git a/client/src/lib/latency.js b/client/src/lib/latency.js new file mode 100644 index 00000000..0715be94 --- /dev/null +++ b/client/src/lib/latency.js @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Helpers for rendering Dgraph's per-phase latency breakdown +// (response.extensions.server_latency). + +const KNOWN_PHASES = [ + ['parsing_ns', 'Parsing'], + ['processing_ns', 'Processing'], + ['encoding_ns', 'Encoding'], + ['assign_timestamp_ns', 'Assign timestamp'], +] + +export 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` +} + +const labelFor = (key) => + key + .replace(/_ns$/, '') + .replace(/_/g, ' ') + .replace(/^./, (c) => c.toUpperCase()) + +/** + * Turns extensions.server_latency into ordered display segments. + * Known phases come first in pipeline order; any other *_ns fields the + * server adds in the future follow with a prettified label. total_ns is + * excluded (it duplicates the sum). + */ +export function serverLatencySegments(serverLatency) { + if (!serverLatency) { + return [] + } + + const segments = [] + const seen = new Set() + + KNOWN_PHASES.forEach(([key, label]) => { + seen.add(key) + const ns = serverLatency[key] + if (typeof ns === 'number' && ns > 0) { + segments.push({ key, label, ns }) + } + }) + + Object.keys(serverLatency) + .sort() + .forEach((key) => { + if (seen.has(key) || key === 'total_ns' || !key.endsWith('_ns')) { + return + } + const ns = serverLatency[key] + if (typeof ns === 'number' && ns > 0) { + segments.push({ key, label: labelFor(key), ns }) + } + }) + + return segments +} + +/** + * Full set of bar segments for a frame: server phases plus network time, + * each with its share of the total. Returns [] when there is nothing to + * show. + */ +export function latencyBarSegments(serverLatency, networkNs) { + const segments = serverLatencySegments(serverLatency) + if (typeof networkNs === 'number' && networkNs > 0) { + segments.push({ key: 'network', label: 'Network', ns: networkNs }) + } + + const totalNs = segments.reduce((sum, s) => sum + s.ns, 0) + if (totalNs <= 0) { + return [] + } + + return segments.map((s) => ({ + ...s, + ratio: s.ns / totalNs, + text: timeToText(s.ns), + })) +} + +export function latencyTooltip(segments) { + if (!segments.length) { + return '' + } + const totalNs = segments.reduce((sum, s) => sum + s.ns, 0) + const lines = segments.map( + (s) => `${s.label}: ${timeToText(s.ns)} (${(s.ratio * 100).toFixed(0)}%)`, + ) + lines.push(`Total: ${timeToText(totalNs)}`) + return lines.join('\n') +} diff --git a/client/src/lib/latency.test.js b/client/src/lib/latency.test.js new file mode 100644 index 00000000..c7bea058 --- /dev/null +++ b/client/src/lib/latency.test.js @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + latencyBarSegments, + latencyTooltip, + serverLatencySegments, + timeToText, +} from './latency' + +describe('timeToText', () => { + it('formats across magnitudes', () => { + expect(timeToText(null)).toBe('') + expect(timeToText(undefined)).toBe('') + expect(timeToText(500)).toBe('500ns') + expect(timeToText(2.5e6)).toBe('3ms') + expect(timeToText(1.5e9)).toBe('1.5s') + expect(timeToText(90e9)).toBe('1m30s') + }) +}) + +describe('serverLatencySegments', () => { + it('returns empty for missing input', () => { + expect(serverLatencySegments(null)).toEqual([]) + expect(serverLatencySegments(undefined)).toEqual([]) + expect(serverLatencySegments({})).toEqual([]) + }) + + it('orders known phases by pipeline order and skips zeros', () => { + const segments = serverLatencySegments({ + encoding_ns: 100, + parsing_ns: 50, + processing_ns: 0, + total_ns: 150, + }) + expect(segments.map((s) => s.key)).toEqual(['parsing_ns', 'encoding_ns']) + expect(segments[0].label).toBe('Parsing') + }) + + it('includes unknown *_ns fields with prettified labels, excludes total', () => { + const segments = serverLatencySegments({ + parsing_ns: 10, + some_new_phase_ns: 20, + total_ns: 30, + not_a_latency: 99, + }) + expect(segments.map((s) => s.key)).toEqual([ + 'parsing_ns', + 'some_new_phase_ns', + ]) + expect(segments[1].label).toBe('Some new phase') + }) +}) + +describe('latencyBarSegments', () => { + it('appends network time and computes ratios', () => { + const segments = latencyBarSegments( + { parsing_ns: 25, processing_ns: 50 }, + 25, + ) + expect(segments.map((s) => s.key)).toEqual([ + 'parsing_ns', + 'processing_ns', + 'network', + ]) + expect(segments.map((s) => s.ratio)).toEqual([0.25, 0.5, 0.25]) + expect(segments[2].label).toBe('Network') + }) + + it('returns empty when there is nothing to show', () => { + expect(latencyBarSegments(null, 0)).toEqual([]) + expect(latencyBarSegments({}, undefined)).toEqual([]) + }) + + it('works with server latency only', () => { + const segments = latencyBarSegments({ processing_ns: 10 }, undefined) + expect(segments).toHaveLength(1) + expect(segments[0].ratio).toBe(1) + }) +}) + +describe('latencyTooltip', () => { + it('lists each phase with percentage and a total', () => { + const tooltip = latencyTooltip( + latencyBarSegments({ parsing_ns: 25, processing_ns: 50 }, 25), + ) + expect(tooltip).toContain('Parsing: ') + expect(tooltip).toContain('(25%)') + expect(tooltip).toContain('Processing: ') + expect(tooltip).toContain('(50%)') + expect(tooltip).toContain('Network: ') + expect(tooltip.split('\n').pop()).toMatch(/^Total: /) + }) + + it('is empty for no segments', () => { + expect(latencyTooltip([])).toBe('') + }) +}) diff --git a/client/src/reducers/frames.js b/client/src/reducers/frames.js index c2ef0451..ec583791 100644 --- a/client/src/reducers/frames.js +++ b/client/src/reducers/frames.js @@ -28,13 +28,17 @@ function getFrameTiming(executionStart, extensions) { return { serverLatencyNs: 0, networkLatencyNs: fullRequestTimeNs, + serverLatency: null, } } const { parsing_ns, processing_ns, encoding_ns } = extensions.server_latency - const serverLatencyNs = parsing_ns + processing_ns + (encoding_ns || 0) + const serverLatencyNs = + (parsing_ns || 0) + (processing_ns || 0) + (encoding_ns || 0) return { serverLatencyNs, networkLatencyNs: fullRequestTimeNs - serverLatencyNs, + // Keep the raw per-phase breakdown for the latency bar tooltip. + serverLatency: extensions.server_latency, } } From eec5bc2c8b086e79a46c5dfe02b2ddd564d125b1 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 10:33:09 -0400 Subject: [PATCH 05/36] feat: download query results as CSV Adds a Download CSV action to the frame results toolbar for query frames with response data. Query responses are flattened into rows (dot-notation for nested objects, '; '-joined scalar arrays, JSON-stringified object arrays, __block column for multi-block responses) and serialized as RFC-4180 CSV, downloaded as ratel-results-.csv. Co-Authored-By: Claude Fable 5 --- .../FrameLayout/FrameBodyToolbar.js | 34 ++++- client/src/lib/csvExport.js | 109 +++++++++++++ client/src/lib/csvExport.test.js | 143 ++++++++++++++++++ 3 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 client/src/lib/csvExport.js create mode 100644 client/src/lib/csvExport.test.js diff --git a/client/src/components/FrameLayout/FrameBodyToolbar.js b/client/src/components/FrameLayout/FrameBodyToolbar.js index 96fd1612..d9a21007 100644 --- a/client/src/components/FrameLayout/FrameBodyToolbar.js +++ b/client/src/components/FrameLayout/FrameBodyToolbar.js @@ -9,6 +9,18 @@ import Tabs from 'react-bootstrap/Tabs' import { TAB_GEO, TAB_JSON, TAB_QUERY, TAB_VISUAL } from 'actions/frames' import GraphIcon from 'components/GraphIcon' +import { downloadCSV } from 'lib/csvExport' + +const ACTION_DOWNLOAD_CSV = 'download-csv' + +const getCsvFilename = () => { + 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/lib/csvExport.js b/client/src/lib/csvExport.js new file mode 100644 index 00000000..19748bac --- /dev/null +++ b/client/src/lib/csvExport.js @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +const isPlainObject = (value) => + value !== null && typeof value === 'object' && !Array.isArray(value) + +const flattenValue = (row, key, value) => { + if (value === null || value === undefined) { + row[key] = '' + return + } + if (Array.isArray(value)) { + if (value.length === 0) { + row[key] = '' + } else if (value.some(isPlainObject)) { + // Arrays of objects are kept as JSON, not exploded into rows. + row[key] = JSON.stringify(value) + } else { + row[key] = value.join('; ') + } + return + } + if (isPlainObject(value)) { + Object.entries(value).forEach(([childKey, childValue]) => + flattenValue(row, `${key}.${childKey}`, childValue), + ) + return + } + row[key] = value +} + +// Flattens a Dgraph query response ({ block: [obj, ...], ... }) into an +// array of flat row objects with dot-notation keys for nested objects. +export function flattenRows(responseData) { + if (!isPlainObject(responseData)) { + return [] + } + const blocks = Object.entries(responseData).filter(([, value]) => + Array.isArray(value), + ) + const multiBlock = blocks.length > 1 + + const rows = [] + blocks.forEach(([blockName, items]) => { + items.forEach((item) => { + if (!isPlainObject(item)) { + return + } + const row = {} + if (multiBlock) { + row.__block = blockName + } + Object.entries(item).forEach(([key, value]) => + flattenValue(row, key, value), + ) + rows.push(row) + }) + }) + return rows +} + +const escapeField = (value) => { + const str = value === null || value === undefined ? '' : String(value) + if (/[",\n\r]/.test(str)) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +// Serializes flat row objects into an RFC-4180 CSV string. The header is +// the union of all row keys, in first-seen order. +export function toCSV(rows) { + if (!Array.isArray(rows) || rows.length === 0) { + return '' + } + const headers = [] + rows.forEach((row) => + Object.keys(row).forEach((key) => { + if (!headers.includes(key)) { + headers.push(key) + } + }), + ) + const lines = [headers.map(escapeField).join(',')] + rows.forEach((row) => + lines.push(headers.map((header) => escapeField(row[header])).join(',')), + ) + return lines.join('\r\n') +} + +// Builds a CSV from a Dgraph query response and triggers a browser +// download. No-op when there is nothing to export. +export function downloadCSV(responseData, filename) { + const csv = toCSV(flattenRows(responseData)) + if (!csv) { + return + } + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} diff --git a/client/src/lib/csvExport.test.js b/client/src/lib/csvExport.test.js new file mode 100644 index 00000000..985502cd --- /dev/null +++ b/client/src/lib/csvExport.test.js @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { downloadCSV, flattenRows, toCSV } from './csvExport' + +describe('flattenRows', () => { + it('flattens simple flat rows from a single block', () => { + const data = { + q: [ + { uid: '0x1', name: 'Alice' }, + { uid: '0x2', name: 'Bob' }, + ], + } + expect(flattenRows(data)).toEqual([ + { uid: '0x1', name: 'Alice' }, + { uid: '0x2', name: 'Bob' }, + ]) + }) + + it('flattens nested objects with dot-notation keys', () => { + const data = { + q: [{ uid: '0x1', address: { city: 'Pune', geo: { lat: 18.5 } } }], + } + expect(flattenRows(data)).toEqual([ + { uid: '0x1', 'address.city': 'Pune', 'address.geo.lat': 18.5 }, + ]) + }) + + it('joins arrays of scalars with "; "', () => { + const data = { + q: [{ uid: '0x1', tags: ['a', 'b', 'c'], scores: [1, 2] }], + } + expect(flattenRows(data)).toEqual([ + { uid: '0x1', tags: 'a; b; c', scores: '1; 2' }, + ]) + }) + + it('JSON-stringifies arrays of objects without exploding rows', () => { + const friends = [ + { uid: '0x2', name: 'Bob' }, + { uid: '0x3', name: 'Carol' }, + ] + const data = { q: [{ uid: '0x1', name: 'Alice', friend: friends }] } + expect(flattenRows(data)).toEqual([ + { uid: '0x1', name: 'Alice', friend: JSON.stringify(friends) }, + ]) + }) + + it('adds a __block column when there is more than one top-level block', () => { + const data = { + people: [{ name: 'Alice' }], + cities: [{ name: 'Pune' }], + } + expect(flattenRows(data)).toEqual([ + { __block: 'people', name: 'Alice' }, + { __block: 'cities', name: 'Pune' }, + ]) + }) + + it('omits the __block column for a single block', () => { + const data = { q: [{ name: 'Alice' }] } + expect(flattenRows(data)[0].__block).toBeUndefined() + }) + + it('converts null and empty-array values to empty strings', () => { + const data = { q: [{ name: null, tags: [] }] } + expect(flattenRows(data)).toEqual([{ name: '', tags: '' }]) + }) + + it('returns an empty array for empty or missing data', () => { + expect(flattenRows(undefined)).toEqual([]) + expect(flattenRows(null)).toEqual([]) + expect(flattenRows({})).toEqual([]) + expect(flattenRows({ q: [] })).toEqual([]) + }) +}) + +describe('toCSV', () => { + it('uses the union of keys in first-seen order as the header', () => { + const rows = [ + { a: 1, b: 2 }, + { b: 3, c: 4 }, + ] + expect(toCSV(rows)).toBe('a,b,c\r\n1,2,\r\n,3,4') + }) + + it('quotes fields containing commas', () => { + expect(toCSV([{ name: 'Doe, Jane' }])).toBe('name\r\n"Doe, Jane"') + }) + + it('quotes and doubles embedded quotes', () => { + expect(toCSV([{ name: 'say "hi"' }])).toBe('name\r\n"say ""hi"""') + }) + + it('quotes fields containing newlines', () => { + expect(toCSV([{ note: 'line1\nline2' }])).toBe('note\r\n"line1\nline2"') + }) + + it('quotes header names that need escaping', () => { + expect(toCSV([{ 'a,b': 1 }])).toBe('"a,b"\r\n1') + }) + + it('returns an empty string for empty or missing rows', () => { + expect(toCSV([])).toBe('') + expect(toCSV(undefined)).toBe('') + }) +}) + +describe('downloadCSV', () => { + let createObjectURL + let revokeObjectURL + + beforeEach(() => { + createObjectURL = jest.fn(() => 'blob:fake-url') + revokeObjectURL = jest.fn() + URL.createObjectURL = createObjectURL + URL.revokeObjectURL = revokeObjectURL + }) + + it('creates, clicks and cleans up a download link', () => { + const click = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}) + + downloadCSV({ q: [{ name: 'Alice' }] }, 'ratel-results-test.csv') + + expect(createObjectURL).toHaveBeenCalledTimes(1) + expect(click).toHaveBeenCalledTimes(1) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:fake-url') + expect(document.querySelector('a[download]')).toBeNull() + + click.mockRestore() + }) + + it('is a no-op for empty or missing data', () => { + downloadCSV(undefined, 'x.csv') + downloadCSV({}, 'x.csv') + downloadCSV({ q: [] }, 'x.csv') + expect(createObjectURL).not.toHaveBeenCalled() + }) +}) From 273ef5832be015e0c9f229e2a2cd8fac7959319c Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 10:34:00 -0400 Subject: [PATCH 06/36] feat: support serving Ratel under a URL prefix (-url-prefix) Adds a -url-prefix flag (with RATEL_URL_PREFIX env fallback) to the Go server so Ratel can be hosted under a subpath behind reverse proxies, e.g. https://example.com/ratel/ (fixes #390). When a prefix is set: - all routes are served under the prefix via http.StripPrefix - the bare prefix redirects (301) to the prefix with a trailing slash - root-relative href/src URLs and the inline loader.js reference in the served index.html are rewritten to include the prefix - paths outside the prefix return 404 with a hint at the prefix With no prefix (the default) behavior is unchanged. Co-Authored-By: Claude Fable 5 --- INSTRUCTIONS.md | 17 ++++ server/server.go | 85 +++++++++++++++++- server/server_test.go | 200 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 server/server_test.go 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/server/server.go b/server/server.go index 1b113eda..04a21b55 100644 --- a/server/server.go +++ b/server/server.go @@ -13,6 +13,7 @@ import ( "log" "net/http" "os" + "regexp" "strings" ) @@ -34,6 +35,8 @@ var ( tlsKey string listenAddr string + + urlPrefix string ) // Run starts the server. @@ -41,17 +44,43 @@ func Run() { parseFlags() indexContent := prepareIndexContent() - http.HandleFunc("/", makeMainHandler(indexContent)) + mux := newServeMux(indexContent, urlPrefix) addrStr := fmt.Sprintf("%s:%d", listenAddr, port) + if urlPrefix != "" { + log.Printf("Serving under URL prefix %s/", urlPrefix) + } log.Printf("Listening on %s...", addrStr) switch { case tlsCrt != "": - log.Fatalln(http.ListenAndServeTLS(addrStr, tlsCrt, tlsKey, nil)) + log.Fatalln(http.ListenAndServeTLS(addrStr, tlsCrt, tlsKey, mux)) default: - log.Fatalln(http.ListenAndServe(addrStr, nil)) + log.Fatalln(http.ListenAndServe(addrStr, mux)) + } +} + +// newServeMux builds the HTTP routing for the Ratel server. With an empty +// prefix all content is served from the root, preserving historic behavior. +// With a prefix (e.g. "/ratel") all content is served under that prefix, the +// bare prefix redirects to "/", and any other path returns 404 with a +// hint pointing at the prefix. +func newServeMux(indexContent *content, prefix string) *http.ServeMux { + mux := http.NewServeMux() + mainHandler := makeMainHandler(indexContent) + + if prefix == "" { + mux.Handle("/", mainHandler) + return mux } + + mux.Handle(prefix+"/", http.StripPrefix(prefix, mainHandler)) + mux.Handle(prefix, http.RedirectHandler(prefix+"/", http.StatusMovedPermanently)) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, fmt.Sprintf("Not found. Ratel is served under %s/", prefix), + http.StatusNotFound) + }) + return mux } func parseFlags() { @@ -61,6 +90,9 @@ func parseFlags() { tlsCrtPtr := flag.String("tls_crt", "", "TLS cert for serving HTTPS requests.") tlsKeyPtr := flag.String("tls_key", "", "TLS key for serving HTTPS requests.") listenAddrPtr := flag.String("listen-addr", defaultAddr, "Address Ratel server should listen on.") + urlPrefixPtr := flag.String("url-prefix", "", + "URL path prefix under which Ratel is served, e.g. \"/ratel\" "+ + "(falls back to the RATEL_URL_PREFIX environment variable).") flag.Parse() @@ -84,6 +116,26 @@ func parseFlags() { tlsKey = *tlsKeyPtr listenAddr = *listenAddrPtr + + prefix := *urlPrefixPtr + if prefix == "" { + prefix = os.Getenv("RATEL_URL_PREFIX") + } + urlPrefix = normalizeURLPrefix(prefix) +} + +// normalizeURLPrefix ensures a prefix has a leading slash and no trailing +// slash. Empty input and "/" normalize to "" (no prefix). +func normalizeURLPrefix(prefix string) string { + prefix = strings.TrimSpace(prefix) + prefix = strings.TrimRight(prefix, "/") + if prefix == "" { + return "" + } + if !strings.HasPrefix(prefix, "/") { + prefix = "/" + prefix + } + return prefix } func prepareIndexContent() *content { @@ -118,10 +170,35 @@ func prepareIndexContent() *content { return &content{ name: info.Name(), modTime: info.ModTime(), - bs: buf.Bytes(), + bs: rewriteURLPrefix(buf.Bytes(), urlPrefix), } } +// hrefSrcRe matches root-relative URLs in href/src attributes, e.g. +// href="/favicon.ico" or src="/static/js/main.js". It deliberately does not +// match protocol-relative URLs such as href="//cdn.example.com/x.js". +var hrefSrcRe = regexp.MustCompile(`\b(href|src)="(/(?:[^/"][^"]*)?)"`) + +// rewriteURLPrefix rewrites root-relative asset URLs in the index.html +// payload so they resolve when Ratel is served under a URL prefix. +func rewriteURLPrefix(bs []byte, prefix string) []byte { + if prefix == "" { + return bs + } + + out := hrefSrcRe.ReplaceAllFunc(bs, func(m []byte) []byte { + sub := hrefSrcRe.FindSubmatch(m) + return []byte(string(sub[1]) + `="` + prefix + string(sub[2]) + `"`) + }) + + // index.html injects the fallback loader script via an absolute path in + // inline JavaScript: injectJs('/loader.js'). + out = bytes.ReplaceAll(out, []byte(`'/loader.js'`), + []byte(`'`+prefix+`/loader.js'`)) + + return out +} + func makeMainHandler(indexContent *content) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/") diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 00000000..5c7c2a66 --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,200 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +const testIndexHTML = ` + + + + + + + + +Go to the release selection screen + + +` + +func testContent() *content { + return &content{ + name: "index.html", + modTime: time.Now(), + bs: []byte(testIndexHTML), + } +} + +func prefixedTestContent(prefix string) *content { + return &content{ + name: "index.html", + modTime: time.Now(), + bs: rewriteURLPrefix([]byte(testIndexHTML), prefix), + } +} + +func get(t *testing.T, mux *http.ServeMux, path string) (*http.Response, string) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + resp := w.Result() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("reading response body for %s: %v", path, err) + } + return resp, string(body) +} + +// anyAssetPath returns the path of some embedded non-index asset, to verify +// static asset routing against the real bindata contents. +func anyAssetPath(t *testing.T) string { + t.Helper() + for _, name := range AssetNames() { + if name != indexPath { + return name + } + } + t.Skip("no non-index assets embedded") + return "" +} + +func TestNormalizeURLPrefix(t *testing.T) { + cases := []struct { + in, want string + }{ + {"", ""}, + {"/", ""}, + {"//", ""}, + {"ratel", "/ratel"}, + {"/ratel", "/ratel"}, + {"/ratel/", "/ratel"}, + {"ratel/", "/ratel"}, + {" /ratel ", "/ratel"}, + {"/a/b/", "/a/b"}, + } + for _, c := range cases { + if got := normalizeURLPrefix(c.in); got != c.want { + t.Errorf("normalizeURLPrefix(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestRewriteURLPrefix(t *testing.T) { + out := string(rewriteURLPrefix([]byte(testIndexHTML), "/ratel")) + + for _, want := range []string{ + `href="/ratel/favicon.ico"`, + `href="/ratel/3rdpartystatic/codemirror/neo.css"`, + `src="/ratel/static/js/main.js"`, + `href="/ratel/?nocookie"`, + `injectJs('/ratel/loader.js')`, + // Protocol-relative URLs must not be rewritten. + `href="//cdn.example.com/external.css"`, + } { + if !strings.Contains(out, want) { + t.Errorf("rewritten html missing %q\nhtml:\n%s", want, out) + } + } +} + +func TestRewriteURLPrefixEmptyIsNoop(t *testing.T) { + if out := string(rewriteURLPrefix([]byte(testIndexHTML), "")); out != testIndexHTML { + t.Errorf("rewriteURLPrefix with empty prefix changed the payload") + } +} + +func TestNoPrefixServesIndexAtRoot(t *testing.T) { + mux := newServeMux(testContent(), "") + + for _, path := range []string{"/", "/index.html"} { + resp, body := get(t, mux, path) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s status = %d, want 200", path, resp.StatusCode) + } + if !strings.Contains(body, `href="/favicon.ico"`) { + t.Errorf("GET %s: asset paths must stay unprefixed", path) + } + } +} + +func TestNoPrefixServesStaticAsset(t *testing.T) { + mux := newServeMux(testContent(), "") + + asset := anyAssetPath(t) + resp, _ := get(t, mux, "/"+asset) + if resp.StatusCode != http.StatusOK { + t.Errorf("GET /%s status = %d, want 200", asset, resp.StatusCode) + } +} + +func TestPrefixServesRewrittenIndex(t *testing.T) { + mux := newServeMux(prefixedTestContent("/ratel"), "/ratel") + + for _, path := range []string{"/ratel/", "/ratel/index.html"} { + resp, body := get(t, mux, path) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s status = %d, want 200", path, resp.StatusCode) + } + if !strings.Contains(body, `src="/ratel/static/js/main.js"`) { + t.Errorf("GET %s: body missing prefixed asset path", path) + } + if strings.Contains(body, `href="/favicon.ico"`) { + t.Errorf("GET %s: body still contains unprefixed asset path", path) + } + } +} + +func TestPrefixBareRedirectsToSlash(t *testing.T) { + mux := newServeMux(prefixedTestContent("/ratel"), "/ratel") + + resp, _ := get(t, mux, "/ratel") + if resp.StatusCode != http.StatusMovedPermanently { + t.Fatalf("GET /ratel status = %d, want %d", + resp.StatusCode, http.StatusMovedPermanently) + } + if loc := resp.Header.Get("Location"); loc != "/ratel/" { + t.Errorf("GET /ratel Location = %q, want %q", loc, "/ratel/") + } +} + +func TestPrefixRootReturns404(t *testing.T) { + mux := newServeMux(prefixedTestContent("/ratel"), "/ratel") + + for _, path := range []string{"/", "/favicon.ico", "/ratelx"} { + resp, body := get(t, mux, path) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("GET %s status = %d, want 404", path, resp.StatusCode) + } + if path == "/" && !strings.Contains(body, "/ratel/") { + t.Errorf("GET / body should hint at the prefix, got %q", body) + } + } +} + +func TestPrefixServesStaticAsset(t *testing.T) { + mux := newServeMux(prefixedTestContent("/ratel"), "/ratel") + + asset := anyAssetPath(t) + resp, _ := get(t, mux, "/ratel/"+asset) + if resp.StatusCode != http.StatusOK { + t.Errorf("GET /ratel/%s status = %d, want 200", asset, resp.StatusCode) + } + + // The same asset must not resolve outside the prefix. + resp, _ = get(t, mux, "/"+asset) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("GET /%s status = %d, want 404", asset, resp.StatusCode) + } +} From 825cf76130028cfeffe121dd2cdce1208d11c167 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 10:36:11 -0400 Subject: [PATCH 07/36] feat: graph style rules, layout switcher and legend filtering Three graph-view features on top of the sigma renderer: - Style rules (Neo4j Bloom-style): a Graph styles panel in the toolbar lists every group in the current result with a color picker and node size slider; overrides apply live via sigma reducers and persist in localStorage. lib/graphStyles.js (sanitize/persist/merge) has 11 unit tests. - Layout switcher: Force (ForceAtlas2 worker), Circular, and Packed (circlepack clustered by group) via a toolbar select; static layouts auto-fit the camera. - Legend filtering: clicking a predicate chip in the entity selector hides/shows that predicate's nodes and edges without re-querying; hidden chips render dimmed with strikethrough. Verified in a real browser against Dgraph v25: layouts switch, style panel renders per-group rows and applies color changes, legend chips toggle - zero page errors. Unit suite green, production build passes. Co-Authored-By: Claude Fable 5 --- client/src/assets/css/Graph.scss | 18 ++++ client/src/components/EntitySelector.js | 41 +++++-- .../components/FrameLayout/FrameSession.js | 18 ++++ client/src/components/GraphContainer.js | 67 ++++++++++++ client/src/components/GraphStylePanel.js | 88 +++++++++++++++ client/src/components/GraphStylePanel.scss | 84 +++++++++++++++ client/src/components/Label.js | 5 +- .../src/components/SigmaGraph/buildGraph.js | 2 + client/src/components/SigmaGraph/index.js | 60 ++++++++++- client/src/lib/graphStyles.js | 69 ++++++++++++ client/src/lib/graphStyles.test.js | 101 ++++++++++++++++++ 11 files changed, 536 insertions(+), 17 deletions(-) create mode 100644 client/src/components/GraphStylePanel.js create mode 100644 client/src/components/GraphStylePanel.scss create mode 100644 client/src/lib/graphStyles.js create mode 100644 client/src/lib/graphStyles.test.js diff --git a/client/src/assets/css/Graph.scss b/client/src/assets/css/Graph.scss index 6de7c1d9..2def8ea4 100644 --- a/client/src/assets/css/Graph.scss +++ b/client/src/assets/css/Graph.scss @@ -126,6 +126,24 @@ } } +.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 { position: absolute; bottom: 8px; 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/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/GraphContainer.js b/client/src/components/GraphContainer.js index ffb49ad4..958ed206 100644 --- a/client/src/components/GraphContainer.js +++ b/client/src/components/GraphContainer.js @@ -9,11 +9,20 @@ import EdgeProperties from 'components/EdgeProperties' import NodeProperties from 'components/NodeProperties' import PartialRenderInfo from 'components/PartialRenderInfo' +import GraphStylePanel from 'components/GraphStylePanel' import MovablePanel from 'components/MovablePanel' import SigmaGraph from 'components/SigmaGraph' +import { loadStyleRules, saveStyleRules } from '../lib/graphStyles' + import '../assets/css/Graph.scss' +const LAYOUTS = [ + ['force', 'Force'], + ['circular', 'Circular'], + ['circlepack', 'Packed'], +] + export default ({ graphUpdateHack, edgesDataset, @@ -28,6 +37,7 @@ export default ({ panelHeight, panelWidth, remainingNodes, + hiddenPredicates, }) => { const [selectedNode, setSelectedNode] = React.useState(null) const [hoveredNode, setHoveredNode] = React.useState(null) @@ -38,6 +48,29 @@ export default ({ const [searchQuery, setSearchQuery] = React.useState('') const [searchFocused, setSearchFocused] = React.useState(false) + const [layout, setLayout] = React.useState('force') + const [styleRules, setStyleRules] = React.useState(loadStyleRules) + const [stylePanelOpen, setStylePanelOpen] = 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 graphRef = React.useRef(null) const onEdgeSelected = (edge) => { @@ -107,6 +140,9 @@ export default ({ activeNode={activeNode} activeEdge={activeEdge} hoveredNode={hoveredNode} + layout={layout} + styleRules={styleRules} + hiddenPredicates={hiddenPredicates} /> {/* Graph toolbar: search + controls */} @@ -140,8 +176,39 @@ export default ({ + +
+ {stylePanelOpen && ( + setStylePanelOpen(false)} + /> + )} + {/* Node/edge count indicator */}
{nodesDataset.size} nodes · {edgesDataset.size} edges diff --git a/client/src/components/GraphStylePanel.js b/client/src/components/GraphStylePanel.js new file mode 100644 index 00000000..e44ab22d --- /dev/null +++ b/client/src/components/GraphStylePanel.js @@ -0,0 +1,88 @@ +/* + * 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..b3566fc6 --- /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 }) => (