From 546ea7ebcf5dee8efb1a87e0cb82d4afc43f4afe Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 15 Jul 2026 15:17:08 +0200 Subject: [PATCH 001/181] refactor(utilities): move sanitizeForAST into StaticUtilities The AST character map lived as a private method on StringDemoDataLoader; hoist it to StaticUtilities so other data loaders can share it. The demo loader delegates to keep its call sites unchanged. --- src/utilities/demo_loader.js | 19 +++---------------- src/utilities/static.js | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/utilities/demo_loader.js b/src/utilities/demo_loader.js index 8f1f0e7..20340f8 100644 --- a/src/utilities/demo_loader.js +++ b/src/utilities/demo_loader.js @@ -1,3 +1,5 @@ +import {StaticUtilities} from './static.js'; + class StringDemoDataLoader { constructor(cache, genes, species = 9606, amountOfNodes = 50, requiredScore = 400) { this.cache = cache; @@ -241,22 +243,7 @@ _getEdgeColor(score, minScore, maxScore) { } _sanitizeForAST(str) { - if (typeof str !== 'string') return str; - - return str - .replace(/\(/g, '{') - .replace(/\)/g, '}') - .replace(/\[/g, '{') - .replace(/]/g, '}') - .replace(/:/g, '-') - .replace(/,/g, ' ') - .replace(/&/g, 'and') - .replace(//g, 'greater') - .replace(/"/g, '') - .replace(/'/g, '') - .replace(/\\/g, '') - .replace(/\//g, ' or '); + return StaticUtilities.sanitizeForAST(str); } _convertToAppFormat(stringData, annotationData) { diff --git a/src/utilities/static.js b/src/utilities/static.js index 57f861f..3cf9b62 100644 --- a/src/utilities/static.js +++ b/src/utilities/static.js @@ -3,6 +3,28 @@ class StaticUtilities { return typeof value === 'string' || value instanceof String; } + /** + * Strip characters the query DSL's AST cannot handle from property names + * and categorical values. + */ + static sanitizeForAST(str) { + if (typeof str !== 'string') return str; + return str + .replace(/\(/g, '{') + .replace(/\)/g, '}') + .replace(/\[/g, '{') + .replace(/]/g, '}') + .replace(/:/g, '-') + .replace(/,/g, ' ') + .replace(/&/g, 'and') + .replace(//g, 'greater') + .replace(/"/g, '') + .replace(/'/g, '') + .replace(/\\/g, '') + .replace(/\//g, ' or '); + } + /** * Escape a value for safe interpolation into an HTML string. Use at every * boundary where untrusted text (node/edge/property names, layout names, From e147aa3f18f2ada6c35893bfddc16c0f8bb365a8 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 15 Jul 2026 15:17:08 +0200 Subject: [PATCH 002/181] feat(io): add Neo4j connector via HTTP transactional Cypher API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetch a graph straight from a Neo4j server: connection popup (URL, credentials, optional database, Cypher query), a row-count preflight that warns before large fetches, and a property-exclusion checklist (large arrays such as embeddings start deselected) before the data enters the shared applyGraph pipeline. Uses plain fetch against /db/{name}/tx/commit with the graph result format — no driver dependency, works in both browser and Electron builds. Labels, relationship types, and list properties map onto pipe-separated multi-value categoricals so the existing filter UI works on them directly. Connection settings persist in localStorage; the password is never stored. --- ARCHITECTURE.md | 3 +- src/gll.js | 2 + src/graph_lens_lite.html | 7 + src/utilities/neo4j_loader.js | 535 ++++++++++++++++++++++++++++++++++ tests/neo4j-loader.test.js | 477 ++++++++++++++++++++++++++++++ 5 files changed, 1023 insertions(+), 1 deletion(-) create mode 100644 src/utilities/neo4j_loader.js create mode 100644 tests/neo4j-loader.test.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1b85cad..cec515a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,12 +64,13 @@ Business logic and UI: - `assistant/` — the natural-language Graph Assistant (intent parsing, query generation, settings, budget UI) -### Utilities (`src/utilities/`, 12 files) +### Utilities (`src/utilities/`, 13 files) - `static.js` — validation, colour math, deep-merge helpers - `popup.js` / `popover_position.js` — modal and popover positioning - `data_editor.js` — spreadsheet-like data editor (`DataTable`), incl. Excel export - `demo_loader.js` — STRING DB protein-interaction demo data +- `neo4j_loader.js` — Neo4j connector (HTTP transactional Cypher API, no driver dependency) - `tour.js` — guided tour with a sample dataset - `color_scale_picker.js` / `numeric_scale_picker.js` / `pie_chart_picker.js` — styling pickers - `selection_hud.js`, `theme.js`, `export_scale.js` diff --git a/src/gll.js b/src/gll.js index b5e5c60..090d6b5 100644 --- a/src/gll.js +++ b/src/gll.js @@ -20,6 +20,7 @@ import {NumericScalePicker} from './utilities/numeric_scale_picker.js'; import {PieChartPicker} from './utilities/pie_chart_picker.js'; import {DataTable, buildDataTable} from "./utilities/data_editor.js"; import {StringDemoDataLoader} from "./utilities/demo_loader.js"; +import {openNeo4jPopup} from "./utilities/neo4j_loader.js"; import {Popup} from "./utilities/popup.js"; import {StaticUtilities} from "./utilities/static.js"; import {generateTourData, GuidedTour} from "./utilities/tour.js"; @@ -452,6 +453,7 @@ async function startTour() { } window.loadDemoData = loadDemoData; +window.loadNeo4jData = () => openNeo4jPopup(cache); window.startTour = startTour; window.cache = cache; diff --git a/src/graph_lens_lite.html b/src/graph_lens_lite.html index 1edb178..3154986 100644 --- a/src/graph_lens_lite.html +++ b/src/graph_lens_lite.html @@ -41,6 +41,11 @@

Graph Lens Lite

STRING Database Explore protein-protein interaction networks from the STRING database +
+ +
diff --git a/src/utilities/neo4j_loader.js b/src/utilities/neo4j_loader.js new file mode 100644 index 0000000..65d6085 --- /dev/null +++ b/src/utilities/neo4j_loader.js @@ -0,0 +1,535 @@ +/** + * Neo4j connector. + * + * Fetches graph data from a Neo4j server via the HTTP transactional Cypher + * API (`POST {url}/db/{database}/tx/commit`) — plain `fetch`, no driver + * dependency. The `graph` result format returns nodes/relationships directly, + * which map onto the app's native `{nodes, edges, ...headers}` payload. + * + * Requires the server's HTTP connector (default port 7474/7473); Neo4j ships + * with `server.http_access_control_allow_origin=*`, so browser and Electron + * contexts both work. Neo4j Aura exposes only Bolt and is not supported. + * + * The interactive flow lives in `openNeo4jPopup` (gll.js wires the buttons): + * connection form → row-count preflight (warn when huge) → fetch → + * property-exclusion checklist → render via the shared applyGraph pipeline. + */ + +import { Popup } from './popup.js'; +import { StaticUtilities } from './static.js'; +import { applyGraph } from '../managers/api_client.js'; + +const sanitizeForAST = StaticUtilities.sanitizeForAST; + +const DEFAULT_DATABASE = 'neo4j'; +const LARGE_RESULT_ROW_THRESHOLD = 2000; +const COUNT_TIMEOUT_MS = 30_000; +const QUERY_TIMEOUT_MS = 300_000; +// Arrays longer than this (e.g. embedding vectors) are deselected by default +// in the property checklist. +const LARGE_ARRAY_THRESHOLD = 32; +const SETTINGS_STORAGE_KEY = 'gllNeo4jConnection'; + +/** @returns {string} the tx/commit endpoint for a base URL + database name */ +function buildTxUrl(baseUrl, database) { + const url = new URL(baseUrl); // throws on malformed input — caught by caller + const base = url.href.replace(/\/+$/, ''); + return `${base}/db/${encodeURIComponent(database || DEFAULT_DATABASE)}/tx/commit`; +} + +/** Base64 for the Basic-auth header; TextEncoder round-trip keeps non-ASCII credentials intact. */ +function basicAuth(username, password) { + const bytes = new TextEncoder().encode(`${username}:${password}`); + return 'Basic ' + btoa(String.fromCharCode(...bytes)); +} + +/** + * POST one or more Cypher statements to the tx/commit endpoint. + * + * @param {{url: string, database?: string, username: string, password: string}} config + * @param {Array<{statement: string, resultDataContents?: string[]}>} statements + * @param {{timeoutMs?: number, fetchImpl?: Function}} [opts] + * @returns {Promise} the `results` array + * @throws {Error} on network failure, non-2xx status, or Cypher errors + */ +async function runCypher(config, statements, opts = {}) { + const fetchImpl = opts.fetchImpl ?? fetch; + const response = await fetchImpl(buildTxUrl(config.url, config.database), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: basicAuth(config.username, config.password), + }, + body: JSON.stringify({ statements }), + signal: AbortSignal.timeout(opts.timeoutMs ?? QUERY_TIMEOUT_MS), + }); + + if (response.status === 401) { + throw new Error('Authentication failed — check username and password.'); + } + if (!response.ok) { + throw new Error(`Neo4j returned HTTP ${response.status}.`); + } + + const payload = await response.json(); + if (payload.errors?.length) { + const first = payload.errors[0]; + throw new Error(`${first.code}: ${first.message}`); + } + return payload.results ?? []; +} + +/** + * Preflight row count by wrapping the query in a CALL subquery (Neo4j 4.1+). + * Returns null when the count cannot be determined (older server, query shape + * the wrapper cannot handle) — callers then proceed without a size warning. + * + * @returns {Promise} + */ +async function countQueryRows(config, query, opts = {}) { + try { + const results = await runCypher( + config, + [{ statement: `CALL { ${query} } RETURN count(*) AS rowCount` }], + { ...opts, timeoutMs: opts.timeoutMs ?? COUNT_TIMEOUT_MS }, + ); + const value = results[0]?.data?.[0]?.row?.[0]; + return typeof value === 'number' ? value : null; + } catch { + return null; + } +} + +/** + * Collect unique nodes and relationships from a tx/commit `graph`-format + * result (the same entity appears once per row it occurs in). + * + * @param {object[]} results the `results` array from runCypher + * @returns {{nodes: object[], relationships: object[]}} + */ +function collectGraph(results) { + const nodes = new Map(); + const relationships = new Map(); + for (const result of results) { + for (const entry of result.data ?? []) { + for (const node of entry.graph?.nodes ?? []) nodes.set(node.id, node); + for (const rel of entry.graph?.relationships ?? []) relationships.set(rel.id, rel); + } + } + return { nodes: [...nodes.values()], relationships: [...relationships.values()] }; +} + +/** + * Coerce a Neo4j property value into something the filter UI can handle. + * Primitive arrays join with ' | ' — the app splits pipe-separated strings + * into multi-value categoricals, so list properties stay filterable per value. + */ +function coerceValue(value) { + if (value === null || value === undefined) return undefined; + if (typeof value === 'number' || typeof value === 'boolean') return value; + if (typeof value === 'string') return sanitizeForAST(value); + if (Array.isArray(value) && value.every((v) => typeof v !== 'object' || v === null)) { + return value.map((v) => sanitizeForAST(String(v))).join(' | '); + } + return sanitizeForAST(JSON.stringify(value)); +} + +/** + * Union of property keys per element kind, with hints for the exclusion + * checklist (sample value, large-array detection). + * + * @returns {Array<{kind: 'node'|'edge', key: string, largeArray: boolean, sample: *}>} + */ +function collectPropertyKeys(nodes, relationships) { + const collect = (elements, kind) => { + const byKey = new Map(); + for (const element of elements) { + for (const [key, value] of Object.entries(element.properties ?? {})) { + const existing = byKey.get(key); + const largeArray = Array.isArray(value) && value.length > LARGE_ARRAY_THRESHOLD; + if (!existing) { + byKey.set(key, { kind, key, largeArray, sample: value }); + } else if (largeArray) { + existing.largeArray = true; + } + } + } + return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key)); + }; + return [...collect(nodes, 'node'), ...collect(relationships, 'edge')]; +} + +/** Pick a display label for a node from conventional name properties. */ +function nodeDisplayLabel(node) { + const props = node.properties ?? {}; + const candidate = props.name ?? props.title ?? props.label ?? props.id; + if (candidate !== undefined && candidate !== null && candidate !== '') { + return String(candidate); + } + return `${node.labels?.[0] ?? 'Node'} ${node.id}`; +} + +/** + * Convert collected Neo4j entities into the app's native payload. + * Properties are grouped by the element's primary label / relationship type; + * a `Neo4j` group carries `Labels` / `Type` so they stay filterable. + * + * @param {object[]} nodes + * @param {object[]} relationships + * @param {{excludedNodeProps?: Set, excludedEdgeProps?: Set}} [options] + * @returns {{nodes: object[], edges: object[], nodeDataHeaders: object[], edgeDataHeaders: object[]}} + */ +function toAppFormat(nodes, relationships, options = {}) { + const excludedNodeProps = options.excludedNodeProps ?? new Set(); + const excludedEdgeProps = options.excludedEdgeProps ?? new Set(); + const nodeHeaders = new Map(); + const edgeHeaders = new Map(); + + const addHeader = (headers, subGroup, key) => { + headers.set(`${subGroup}::${key}`, { subGroup, key }); + }; + + const buildFilters = (properties, subGroup, excluded, headers) => { + const filters = {}; + for (const [rawKey, raw] of Object.entries(properties ?? {})) { + if (excluded.has(rawKey)) continue; + const value = coerceValue(raw); + if (value === undefined) continue; + const key = sanitizeForAST(rawKey); + filters[key] = value; + addHeader(headers, subGroup, key); + } + return filters; + }; + + const appNodes = nodes.map((node) => { + const subGroup = sanitizeForAST(node.labels?.[0] ?? 'Node'); + const filters = { + [subGroup]: buildFilters(node.properties, subGroup, excludedNodeProps, nodeHeaders), + // Multi-label nodes join with ' | ' so each label is its own category. + Neo4j: { Labels: (node.labels ?? []).map(sanitizeForAST).join(' | ') || 'none' }, + }; + addHeader(nodeHeaders, 'Neo4j', 'Labels'); + return { + id: node.id, + label: nodeDisplayLabel(node), + D4Data: { 'Node filters': filters }, + }; + }); + + const appEdges = relationships.map((rel) => { + const subGroup = sanitizeForAST(rel.type || 'Relationship'); + const filters = { + [subGroup]: buildFilters(rel.properties, subGroup, excludedEdgeProps, edgeHeaders), + Neo4j: { Type: sanitizeForAST(rel.type) || 'none' }, + }; + addHeader(edgeHeaders, 'Neo4j', 'Type'); + return { + id: rel.id, + source: rel.startNode, + target: rel.endNode, + label: rel.type, + D4Data: { 'Edge filters': filters }, + }; + }); + + return { + nodes: appNodes, + edges: appEdges, + nodeDataHeaders: [...nodeHeaders.values()], + edgeDataHeaders: [...edgeHeaders.values()], + }; +} + +/** Persisted connection settings — everything except the password. */ +function readSavedSettings(storage = globalThis.localStorage) { + try { + const raw = storage?.getItem(SETTINGS_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : null; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function saveSettings(settings, storage = globalThis.localStorage) { + try { + const { url, username, database, query } = settings; + storage?.setItem(SETTINGS_STORAGE_KEY, JSON.stringify({ url, username, database, query })); + } catch { + // Storage unavailable (private mode, file://) — settings just aren't remembered. + } +} + +/** + * Property-exclusion checklist. Resolves with the excluded key sets, or null + * when the user cancels. Large arrays (embeddings) start deselected. + * + * @param {ReturnType} propertyKeys + * @returns {Promise<{excludedNodeProps: Set, excludedEdgeProps: Set}|null>} + */ +function showPropertyChecklist(propertyKeys) { + if (propertyKeys.length === 0) { + return Promise.resolve({ excludedNodeProps: new Set(), excludedEdgeProps: new Set() }); + } + + return new Promise((resolve) => { + const content = document.createElement('div'); + const intro = document.createElement('p'); + intro.textContent = + 'Select the properties to import. Deselected properties are dropped before the graph is built.'; + content.appendChild(intro); + + const buildSection = (title, entries) => { + if (entries.length === 0) return; + const heading = document.createElement('h4'); + heading.textContent = title; + content.appendChild(heading); + for (const entry of entries) { + const row = document.createElement('label'); + row.style.display = 'block'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = !entry.largeArray; + checkbox.dataset.kind = entry.kind; + checkbox.dataset.key = entry.key; + row.appendChild(checkbox); + row.appendChild( + document.createTextNode( + ` ${entry.key}${entry.largeArray ? ' (large array — e.g. embedding)' : ''}`, + ), + ); + content.appendChild(row); + } + }; + buildSection('Node properties', propertyKeys.filter((p) => p.kind === 'node')); + buildSection('Relationship properties', propertyKeys.filter((p) => p.kind === 'edge')); + + const footer = document.createElement('div'); + footer.className = 'p-footer'; + const cancelBtn = document.createElement('button'); + cancelBtn.textContent = 'Cancel'; + cancelBtn.className = 'p-button p-button-secondary'; + const importBtn = document.createElement('button'); + importBtn.textContent = 'Import'; + importBtn.className = 'p-button p-button-primary'; + footer.appendChild(cancelBtn); + footer.appendChild(importBtn); + content.appendChild(footer); + + let resolved = false; + const popup = new Popup(content, { + title: 'Neo4j Properties', + width: '420px', + showFullscreenButton: false, + closeOnClickOutside: false, + onClose: () => { + if (!resolved) resolve(null); + }, + }); + + importBtn.addEventListener('click', () => { + const excludedNodeProps = new Set(); + const excludedEdgeProps = new Set(); + for (const checkbox of content.querySelectorAll('input[type="checkbox"]')) { + if (checkbox.checked) continue; + (checkbox.dataset.kind === 'node' ? excludedNodeProps : excludedEdgeProps).add( + checkbox.dataset.key, + ); + } + resolved = true; + popup.close(); + resolve({ excludedNodeProps, excludedEdgeProps }); + }); + cancelBtn.addEventListener('click', () => { + resolved = true; + popup.close(); + resolve(null); + }); + }); +} + +/** @returns {HTMLElement} the connection form body (inputs carry ids for lookup) */ +function buildConnectionForm(saved) { + const form = document.createElement('div'); + form.innerHTML = ` +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ Uses the Neo4j HTTP API (port 7474/7473). Credentials are sent only to the + server above and are not stored; the URL, username, database, and query + are remembered locally. Prefer https:// for remote servers — Basic auth + over http:// is unencrypted. Neo4j Aura (Bolt-only) is not supported. +
+ + `; + form.querySelector('#neo4j-url').value = saved.url ?? ''; + form.querySelector('#neo4j-username').value = saved.username ?? ''; + form.querySelector('#neo4j-database').value = saved.database ?? ''; + if (saved.query) form.querySelector('#neo4j-query').value = saved.query; + return form; +} + +/** + * Non-form part of the import: count preflight (warn when huge) → fetch → + * property checklist → render. Collaborators are injectable for tests. + * + * @param {object} cache the app cache + * @param {{url: string, username: string, password: string, database: string, query: string}} config + * @param {{fetchImpl?: Function, confirm?: Function, checklist?: Function, apply?: Function}} [deps] + * @returns {Promise} true when a graph was rendered + */ +async function executeNeo4jImport(cache, config, deps = {}) { + const confirm = deps.confirm ?? Popup.confirm; + const checklist = deps.checklist ?? showPropertyChecklist; + const apply = deps.apply ?? applyGraph; + const opts = deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}; + + try { + await cache.ui.showLoading('Neo4j', `Counting query results on ${config.url} …`); + const rowCount = await countQueryRows(config, config.query, opts); + await cache.ui.hideLoading(); + + if (rowCount !== null && rowCount > LARGE_RESULT_ROW_THRESHOLD) { + const proceed = await confirm( + `The query matches ${rowCount.toLocaleString()} rows, which may be slow to fetch and render. Continue anyway? (Tip: add a LIMIT clause.)`, + ); + if (proceed !== true) return false; + } + + await cache.ui.showLoading('Neo4j', `Fetching graph from ${config.url} …`); + const results = await runCypher( + config, + [{ statement: config.query, resultDataContents: ['graph'] }], + opts, + ); + const { nodes, relationships } = collectGraph(results); + await cache.ui.hideLoading(); + + if (nodes.length === 0) { + cache.ui.error( + 'The query returned no graph elements. Return nodes, relationships, or paths (e.g. MATCH (n)-[r]->(m) RETURN n, r, m).', + ); + return false; + } + + const exclusions = await checklist(collectPropertyKeys(nodes, relationships)); + if (!exclusions) return false; + + const rendered = await apply(cache, toAppFormat(nodes, relationships, exclusions)); + if (rendered) { + cache.ui.setDataSourceLabel(`Neo4j: ${config.database}`); + } + return rendered; + } catch (err) { + await cache.ui.hideLoading(); + const hint = + err.name === 'TimeoutError' + ? 'The request timed out.' + : err.message === 'Failed to fetch' + ? 'Could not reach the server — check the URL, that the HTTP connector is enabled, and CORS settings.' + : err.message; + cache.ui.error(`Neo4j: ${hint}`); + return false; + } +} + +/** + * Full interactive flow: connection form, then executeNeo4jImport. + * + * @param {object} cache the app cache + * @returns {Promise} true when a graph was rendered + */ +function openNeo4jPopup(cache) { + const form = buildConnectionForm(readSavedSettings()); + + return new Promise((resolve) => { + const popup = new Popup(form, { + title: 'Load from Neo4j', + width: '480px', + showFullscreenButton: false, + closeOnClickOutside: false, + onClose: () => resolve(false), + }); + + const readConfig = () => ({ + url: form.querySelector('#neo4j-url').value.trim(), + username: form.querySelector('#neo4j-username').value.trim(), + password: form.querySelector('#neo4j-password').value, + database: form.querySelector('#neo4j-database').value.trim() || DEFAULT_DATABASE, + query: form.querySelector('#neo4j-query').value.trim(), + }); + + const handleLoad = async () => { + const config = readConfig(); + if (!config.url || !config.query) { + cache.ui.error('Server URL and Cypher query are required.'); + return; + } + try { + new URL(config.url); + } catch { + cache.ui.error(`Invalid server URL: ${config.url}`); + return; + } + + popup.close(); + saveSettings(config); + resolve(await executeNeo4jImport(cache, config)); + }; + + form.querySelector('#neo4j-load-btn').addEventListener('click', handleLoad); + form.querySelector('#neo4j-cancel-btn').addEventListener('click', () => { + popup.close(); + resolve(false); + }); + setTimeout(() => form.querySelector('#neo4j-url').focus(), 100); + }); +} + +export { + openNeo4jPopup, + executeNeo4jImport, + buildConnectionForm, + sanitizeForAST, + runCypher, + countQueryRows, + collectGraph, + collectPropertyKeys, + toAppFormat, + coerceValue, + buildTxUrl, + basicAuth, + nodeDisplayLabel, + readSavedSettings, + saveSettings, + showPropertyChecklist, + DEFAULT_DATABASE, + LARGE_RESULT_ROW_THRESHOLD, + LARGE_ARRAY_THRESHOLD, + SETTINGS_STORAGE_KEY, +}; diff --git a/tests/neo4j-loader.test.js b/tests/neo4j-loader.test.js new file mode 100644 index 0000000..e0391b0 --- /dev/null +++ b/tests/neo4j-loader.test.js @@ -0,0 +1,477 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + executeNeo4jImport, + buildConnectionForm, + runCypher, + countQueryRows, + collectGraph, + collectPropertyKeys, + toAppFormat, + coerceValue, + sanitizeForAST, + buildTxUrl, + basicAuth, + nodeDisplayLabel, + readSavedSettings, + saveSettings, + showPropertyChecklist, + DEFAULT_DATABASE, + LARGE_ARRAY_THRESHOLD, + SETTINGS_STORAGE_KEY, + LARGE_RESULT_ROW_THRESHOLD, +} from '../src/utilities/neo4j_loader.js'; + +const CONFIG = { + url: 'http://localhost:7474', + username: 'neo4j', + password: 'secret', + database: 'movies', +}; + +function jsonResponse(body, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +describe('buildTxUrl', () => { + it('builds the tx/commit endpoint', () => { + expect(buildTxUrl('http://localhost:7474', 'movies')).toBe( + 'http://localhost:7474/db/movies/tx/commit', + ); + }); + + it('strips trailing slashes and defaults the database', () => { + expect(buildTxUrl('https://graph.example.com/', '')).toBe( + `https://graph.example.com/db/${DEFAULT_DATABASE}/tx/commit`, + ); + }); + + it('throws on a malformed URL', () => { + expect(() => buildTxUrl('not a url', 'neo4j')).toThrow(); + }); +}); + +describe('basicAuth', () => { + it('encodes user:password as Basic auth', () => { + expect(basicAuth('neo4j', 'secret')).toBe('Basic ' + btoa('neo4j:secret')); + }); + + it('handles non-ASCII credentials', () => { + expect(() => basicAuth('neo4j', 'pässwörd')).not.toThrow(); + }); +}); + +describe('runCypher', () => { + it('POSTs statements with auth and returns results', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ results: [{ data: [] }], errors: [] })); + const results = await runCypher(CONFIG, [{ statement: 'RETURN 1' }], { fetchImpl }); + + expect(results).toEqual([{ data: [] }]); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe('http://localhost:7474/db/movies/tx/commit'); + expect(init.method).toBe('POST'); + expect(init.headers.Authorization).toBe('Basic ' + btoa('neo4j:secret')); + expect(JSON.parse(init.body)).toEqual({ statements: [{ statement: 'RETURN 1' }] }); + }); + + it('throws a friendly error on 401', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({}, 401)); + await expect(runCypher(CONFIG, [], { fetchImpl })).rejects.toThrow(/Authentication failed/); + }); + + it('throws on non-2xx status', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({}, 503)); + await expect(runCypher(CONFIG, [], { fetchImpl })).rejects.toThrow(/HTTP 503/); + }); + + it('surfaces Cypher errors from the payload', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + results: [], + errors: [{ code: 'Neo.ClientError.Statement.SyntaxError', message: 'bad query' }], + }), + ); + await expect(runCypher(CONFIG, [], { fetchImpl })).rejects.toThrow( + /SyntaxError: bad query/, + ); + }); +}); + +describe('countQueryRows', () => { + it('wraps the query in a CALL subquery and reads the count', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(jsonResponse({ results: [{ data: [{ row: [42] }] }], errors: [] })); + const count = await countQueryRows(CONFIG, 'MATCH (n) RETURN n', { fetchImpl }); + + expect(count).toBe(42); + const body = JSON.parse(fetchImpl.mock.calls[0][1].body); + expect(body.statements[0].statement).toBe( + 'CALL { MATCH (n) RETURN n } RETURN count(*) AS rowCount', + ); + }); + + it('returns null when the count query fails', async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error('boom')); + expect(await countQueryRows(CONFIG, 'MATCH (n) RETURN n', { fetchImpl })).toBeNull(); + }); +}); + +describe('collectGraph', () => { + it('deduplicates nodes and relationships across rows', () => { + const node = (id) => ({ id, labels: ['Person'], properties: {} }); + const rel = (id) => ({ id, type: 'KNOWS', startNode: '1', endNode: '2', properties: {} }); + const results = [ + { + data: [ + { graph: { nodes: [node('1'), node('2')], relationships: [rel('r1')] } }, + { graph: { nodes: [node('1'), node('3')], relationships: [rel('r1')] } }, + ], + }, + ]; + + const { nodes, relationships } = collectGraph(results); + expect(nodes.map((n) => n.id).sort()).toEqual(['1', '2', '3']); + expect(relationships).toHaveLength(1); + }); + + it('tolerates rows without graph data', () => { + const { nodes, relationships } = collectGraph([{ data: [{ row: [1] }] }, {}]); + expect(nodes).toEqual([]); + expect(relationships).toEqual([]); + }); +}); + +describe('coerceValue', () => { + it('passes primitives through (sanitizing strings)', () => { + expect(coerceValue(3.5)).toBe(3.5); + expect(coerceValue(true)).toBe(true); + expect(coerceValue('plain')).toBe('plain'); + expect(coerceValue('a:b')).toBe('a-b'); + }); + + it('drops null and undefined', () => { + expect(coerceValue(null)).toBeUndefined(); + expect(coerceValue(undefined)).toBeUndefined(); + }); + + it('joins primitive arrays with pipes for multi-value categoricals', () => { + expect(coerceValue(['a', 'b'])).toBe('a | b'); + expect(coerceValue([1, 2])).toBe('1 | 2'); + }); + + it('stringifies nested structures', () => { + expect(coerceValue({ lat: 1 })).toBe(sanitizeForAST(JSON.stringify({ lat: 1 }))); + expect(coerceValue([{ a: 1 }])).toBe(sanitizeForAST(JSON.stringify([{ a: 1 }]))); + }); +}); + +describe('collectPropertyKeys', () => { + it('unions keys per kind and flags large arrays', () => { + const nodes = [ + { properties: { name: 'A', embedding: Array(LARGE_ARRAY_THRESHOLD + 1).fill(0) } }, + { properties: { age: 3 } }, + ]; + const rels = [{ properties: { weight: 1 } }]; + + const keys = collectPropertyKeys(nodes, rels); + expect(keys).toEqual([ + { kind: 'node', key: 'age', largeArray: false, sample: 3 }, + { kind: 'node', key: 'embedding', largeArray: true, sample: expect.any(Array) }, + { kind: 'node', key: 'name', largeArray: false, sample: 'A' }, + { kind: 'edge', key: 'weight', largeArray: false, sample: 1 }, + ]); + }); +}); + +describe('nodeDisplayLabel', () => { + it('prefers name, then title, then label, then id property', () => { + expect(nodeDisplayLabel({ id: '1', properties: { name: 'Ada', title: 'x' } })).toBe('Ada'); + expect(nodeDisplayLabel({ id: '1', properties: { title: 'Matrix' } })).toBe('Matrix'); + expect(nodeDisplayLabel({ id: '1', properties: { id: 'P42' } })).toBe('P42'); + }); + + it('falls back to label + internal id', () => { + expect(nodeDisplayLabel({ id: '7', labels: ['Person'], properties: {} })).toBe('Person 7'); + expect(nodeDisplayLabel({ id: '7', labels: [], properties: {} })).toBe('Node 7'); + }); +}); + +describe('toAppFormat', () => { + const nodes = [ + { + id: '1', + labels: ['Person', 'Actor'], + properties: { name: 'Keanu', born: 1964, ignored: 'x' }, + }, + { id: '2', labels: ['Movie'], properties: { title: 'The Matrix' } }, + ]; + const relationships = [ + { id: 'r1', type: 'ACTED_IN', startNode: '1', endNode: '2', properties: { roles: ['Neo'] } }, + ]; + + it('maps nodes and relationships into the native payload', () => { + const data = toAppFormat(nodes, relationships); + + expect(data.nodes[0]).toEqual({ + id: '1', + label: 'Keanu', + D4Data: { + 'Node filters': { + Person: { name: 'Keanu', born: 1964, ignored: 'x' }, + Neo4j: { Labels: 'Person | Actor' }, + }, + }, + }); + expect(data.edges[0]).toEqual({ + id: 'r1', + source: '1', + target: '2', + label: 'ACTED_IN', + D4Data: { + 'Edge filters': { + ACTED_IN: { roles: 'Neo' }, + Neo4j: { Type: 'ACTED_IN' }, + }, + }, + }); + }); + + it('builds deduplicated headers', () => { + const data = toAppFormat(nodes, relationships); + expect(data.nodeDataHeaders).toContainEqual({ subGroup: 'Person', key: 'name' }); + expect(data.nodeDataHeaders).toContainEqual({ subGroup: 'Movie', key: 'title' }); + expect(data.nodeDataHeaders).toContainEqual({ subGroup: 'Neo4j', key: 'Labels' }); + expect(data.edgeDataHeaders).toContainEqual({ subGroup: 'Neo4j', key: 'Type' }); + const labelHeaders = data.nodeDataHeaders.filter((h) => h.key === 'Labels'); + expect(labelHeaders).toHaveLength(1); + }); + + it('honors property exclusions per kind', () => { + const data = toAppFormat(nodes, relationships, { + excludedNodeProps: new Set(['ignored']), + excludedEdgeProps: new Set(['roles']), + }); + + expect(data.nodes[0].D4Data['Node filters'].Person).toEqual({ name: 'Keanu', born: 1964 }); + expect(data.edges[0].D4Data['Edge filters'].ACTED_IN).toEqual({}); + expect(data.nodeDataHeaders).not.toContainEqual({ subGroup: 'Person', key: 'ignored' }); + }); + + it('skips null-valued properties so IS MISSING works', () => { + const data = toAppFormat([{ id: '1', labels: ['A'], properties: { p: null } }], []); + expect(data.nodes[0].D4Data['Node filters'].A).toEqual({}); + }); +}); + +describe('settings persistence', () => { + beforeEach(() => localStorage.clear()); + + it('round-trips everything except the password', () => { + saveSettings({ ...CONFIG, password: 'secret', query: 'MATCH (n) RETURN n' }); + const saved = readSavedSettings(); + expect(saved).toEqual({ + url: CONFIG.url, + username: CONFIG.username, + database: CONFIG.database, + query: 'MATCH (n) RETURN n', + }); + expect(localStorage.getItem(SETTINGS_STORAGE_KEY)).not.toContain('secret'); + }); + + it('returns an empty object on corrupt storage', () => { + localStorage.setItem(SETTINGS_STORAGE_KEY, '{nope'); + expect(readSavedSettings()).toEqual({}); + }); +}); + +describe('executeNeo4jImport', () => { + const graphResults = { + results: [ + { + data: [ + { + graph: { + nodes: [{ id: '1', labels: ['Person'], properties: { name: 'Ada' } }], + relationships: [], + }, + }, + ], + }, + ], + errors: [], + }; + + const makeUiCache = () => ({ + ui: { + showLoading: vi.fn().mockResolvedValue(undefined), + hideLoading: vi.fn().mockResolvedValue(undefined), + error: vi.fn(), + setDataSourceLabel: vi.fn(), + }, + }); + + const importConfig = { ...CONFIG, query: 'MATCH (n) RETURN n' }; + + it('counts, fetches, applies exclusions, renders, and sets the source label', async () => { + const cache = makeUiCache(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [{ row: [1] }] }], errors: [] })) + .mockResolvedValueOnce(jsonResponse(graphResults)); + const checklist = vi + .fn() + .mockResolvedValue({ excludedNodeProps: new Set(), excludedEdgeProps: new Set() }); + const apply = vi.fn().mockResolvedValue(true); + + const rendered = await executeNeo4jImport(cache, importConfig, { + fetchImpl, + checklist, + apply, + }); + + expect(rendered).toBe(true); + expect(apply).toHaveBeenCalledWith( + cache, + expect.objectContaining({ nodes: [expect.objectContaining({ id: '1', label: 'Ada' })] }), + ); + expect(cache.ui.setDataSourceLabel).toHaveBeenCalledWith('Neo4j: movies'); + expect(JSON.parse(fetchImpl.mock.calls[1][1].body).statements[0].resultDataContents).toEqual([ + 'graph', + ]); + }); + + it('asks for confirmation above the row threshold and aborts on decline', async () => { + const cache = makeUiCache(); + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + results: [{ data: [{ row: [LARGE_RESULT_ROW_THRESHOLD + 1] }] }], + errors: [], + }), + ); + const confirm = vi.fn().mockResolvedValue(false); + + const rendered = await executeNeo4jImport(cache, importConfig, { fetchImpl, confirm }); + + expect(confirm).toHaveBeenCalledOnce(); + expect(rendered).toBe(false); + expect(fetchImpl).toHaveBeenCalledTimes(1); // count only, no data fetch + }); + + it('errors when the query returns no graph elements', async () => { + const cache = makeUiCache(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [{ row: [0] }] }], errors: [] })) + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [] }], errors: [] })); + + const rendered = await executeNeo4jImport(cache, importConfig, { fetchImpl }); + + expect(rendered).toBe(false); + expect(cache.ui.error).toHaveBeenCalledWith(expect.stringContaining('no graph elements')); + }); + + it('aborts without rendering when the checklist is cancelled', async () => { + const cache = makeUiCache(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [{ row: [1] }] }], errors: [] })) + .mockResolvedValueOnce(jsonResponse(graphResults)); + const apply = vi.fn(); + + const rendered = await executeNeo4jImport(cache, importConfig, { + fetchImpl, + checklist: vi.fn().mockResolvedValue(null), + apply, + }); + + expect(rendered).toBe(false); + expect(apply).not.toHaveBeenCalled(); + }); + + it('surfaces fetch failures as a friendly connectivity error', async () => { + const cache = makeUiCache(); + const fetchImpl = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + + const rendered = await executeNeo4jImport(cache, importConfig, { fetchImpl }); + + expect(rendered).toBe(false); + expect(cache.ui.error).toHaveBeenCalledWith(expect.stringContaining('Could not reach')); + }); + + it('reports Cypher errors from the data fetch', async () => { + const cache = makeUiCache(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [{ row: [1] }] }], errors: [] })) + .mockResolvedValueOnce( + jsonResponse({ results: [], errors: [{ code: 'Neo.X', message: 'nope' }] }), + ); + + const rendered = await executeNeo4jImport(cache, importConfig, { fetchImpl }); + + expect(rendered).toBe(false); + expect(cache.ui.error).toHaveBeenCalledWith(expect.stringContaining('Neo.X: nope')); + }); +}); + +describe('buildConnectionForm', () => { + it('prefills saved settings without interpolating them into markup', () => { + const form = buildConnectionForm({ + url: 'http://db:7474', + username: '', + database: 'movies', + query: 'MATCH (n) RETURN n LIMIT 5', + }); + + expect(form.querySelector('#neo4j-url').value).toBe('http://db:7474'); + expect(form.querySelector('#neo4j-username').value).toBe(''); + expect(form.querySelector('#neo4j-database').value).toBe('movies'); + expect(form.querySelector('#neo4j-query').value).toBe('MATCH (n) RETURN n LIMIT 5'); + expect(form.querySelector('img')).toBeNull(); + expect(form.querySelector('#neo4j-password').value).toBe(''); + }); +}); + +describe('showPropertyChecklist', () => { + it('resolves immediately with no exclusions when there are no properties', async () => { + expect(await showPropertyChecklist([])).toEqual({ + excludedNodeProps: new Set(), + excludedEdgeProps: new Set(), + }); + }); + + it('excludes deselected properties (large arrays start deselected)', async () => { + const promise = showPropertyChecklist([ + { kind: 'node', key: 'name', largeArray: false, sample: 'A' }, + { kind: 'node', key: 'embedding', largeArray: true, sample: [] }, + { kind: 'edge', key: 'weight', largeArray: false, sample: 1 }, + ]); + + const checkboxes = [...document.querySelectorAll('.p-custom input[type="checkbox"]')]; + expect(checkboxes).toHaveLength(3); + expect(checkboxes.find((c) => c.dataset.key === 'embedding').checked).toBe(false); + + checkboxes.find((c) => c.dataset.key === 'weight').checked = false; + const buttons = [...document.querySelectorAll('.p-custom button')]; + buttons.find((b) => b.textContent === 'Import').click(); + + expect(await promise).toEqual({ + excludedNodeProps: new Set(['embedding']), + excludedEdgeProps: new Set(['weight']), + }); + }); + + it('resolves null on cancel', async () => { + const promise = showPropertyChecklist([ + { kind: 'node', key: 'name', largeArray: false, sample: 'A' }, + ]); + const buttons = [...document.querySelectorAll('.p-custom button')]; + buttons.find((b) => b.textContent === 'Cancel').click(); + expect(await promise).toBeNull(); + }); +}); From 86463fde74eab3cbea2bb5162201d522996cbc3e Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 15 Jul 2026 15:18:41 +0200 Subject: [PATCH 003/181] chore(scripts): fix ruff findings in package_latex_submission Remove an unused venue_dir assignment and four placeholder-less f-strings flagged by ruff (F841, F541). --- scripts/package_latex_submission.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/package_latex_submission.py b/scripts/package_latex_submission.py index c3ee20a..f8d9bdc 100755 --- a/scripts/package_latex_submission.py +++ b/scripts/package_latex_submission.py @@ -135,13 +135,12 @@ def clean_build_artifacts(work_dir: Path) -> None: def main() -> None: venue = pick_venue() - venue_dir = MANUSCRIPT / venue print(f"Packaging '{venue}' submission …\n") files = collect_files(venue) missing = [src for src, _ in files if not src.exists()] if missing: - die(f"Missing files:\n " + "\n ".join(str(p) for p in missing)) + die("Missing files:\n " + "\n ".join(str(p) for p in missing)) # 1. Create temp directory and copy files tmp = Path(tempfile.mkdtemp(prefix="gll-submission-")) @@ -152,10 +151,10 @@ def main() -> None: # 2. Flatten paths in main.tex flatten_main_tex(tmp / "main.tex") - print(f"\n Flattened paths in main.tex") + print("\n Flattened paths in main.tex") # 3. Compile test PDF - print(f"\n Compiling test PDF …") + print("\n Compiling test PDF …") pdf = compile_pdf(tmp) print(f" OK — {pdf.stat().st_size / 1024:.0f} KB\n") @@ -181,7 +180,7 @@ def main() -> None: shutil.rmtree(tmp) print(f"\n Created {final_zip.relative_to(REPO_ROOT)} ({size_mb:.1f} MB)") - print(f" Temp directory removed.") + print(" Temp directory removed.") if __name__ == "__main__": From 84f2f6abc347d4c2de13fb437610b0119b2dd689 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 15 Jul 2026 15:23:38 +0200 Subject: [PATCH 004/181] fix(io): wire Neo4j popup buttons before Popup relocates the footer Popup.createPopup moves the .p-footer out of the content element into the popup root, so querying the form for the buttons after construction returned null and the Fetch/Cancel listeners were never attached. Capture the button references before constructing the Popup; add regression tests that click the buttons through the live document. --- src/utilities/neo4j_loader.js | 9 +++++++-- tests/neo4j-loader.test.js | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/utilities/neo4j_loader.js b/src/utilities/neo4j_loader.js index 65d6085..493c32c 100644 --- a/src/utilities/neo4j_loader.js +++ b/src/utilities/neo4j_loader.js @@ -466,6 +466,11 @@ async function executeNeo4jImport(cache, config, deps = {}) { */ function openNeo4jPopup(cache) { const form = buildConnectionForm(readSavedSettings()); + // Grab button references before constructing the Popup — it relocates the + // .p-footer out of the content element, so querying the form afterwards + // would come up empty. The references stay valid across the move. + const loadBtn = form.querySelector('#neo4j-load-btn'); + const cancelBtn = form.querySelector('#neo4j-cancel-btn'); return new Promise((resolve) => { const popup = new Popup(form, { @@ -502,8 +507,8 @@ function openNeo4jPopup(cache) { resolve(await executeNeo4jImport(cache, config)); }; - form.querySelector('#neo4j-load-btn').addEventListener('click', handleLoad); - form.querySelector('#neo4j-cancel-btn').addEventListener('click', () => { + loadBtn.addEventListener('click', handleLoad); + cancelBtn.addEventListener('click', () => { popup.close(); resolve(false); }); diff --git a/tests/neo4j-loader.test.js b/tests/neo4j-loader.test.js index e0391b0..3b5fde6 100644 --- a/tests/neo4j-loader.test.js +++ b/tests/neo4j-loader.test.js @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { executeNeo4jImport, buildConnectionForm, + openNeo4jPopup, runCypher, countQueryRows, collectGraph, @@ -437,6 +438,41 @@ describe('buildConnectionForm', () => { }); }); +describe('openNeo4jPopup', () => { + // The Popup relocates the .p-footer out of the content element, so these + // tests must drive the buttons through the live document, exactly like a + // user click — regression coverage for the wiring breaking silently. + beforeEach(() => { + document.body.innerHTML = ''; + localStorage.clear(); + }); + + it('wires the Fetch button after Popup construction (footer is relocated)', async () => { + const cache = { ui: { error: vi.fn() } }; + const promise = openNeo4jPopup(cache); + + const loadBtn = document.getElementById('neo4j-load-btn'); + expect(loadBtn).not.toBeNull(); + loadBtn.click(); // empty form → validation error, popup stays open + expect(cache.ui.error).toHaveBeenCalledWith('Server URL and Cypher query are required.'); + + document.getElementById('neo4j-cancel-btn').click(); + expect(await promise).toBe(false); + }); + + it('rejects an invalid URL without closing the popup', async () => { + const cache = { ui: { error: vi.fn() } }; + const promise = openNeo4jPopup(cache); + + document.getElementById('neo4j-url').value = 'not a url'; + document.getElementById('neo4j-load-btn').click(); + expect(cache.ui.error).toHaveBeenCalledWith('Invalid server URL: not a url'); + + document.getElementById('neo4j-cancel-btn').click(); + expect(await promise).toBe(false); + }); +}); + describe('showPropertyChecklist', () => { it('resolves immediately with no exclusions when there are no properties', async () => { expect(await showPropertyChecklist([])).toEqual({ From 9b57fa5cfd37109f5cac4614b8d46d4c1ce06a6f Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 15 Jul 2026 15:39:35 +0200 Subject: [PATCH 005/181] feat(io): polish Neo4j connector UX, checklist metadata, auto-coloring Connection modal: theme-token styling (dark-mode correct), inline error box (role=alert) that keeps the popup open with inputs intact, in-button spinner while counting/fetching, buttons disabled during work. executeNeo4jImport gains injectable progress/onError/onFetched hooks so the popup drives its own feedback while the default flow keeps the global overlay. Property checklist: value type badge (number/text/boolean/list/object/mixed, 'large list' for embedding-sized arrays), up to two truncated example values per property, per-section select-all with indeterminate state, scrollable themed list. Mapping: the last label is now the primary one (class-hierarchy tooling stores ancestors first, leaf last), the synthetic 'Neo4j > Labels'/'Type' filter group is dropped as redundant with per-label property groups, and nodes/edges are auto-colored per label/relationship type (brand SLICE_PALETTE first, golden-angle hues beyond, only when >=2 categories). --- src/style.css | 142 ++++++++++++++++ src/utilities/neo4j_loader.js | 300 +++++++++++++++++++++++++++------- tests/neo4j-loader.test.js | 245 +++++++++++++++++++++++---- 3 files changed, 597 insertions(+), 90 deletions(-) diff --git a/src/style.css b/src/style.css index a4c92ff..86283c3 100644 --- a/src/style.css +++ b/src/style.css @@ -5825,3 +5825,145 @@ input:checked + .slider:before { border-color: #e3b5b5; background-color: #fdf7f7; } + +/************************* + Neo4j Connector + **************************/ +.neo4j-field { + margin-bottom: 12px; +} + +.neo4j-field label { + display: block; + margin-bottom: 4px; + font-size: 13px; + color: var(--text-strong); +} + +.neo4j-field .p-prompt { + margin-top: 0; + background-color: var(--input-bg); + color: var(--text); +} + +.neo4j-field-row { + display: flex; + gap: 10px; +} + +.neo4j-field-row .neo4j-field { + flex: 1; +} + +.neo4j-query { + font-family: monospace; + resize: vertical; +} + +.neo4j-hint { + font-size: 11.5px; + color: var(--text-faint); +} + +.neo4j-info { + padding: 10px 12px; + background-color: var(--surface-2); + border: 1px solid var(--border-soft); + border-radius: 4px; + font-size: 12px; + color: var(--text-muted); + margin-bottom: 12px; +} + +.neo4j-error { + padding: 10px 12px; + border: 1px solid var(--danger-text); + border-radius: 4px; + color: var(--danger-text); + font-size: 12.5px; + margin-bottom: 12px; + white-space: pre-wrap; + overflow-wrap: break-word; +} + +.neo4j-btn-spinner { + display: inline-block; + width: 11px; + height: 11px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + margin-right: 6px; + vertical-align: -1px; + animation: neo4j-spin 0.8s linear infinite; +} + +@keyframes neo4j-spin { + to { + transform: rotate(360deg); + } +} + +.neo4j-props-heading { + margin: 12px 0 6px; + font-weight: 600; + font-size: 13px; + color: var(--brand-text); +} + +.neo4j-props-heading label { + cursor: pointer; +} + +.neo4j-props-list { + max-height: 280px; + overflow-y: auto; + border: 1px solid var(--border-soft); + border-radius: 4px; + background-color: var(--input-bg); +} + +.neo4j-prop-row { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 2px 8px; + align-items: center; + padding: 6px 10px; + border-bottom: 1px solid var(--border-soft); + cursor: pointer; +} + +.neo4j-prop-row:last-child { + border-bottom: none; +} + +.neo4j-prop-row:hover { + background-color: var(--row-hover); +} + +.neo4j-prop-name { + font-size: 13px; + font-weight: 600; + color: var(--text-strong); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.neo4j-prop-type { + font-size: 10.5px; + padding: 1px 8px; + border-radius: 10px; + background-color: var(--surface-3); + color: var(--text-muted); + white-space: nowrap; +} + +.neo4j-prop-examples { + grid-column: 2 / -1; + font-size: 11.5px; + color: var(--text-faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/utilities/neo4j_loader.js b/src/utilities/neo4j_loader.js index 493c32c..35740f6 100644 --- a/src/utilities/neo4j_loader.js +++ b/src/utilities/neo4j_loader.js @@ -18,8 +18,13 @@ import { Popup } from './popup.js'; import { StaticUtilities } from './static.js'; import { applyGraph } from '../managers/api_client.js'; +import { DEFAULTS } from '../config.js'; const sanitizeForAST = StaticUtilities.sanitizeForAST; +// Edge strokes get this alpha suffix so auto-colored edges stay subordinate +// to nodes, matching the translucency of the default edge color. +const EDGE_COLOR_ALPHA = '90'; +const EXAMPLE_MAX_LENGTH = 40; const DEFAULT_DATABASE = 'neo4j'; const LARGE_RESULT_ROW_THRESHOLD = 2000; @@ -135,31 +140,68 @@ function coerceValue(value) { return sanitizeForAST(JSON.stringify(value)); } +/** @returns {string|null} a display type for a property value, null for empty values */ +function describeType(value) { + if (value === null || value === undefined) return null; + if (Array.isArray(value)) return 'list'; + const t = typeof value; + if (t === 'number' || t === 'boolean') return t; + if (t === 'object') return 'object'; + return 'text'; +} + /** - * Union of property keys per element kind, with hints for the exclusion - * checklist (sample value, large-array detection). + * Union of property keys per element kind, with display metadata for the + * exclusion checklist: value type ('mixed' when inconsistent), up to two + * distinct truncated example values, and large-array detection. * - * @returns {Array<{kind: 'node'|'edge', key: string, largeArray: boolean, sample: *}>} + * @returns {Array<{kind: 'node'|'edge', key: string, type: string, examples: string[], largeArray: boolean}>} */ function collectPropertyKeys(nodes, relationships) { const collect = (elements, kind) => { const byKey = new Map(); for (const element of elements) { for (const [key, value] of Object.entries(element.properties ?? {})) { - const existing = byKey.get(key); - const largeArray = Array.isArray(value) && value.length > LARGE_ARRAY_THRESHOLD; - if (!existing) { - byKey.set(key, { kind, key, largeArray, sample: value }); - } else if (largeArray) { - existing.largeArray = true; + const type = describeType(value); + if (type === null) continue; // null-valued: coerceValue drops these anyway + let entry = byKey.get(key); + if (!entry) { + entry = { kind, key, types: new Set(), examples: [], largeArray: false }; + byKey.set(key, entry); + } + entry.types.add(type); + if (Array.isArray(value) && value.length > LARGE_ARRAY_THRESHOLD) { + entry.largeArray = true; + } + if (entry.examples.length < 2) { + const text = String(coerceValue(value)); + const short = + text.length > EXAMPLE_MAX_LENGTH ? `${text.slice(0, EXAMPLE_MAX_LENGTH - 1)}…` : text; + if (short !== '' && !entry.examples.includes(short)) entry.examples.push(short); } } } - return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key)); + return [...byKey.values()] + .map(({ types, ...rest }) => ({ + ...rest, + type: types.size === 1 ? [...types][0] : 'mixed', + })) + .sort((a, b) => a.key.localeCompare(b.key)); }; return [...collect(nodes, 'node'), ...collect(relationships, 'edge')]; } +/** + * Most specific label of a node. Class-hierarchy tooling (neomodel-style) + * stores the full ancestor chain as labels with the leaf class last + * (e.g. Entity:AddOnCat:Cell → Cell), so the last entry is the primary one. + * The complete label set stays filterable via the `Neo4j > Labels` category. + */ +function primaryLabel(node) { + const labels = node.labels ?? []; + return labels.length ? labels[labels.length - 1] : 'Node'; +} + /** Pick a display label for a node from conventional name properties. */ function nodeDisplayLabel(node) { const props = node.properties ?? {}; @@ -167,7 +209,45 @@ function nodeDisplayLabel(node) { if (candidate !== undefined && candidate !== null && candidate !== '') { return String(candidate); } - return `${node.labels?.[0] ?? 'Node'} ${node.id}`; + return `${primaryLabel(node)} ${node.id}`; +} + +/** hsl(h°, s%, l%) → #RRGGBB */ +function hslToHex(h, s, l) { + s /= 100; + l /= 100; + const k = (n) => (n + h / 30) % 12; + const a = s * Math.min(l, 1 - l); + const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)); + const toHex = (x) => + Math.round(255 * x) + .toString(16) + .padStart(2, '0'); + return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`.toUpperCase(); +} + +/** + * Deterministic categorical color: the app's brand palette first, then a + * golden-angle hue walk so any number of categories stays distinguishable. + */ +function categoryColor(index) { + const palette = DEFAULTS.NODE.PIE.SLICE_PALETTE; + if (index < palette.length) return palette[index]; + return hslToHex((index * 137.508) % 360, 55, 55); +} + +/** + * Map each category to a color, or null when there are fewer than two + * categories (a single category carries no visual information — keep the + * app defaults). Categories are sorted so colors are deterministic. + * + * @param {string[]} categories + * @returns {Map|null} + */ +function buildCategoryColors(categories) { + const unique = [...new Set(categories)].sort(); + if (unique.length < 2) return null; + return new Map(unique.map((category, index) => [category, categoryColor(index)])); } /** @@ -203,17 +283,22 @@ function toAppFormat(nodes, relationships, options = {}) { return filters; }; + const nodeColors = buildCategoryColors(nodes.map((n) => sanitizeForAST(primaryLabel(n)))); + const edgeColors = buildCategoryColors( + relationships.map((r) => sanitizeForAST(r.type || 'Relationship')), + ); + const appNodes = nodes.map((node) => { - const subGroup = sanitizeForAST(node.labels?.[0] ?? 'Node'); + const subGroup = sanitizeForAST(primaryLabel(node)); + // No synthetic type property — the per-label property groups and the + // auto-coloring already carry the entity type. const filters = { [subGroup]: buildFilters(node.properties, subGroup, excludedNodeProps, nodeHeaders), - // Multi-label nodes join with ' | ' so each label is its own category. - Neo4j: { Labels: (node.labels ?? []).map(sanitizeForAST).join(' | ') || 'none' }, }; - addHeader(nodeHeaders, 'Neo4j', 'Labels'); return { id: node.id, label: nodeDisplayLabel(node), + ...(nodeColors ? { style: { fill: nodeColors.get(subGroup) } } : {}), D4Data: { 'Node filters': filters }, }; }); @@ -222,14 +307,15 @@ function toAppFormat(nodes, relationships, options = {}) { const subGroup = sanitizeForAST(rel.type || 'Relationship'); const filters = { [subGroup]: buildFilters(rel.properties, subGroup, excludedEdgeProps, edgeHeaders), - Neo4j: { Type: sanitizeForAST(rel.type) || 'none' }, }; - addHeader(edgeHeaders, 'Neo4j', 'Type'); return { id: rel.id, source: rel.startNode, target: rel.endNode, label: rel.type, + ...(edgeColors + ? { style: { stroke: `${edgeColors.get(subGroup)}${EDGE_COLOR_ALPHA}` } } + : {}), D4Data: { 'Edge filters': filters }, }; }); @@ -277,31 +363,70 @@ function showPropertyChecklist(propertyKeys) { return new Promise((resolve) => { const content = document.createElement('div'); const intro = document.createElement('p'); + intro.className = 'neo4j-hint'; intro.textContent = 'Select the properties to import. Deselected properties are dropped before the graph is built.'; content.appendChild(intro); const buildSection = (title, entries) => { if (entries.length === 0) return; - const heading = document.createElement('h4'); - heading.textContent = title; + + const heading = document.createElement('div'); + heading.className = 'neo4j-props-heading'; + const headingLabel = document.createElement('label'); + const toggleAll = document.createElement('input'); + toggleAll.type = 'checkbox'; + headingLabel.appendChild(toggleAll); + headingLabel.appendChild(document.createTextNode(` ${title}`)); + heading.appendChild(headingLabel); content.appendChild(heading); + + const list = document.createElement('div'); + list.className = 'neo4j-props-list'; + const rowBoxes = []; for (const entry of entries) { const row = document.createElement('label'); - row.style.display = 'block'; + row.className = 'neo4j-prop-row'; + const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = !entry.largeArray; checkbox.dataset.kind = entry.kind; checkbox.dataset.key = entry.key; + rowBoxes.push(checkbox); row.appendChild(checkbox); - row.appendChild( - document.createTextNode( - ` ${entry.key}${entry.largeArray ? ' (large array — e.g. embedding)' : ''}`, - ), - ); - content.appendChild(row); + + const name = document.createElement('span'); + name.className = 'neo4j-prop-name'; + name.textContent = entry.key; + row.appendChild(name); + + const type = document.createElement('span'); + type.className = 'neo4j-prop-type'; + type.textContent = entry.largeArray ? 'large list' : entry.type; + if (entry.largeArray) type.title = 'Long array (e.g. an embedding) — deselected by default'; + row.appendChild(type); + + if (entry.examples.length) { + const examples = document.createElement('span'); + examples.className = 'neo4j-prop-examples'; + examples.textContent = `e.g. ${entry.examples.join(' · ')}`; + row.appendChild(examples); + } + list.appendChild(row); } + content.appendChild(list); + + const syncToggleAll = () => { + const checked = rowBoxes.filter((box) => box.checked).length; + toggleAll.checked = checked === rowBoxes.length; + toggleAll.indeterminate = checked > 0 && checked < rowBoxes.length; + }; + syncToggleAll(); + toggleAll.addEventListener('change', () => { + rowBoxes.forEach((box) => (box.checked = toggleAll.checked)); + }); + list.addEventListener('change', syncToggleAll); }; buildSection('Node properties', propertyKeys.filter((p) => p.kind === 'node')); buildSection('Relationship properties', propertyKeys.filter((p) => p.kind === 'edge')); @@ -321,7 +446,7 @@ function showPropertyChecklist(propertyKeys) { let resolved = false; const popup = new Popup(content, { title: 'Neo4j Properties', - width: '420px', + width: '480px', showFullscreenButton: false, closeOnClickOutside: false, onClose: () => { @@ -332,7 +457,7 @@ function showPropertyChecklist(propertyKeys) { importBtn.addEventListener('click', () => { const excludedNodeProps = new Set(); const excludedEdgeProps = new Set(); - for (const checkbox of content.querySelectorAll('input[type="checkbox"]')) { + for (const checkbox of content.querySelectorAll('input[data-key]')) { if (checkbox.checked) continue; (checkbox.dataset.kind === 'node' ? excludedNodeProps : excludedEdgeProps).add( checkbox.dataset.key, @@ -354,29 +479,31 @@ function showPropertyChecklist(propertyKeys) { function buildConnectionForm(saved) { const form = document.createElement('div'); form.innerHTML = ` -
- - +
+ +
-
-
- - +
+
+ +
-
- - +
+ +
-
- - +
+ +
-
- - +
+ + + Must return nodes, relationships, or paths.
-
+ +
Uses the Neo4j HTTP API (port 7474/7473). Credentials are sent only to the server above and are not stored; the URL, username, database, and query are remembered locally. Prefer https:// for remote servers — Basic auth @@ -407,12 +534,22 @@ async function executeNeo4jImport(cache, config, deps = {}) { const confirm = deps.confirm ?? Popup.confirm; const checklist = deps.checklist ?? showPropertyChecklist; const apply = deps.apply ?? applyGraph; + const onError = deps.onError ?? ((message) => cache.ui.error(message)); + // progress(message) starts/updates a busy indicator, progress(null) clears + // it. Defaults to the global loading overlay; the connection popup injects + // an in-button spinner instead so the modal stays interactive underneath. + const progress = + deps.progress ?? + (async (message) => { + if (message) await cache.ui.showLoading('Neo4j', message); + else await cache.ui.hideLoading(); + }); const opts = deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}; try { - await cache.ui.showLoading('Neo4j', `Counting query results on ${config.url} …`); + await progress('Counting matching rows …'); const rowCount = await countQueryRows(config, config.query, opts); - await cache.ui.hideLoading(); + await progress(null); if (rowCount !== null && rowCount > LARGE_RESULT_ROW_THRESHOLD) { const proceed = await confirm( @@ -421,22 +558,23 @@ async function executeNeo4jImport(cache, config, deps = {}) { if (proceed !== true) return false; } - await cache.ui.showLoading('Neo4j', `Fetching graph from ${config.url} …`); + await progress('Fetching graph data …'); const results = await runCypher( config, [{ statement: config.query, resultDataContents: ['graph'] }], opts, ); const { nodes, relationships } = collectGraph(results); - await cache.ui.hideLoading(); + await progress(null); if (nodes.length === 0) { - cache.ui.error( + onError( 'The query returned no graph elements. Return nodes, relationships, or paths (e.g. MATCH (n)-[r]->(m) RETURN n, r, m).', ); return false; } + deps.onFetched?.(); const exclusions = await checklist(collectPropertyKeys(nodes, relationships)); if (!exclusions) return false; @@ -446,14 +584,14 @@ async function executeNeo4jImport(cache, config, deps = {}) { } return rendered; } catch (err) { - await cache.ui.hideLoading(); + await progress(null); const hint = err.name === 'TimeoutError' ? 'The request timed out.' : err.message === 'Failed to fetch' ? 'Could not reach the server — check the URL, that the HTTP connector is enabled, and CORS settings.' : err.message; - cache.ui.error(`Neo4j: ${hint}`); + onError(`Neo4j: ${hint}`); return false; } } @@ -466,21 +604,47 @@ async function executeNeo4jImport(cache, config, deps = {}) { */ function openNeo4jPopup(cache) { const form = buildConnectionForm(readSavedSettings()); - // Grab button references before constructing the Popup — it relocates the + // Grab element references before constructing the Popup — it relocates the // .p-footer out of the content element, so querying the form afterwards // would come up empty. The references stay valid across the move. const loadBtn = form.querySelector('#neo4j-load-btn'); const cancelBtn = form.querySelector('#neo4j-cancel-btn'); + const errorBox = form.querySelector('#neo4j-error'); return new Promise((resolve) => { + // Popup.close() always fires onClose, so guard against double-settling + // and against the deliberate close after a successful fetch. + let settled = false; + let dataFetched = false; + const settle = (value) => { + if (!settled) { + settled = true; + resolve(value); + } + }; + const popup = new Popup(form, { title: 'Load from Neo4j', width: '480px', showFullscreenButton: false, closeOnClickOutside: false, - onClose: () => resolve(false), + onClose: () => { + if (!dataFetched) settle(false); + }, }); + const setBusy = (message) => { + loadBtn.disabled = !!message; + cancelBtn.disabled = !!message; + loadBtn.innerHTML = message + ? `${message}` + : 'Fetch'; + }; + const showError = (message) => { + errorBox.textContent = message; + errorBox.hidden = false; + }; + const readConfig = () => ({ url: form.querySelector('#neo4j-url').value.trim(), username: form.querySelector('#neo4j-username').value.trim(), @@ -490,27 +654,40 @@ function openNeo4jPopup(cache) { }); const handleLoad = async () => { + errorBox.hidden = true; const config = readConfig(); if (!config.url || !config.query) { - cache.ui.error('Server URL and Cypher query are required.'); + showError('Server URL and Cypher query are required.'); return; } try { new URL(config.url); } catch { - cache.ui.error(`Invalid server URL: ${config.url}`); + showError(`Invalid server URL: ${config.url}`); return; } - popup.close(); saveSettings(config); - resolve(await executeNeo4jImport(cache, config)); + const rendered = await executeNeo4jImport(cache, config, { + progress: (message) => setBusy(message), + onError: showError, + // Close the connection popup once data has arrived — the property + // checklist takes over from here. Failures before this point keep + // the popup open with an inline error so inputs are preserved. + onFetched: () => { + dataFetched = true; + popup.close(); + }, + }); + + if (dataFetched) settle(rendered); + else setBusy(null); }; loadBtn.addEventListener('click', handleLoad); cancelBtn.addEventListener('click', () => { popup.close(); - resolve(false); + settle(false); }); setTimeout(() => form.querySelector('#neo4j-url').focus(), 100); }); @@ -527,9 +704,14 @@ export { collectPropertyKeys, toAppFormat, coerceValue, + describeType, buildTxUrl, basicAuth, nodeDisplayLabel, + primaryLabel, + categoryColor, + buildCategoryColors, + hslToHex, readSavedSettings, saveSettings, showPropertyChecklist, diff --git a/tests/neo4j-loader.test.js b/tests/neo4j-loader.test.js index 3b5fde6..852d202 100644 --- a/tests/neo4j-loader.test.js +++ b/tests/neo4j-loader.test.js @@ -10,6 +10,9 @@ import { collectPropertyKeys, toAppFormat, coerceValue, + categoryColor, + buildCategoryColors, + primaryLabel, sanitizeForAST, buildTxUrl, basicAuth, @@ -172,21 +175,74 @@ describe('coerceValue', () => { }); describe('collectPropertyKeys', () => { - it('unions keys per kind and flags large arrays', () => { + it('unions keys per kind with types, examples, and large-array flags', () => { const nodes = [ { properties: { name: 'A', embedding: Array(LARGE_ARRAY_THRESHOLD + 1).fill(0) } }, - { properties: { age: 3 } }, + { properties: { name: 'B', age: 3 } }, ]; const rels = [{ properties: { weight: 1 } }]; const keys = collectPropertyKeys(nodes, rels); expect(keys).toEqual([ - { kind: 'node', key: 'age', largeArray: false, sample: 3 }, - { kind: 'node', key: 'embedding', largeArray: true, sample: expect.any(Array) }, - { kind: 'node', key: 'name', largeArray: false, sample: 'A' }, - { kind: 'edge', key: 'weight', largeArray: false, sample: 1 }, + { kind: 'node', key: 'age', type: 'number', examples: ['3'], largeArray: false }, + { + kind: 'node', + key: 'embedding', + type: 'list', + examples: [expect.any(String)], + largeArray: true, + }, + { kind: 'node', key: 'name', type: 'text', examples: ['A', 'B'], largeArray: false }, + { kind: 'edge', key: 'weight', type: 'number', examples: ['1'], largeArray: false }, ]); }); + + it('marks inconsistent value types as mixed and truncates long examples', () => { + const nodes = [ + { properties: { p: 'text value' } }, + { properties: { p: 42 } }, + { properties: { q: 'x'.repeat(100) } }, + ]; + const keys = collectPropertyKeys(nodes, []); + const p = keys.find((k) => k.key === 'p'); + const q = keys.find((k) => k.key === 'q'); + expect(p.type).toBe('mixed'); + expect(q.examples[0].length).toBeLessThanOrEqual(40); + expect(q.examples[0].endsWith('…')).toBe(true); + }); + + it('skips null-valued occurrences', () => { + const keys = collectPropertyKeys([{ properties: { p: null } }], []); + expect(keys).toEqual([]); + }); +}); + +describe('category colors', () => { + it('uses the brand palette first, then generated hues, deterministically', () => { + expect(categoryColor(0)).toBe('#C33D35'); // SLICE_PALETTE[0] + expect(categoryColor(7)).toMatch(/^#[0-9A-F]{6}$/); + expect(categoryColor(7)).toBe(categoryColor(7)); + expect(categoryColor(7)).not.toBe(categoryColor(8)); + }); + + it('returns null for fewer than two categories', () => { + expect(buildCategoryColors(['A', 'A'])).toBeNull(); + expect(buildCategoryColors([])).toBeNull(); + }); + + it('assigns colors by sorted category order', () => { + const colors = buildCategoryColors(['B', 'A', 'B']); + expect(colors.get('A')).toBe(categoryColor(0)); + expect(colors.get('B')).toBe(categoryColor(1)); + }); +}); + +describe('primaryLabel', () => { + it('uses the last label — leaf class of a stored hierarchy', () => { + expect(primaryLabel({ labels: ['Entity', 'AddOnCat', 'Cell'] })).toBe('Cell'); + expect(primaryLabel({ labels: ['Person'] })).toBe('Person'); + expect(primaryLabel({ labels: [] })).toBe('Node'); + }); }); describe('nodeDisplayLabel', () => { @@ -215,16 +271,16 @@ describe('toAppFormat', () => { { id: 'r1', type: 'ACTED_IN', startNode: '1', endNode: '2', properties: { roles: ['Neo'] } }, ]; - it('maps nodes and relationships into the native payload', () => { + it('maps nodes and relationships into the native payload (last label = primary)', () => { const data = toAppFormat(nodes, relationships); expect(data.nodes[0]).toEqual({ id: '1', label: 'Keanu', + style: { fill: expect.stringMatching(/^#[0-9A-Fa-f]{6}$/) }, D4Data: { 'Node filters': { - Person: { name: 'Keanu', born: 1964, ignored: 'x' }, - Neo4j: { Labels: 'Person | Actor' }, + Actor: { name: 'Keanu', born: 1964, ignored: 'x' }, }, }, }); @@ -236,20 +292,48 @@ describe('toAppFormat', () => { D4Data: { 'Edge filters': { ACTED_IN: { roles: 'Neo' }, - Neo4j: { Type: 'ACTED_IN' }, }, }, }); }); - it('builds deduplicated headers', () => { + it('auto-colors nodes per primary label when there are at least two', () => { const data = toAppFormat(nodes, relationships); - expect(data.nodeDataHeaders).toContainEqual({ subGroup: 'Person', key: 'name' }); + expect(data.nodes[0].style.fill).not.toBe(data.nodes[1].style.fill); + }); + + it('keeps default styling for a single node label / edge type', () => { + const single = toAppFormat( + [{ id: '1', labels: ['A'], properties: {} }, { id: '2', labels: ['A'], properties: {} }], + [{ id: 'r', type: 'REL', startNode: '1', endNode: '2', properties: {} }], + ); + expect(single.nodes[0].style).toBeUndefined(); + expect(single.edges[0].style).toBeUndefined(); + }); + + it('auto-colors edges per type with translucency', () => { + const data = toAppFormat( + [{ id: '1', labels: ['A'], properties: {} }], + [ + { id: 'r1', type: 'REL_A', startNode: '1', endNode: '1', properties: {} }, + { id: 'r2', type: 'REL_B', startNode: '1', endNode: '1', properties: {} }, + ], + ); + expect(data.edges[0].style.stroke).toMatch(/^#[0-9A-Fa-f]{6}90$/); + expect(data.edges[0].style.stroke).not.toBe(data.edges[1].style.stroke); + }); + + it('builds deduplicated headers without synthetic type properties', () => { + const twoActors = [ + ...nodes, + { id: '3', labels: ['Actor'], properties: { name: 'Carrie' } }, + ]; + const data = toAppFormat(twoActors, relationships); + expect(data.nodeDataHeaders).toContainEqual({ subGroup: 'Actor', key: 'name' }); expect(data.nodeDataHeaders).toContainEqual({ subGroup: 'Movie', key: 'title' }); - expect(data.nodeDataHeaders).toContainEqual({ subGroup: 'Neo4j', key: 'Labels' }); - expect(data.edgeDataHeaders).toContainEqual({ subGroup: 'Neo4j', key: 'Type' }); - const labelHeaders = data.nodeDataHeaders.filter((h) => h.key === 'Labels'); - expect(labelHeaders).toHaveLength(1); + expect(data.nodeDataHeaders.filter((h) => h.key === 'name')).toHaveLength(1); + expect(data.nodeDataHeaders.filter((h) => h.subGroup === 'Neo4j')).toHaveLength(0); + expect(data.edgeDataHeaders.filter((h) => h.subGroup === 'Neo4j')).toHaveLength(0); }); it('honors property exclusions per kind', () => { @@ -258,9 +342,9 @@ describe('toAppFormat', () => { excludedEdgeProps: new Set(['roles']), }); - expect(data.nodes[0].D4Data['Node filters'].Person).toEqual({ name: 'Keanu', born: 1964 }); + expect(data.nodes[0].D4Data['Node filters'].Actor).toEqual({ name: 'Keanu', born: 1964 }); expect(data.edges[0].D4Data['Edge filters'].ACTED_IN).toEqual({}); - expect(data.nodeDataHeaders).not.toContainEqual({ subGroup: 'Person', key: 'ignored' }); + expect(data.nodeDataHeaders).not.toContainEqual({ subGroup: 'Actor', key: 'ignored' }); }); it('skips null-valued properties so IS MISSING works', () => { @@ -404,6 +488,49 @@ describe('executeNeo4jImport', () => { expect(cache.ui.error).toHaveBeenCalledWith(expect.stringContaining('Could not reach')); }); + it('drives injected progress/onFetched hooks in order', async () => { + const cache = makeUiCache(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [{ row: [1] }] }], errors: [] })) + .mockResolvedValueOnce(jsonResponse(graphResults)); + const calls = []; + const rendered = await executeNeo4jImport(cache, importConfig, { + fetchImpl, + progress: (message) => calls.push(['progress', message]), + onFetched: () => calls.push(['fetched']), + checklist: vi.fn().mockImplementation(() => { + calls.push(['checklist']); + return Promise.resolve({ excludedNodeProps: new Set(), excludedEdgeProps: new Set() }); + }), + apply: vi.fn().mockResolvedValue(true), + }); + + expect(rendered).toBe(true); + expect(calls).toEqual([ + ['progress', 'Counting matching rows …'], + ['progress', null], + ['progress', 'Fetching graph data …'], + ['progress', null], + ['fetched'], + ['checklist'], + ]); + // Injected progress used — the global overlay stays untouched. + expect(cache.ui.showLoading).not.toHaveBeenCalled(); + }); + + it('routes errors to the injected onError handler', async () => { + const cache = makeUiCache(); + const onError = vi.fn(); + const rendered = await executeNeo4jImport(cache, importConfig, { + fetchImpl: vi.fn().mockRejectedValue(new TypeError('Failed to fetch')), + onError, + }); + expect(rendered).toBe(false); + expect(onError).toHaveBeenCalledWith(expect.stringContaining('Could not reach')); + expect(cache.ui.error).not.toHaveBeenCalled(); + }); + it('reports Cypher errors from the data fetch', async () => { const cache = makeUiCache(); const fetchImpl = vi @@ -448,32 +575,65 @@ describe('openNeo4jPopup', () => { }); it('wires the Fetch button after Popup construction (footer is relocated)', async () => { - const cache = { ui: { error: vi.fn() } }; - const promise = openNeo4jPopup(cache); + const promise = openNeo4jPopup({ ui: {} }); const loadBtn = document.getElementById('neo4j-load-btn'); expect(loadBtn).not.toBeNull(); - loadBtn.click(); // empty form → validation error, popup stays open - expect(cache.ui.error).toHaveBeenCalledWith('Server URL and Cypher query are required.'); + loadBtn.click(); // empty form → inline validation error, popup stays open + const errorBox = document.getElementById('neo4j-error'); + expect(errorBox.hidden).toBe(false); + expect(errorBox.textContent).toBe('Server URL and Cypher query are required.'); document.getElementById('neo4j-cancel-btn').click(); expect(await promise).toBe(false); }); - it('rejects an invalid URL without closing the popup', async () => { - const cache = { ui: { error: vi.fn() } }; - const promise = openNeo4jPopup(cache); + it('rejects an invalid URL inline without closing the popup', async () => { + const promise = openNeo4jPopup({ ui: {} }); document.getElementById('neo4j-url').value = 'not a url'; document.getElementById('neo4j-load-btn').click(); - expect(cache.ui.error).toHaveBeenCalledWith('Invalid server URL: not a url'); + const errorBox = document.getElementById('neo4j-error'); + expect(errorBox.hidden).toBe(false); + expect(errorBox.textContent).toBe('Invalid server URL: not a url'); document.getElementById('neo4j-cancel-btn').click(); expect(await promise).toBe(false); }); + + it('shows fetch failures inline and re-enables the buttons', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockRejectedValue(new TypeError('Failed to fetch')), + ); + try { + openNeo4jPopup({ ui: {} }); + document.getElementById('neo4j-url').value = 'http://localhost:7474'; + + const loadBtn = document.getElementById('neo4j-load-btn'); + loadBtn.click(); + expect(loadBtn.disabled).toBe(true); // spinner state while working + await vi.waitFor(() => { + expect(document.getElementById('neo4j-error').hidden).toBe(false); + }); + expect(document.getElementById('neo4j-error').textContent).toContain('Could not reach'); + expect(loadBtn.disabled).toBe(false); + expect(loadBtn.textContent).toBe('Fetch'); + // Settings were saved even though the fetch failed. + expect(JSON.parse(localStorage.getItem(SETTINGS_STORAGE_KEY)).url).toBe( + 'http://localhost:7474', + ); + } finally { + vi.unstubAllGlobals(); + } + }); }); describe('showPropertyChecklist', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + it('resolves immediately with no exclusions when there are no properties', async () => { expect(await showPropertyChecklist([])).toEqual({ excludedNodeProps: new Set(), @@ -483,12 +643,12 @@ describe('showPropertyChecklist', () => { it('excludes deselected properties (large arrays start deselected)', async () => { const promise = showPropertyChecklist([ - { kind: 'node', key: 'name', largeArray: false, sample: 'A' }, - { kind: 'node', key: 'embedding', largeArray: true, sample: [] }, - { kind: 'edge', key: 'weight', largeArray: false, sample: 1 }, + { kind: 'node', key: 'name', type: 'text', examples: ['A'], largeArray: false }, + { kind: 'node', key: 'embedding', type: 'list', examples: ['0 | 0'], largeArray: true }, + { kind: 'edge', key: 'weight', type: 'number', examples: ['1'], largeArray: false }, ]); - const checkboxes = [...document.querySelectorAll('.p-custom input[type="checkbox"]')]; + const checkboxes = [...document.querySelectorAll('.p-custom input[data-key]')]; expect(checkboxes).toHaveLength(3); expect(checkboxes.find((c) => c.dataset.key === 'embedding').checked).toBe(false); @@ -504,10 +664,33 @@ describe('showPropertyChecklist', () => { it('resolves null on cancel', async () => { const promise = showPropertyChecklist([ - { kind: 'node', key: 'name', largeArray: false, sample: 'A' }, + { kind: 'node', key: 'name', type: 'text', examples: ['A'], largeArray: false }, ]); const buttons = [...document.querySelectorAll('.p-custom button')]; buttons.find((b) => b.textContent === 'Cancel').click(); expect(await promise).toBeNull(); }); + + it('renders type badges and examples, and section toggle-all works', async () => { + const promise = showPropertyChecklist([ + { kind: 'node', key: 'born', type: 'number', examples: ['1964', '1791'], largeArray: false }, + { kind: 'node', key: 'name', type: 'text', examples: ['Keanu'], largeArray: false }, + ]); + + const types = [...document.querySelectorAll('.neo4j-prop-type')].map((el) => el.textContent); + expect(types).toEqual(['number', 'text']); + expect(document.querySelector('.neo4j-prop-examples').textContent).toBe('e.g. 1964 · 1791'); + + const toggleAll = document.querySelector('.neo4j-props-heading input'); + expect(toggleAll.checked).toBe(true); + toggleAll.checked = false; + toggleAll.dispatchEvent(new Event('change')); + + const buttons = [...document.querySelectorAll('.p-custom button')]; + buttons.find((b) => b.textContent === 'Import').click(); + expect(await promise).toEqual({ + excludedNodeProps: new Set(['born', 'name']), + excludedEdgeProps: new Set(), + }); + }); }); From 0da6051ad423ef8676a0cae54bf6ddfc3bc88b06 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 15 Jul 2026 15:39:35 +0200 Subject: [PATCH 006/181] chore: sync package-lock version to 1.15.5 --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 945561c..c3cfbed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "graph-lens-lite", - "version": "1.15.4", + "version": "1.15.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "graph-lens-lite", - "version": "1.15.4", + "version": "1.15.5", "license": "MIT", "dependencies": { "@antv/layout": "^2.0.0", From e72e1426618a140d512ea4971c68f46ffec7fa8d Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 15 Jul 2026 15:40:15 +0200 Subject: [PATCH 007/181] =?UTF-8?q?chore(release):=201.16.0=20=E2=80=94=20?= =?UTF-8?q?Neo4j=20connector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/config.js | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f3658..524c1d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.16.0 — 2026-07-15 + +Saved graph files load unchanged — this release adds a new data source. + +### Features + +* **Neo4j connector.** Fetch a graph straight from a Neo4j server: a new **🗄️ Neo4j Database** card on the landing page (and a sidebar button) opens a connection dialog for server URL, credentials, optional database name, and a Cypher query returning nodes, relationships, or paths. Before fetching, the connector counts the matching rows and asks for confirmation above 2,000; after fetching, a property checklist shows each property's type and example values and lets you drop unwanted ones — long arrays such as embeddings start deselected. Nodes are colored per entity label and edges per relationship type (when there is more than one), property groups use the most specific label of a stored class hierarchy, and list properties become pipe-separated multi-value categories so the regular filters work on them. The connection settings (never the password) are remembered locally. Uses the Neo4j HTTP API on port 7474/7473 with no driver dependency; Neo4j Aura (Bolt-only) is not supported. + ## 1.15.5 — 2026-07-10 Saved graph files load unchanged; older files (and files saved by earlier versions) default the new opacity to fully opaque, so their appearance is identical. diff --git a/package-lock.json b/package-lock.json index c3cfbed..8e73b37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "graph-lens-lite", - "version": "1.15.5", + "version": "1.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "graph-lens-lite", - "version": "1.15.5", + "version": "1.16.0", "license": "MIT", "dependencies": { "@antv/layout": "^2.0.0", diff --git a/package.json b/package.json index c36c78a..aa7e02e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graph-lens-lite", - "version": "1.15.5", + "version": "1.16.0", "main": "src/package/electron_app.js", "description": "Visualise and explore property graphs in a lightweight desktop app.", "homepage": "https://github.com/Delta4AI/GraphLensLite", diff --git a/src/config.js b/src/config.js index 606d70a..6a60dbb 100644 --- a/src/config.js +++ b/src/config.js @@ -1,7 +1,7 @@ /** * Defaults for the graph, layouts and UI */ -const VERSION = "1.15.5"; +const VERSION = "1.16.0"; const DEFAULTS = { NODE: { From a60655f61b4433defbdafad3615ed8b4cd1ecb46 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 15 Jul 2026 15:46:28 +0200 Subject: [PATCH 008/181] fix(io): map Neo4j booleans to categorical strings so filters match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw booleans pass the importer's isNaN numeric check (isNaN(true) === false) and became degenerate [1,1] range sliders whose generated 'BETWEEN 1 AND 1' condition never validates — the query AST requires typeof number. Under an OR filter join, which includes every active filter, that hid all edges carrying only boolean properties; the AND join appeared to work only because nothing was narrowed, yielding an empty (unconstrained) query. Booleans now coerce to 'true'/'false' strings, giving a categorical true/false checklist that IN-condition evaluation matches correctly. --- CHANGELOG.md | 2 +- src/utilities/neo4j_loader.js | 7 ++++++- tests/neo4j-loader.test.js | 11 +++++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 524c1d7..2ad17e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Saved graph files load unchanged — this release adds a new data source. ### Features -* **Neo4j connector.** Fetch a graph straight from a Neo4j server: a new **🗄️ Neo4j Database** card on the landing page (and a sidebar button) opens a connection dialog for server URL, credentials, optional database name, and a Cypher query returning nodes, relationships, or paths. Before fetching, the connector counts the matching rows and asks for confirmation above 2,000; after fetching, a property checklist shows each property's type and example values and lets you drop unwanted ones — long arrays such as embeddings start deselected. Nodes are colored per entity label and edges per relationship type (when there is more than one), property groups use the most specific label of a stored class hierarchy, and list properties become pipe-separated multi-value categories so the regular filters work on them. The connection settings (never the password) are remembered locally. Uses the Neo4j HTTP API on port 7474/7473 with no driver dependency; Neo4j Aura (Bolt-only) is not supported. +* **Neo4j connector.** Fetch a graph straight from a Neo4j server: a new **🗄️ Neo4j Database** card on the landing page (and a sidebar button) opens a connection dialog for server URL, credentials, optional database name, and a Cypher query returning nodes, relationships, or paths. Before fetching, the connector counts the matching rows and asks for confirmation above 2,000; after fetching, a property checklist shows each property's type and example values and lets you drop unwanted ones — long arrays such as embeddings start deselected. Nodes are colored per entity label and edges per relationship type (when there is more than one), property groups use the most specific label of a stored class hierarchy, and list properties become pipe-separated multi-value categories and booleans true/false categories so the regular filters work on them. The connection settings (never the password) are remembered locally. Uses the Neo4j HTTP API on port 7474/7473 with no driver dependency; Neo4j Aura (Bolt-only) is not supported. ## 1.15.5 — 2026-07-10 diff --git a/src/utilities/neo4j_loader.js b/src/utilities/neo4j_loader.js index 35740f6..67b95c2 100644 --- a/src/utilities/neo4j_loader.js +++ b/src/utilities/neo4j_loader.js @@ -132,7 +132,12 @@ function collectGraph(results) { */ function coerceValue(value) { if (value === null || value === undefined) return undefined; - if (typeof value === 'number' || typeof value === 'boolean') return value; + if (typeof value === 'number') return value; + // Booleans become categorical 'true'/'false' strings. Raw booleans would be + // classified as numeric by the importer (isNaN(true) === false) and end up + // as degenerate [1,1] sliders whose BETWEEN condition never validates + // (the query AST requires typeof 'number'), hiding every carrier under OR. + if (typeof value === 'boolean') return String(value); if (typeof value === 'string') return sanitizeForAST(value); if (Array.isArray(value) && value.every((v) => typeof v !== 'object' || v === null)) { return value.map((v) => sanitizeForAST(String(v))).join(' | '); diff --git a/tests/neo4j-loader.test.js b/tests/neo4j-loader.test.js index 852d202..9768872 100644 --- a/tests/neo4j-loader.test.js +++ b/tests/neo4j-loader.test.js @@ -151,13 +151,20 @@ describe('collectGraph', () => { }); describe('coerceValue', () => { - it('passes primitives through (sanitizing strings)', () => { + it('passes numbers through and sanitizes strings', () => { expect(coerceValue(3.5)).toBe(3.5); - expect(coerceValue(true)).toBe(true); expect(coerceValue('plain')).toBe('plain'); expect(coerceValue('a:b')).toBe('a-b'); }); + it('maps booleans to categorical strings so filters can match them', () => { + // Raw booleans would become numeric [1,1] sliders whose BETWEEN condition + // never validates (query AST requires typeof number) — under an OR join + // that hides every element carrying the property. + expect(coerceValue(true)).toBe('true'); + expect(coerceValue(false)).toBe('false'); + }); + it('drops null and undefined', () => { expect(coerceValue(null)).toBeUndefined(); expect(coerceValue(undefined)).toBeUndefined(); From 97a8c7af30c36663200653e418a4095d305e1afd Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 15 Jul 2026 15:50:16 +0200 Subject: [PATCH 009/181] fix(io): normalize boolean D4Data values at the shared import boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw booleans from any source (Excel boolean cells, hand-written JSON payloads, live API pushes) mis-classify as numeric (isNaN(true) === false) and become degenerate [1,1] sliders whose BETWEEN condition never validates, hiding their carriers under an OR filter join — the same defect just fixed source-locally for the Neo4j loader. preProcessData now stringifies boolean D4Data values to 'true'/'false' so every import path yields categorical true/false filters. --- src/managers/io.js | 37 ++++++++++++++++++++- tests/io-boolean-normalize.test.js | 53 ++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 tests/io-boolean-normalize.test.js diff --git a/src/managers/io.js b/src/managers/io.js index 6224a2e..0997a2b 100644 --- a/src/managers/io.js +++ b/src/managers/io.js @@ -27,6 +27,33 @@ function writeExportScale(scale) { } } +/** + * Replace boolean D4Data values with 'true'/'false' strings, in place, on + * every node and edge. Raw booleans mis-classify as numeric downstream + * (isNaN(true) === false) and become degenerate [1,1] range sliders whose + * generated BETWEEN condition can never validate — the query AST requires + * typeof number — so a boolean property silently hides its carriers under an + * OR filter join. As strings they become categorical true/false filters. + * Runs at the shared import boundary so every source (Excel, JSON, live API, + * Neo4j) is covered regardless of how it encodes booleans. + * + * @param {{nodes?: object[], edges?: object[]}} fileData + */ +function normalizeD4DataBooleans(fileData) { + for (const element of [...(fileData.nodes ?? []), ...(fileData.edges ?? [])]) { + if (!element?.D4Data) continue; + for (const group of Object.values(element.D4Data)) { + if (!group || typeof group !== 'object') continue; + for (const subGroup of Object.values(group)) { + if (!subGroup || typeof subGroup !== 'object') continue; + for (const [prop, value] of Object.entries(subGroup)) { + if (typeof value === 'boolean') subGroup[prop] = String(value); + } + } + } + } +} + // @formatter:off let excelData = {"s":{"readme":{"d":[[0,0,"Color Codes Explained"],[0,1,"Description"],[1,0,"Required"],[1,1,"Strictly required property"],[2,0,"Optional"],[2,1,"Optional property; Column name can not be re-used for user-defined data"],[3,0,"User Data [group]"],[3,1,"Add custom properties in new columns. Use [brackets] for grouping (e.g., \"Temperature [Celsius] [Physics]\"). The last [bracket] becomes the group name."],[5,0,"Data Types Explained"],[5,1,"Description"],[6,0,"text"],[6,1,"any text"],[7,0,"number"],[7,1,"integers or floating-point numbers"],[8,0,"boolean"],[8,1,"true or TRUE or 1, false or FALSE or 0"],[9,0,"RGBA"],[9,1,"RGBA hex color code (e.g. #C33D3580 for 50% opacity) (last 2 digits are optional)"],[10,0,"value 1 | value 2"],[10,1,"List of categorical values of which one must be matched (excluding optional information in brackets)"],[11,0,"any"],[11,1,"Categorical view when text is given, slider-based view when numerical input is given"],[13,0,"Node Properties Explained"],[13,1,"Description"],[13,2,"Default Value"],[13,3,"Type"],[14,0,"ID"],[14,1,"Unique identifier for a node"],[14,2,"-"],[14,3,"text (unique)"],[15,0,"Label"],[15,1,"Label of the node; if no Label is given, the ID is displayed per default"],[15,2,"-"],[15,3,"text"],[16,0,"Description"],[16,1,"Description of the node, displayed in the tooltip text"],[16,2,"-"],[16,3,"text"],[17,0,"Shape"],[17,1,"The shape of the node"],[17,2,"hexagon"],[17,3,"circle (●) | diamond (◆) | hexagon (⬢) | rect (■) | triangle (▲) | star (★)"],[18,0,"Size"],[18,1,"The size of the node"],[18,2,"20"],[18,3,"number"],[19,0,"Fill Color"],[19,1,"The fill color of the node in RGBA format (e.g. #FF000080 for a red node with 50% opacity)"],[19,2,"#C33D35"],[19,3,"rgba"],[20,0,"Border Size"],[20,1,"The stroke width"],[20,2,"1"],[20,3,"number"],[21,0,"Border Color"],[21,1,"The stroke color of the node in RGBA format"],[21,2,"-"],[21,3,"rgba"],[22,0,"Opacity"],[22,1,"The opacity of the node, applied to its fill and border (1 = opaque, 0 = transparent). Multiplies into the color’s own alpha, so it composes with an RGBA fill/border color."],[22,2,"1"],[22,3,"number"],[23,0,"Label Font Size"],[23,1,"Label font size"],[23,2,"12"],[23,3,"number"],[24,0,"Label Placement"],[24,1,"Label position relative to the main shape of the node"],[24,2,"bottom"],[24,3,"left | right | top | bottom | left-top | left-bottom | right-top | right-bottom | top-left | top-right | bottom-left | bottom-right | center"],[25,0,"Label Color"],[25,1,"The color of the nodes label"],[25,2,"-"],[25,3,"rgba"],[26,0,"Label Background Color"],[26,1,"Label background fill color"],[26,2,"-"],[26,3,"rgba"],[27,0,"X Coordinate"],[27,1,"The x coordinate of the node"],[27,2,"-"],[27,3,"number"],[28,0,"Y Coordinate"],[28,1,"The y coordinate of the node"],[28,2,"-"],[28,3,"number"],[29,0,"property A [group A]"],[29,1,"User-defined custom node properties"],[29,2,"-"],[29,3,"any"],[31,0,"Edge Properties Explained"],[31,1,"Description"],[31,2,"Default Value"],[31,3,"Type"],[32,0,"Source ID"],[32,1,"The ID of the source node"],[32,2,"-"],[32,3,"text"],[33,0,"Target ID"],[33,1,"The ID of the target node"],[33,2,"-"],[33,3,"text"],[34,0,"Label"],[34,1,"Label of the edge; if no Label is given, the edge is only visible as line without any text"],[34,2,"-"],[34,3,"text"],[35,0,"Description"],[35,1,"Description of the edge, displayed in the tooltip text"],[35,2,"-"],[35,3,"text"],[36,0,"Type"],[36,1,"The edge type"],[36,2,"line"],[36,3,"line | cubic | quadratic | polyline"],[37,0,"Line Width"],[37,1,"The border width of the edge"],[37,2,"0.75"],[37,3,"number"],[38,0,"Line Dash"],[38,1,"The dash offset of the edge line"],[38,2,"0"],[38,3,"number"],[39,0,"Color"],[39,1,"The stroke color of the edge in RGBA format"],[39,2,"#403C5390"],[39,3,"rgba"],[40,0,"Opacity"],[40,1,"The opacity of the edge (1 = opaque, 0 = transparent). Multiplies into the color’s own alpha, so it composes with an RGBA edge color."],[40,2,"1"],[40,3,"number"],[41,0,"Label Font Size"],[41,1,"The font size of the edges label"],[41,2,"12"],[41,3,"number"],[42,0,"Label Placement"],[42,1,"The position of the label relative to the edge"],[42,2,"center"],[42,3,"start | center | end"],[43,0,"Label Auto Rotate"],[43,1,"Whether to automatically rotate the label to match the edge’s direction"],[43,2,{"formula":"FALSE()"}],[43,3,"boolean"],[44,0,"Label Offset X"],[44,1,"The offset of the label on the X-Axis"],[44,2,"4"],[44,3,"number"],[45,0,"Label Offset Y"],[45,1,"The offset of the label on the Y-Axis"],[45,2,"0"],[45,3,"number"],[46,0,"Label Color"],[46,1,"The color of the edges label text"],[46,2,"#000000"],[46,3,"rgba"],[47,0,"Label Background Color"],[47,1,"The color for the edge label’s background"],[47,2,"-"],[47,3,"rgba"],[48,0,"Start Arrow"],[48,1,"Whether to display the start arrow on the edge"],[48,2,{"formula":"FALSE()"}],[48,3,"boolean"],[49,0,"Start Arrow Size"],[49,1,"The size of the start arrow"],[49,2,"8"],[49,3,"number"],[50,0,"Start Arrow Type"],[50,1,"The type of the start arrow"],[50,2,"arrow"],[50,3,"arrow | rect | diamond | circle | tee | triangle | vee | triangleRect | simple | square"],[51,0,"Start Arrow Color"],[51,1,"The fill color of the start arrow; inherits the edge color if unset"],[51,2,"-"],[51,3,"rgba"],[52,0,"Start Arrow Border Color"],[52,1,"The border color of the start arrow; no border if unset"],[52,2,"-"],[52,3,"rgba"],[53,0,"Start Arrow Border Size"],[53,1,"The border width of the start arrow (0 = auto, ~20% of the marker)"],[53,2,"0"],[53,3,"number"],[54,0,"End Arrow"],[54,1,"Whether to display the end arrow on the edge"],[54,2,{"formula":"FALSE()"}],[54,3,"boolean"],[55,0,"End Arrow Size"],[55,1,"The size of the end arrow"],[55,2,"8"],[55,3,"number"],[56,0,"End Arrow Type"],[56,1,"The type of the end arrow"],[56,2,"arrow"],[56,3,"arrow | rect | diamond | circle | tee | triangle | vee | triangleRect | simple | square"],[57,0,"End Arrow Color"],[57,1,"The fill color of the end arrow; inherits the edge color if unset"],[57,2,"-"],[57,3,"rgba"],[58,0,"End Arrow Border Color"],[58,1,"The border color of the end arrow; no border if unset"],[58,2,"-"],[58,3,"rgba"],[59,0,"End Arrow Border Size"],[59,1,"The border width of the end arrow (0 = auto, ~20% of the marker)"],[59,2,"0"],[59,3,"number"]],"st":{"A1":0,"B1":0,"C1":1,"D1":2,"A2":3,"B2":2,"C2":1,"D2":2,"A3":4,"B3":2,"C3":1,"D3":2,"A4":5,"B4":2,"C4":1,"D4":2,"A5":2,"B5":2,"C5":1,"D5":2,"A6":0,"B6":0,"C6":6,"D6":7,"A7":8,"B7":2,"C7":1,"D7":2,"A8":8,"B8":2,"C8":1,"D8":2,"A9":8,"B9":2,"C9":1,"D9":2,"A10":8,"B10":2,"C10":1,"D10":2,"A11":9,"B11":2,"C11":1,"D11":2,"A12":8,"B12":2,"C12":1,"D12":2,"A13":2,"B13":2,"C13":1,"D13":2,"A14":10,"B14":10,"C14":11,"D14":10,"A15":3,"B15":2,"C15":1,"D15":2,"A16":4,"B16":2,"C16":1,"D16":2,"A17":4,"B17":2,"C17":1,"D17":2,"A18":4,"B18":2,"C18":1,"D18":12,"A19":4,"B19":2,"C19":1,"D19":2,"A20":4,"B20":2,"C20":1,"D20":2,"A21":4,"B21":2,"C21":1,"D21":2,"A22":4,"B22":2,"C22":1,"D22":2,"A23":4,"B23":2,"C23":1,"D23":2,"A24":4,"B24":2,"C24":1,"D24":2,"A25":4,"B25":2,"C25":1,"D25":12,"A26":4,"B26":2,"C26":1,"D26":2,"A27":4,"B27":2,"C27":1,"D27":2,"A28":4,"B28":2,"C28":1,"D28":2,"A29":4,"B29":2,"C29":1,"D29":2,"A30":5,"B30":2,"C30":1,"D30":2,"A31":2,"B31":2,"C31":1,"D31":2,"A32":10,"B32":10,"C32":11,"D32":10,"A33":3,"B33":2,"C33":1,"D33":2,"A34":3,"B34":2,"C34":1,"D34":2,"A35":4,"B35":2,"C35":1,"D35":2,"A36":4,"B36":2,"C36":1,"D36":2,"A37":4,"B37":2,"C37":1,"D37":12,"A38":4,"B38":2,"C38":1,"D38":2,"A39":4,"B39":2,"C39":1,"D39":2,"A40":4,"B40":2,"C40":1,"D40":2,"A41":4,"B41":2,"C41":1,"D41":2,"A42":4,"B42":2,"C42":1,"D42":2,"A43":4,"B43":2,"C43":1,"D43":12,"A44":4,"B44":2,"C44":13,"D44":2,"A45":4,"B45":2,"C45":1,"D45":2,"A46":4,"B46":2,"C46":1,"D46":2,"A47":4,"B47":2,"C47":1,"D47":2,"A48":4,"B48":2,"C48":13,"D48":2,"A49":4,"B49":2,"C49":13,"D49":2,"A50":4,"B50":2,"C50":1,"D50":2,"A51":4,"B51":2,"C51":13,"D51":12,"A52":4,"B52":2,"C52":13,"D52":12,"A53":4,"B53":2,"C53":13,"D53":12,"A54":4,"B54":2,"C54":1,"D54":12,"A55":4,"B55":2,"C55":13,"D55":2,"A56":4,"B56":2,"C56":1,"D56":14,"A57":4,"B57":2,"C57":13,"D57":12,"A58":4,"B58":2,"C58":13,"D58":12,"A59":4,"B59":2,"C59":13,"D59":12,"A60":4,"B60":2,"C60":1,"D60":12},"dim":[60,4]},"nodes":{"d":[[0,0,"ID"],[0,1,"Label"],[0,2,"Description"],[0,3,"Shape"],[0,4,"Size"],[0,5,"Fill Color"],[0,6,"Border Color"],[0,7,"Feature X [group A]"],[0,8,"Feature Y [nm] [group A]"],[0,9,"Feature Z [group B]"],[1,0,"A"],[1,1,"Node 1"],[1,2,"The first node"],[1,3,"circle"],[1,4,"60"],[1,5,"#403C53"],[1,6,"#C33D35"],[1,7,"1"],[1,8,"foo"],[1,9,"1"],[2,0,"B"],[2,1,"Node 2"],[2,2,"The second node"],[2,7,"0.5"],[2,8,"foo"],[2,9,"2"],[3,0,"C"],[3,1,"Node 3"],[3,2,"The third node"],[3,7,"1.1"],[3,8,"foo"],[3,9,"1"],[4,0,"D"],[4,1,"Node 4"],[4,2,"The fourth node"],[4,7,"1.3"],[4,8,"bar"],[4,9,"0"],[5,0,"E"],[5,7,"0"],[5,8,"bar"],[5,9,"-1"],[6,0,"F"],[6,1,"Lonely Node"],[6,7,"-1"]],"st":{"A1":3,"B1":4,"C1":4,"D1":4,"E1":4,"F1":4,"G1":4,"H1":5,"I1":5,"J1":5,"A2":15,"B2":16,"C2":16,"D2":16,"E2":16,"F2":16,"G2":16,"H2":17,"I2":17,"J2":17,"A3":15,"B3":16,"C3":16,"D3":16,"E3":16,"F3":16,"G3":16,"H3":17,"I3":17,"J3":17,"A4":15,"B4":16,"C4":16,"D4":16,"E4":16,"F4":16,"G4":16,"H4":17,"I4":17,"J4":17,"A5":15,"B5":16,"C5":16,"D5":16,"E5":16,"F5":16,"G5":16,"H5":17,"I5":17,"J5":17,"A6":15,"B6":16,"C6":16,"D6":16,"E6":16,"F6":16,"G6":16,"H6":17,"I6":17,"J6":17,"A7":15,"B7":16,"C7":16,"D7":16,"E7":16,"F7":16,"G7":16,"H7":17,"I7":17,"J7":17},"dim":[7,10]},"edges":{"d":[[0,0,"Source ID"],[0,1,"Target ID"],[0,2,"Color"],[0,3,"Line Width"],[0,4,"Label"],[0,5,"Feature EX [group X]"],[0,6,"Feature EY [group X]"],[0,7,"Feature EZ [group Y]"],[1,0,"A"],[1,1,"B"],[1,2,"#FF0000"],[1,3,"0.75"],[1,4,"foo"],[1,5,"1"],[1,6,"Dummy Category 1"],[1,7,"1"],[2,0,"A"],[2,1,"C"],[2,5,"0.5"],[2,6,"Dummy Category 2"],[2,7,"2"],[3,0,"C"],[3,1,"D"],[3,5,"1.1"],[3,6,"Dummy Category 3"],[3,7,"1"],[4,0,"D"],[4,1,"E"],[4,5,"1.3"],[4,6,"Dummy Category 4"],[4,7,"0"]],"st":{"A1":3,"B1":3,"C1":4,"D1":4,"E1":4,"F1":5,"G1":5,"H1":5,"A2":15,"B2":15,"C2":16,"D2":16,"E2":16,"F2":17,"G2":17,"H2":17,"A3":15,"B3":15,"C3":16,"D3":16,"E3":16,"F3":17,"G3":17,"H3":17,"A4":15,"B4":15,"C4":16,"D4":16,"E4":16,"F4":17,"G4":17,"H4":17,"A5":15,"B5":15,"C5":16,"D5":16,"E5":16,"F5":17,"G5":17,"H5":17},"dim":[5,8]}},"st":{"0":{"f":{"b":1,"sz":12,"n":"Arial"},"fill":{"fg":"E4E3EA","bg":"FEFFE1"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"1":{"f":{"sz":10,"n":"Arial"},"fill":{"p":"none"},"a":{"h":"left","v":"bottom"}},"2":{"f":{"sz":10,"n":"Arial"},"fill":{"p":"none"},"a":{"v":"bottom"}},"3":{"f":{"b":1,"sz":10,"n":"Arial"},"fill":{"fg":"FF9A9A","bg":"FF8080"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"4":{"f":{"b":1,"sz":10,"n":"Arial"},"fill":{"fg":"FEFFE1","bg":"FFFFFF"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"5":{"f":{"b":1,"sz":10,"n":"Arial"},"fill":{"fg":"81D41A","bg":"969696"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"6":{"f":{"b":1,"sz":12,"n":"Arial"},"fill":{"p":"none"},"a":{"h":"left","v":"bottom"}},"7":{"f":{"b":1,"sz":12,"n":"Arial"},"fill":{"p":"none"},"a":{"v":"bottom"}},"8":{"f":{"b":1,"sz":10,"n":"Arial"},"fill":{"fg":"E4E3EA","bg":"FEFFE1"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"9":{"f":{"b":1,"i":1,"sz":10,"n":"Arial"},"fill":{"fg":"E4E3EA","bg":"FEFFE1"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"10":{"f":{"b":1,"sz":12,"n":"Arial"},"fill":{"fg":"E4E3EA","bg":"FEFFE1"},"b":{"t":["thin","000000"],"b":["thin","000000"]},"a":{"v":"bottom"}},"11":{"f":{"b":1,"sz":12,"n":"Arial"},"fill":{"fg":"E4E3EA","bg":"FEFFE1"},"b":{"t":["thin","000000"],"b":["thin","000000"]},"a":{"h":"left","v":"bottom"}},"12":{"f":{"i":1,"sz":10,"n":"Arial"},"fill":{"p":"none"},"a":{"v":"bottom"}},"13":{"f":{"sz":10,"n":"Arial"},"fill":{"p":"none"},"a":{"h":"left","v":"bottom"},"nf":"\"TRUE\";\"TRUE\";\"FALSE\""},"14":{"f":{"u":1,"sz":10,"n":"Arial"},"fill":{"p":"none"},"a":{"v":"bottom"}},"15":{"f":{"sz":10,"n":"Arial"},"fill":{"fg":"FF9A9A","bg":"FF8080"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"16":{"f":{"sz":10,"n":"Arial"},"fill":{"fg":"FEFFE1","bg":"FFFFFF"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}},"17":{"f":{"sz":10,"n":"Arial"},"fill":{"fg":"81D41A","bg":"969696"},"b":{"t":["thin","000000"],"b":["thin","000000"],"l":["thin","000000"],"r":["thin","000000"]},"a":{"v":"bottom"}}},"sc":18}; // @formatter:on @@ -1188,6 +1215,7 @@ class IOManager { preProcessData(fileData) { this.cache.reset(); + normalizeD4DataBooleans(fileData); this.cache.CFG.HIDE_LABELS = fileData.nodes.length > this.cache.CFG.MAX_NODES_BEFORE_HIDING_LABELS; @@ -1895,4 +1923,11 @@ class IOManager { } } -export { excelData, ExcelTemplate, EXCEL_NODE_PROPERTIES, EXCEL_EDGE_PROPERTIES, IOManager }; +export { + excelData, + ExcelTemplate, + EXCEL_NODE_PROPERTIES, + EXCEL_EDGE_PROPERTIES, + IOManager, + normalizeD4DataBooleans, +}; diff --git a/tests/io-boolean-normalize.test.js b/tests/io-boolean-normalize.test.js new file mode 100644 index 0000000..be4e204 --- /dev/null +++ b/tests/io-boolean-normalize.test.js @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; + +const { normalizeD4DataBooleans } = await import('../src/managers/io.js'); + +// -------------------------------------------------------------------------- +// Regression: raw boolean D4Data values mis-classify as numeric downstream +// (isNaN(true) === false) and become [1,1] sliders whose BETWEEN condition +// never validates (query AST requires typeof number), hiding their carriers +// under an OR filter join. The import boundary must stringify them so every +// source (Excel boolean cells, hand-written JSON, live API pushes) yields +// categorical true/false filters. +// -------------------------------------------------------------------------- + +describe('normalizeD4DataBooleans', () => { + it('stringifies boolean values on nodes and edges in place', () => { + const fileData = { + nodes: [ + { id: 'a', D4Data: { 'Node filters': { Cell: { active: true, name: 'A', size: 3 } } } }, + ], + edges: [ + { + id: 'e', + D4Data: { 'Edge filters': { ANNOTATES: { mined: true, explicit: false } } }, + }, + ], + }; + + normalizeD4DataBooleans(fileData); + + expect(fileData.nodes[0].D4Data['Node filters'].Cell).toEqual({ + active: 'true', + name: 'A', + size: 3, + }); + expect(fileData.edges[0].D4Data['Edge filters'].ANNOTATES).toEqual({ + mined: 'true', + explicit: 'false', + }); + }); + + it('tolerates elements without D4Data and malformed groups', () => { + const fileData = { + nodes: [{ id: 'a' }, { id: 'b', D4Data: { 'Node filters': null } }], + edges: [{ id: 'e', D4Data: { 'Edge filters': { X: null } } }], + }; + expect(() => normalizeD4DataBooleans(fileData)).not.toThrow(); + }); + + it('tolerates missing nodes/edges arrays', () => { + expect(() => normalizeD4DataBooleans({})).not.toThrow(); + }); +}); From bab0586835c32364d2c99444b7eb1792d7ad919b Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 15 Jul 2026 15:51:13 +0200 Subject: [PATCH 010/181] =?UTF-8?q?chore(release):=201.16.1=20=E2=80=94=20?= =?UTF-8?q?boolean=20property=20filtering=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/config.js | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ad17e9..1612a46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.16.1 — 2026-07-15 + +Saved graph files load unchanged — this release is bug fixes only. + +### Fixes + +* **Boolean properties now filter correctly.** A boolean data property (e.g. `mined: true` on an edge) was classified as numeric and became a degenerate range slider whose condition could never match, silently hiding every element carrying it under an **OR** filter join — while **AND** appeared to work only because un-narrowed filters don't constrain. Booleans from any data source (Neo4j, Excel boolean cells, JSON payloads, live API pushes) are now normalized to categorical `true`/`false` filters at the import boundary. Reload affected data to pick up the fix. + ## 1.16.0 — 2026-07-15 Saved graph files load unchanged — this release adds a new data source. diff --git a/package-lock.json b/package-lock.json index 8e73b37..67945a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "graph-lens-lite", - "version": "1.16.0", + "version": "1.16.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "graph-lens-lite", - "version": "1.16.0", + "version": "1.16.1", "license": "MIT", "dependencies": { "@antv/layout": "^2.0.0", diff --git a/package.json b/package.json index aa7e02e..ebe0052 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graph-lens-lite", - "version": "1.16.0", + "version": "1.16.1", "main": "src/package/electron_app.js", "description": "Visualise and explore property graphs in a lightweight desktop app.", "homepage": "https://github.com/Delta4AI/GraphLensLite", diff --git a/src/config.js b/src/config.js index 6a60dbb..59d96fe 100644 --- a/src/config.js +++ b/src/config.js @@ -1,7 +1,7 @@ /** * Defaults for the graph, layouts and UI */ -const VERSION = "1.16.0"; +const VERSION = "1.16.1"; const DEFAULTS = { NODE: { From c04583f8beae6e79283a8edf3cce28f5d113e628 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 15 Jul 2026 19:57:10 +0200 Subject: [PATCH 011/181] feat(neo4j): expand selection and join-query merge for active sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grow a Neo4j graph without re-importing, gated behind the active session (password in memory only; buttons hide when another source stamps the data-source label): - Expand (selection HUD): preflight counts neighbors per (relationship type, leaf label) pair, checklist with sum warning, exact pair-filtered fetch via parameterized elementId() Cypher (Neo4j 5+). - Add query (workspace toolbar): slim popup against the authenticated session with the same row-count confirm as the import. - Merge primitive unions raw entities per session accumulator (colors stay stable, re-fetched ids refresh properties), re-applies import-time property exclusions, and declares the current workspace with full positions so the apply pipeline skips the Excel-path force pass — existing nodes render exactly where they were, in one paint. - New nodes seed near a positioned neighbor (else centroid) and float into place via settlePinnedForce: per-iteration FA2 over the full graph with all pre-existing nodes pinned (iterate-and-restore; bundled FA2 has no native pinning). - Optional stitch pass (checkbox in both dialogs, default on) fetches relationships among all loaded nodes; USING JOIN ON m forces a NodeHashJoin after a 372k-degree supernode made the naive per-row IN filter take minutes on a live database. - 🛢️ replaces 🗄️ as the Neo4j glyph everywhere (legible at toolbar size). --- ARCHITECTURE.md | 1 + CHANGELOG.md | 6 +- src/gll.js | 3 + src/graph/layout_algorithms.js | 52 +++ src/graph_lens_lite.html | 10 +- src/managers/ui.js | 5 + src/style.css | 27 ++ src/utilities/neo4j_loader.js | 68 +++ src/utilities/neo4j_session.js | 647 +++++++++++++++++++++++++++ tests/layout-algorithms.test.js | 49 +- tests/neo4j-session.test.js | 771 ++++++++++++++++++++++++++++++++ 11 files changed, 1635 insertions(+), 4 deletions(-) create mode 100644 src/utilities/neo4j_session.js create mode 100644 tests/neo4j-session.test.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cec515a..61d9137 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,6 +71,7 @@ Business logic and UI: - `data_editor.js` — spreadsheet-like data editor (`DataTable`), incl. Excel export - `demo_loader.js` — STRING DB protein-interaction demo data - `neo4j_loader.js` — Neo4j connector (HTTP transactional Cypher API, no driver dependency) +- `neo4j_session.js` — Neo4j session extensions: expand selected nodes, merge additional queries - `tour.js` — guided tour with a sample dataset - `color_scale_picker.js` / `numeric_scale_picker.js` / `pie_chart_picker.js` — styling pickers - `selection_hud.js`, `theme.js`, `export_scale.js` diff --git a/CHANGELOG.md b/CHANGELOG.md index 1612a46..349789d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,11 @@ Saved graph files load unchanged — this release adds a new data source. ### Features -* **Neo4j connector.** Fetch a graph straight from a Neo4j server: a new **🗄️ Neo4j Database** card on the landing page (and a sidebar button) opens a connection dialog for server URL, credentials, optional database name, and a Cypher query returning nodes, relationships, or paths. Before fetching, the connector counts the matching rows and asks for confirmation above 2,000; after fetching, a property checklist shows each property's type and example values and lets you drop unwanted ones — long arrays such as embeddings start deselected. Nodes are colored per entity label and edges per relationship type (when there is more than one), property groups use the most specific label of a stored class hierarchy, and list properties become pipe-separated multi-value categories and booleans true/false categories so the regular filters work on them. The connection settings (never the password) are remembered locally. Uses the Neo4j HTTP API on port 7474/7473 with no driver dependency; Neo4j Aura (Bolt-only) is not supported. +* **Neo4j connector.** Fetch a graph straight from a Neo4j server: a new **🛢️ Neo4j Database** card on the landing page (and a sidebar button) opens a connection dialog for server URL, credentials, optional database name, and a Cypher query returning nodes, relationships, or paths. Before fetching, the connector counts the matching rows and asks for confirmation above 2,000; after fetching, a property checklist shows each property's type and example values and lets you drop unwanted ones — long arrays such as embeddings start deselected. Nodes are colored per entity label and edges per relationship type (when there is more than one), property groups use the most specific label of a stored class hierarchy, and list properties become pipe-separated multi-value categories and booleans true/false categories so the regular filters work on them. The connection settings (never the password) are remembered locally. Uses the Neo4j HTTP API on port 7474/7473 with no driver dependency; Neo4j Aura (Bolt-only) is not supported. +* **Growing a Neo4j graph in place.** After a Neo4j import, the graph can be extended without starting over — both features live behind the active session (the password is kept in memory only and never persisted) and disappear when another data source replaces the graph: + * **🛢️ Expand** (selection panel): with nodes selected, a checklist shows what surrounds them — one row per relationship type and neighbor label, with counts, everything preselected — and fetches the checked groups. New neighbors appear next to the node they connect to and float into place with a short force animation that feels the whole network but moves only them; existing nodes never move. The same **Stitch** checkbox as the join-query dialog (on by default) additionally fetches the relationships between the new neighbors and everything already loaded — including among the new neighbors themselves, which the expansion pattern alone never returns. + * **🛢️ Add query** (workspace toolbar): runs an additional Cypher query against the connected server and merges the results into the current graph, even when they are disconnected from it. The same row-count confirmation as the initial import applies. A **Stitch** checkbox (on by default) runs one extra query that fetches all relationships between the new results and everything already loaded — individual queries only return the relationships their own pattern matched, so without it two queries about different entities never reveal how their neighborhoods interconnect. + * Merged data reuses the property exclusions chosen at import time (re-import to change them), entity colors stay stable across merges, and node identity follows Neo4j's ids — re-fetched nodes refresh their properties instead of duplicating. Filter narrowing resets on merge. Expansion requires Neo4j 5+ (`elementId`); the plain import keeps working on older servers. ## 1.15.5 — 2026-07-10 diff --git a/src/gll.js b/src/gll.js index 090d6b5..2c4086d 100644 --- a/src/gll.js +++ b/src/gll.js @@ -21,6 +21,7 @@ import {PieChartPicker} from './utilities/pie_chart_picker.js'; import {DataTable, buildDataTable} from "./utilities/data_editor.js"; import {StringDemoDataLoader} from "./utilities/demo_loader.js"; import {openNeo4jPopup} from "./utilities/neo4j_loader.js"; +import {expandNeo4jSelection, openNeo4jJoinPopup} from "./utilities/neo4j_session.js"; import {Popup} from "./utilities/popup.js"; import {StaticUtilities} from "./utilities/static.js"; import {generateTourData, GuidedTour} from "./utilities/tour.js"; @@ -454,6 +455,8 @@ async function startTour() { window.loadDemoData = loadDemoData; window.loadNeo4jData = () => openNeo4jPopup(cache); +window.expandNeo4jSelection = () => expandNeo4jSelection(cache); +window.openNeo4jJoinPopup = () => openNeo4jJoinPopup(cache); window.startTour = startTour; window.cache = cache; diff --git a/src/graph/layout_algorithms.js b/src/graph/layout_algorithms.js index 681a7f1..6264bb5 100644 --- a/src/graph/layout_algorithms.js +++ b/src/graph/layout_algorithms.js @@ -262,6 +262,58 @@ export function layoutSelectionSubgraph(nodes, edges, type, center) { return positions; } +// Pinned settle: FA2 iterations run per animation tick; a few per frame read +// as motion without hogging the frame at the sizes merges target. +const SETTLE_ITERATIONS_PER_TICK = 2; + +/** + * Animated force settle for a subset of nodes: FA2 runs over the FULL graph, + * so the free nodes feel every attraction/repulsion in the network, but all + * other nodes are pinned — their coordinates are restored after every single + * iteration, so only the free nodes end up moving. Used after a Neo4j + * expand/join merge to float the newly added nodes into place without + * disturbing the existing arrangement. + * + * The bundled FA2 has no native pinning, hence the iterate-and-restore loop. + * Runs on the live graph in rAF ticks (sigma is bound to the instance, so + * every tick paints) within the same bounded time window as the whole-graph + * animated path. + * + * @param {import('graphology').default} graph live graphology instance + * @param {Iterable} freeIds the only nodes allowed to move + * @param {{durationMs?: number, raf?: Function}} [opts] test seams + * @returns {Promise} resolves once the window elapses + */ +export async function settlePinnedForce(graph, freeIds, opts = {}) { + const free = new Set(freeIds); + if (graph.order < 2 || free.size === 0 || free.size >= graph.order) return; + + const pinned = new Map(); + graph.forEachNode((id, attrs) => { + if (!free.has(id)) pinned.set(id, { x: attrs.x, y: attrs.y }); + }); + + const settings = forceAtlas2.inferSettings(graph); + const durationMs = + opts.durationMs ?? + Math.min(FORCE_ANIMATE_MAX_MS, FORCE_ANIMATE_BASE_MS + graph.order * FORCE_ANIMATE_PER_NODE_MS); + // jsdom lacks rAF unless pretendToBeVisual; a timeout tick is equivalent here. + const raf = opts.raf ?? globalThis.requestAnimationFrame ?? ((cb) => setTimeout(cb, 16)); + const deadline = Date.now() + durationMs; + + await new Promise((resolve) => { + const tick = () => { + for (let i = 0; i < SETTLE_ITERATIONS_PER_TICK; i++) { + forceAtlas2.assign(graph, { iterations: 1, settings }); + for (const [id, pos] of pinned) graph.mergeNodeAttributes(id, pos); + } + if (Date.now() < deadline) raf(tick); + else resolve(); + }; + raf(tick); + }); +} + /** * Execute a layout spec against a graphology graph, assigning x/y per node. * @param {import('graphology').default} graph diff --git a/src/graph_lens_lite.html b/src/graph_lens_lite.html index 3154986..f3c1b1b 100644 --- a/src/graph_lens_lite.html +++ b/src/graph_lens_lite.html @@ -42,7 +42,7 @@

Graph Lens Lite

Explore protein-protein interaction networks from the STRING database @@ -110,7 +110,7 @@



- +

@@ -127,6 +127,9 @@

+

Shown: @@ -190,6 +193,9 @@
Shown: +
diff --git a/src/managers/ui.js b/src/managers/ui.js index fd719b7..07ab8ef 100644 --- a/src/managers/ui.js +++ b/src/managers/ui.js @@ -5,6 +5,7 @@ import { Popup } from '../utilities/popup.js'; import { applyTheme, currentTheme, nodeLabelColorForTheme } from '../utilities/theme.js'; import { EXPORT_SCALES } from '../utilities/export_scale.js'; import { clampPopoverLeft } from '../utilities/popover_position.js'; +import { refreshNeo4jSessionUI } from '../utilities/neo4j_loader.js'; // Persisted preference: whether the filter panel reveals exact numeric inputs // and the per-row group / selection actions. Off keeps rows scannable when a @@ -39,6 +40,9 @@ class UIManager { label.textContent = text; label.title = text; } + // Every loader stamps the label, so this is the one seam where "another + // source replaced the graph" is visible — sync the Neo4j session buttons. + refreshNeo4jSessionUI(); } /** @@ -143,6 +147,7 @@ class UIManager { 'Reduce Neighbors', 'deselectNodesBtn', 'focusNodesBtn', + 'neo4jExpandBtn', ], enable ); diff --git a/src/style.css b/src/style.css index 86283c3..7767b0d 100644 --- a/src/style.css +++ b/src/style.css @@ -5886,6 +5886,33 @@ input:checked + .slider:before { overflow-wrap: break-word; } +/* Inline size warning in the expand checklist (sum of checked counts). + Colors mirror the --danger-text pattern; #E57B3C fails AA contrast at this size. */ +.neo4j-warning { + padding: 10px 12px; + border: 1px solid #9a5b1f; + border-radius: 4px; + color: #9a5b1f; + font-size: 12.5px; + margin: 8px 0 12px; + overflow-wrap: break-word; +} + +[data-theme="dark"] .neo4j-warning { + border-color: #e0a36a; + color: #e0a36a; +} + +/* Stitch checkbox in the join-query popup. */ +.neo4j-stitch-row { + display: flex; + align-items: baseline; + gap: 6px; + font-size: 12.5px; + margin-bottom: 12px; + cursor: pointer; +} + .neo4j-btn-spinner { display: inline-block; width: 11px; diff --git a/src/utilities/neo4j_loader.js b/src/utilities/neo4j_loader.js index 67b95c2..4563ca4 100644 --- a/src/utilities/neo4j_loader.js +++ b/src/utilities/neo4j_loader.js @@ -333,6 +333,69 @@ function toAppFormat(nodes, relationships, options = {}) { }; } +/** + * Active Neo4j session — set after a successful import, replaced by the next + * one. Enables the expand/join-query features (see neo4j_session.js). Raw + * Neo4j entities (not app format) are accumulated so toAppFormat re-computes + * auto-colors over the full category set on every merge, keeping colors + * stable across fetches. The password lives in memory only — never persisted + * (saveSettings strips it). + * + * The session is never proactively cleared when another loader replaces the + * graph; the buttons are simply hidden because the data-source label no + * longer starts with `Neo4j:` (see neo4jSessionActive). A stale session is + * inert — it is only reachable again through a fresh Neo4j import. + */ +let neo4jSession = null; + +/** + * @param {{url: string, username: string, password: string, database: string}} config + * @param {object[]} nodes raw Neo4j nodes from collectGraph + * @param {object[]} relationships raw Neo4j relationships from collectGraph + * @param {{excludedNodeProps: Set, excludedEdgeProps: Set}} exclusions + */ +function startNeo4jSession(config, nodes, relationships, exclusions) { + neo4jSession = { + config, + rawNodes: new Map(nodes.map((node) => [node.id, node])), + rawRels: new Map(relationships.map((rel) => [rel.id, rel])), + exclusions, + }; + refreshNeo4jSessionUI(); +} + +function getNeo4jSession() { + return neo4jSession; +} + +function clearNeo4jSession() { + neo4jSession = null; + refreshNeo4jSessionUI(); +} + +/** + * True while the rendered graph belongs to the Neo4j session. Every loader + * stamps the data-source label, so the label check alone detects that another + * source has replaced the graph — no lifecycle events needed. + */ +function neo4jSessionActive() { + const label = document.getElementById('dataSourceLabel')?.textContent ?? ''; + return neo4jSession !== null && label.startsWith('Neo4j:'); +} + +/** + * Show/hide the expand and join-query buttons. Called after a Neo4j import + * and from ui.setDataSourceLabel — the single choke point every import flow + * goes through. + */ +function refreshNeo4jSessionUI() { + const active = neo4jSessionActive(); + for (const id of ['neo4jExpandBtn', 'neo4jJoinBtn']) { + const btn = document.getElementById(id); + if (btn) btn.style.display = active ? '' : 'none'; + } +} + /** Persisted connection settings — everything except the password. */ function readSavedSettings(storage = globalThis.localStorage) { try { @@ -586,6 +649,7 @@ async function executeNeo4jImport(cache, config, deps = {}) { const rendered = await apply(cache, toAppFormat(nodes, relationships, exclusions)); if (rendered) { cache.ui.setDataSourceLabel(`Neo4j: ${config.database}`); + startNeo4jSession(config, nodes, relationships, exclusions); } return rendered; } catch (err) { @@ -720,6 +784,10 @@ export { readSavedSettings, saveSettings, showPropertyChecklist, + startNeo4jSession, + getNeo4jSession, + clearNeo4jSession, + refreshNeo4jSessionUI, DEFAULT_DATABASE, LARGE_RESULT_ROW_THRESHOLD, LARGE_ARRAY_THRESHOLD, diff --git a/src/utilities/neo4j_session.js b/src/utilities/neo4j_session.js new file mode 100644 index 0000000..39a639a --- /dev/null +++ b/src/utilities/neo4j_session.js @@ -0,0 +1,647 @@ +/** + * Neo4j session extensions — grow the current graph without a fresh import: + * + * - Expand: preflight what surrounds the selected nodes (relationship type × + * neighbor label, with counts), let the user pick pairs in a checklist, + * fetch, merge. + * - Join query: run an additional Cypher query against the connected server + * and merge its results (they may be disconnected from the current graph). + * + * Both exist only while a Neo4j session is active (successful Neo4j import in + * this browser session — see startNeo4jSession / neo4jSessionActive in + * neo4j_loader.js). + * + * Merging is deliberately NOT incremental rendering: new raw entities are + * unioned into the session accumulator, toAppFormat re-runs over the full set + * (keeps auto-colors stable across fetches), and the result goes through the + * shared applyGraph replace pipeline. Current node positions are captured + * first and stamped onto the payload so the arrangement survives; new nodes + * are seeded near a positioned neighbor, falling back to the centroid of the + * existing arrangement. Filter narrowing resets per merge (accepted); + * property exclusions from the initial import re-apply silently. + */ + +import { Popup } from './popup.js'; +import { applyGraph } from '../managers/api_client.js'; +import { settlePinnedForce } from '../graph/layout_algorithms.js'; +import { + runCypher, + countQueryRows, + collectGraph, + toAppFormat, + getNeo4jSession, + LARGE_RESULT_ROW_THRESHOLD, +} from './neo4j_loader.js'; + +// New nodes land one SEED_RADIUS from their anchor, jittered so batches of +// siblings don't stack on the exact same spot. App-model units (the default +// layouts spread nodes over a few hundred units). +const SEED_RADIUS = 80; +const SEED_JITTER = 60; +const PREFLIGHT_TIMEOUT_MS = 30_000; + +/** + * Expansion matches nodes by `elementId()` — the app's node ids are the + * legacy ids the HTTP graph format carries as `id`, while Neo4j 5 responses + * carry `elementId` alongside. `id()` is deprecated and integer-typed (the + * JSON ids are strings), so elementId is used unconditionally; Neo4j 4.x + * servers (no elementId in results) are not supported for expansion. + * + * @param {object} session + * @param {string[]} nodeIds app node ids (= raw Neo4j node ids) + * @returns {string[]} + */ +function elementIdsFor(session, nodeIds) { + return nodeIds.map((id) => session.rawNodes.get(id)?.elementId ?? String(id)); +} + +/** + * Preflight statement: one row per (relationship type, neighbor label) pair + * around the given nodes, with counts, largest first. Ids travel as + * parameters — never interpolated into the Cypher text. The leaf label + * mirrors primaryLabel (class hierarchies store the leaf last); coalesce + * keeps label-less neighbors matchable (list equality with null never + * matches in Cypher). + */ +function buildExpandPreflight(elementIds) { + return { + statement: + 'MATCH (n) WHERE elementId(n) IN $ids ' + + 'MATCH (n)-[r]-(m) ' + + "RETURN type(r) AS relType, coalesce(labels(m)[-1], '') AS neighborLabel, " + + 'count(*) AS cnt ORDER BY cnt DESC', + parameters: { ids: elementIds }, + resultDataContents: ['row'], + }; +} + +/** + * Fetch statement for the checked pairs — exact (relType, neighborLabel) + * matching via list membership, so checking (A→X) and (B→Y) does not also + * fetch (A→Y). + * + * @param {string[]} elementIds + * @param {Array<{relType: string, neighborLabel: string}>} pairs + */ +function buildExpandFetch(elementIds, pairs) { + return { + statement: + 'MATCH (n) WHERE elementId(n) IN $ids ' + + 'MATCH (n)-[r]-(m) ' + + "WHERE [type(r), coalesce(labels(m)[-1], '')] IN $pairs " + + 'RETURN n, r, m', + parameters: { + ids: elementIds, + pairs: pairs.map((pair) => [pair.relType, pair.neighborLabel]), + }, + resultDataContents: ['graph'], + }; +} + +/** + * Stitch statement: every relationship whose BOTH endpoints are already part + * of the graph. Individual queries only return relationships their own + * pattern matched, so two fetches about different entities never reveal how + * their result sets interconnect — this closes that gap. Returns no new + * nodes by construction (both endpoints constrained to $ids). + * + * USING JOIN ON m forces a NodeHashJoin: without it the planner (which + * under-estimates the expansion when the loaded set contains supernodes) + * checks `elementId(m) IN $ids` as a linear list scan PER expanded + * relationship — a 372k-degree hub × a 500-id list took minutes; the hash + * join makes it one O(1) probe per relationship. `<=` returns each + * relationship once instead of once per direction (self-loops included). + */ +function buildStitchQuery(elementIds) { + return { + statement: + 'MATCH (n)-[r]-(m) ' + + 'USING JOIN ON m ' + + 'WHERE elementId(n) IN $ids AND elementId(m) IN $ids ' + + 'AND elementId(n) <= elementId(m) ' + + 'RETURN r', + parameters: { ids: elementIds }, + resultDataContents: ['graph'], + }; +} + +/** Element ids of everything the session has accumulated plus the given new nodes. */ +function allElementIds(session, newNodes) { + const ids = new Set(); + for (const node of session.rawNodes.values()) ids.add(node.elementId ?? String(node.id)); + for (const node of newNodes) ids.add(node.elementId ?? String(node.id)); + return [...ids]; +} + +/** + * Run the stitch query and append its results to the fetched batch in place. + * Stitched endpoints are already-known nodes and duplicate ids are deduped by + * the session accumulator — the relationships are the point. + */ +async function appendStitch(session, nodes, relationships, opts) { + const results = await runCypher( + session.config, + [buildStitchQuery(allElementIds(session, nodes))], + opts, + ); + const stitched = collectGraph(results); + nodes.push(...stitched.nodes); + relationships.push(...stitched.relationships); +} + +/** The stitch checkbox row shared by the expand checklist and the join popup. */ +function buildStitchRow() { + const row = document.createElement('label'); + row.className = 'neo4j-stitch-row'; + row.title = + 'One extra query after the fetch: MATCH (n)-[r]-(m) with both endpoints already in the graph. Adds no nodes, only the missing links.'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = true; + row.appendChild(checkbox); + row.appendChild( + document.createTextNode( + ' Stitch: also fetch relationships between the new results and everything already loaded', + ), + ); + return { row, checkbox }; +} + +/** + * Stamp captured positions onto nodes that already existed and seed the new + * ones — near a positioned neighbor when one exists (expand), else at the + * centroid of the captured arrangement (join query). Every node ends up + * positioned, so the apply pipeline restores the arrangement instead of + * inventing a new one. With nothing captured (no graph yet) the payload is + * left untouched and the normal initial layout places everything. + * + * @param {{nodes: object[], edges: object[]}} data app-format payload (mutated) + * @param {Map} positions app-model (y-down) + */ +function seedMergedPositions(data, positions) { + if (positions.size === 0) return; + + const neighbors = new Map(); + const link = (a, b) => { + if (!neighbors.has(a)) neighbors.set(a, []); + neighbors.get(a).push(b); + }; + for (const edge of data.edges) { + link(edge.source, edge.target); + link(edge.target, edge.source); + } + + let centroidX = 0; + let centroidY = 0; + for (const pos of positions.values()) { + centroidX += pos.x; + centroidY += pos.y; + } + centroidX /= positions.size; + centroidY /= positions.size; + + const seedNear = (anchor) => { + const angle = Math.random() * 2 * Math.PI; + const jitter = () => (Math.random() - 0.5) * 2 * SEED_JITTER; + return { + x: anchor.x + Math.cos(angle) * SEED_RADIUS + jitter(), + y: anchor.y + Math.sin(angle) * SEED_RADIUS + jitter(), + }; + }; + + for (const node of data.nodes) { + let pos = positions.get(node.id); + if (!pos) { + let anchor = null; + for (const neighborId of neighbors.get(node.id) ?? []) { + anchor = positions.get(neighborId); + if (anchor) break; + } + pos = seedNear(anchor ?? { x: centroidX, y: centroidY }); + } + node.style = { ...node.style, x: pos.x, y: pos.y }; + } +} + +/** + * The merge primitive both flows share: capture positions, union the raw + * entities into the session (Map.set — re-fetched ids refresh stale + * properties), re-run toAppFormat over the whole accumulator with the + * session's exclusions, seed positions, re-apply. + * + * @param {object} cache + * @param {object[]} newNodes raw Neo4j nodes + * @param {object[]} newRels raw Neo4j relationships + * @param {{apply?: Function}} [deps] injectable for tests + * @returns {Promise} true when the merged graph was rendered + */ +async function mergeAndApply(cache, newNodes, newRels, deps = {}) { + const session = getNeo4jSession(); + const apply = deps.apply ?? applyGraph; + + // Capture the arrangement BEFORE anything else. getNodeData() syncs x/y + // from the live graphology graph into the refs and returns app-model + // (y-down) coordinates — the same space persisted positions use. (Reading + // any later, e.g. after the apply started tearing down, would lose it.) + const positions = new Map(); + for (const node of (await cache.graph?.getNodeData()) ?? []) { + positions.set(node.id, { x: node.style.x, y: node.style.y }); + } + + for (const node of newNodes) session.rawNodes.set(node.id, node); + for (const rel of newRels) session.rawRels.set(rel.id, rel); + + const data = toAppFormat( + [...session.rawNodes.values()], + [...session.rawRels.values()], + session.exclusions, + ); + seedMergedPositions(data, positions); + + // Declare the (single, current) workspace in the payload so preProcessData + // takes the JSON-import path: with a layout whose positions cover every + // node, no initial force layout fires. Bare stamped styles would take the + // Excel path instead, whose post-render force pass visibly shuffles the + // graph and then snaps back — the arrangement must land exactly where the + // seeding put it, in one paint. + if (positions.size > 0) { + // ponytail: a workspace the user literally named "custom" (the Excel + // sentinel, DEFAULTS.CUSTOM_LAYOUT_NAME) still triggers that force pass. + const layoutName = cache.data?.selectedLayout || 'Default'; + data.selectedLayout = layoutName; + data.layouts = { + [layoutName]: { + isCustom: true, + positions: Object.fromEntries( + data.nodes.map((node) => [node.id, { style: { x: node.style.x, y: node.style.y } }]), + ), + }, + }; + } + + const rendered = await apply(cache, data); + if (rendered) { + // applyGraph stamps its own data-source label — restore the session's + // (this also re-shows the expand/join buttons via the label hook). + cache.ui.setDataSourceLabel(`Neo4j: ${session.config.database}`); + + // Float the new nodes into place: an animated force pass over the full + // graph with everything else pinned, then persist the settled positions + // (graphology is the source of truth after the settle, so the + // getNodeData-based persist reads the right direction). + const newIds = data.nodes.filter((node) => !positions.has(node.id)).map((node) => node.id); + if (positions.size > 0 && newIds.length > 0 && cache.graphData) { + const settle = deps.settle ?? settlePinnedForce; + await settle(cache.graphData, newIds); + await cache.lm.persistNodePositions(); + } + } + return rendered; +} + +/** + * Checklist over the preflight pairs. Resolves with the checked pairs and + * the stitch choice, or null on cancel. All pairs (and stitch) start checked; + * the summed count of the checked rows doubles as the size warning (same + * threshold as the import preflight). + * + * @param {Array<{relType: string, neighborLabel: string, count: number}>} pairs + * @returns {Promise<{pairs: Array<{relType: string, neighborLabel: string, count: number}>, stitch: boolean}|null>} + */ +function showExpandChecklist(pairs) { + return new Promise((resolve) => { + const content = document.createElement('div'); + const intro = document.createElement('p'); + intro.className = 'neo4j-hint'; + intro.textContent = + 'Neighbors of the selected nodes, grouped by relationship type and label. ' + + 'Checked groups are fetched and merged into the graph.'; + content.appendChild(intro); + + const heading = document.createElement('div'); + heading.className = 'neo4j-props-heading'; + const headingLabel = document.createElement('label'); + const toggleAll = document.createElement('input'); + toggleAll.type = 'checkbox'; + headingLabel.appendChild(toggleAll); + headingLabel.appendChild(document.createTextNode(' Neighbor groups')); + heading.appendChild(headingLabel); + content.appendChild(heading); + + const list = document.createElement('div'); + list.className = 'neo4j-props-list'; + const rowBoxes = []; + pairs.forEach((pair, index) => { + const row = document.createElement('label'); + row.className = 'neo4j-prop-row'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = true; + checkbox.dataset.index = index; + rowBoxes.push(checkbox); + row.appendChild(checkbox); + + const name = document.createElement('span'); + name.className = 'neo4j-prop-name'; + name.textContent = `${pair.relType} → ${pair.neighborLabel || 'Node'}`; + row.appendChild(name); + + const count = document.createElement('span'); + count.className = 'neo4j-prop-type'; + count.textContent = String(pair.count); + count.title = 'Matching relationships'; + row.appendChild(count); + + list.appendChild(row); + }); + content.appendChild(list); + + const warning = document.createElement('div'); + warning.className = 'neo4j-warning'; + warning.setAttribute('role', 'status'); // announced when the sum crosses the threshold + warning.hidden = true; + content.appendChild(warning); + + const { row: stitchRow, checkbox: stitchBox } = buildStitchRow(); + content.appendChild(stitchRow); + + const footer = document.createElement('div'); + footer.className = 'p-footer'; + const cancelBtn = document.createElement('button'); + cancelBtn.textContent = 'Cancel'; + cancelBtn.className = 'p-button p-button-secondary'; + const fetchBtn = document.createElement('button'); + fetchBtn.textContent = 'Fetch & merge'; + fetchBtn.className = 'p-button p-button-primary'; + footer.appendChild(cancelBtn); + footer.appendChild(fetchBtn); + content.appendChild(footer); + + const syncState = () => { + const checked = rowBoxes.filter((box) => box.checked); + toggleAll.checked = checked.length === rowBoxes.length; + toggleAll.indeterminate = checked.length > 0 && checked.length < rowBoxes.length; + fetchBtn.disabled = checked.length === 0; + const sum = checked.reduce((total, box) => total + pairs[box.dataset.index].count, 0); + warning.hidden = sum <= LARGE_RESULT_ROW_THRESHOLD; + warning.textContent = warning.hidden + ? '' + : `The checked groups sum to ${sum.toLocaleString()} relationships, which may be slow to fetch and render.`; + }; + syncState(); + toggleAll.addEventListener('change', () => { + rowBoxes.forEach((box) => (box.checked = toggleAll.checked)); + syncState(); + }); + list.addEventListener('change', syncState); + + let resolved = false; + const popup = new Popup(content, { + title: 'Expand from Neo4j', + width: '480px', + showFullscreenButton: false, + closeOnClickOutside: false, + onClose: () => { + if (!resolved) resolve(null); + }, + }); + + fetchBtn.addEventListener('click', () => { + resolved = true; + popup.close(); + resolve({ + pairs: rowBoxes.filter((box) => box.checked).map((box) => pairs[box.dataset.index]), + stitch: stitchBox.checked, + }); + }); + cancelBtn.addEventListener('click', () => { + resolved = true; + popup.close(); + resolve(null); + }); + }); +} + +/** + * Expand flow: preflight → checklist → fetch → merge. Entry point for the + * selection-HUD button; no-ops without an active session or selection. + * + * @param {object} cache + * @param {{fetchImpl?: Function, checklist?: Function, apply?: Function}} [deps] + * @returns {Promise} true when a merged graph was rendered + */ +async function expandNeo4jSelection(cache, deps = {}) { + const session = getNeo4jSession(); + const selected = cache.selectedNodes ?? []; + if (!session || selected.length === 0) return false; + + const checklist = deps.checklist ?? showExpandChecklist; + const opts = deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}; + const progress = async (message) => { + if (message) await cache.ui.showLoading('Neo4j', message); + else await cache.ui.hideLoading(); + }; + + try { + const ids = elementIdsFor(session, selected); + + await progress('Checking the neighborhood …'); + const preflight = await runCypher(session.config, [buildExpandPreflight(ids)], { + ...opts, + timeoutMs: PREFLIGHT_TIMEOUT_MS, + }); + await progress(null); + + const pairs = (preflight[0]?.data ?? []).map(({ row }) => ({ + relType: row[0], + neighborLabel: row[1], + count: row[2], + })); + if (pairs.length === 0) { + cache.ui.info('The selected nodes have no neighbors in Neo4j.'); + return false; + } + + const chosen = await checklist(pairs); + if (!chosen || chosen.pairs.length === 0) return false; + + await progress('Fetching neighbors …'); + const results = await runCypher(session.config, [buildExpandFetch(ids, chosen.pairs)], opts); + const { nodes, relationships } = collectGraph(results); + if (nodes.length === 0) { + await progress(null); + // Counts said otherwise, so the data changed under us — nothing to merge. + cache.ui.info('The expansion returned no graph elements.'); + return false; + } + + if (chosen.stitch) { + await progress('Stitching …'); + await appendStitch(session, nodes, relationships, opts); + } + await progress(null); + + return await mergeAndApply(cache, nodes, relationships, deps); + } catch (err) { + await progress(null); + cache.ui.error(`Neo4j expand: ${err.message}`); + return false; + } +} + +/** @returns {HTMLElement} join-query form body */ +function buildJoinForm(database) { + const form = document.createElement('div'); + form.innerHTML = ` +
+ + + Must return nodes, relationships, or paths. +
+ +
+ + `; + // textContent — the database name is user input and must not hit innerHTML. + form.querySelector('#neo4j-join-info').textContent = + `Runs against the connected session (database "${database}") and merges the ` + + 'results into the current graph. Results may be disconnected from it. ' + + 'Properties excluded at import stay excluded — re-import to change that.'; + + const { row, checkbox } = buildStitchRow(); + checkbox.id = 'neo4j-join-stitch'; + form.insertBefore(row, form.querySelector('#neo4j-join-error')); + return form; +} + +/** + * Join-query flow: a slim popup (query only — the session is already + * authenticated) with the same count preflight and huge-result confirm as the + * initial import, then merge. + * + * @param {object} cache + * @param {{fetchImpl?: Function, confirm?: Function, apply?: Function}} [deps] + * @returns {Promise} true when a merged graph was rendered + */ +function openNeo4jJoinPopup(cache, deps = {}) { + const session = getNeo4jSession(); + if (!session) return Promise.resolve(false); + + const form = buildJoinForm(session.config.database); + // Grab element references before constructing the Popup — it relocates the + // .p-footer out of the content element (see openNeo4jPopup). + const fetchBtn = form.querySelector('#neo4j-join-fetch-btn'); + const cancelBtn = form.querySelector('#neo4j-join-cancel-btn'); + const errorBox = form.querySelector('#neo4j-join-error'); + const queryBox = form.querySelector('#neo4j-join-query'); + const stitchBox = form.querySelector('#neo4j-join-stitch'); + + return new Promise((resolve) => { + let settled = false; + let dataFetched = false; + const settle = (value) => { + if (!settled) { + settled = true; + resolve(value); + } + }; + + const popup = new Popup(form, { + title: 'Add Neo4j Query', + width: '480px', + showFullscreenButton: false, + closeOnClickOutside: false, + onClose: () => { + if (!dataFetched) settle(false); + }, + }); + + const setBusy = (message) => { + fetchBtn.disabled = !!message; + cancelBtn.disabled = !!message; + fetchBtn.innerHTML = message + ? `${message}` + : 'Fetch & merge'; + }; + const showError = (message) => { + errorBox.textContent = message; + errorBox.hidden = false; + }; + + const handleFetch = async () => { + errorBox.hidden = true; + const query = queryBox.value.trim(); + if (!query) { + showError('A Cypher query is required.'); + return; + } + const confirm = deps.confirm ?? Popup.confirm; + const opts = deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}; + + try { + setBusy('Counting …'); + const rowCount = await countQueryRows(session.config, query, opts); + if (rowCount !== null && rowCount > LARGE_RESULT_ROW_THRESHOLD) { + setBusy(null); + const proceed = await confirm( + `The query matches ${rowCount.toLocaleString()} rows, which may be slow to fetch and render. Continue anyway? (Tip: add a LIMIT clause.)`, + ); + if (proceed !== true) return; + } + + setBusy('Fetching …'); + const results = await runCypher( + session.config, + [{ statement: query, resultDataContents: ['graph'] }], + opts, + ); + const { nodes, relationships } = collectGraph(results); + if (nodes.length === 0) { + setBusy(null); + showError( + 'The query returned no graph elements. Return nodes, relationships, or paths (e.g. MATCH (n)-[r]->(m) RETURN n, r, m).', + ); + return; + } + + if (stitchBox.checked) { + setBusy('Stitching …'); + await appendStitch(session, nodes, relationships, opts); + } + + dataFetched = true; + popup.close(); + settle(await mergeAndApply(cache, nodes, relationships, deps)); + } catch (err) { + setBusy(null); + showError(`Neo4j: ${err.message}`); + } + }; + + fetchBtn.addEventListener('click', handleFetch); + cancelBtn.addEventListener('click', () => { + popup.close(); + settle(false); + }); + setTimeout(() => queryBox.focus(), 100); + }); +} + +export { + expandNeo4jSelection, + openNeo4jJoinPopup, + mergeAndApply, + seedMergedPositions, + showExpandChecklist, + buildExpandPreflight, + buildExpandFetch, + buildStitchQuery, + elementIdsFor, + SEED_RADIUS, + SEED_JITTER, +}; diff --git a/tests/layout-algorithms.test.js b/tests/layout-algorithms.test.js index 51c86e5..cf065cc 100644 --- a/tests/layout-algorithms.test.js +++ b/tests/layout-algorithms.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { Graph, forceAtlas2 } from "../src/lib/graphology.bundle.mjs"; -import { applyNoverlap, executeLayout } from "../src/graph/layout_algorithms.js"; +import { applyNoverlap, executeLayout, settlePinnedForce } from "../src/graph/layout_algorithms.js"; import { DEFAULTS } from "../src/config.js"; // ========================================================================== @@ -698,3 +698,50 @@ describe("executeLayout — edge cases", () => { }, ); }); + +// ========================================================================== +// settlePinnedForce — animated FA2 over the full graph with all but the +// given nodes pinned (Neo4j merge settle). Pinning is iterate-and-restore, +// so pinned coordinates must be bit-identical afterwards. +// ========================================================================== +describe("settlePinnedForce", () => { + const fastOpts = { durationMs: 25, raf: (cb) => setTimeout(cb, 0) }; + + it("moves only the free nodes; pinned coordinates are bit-identical", async () => { + const graph = starGraph(6); + const before = new Map(graph.mapNodes((id, a) => [id, { x: a.x, y: a.y }])); + + await settlePinnedForce(graph, ["n0", "n1"], fastOpts); + + let movedFree = 0; + graph.forEachNode((id, attrs) => { + const prev = before.get(id); + if (id === "n0" || id === "n1") { + expect(Number.isFinite(attrs.x)).toBe(true); + if (attrs.x !== prev.x || attrs.y !== prev.y) movedFree++; + } else { + expect(attrs.x).toBe(prev.x); + expect(attrs.y).toBe(prev.y); + } + }); + expect(movedFree).toBeGreaterThan(0); + }); + + it("no-ops when nothing is free, everything is free, or the graph is tiny", async () => { + const graph = starGraph(3); + const before = new Map(graph.mapNodes((id, a) => [id, { x: a.x, y: a.y }])); + + await settlePinnedForce(graph, [], fastOpts); + await settlePinnedForce(graph, graph.nodes(), fastOpts); + + graph.forEachNode((id, attrs) => { + expect(attrs.x).toBe(before.get(id).x); + expect(attrs.y).toBe(before.get(id).y); + }); + + const single = new Graph(); + single.addNode("only", { x: 1, y: 1 }); + await settlePinnedForce(single, ["only"], fastOpts); + expect(single.getNodeAttribute("only", "x")).toBe(1); + }); +}); diff --git a/tests/neo4j-session.test.js b/tests/neo4j-session.test.js new file mode 100644 index 0000000..fb0c5dd --- /dev/null +++ b/tests/neo4j-session.test.js @@ -0,0 +1,771 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + expandNeo4jSelection, + openNeo4jJoinPopup, + mergeAndApply, + seedMergedPositions, + showExpandChecklist, + buildExpandPreflight, + buildExpandFetch, + buildStitchQuery, + elementIdsFor, + SEED_RADIUS, + SEED_JITTER, +} from '../src/utilities/neo4j_session.js'; +import { + runCypher, + executeNeo4jImport, + startNeo4jSession, + getNeo4jSession, + clearNeo4jSession, + refreshNeo4jSessionUI, + toAppFormat, + LARGE_RESULT_ROW_THRESHOLD, + SETTINGS_STORAGE_KEY, +} from '../src/utilities/neo4j_loader.js'; + +const CONFIG = { + url: 'http://localhost:7474', + username: 'neo4j', + password: 'sup3rsecret', + database: 'movies', +}; + +const NO_EXCLUSIONS = { excludedNodeProps: new Set(), excludedEdgeProps: new Set() }; + +const rawNode = (id, label = 'Person', properties = {}, extra = {}) => ({ + id, + labels: [label], + properties, + ...extra, +}); +const rawRel = (id, startNode, endNode, type = 'KNOWS') => ({ + id, + type, + startNode, + endNode, + properties: {}, +}); + +function jsonResponse(body, status = 200) { + return { ok: status >= 200 && status < 300, status, json: async () => body }; +} + +function graphResponse(nodes, relationships) { + return jsonResponse({ + results: [{ data: [{ graph: { nodes, relationships } }] }], + errors: [], + }); +} + +/** Minimal app cache: rendered nodes with app-model positions + ui mocks. */ +function makeCache(renderedNodes = [], selectedNodes = []) { + return { + selectedNodes, + graph: { + getNodeData: vi.fn().mockResolvedValue(renderedNodes), + }, + ui: { + showLoading: vi.fn().mockResolvedValue(undefined), + hideLoading: vi.fn().mockResolvedValue(undefined), + setDataSourceLabel: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, + }; +} + +beforeEach(() => { + document.body.innerHTML = ''; + localStorage.clear(); + clearNeo4jSession(); +}); + +describe('runCypher statement passthrough (expand/join contract)', () => { + it('sends parameters and per-statement resultDataContents verbatim', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ results: [], errors: [] })); + const statement = { + statement: 'MATCH (n) WHERE elementId(n) IN $ids RETURN n', + parameters: { ids: ['4:abc:1'] }, + resultDataContents: ['row'], + }; + await runCypher(CONFIG, [statement], { fetchImpl }); + + expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({ statements: [statement] }); + }); +}); + +describe('expand query builders', () => { + const ids = ['4:abc:1', '4:abc:2']; + + it('parameterizes ids — never interpolated into the Cypher text', () => { + for (const built of [buildExpandPreflight(ids), buildExpandFetch(ids, [])]) { + expect(built.statement).not.toContain('4:abc'); + expect(built.parameters.ids).toEqual(ids); + } + }); + + it('preflight returns scalar rows grouped by type and leaf label', () => { + const built = buildExpandPreflight(ids); + expect(built.resultDataContents).toEqual(['row']); + expect(built.statement).toContain("coalesce(labels(m)[-1], '')"); + expect(built.statement).toContain('count(*)'); + }); + + it('fetch filters by exact (type, label) pairs and returns graph data', () => { + const built = buildExpandFetch(ids, [ + { relType: 'ACTED_IN', neighborLabel: 'Movie' }, + { relType: 'KNOWS', neighborLabel: '' }, + ]); + expect(built.resultDataContents).toEqual(['graph']); + expect(built.statement).toContain("[type(r), coalesce(labels(m)[-1], '')] IN $pairs"); + expect(built.parameters.pairs).toEqual([ + ['ACTED_IN', 'Movie'], + ['KNOWS', ''], + ]); + }); +}); + +describe('buildStitchQuery', () => { + it('constrains BOTH endpoints to the loaded ids, parameterized, graph format', () => { + const built = buildStitchQuery(['4:abc:1', '4:abc:2']); + expect(built.statement).toContain('elementId(n) IN $ids AND elementId(m) IN $ids'); + expect(built.statement).not.toContain('4:abc'); + expect(built.parameters.ids).toEqual(['4:abc:1', '4:abc:2']); + expect(built.resultDataContents).toEqual(['graph']); + }); + + it('hash-joins on the far endpoint and dedupes directions (supernode regression)', () => { + // Without USING JOIN the planner checks the far end with a linear list + // scan per expanded relationship — a 372k-degree hub made a 500-node + // stitch take minutes. `<=` keeps self-loops while halving the rows. + const built = buildStitchQuery(['4:abc:1']); + expect(built.statement).toContain('USING JOIN ON m'); + expect(built.statement).toContain('elementId(n) <= elementId(m)'); + }); +}); + +describe('elementIdsFor', () => { + it('prefers the raw node elementId and falls back to the app id', () => { + startNeo4jSession( + CONFIG, + [rawNode('1', 'Person', {}, { elementId: '4:abc:1' }), rawNode('2')], + [], + NO_EXCLUSIONS, + ); + expect(elementIdsFor(getNeo4jSession(), ['1', '2'])).toEqual(['4:abc:1', '2']); + }); +}); + +describe('seedMergedPositions', () => { + const maxSeedDistance = SEED_RADIUS + 2 * SEED_JITTER; + + it('keeps captured positions for existing nodes', () => { + const data = { nodes: [{ id: 'a', style: { fill: '#FF0000' } }], edges: [] }; + seedMergedPositions(data, new Map([['a', { x: 10, y: -20 }]])); + expect(data.nodes[0].style).toEqual({ fill: '#FF0000', x: 10, y: -20 }); + }); + + it('seeds new nodes near a positioned neighbor', () => { + const data = { + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [{ source: 'a', target: 'b' }], + }; + seedMergedPositions(data, new Map([['a', { x: 100, y: 200 }]])); + const b = data.nodes[1].style; + expect(Math.hypot(b.x - 100, b.y - 200)).toBeLessThanOrEqual(maxSeedDistance); + expect(Math.hypot(b.x - 100, b.y - 200)).toBeGreaterThan(0); + }); + + it('seeds disconnected new nodes at the centroid of the arrangement', () => { + const data = { nodes: [{ id: 'lonely' }], edges: [] }; + const positions = new Map([ + ['a', { x: 0, y: 0 }], + ['b', { x: 200, y: 100 }], + ]); + seedMergedPositions(data, positions); + const style = data.nodes[0].style; + expect(Math.hypot(style.x - 100, style.y - 50)).toBeLessThanOrEqual(maxSeedDistance); + }); + + it('leaves the payload untouched when nothing was captured', () => { + const data = { nodes: [{ id: 'a' }], edges: [] }; + seedMergedPositions(data, new Map()); + expect(data.nodes[0].style).toBeUndefined(); + }); +}); + +describe('mergeAndApply', () => { + it('captures positions before the union, keeps them, and refreshes overlapping ids', async () => { + startNeo4jSession(CONFIG, [rawNode('1', 'Person', { name: 'Old' })], [], NO_EXCLUSIONS); + const cache = makeCache([{ id: '1', style: { x: 42, y: 24 } }]); + const apply = vi.fn().mockResolvedValue(true); + + const rendered = await mergeAndApply( + cache, + [rawNode('1', 'Person', { name: 'Fresh' }), rawNode('2', 'Movie', { title: 'M' })], + [rawRel('r1', '1', '2', 'ACTED_IN')], + { apply }, + ); + + expect(rendered).toBe(true); + const data = apply.mock.calls[0][1]; + const node1 = data.nodes.find((n) => n.id === '1'); + // Re-fetched id refreshed stale properties, position survived. + expect(node1.D4Data['Node filters'].Person.name).toBe('Fresh'); + expect(node1.style.x).toBe(42); + expect(node1.style.y).toBe(24); + expect(data.edges).toHaveLength(1); + // Session accumulator now carries the union for the next merge. + expect([...getNeo4jSession().rawNodes.keys()].sort()).toEqual(['1', '2']); + }); + + it('declares the current workspace with full positions so no initial layout fires', async () => { + startNeo4jSession(CONFIG, [rawNode('1')], [rawRel('r1', '1', '2')], NO_EXCLUSIONS); + const cache = makeCache([{ id: '1', style: { x: 7, y: 8 } }]); + cache.data = { selectedLayout: 'My view' }; + const apply = vi.fn().mockResolvedValue(true); + + await mergeAndApply(cache, [rawNode('2')], [], { apply }); + + const data = apply.mock.calls[0][1]; + // The JSON-import path (fileData.layouts present, positions for every + // node, no layoutType) skips the Excel path's post-render force pass — + // regression guard for the visible re-layout jump after a merge. + expect(data.selectedLayout).toBe('My view'); + const layout = data.layouts['My view']; + expect(layout.layoutType).toBeUndefined(); + expect(Object.keys(layout.positions).sort()).toEqual(['1', '2']); + expect(layout.positions['1']).toEqual({ style: { x: 7, y: 8 } }); + expect(Number.isFinite(layout.positions['2'].style.x)).toBe(true); + }); + + it('settles only the new nodes after apply, then persists the positions', async () => { + startNeo4jSession(CONFIG, [rawNode('1')], [], NO_EXCLUSIONS); + const cache = makeCache([{ id: '1', style: { x: 0, y: 0 } }]); + cache.graphData = { order: 2 }; // live graphology stand-in + cache.lm = { persistNodePositions: vi.fn().mockResolvedValue(undefined) }; + const settle = vi.fn().mockResolvedValue(undefined); + + await mergeAndApply(cache, [rawNode('2')], [], { + apply: vi.fn().mockResolvedValue(true), + settle, + }); + + expect(settle).toHaveBeenCalledWith(cache.graphData, ['2']); + expect(cache.lm.persistNodePositions).toHaveBeenCalledOnce(); + }); + + it('skips the settle when the merge brought no new nodes', async () => { + startNeo4jSession(CONFIG, [rawNode('1')], [], NO_EXCLUSIONS); + const cache = makeCache([{ id: '1', style: { x: 0, y: 0 } }]); + cache.graphData = { order: 1 }; + cache.lm = { persistNodePositions: vi.fn() }; + const settle = vi.fn(); + + await mergeAndApply(cache, [rawNode('1', 'Person', { name: 'refreshed' })], [], { + apply: vi.fn().mockResolvedValue(true), + settle, + }); + + expect(settle).not.toHaveBeenCalled(); + expect(cache.lm.persistNodePositions).not.toHaveBeenCalled(); + }); + + it('omits the workspace declaration when nothing was rendered yet', async () => { + startNeo4jSession(CONFIG, [], [], NO_EXCLUSIONS); + const cache = makeCache([]); + const apply = vi.fn().mockResolvedValue(true); + + await mergeAndApply(cache, [rawNode('1')], [], { apply }); + + expect(apply.mock.calls[0][1].layouts).toBeUndefined(); + }); + + it('re-sets the Neo4j data-source label after apply (applyGraph clobbers it)', async () => { + startNeo4jSession(CONFIG, [], [], NO_EXCLUSIONS); + const cache = makeCache(); + await mergeAndApply(cache, [rawNode('1')], [], { apply: vi.fn().mockResolvedValue(true) }); + expect(cache.ui.setDataSourceLabel).toHaveBeenCalledWith('Neo4j: movies'); + }); + + it('does not re-label when apply fails', async () => { + startNeo4jSession(CONFIG, [], [], NO_EXCLUSIONS); + const cache = makeCache(); + const rendered = await mergeAndApply(cache, [rawNode('1')], [], { + apply: vi.fn().mockResolvedValue(false), + }); + expect(rendered).toBe(false); + expect(cache.ui.setDataSourceLabel).not.toHaveBeenCalled(); + }); + + it('keeps colors stable across merges (same category set → same colors)', async () => { + const initialNodes = [rawNode('1', 'Person'), rawNode('2', 'Movie')]; + const before = toAppFormat(initialNodes, [], NO_EXCLUSIONS); + const colorBefore = before.nodes.find((n) => n.id === '1').style.fill; + + startNeo4jSession(CONFIG, initialNodes, [], NO_EXCLUSIONS); + const apply = vi.fn().mockResolvedValue(true); + await mergeAndApply(makeCache(), [rawNode('3', 'Person')], [], { apply }); + + const merged = apply.mock.calls[0][1]; + expect(merged.nodes.find((n) => n.id === '1').style.fill).toBe(colorBefore); + expect(merged.nodes.find((n) => n.id === '3').style.fill).toBe(colorBefore); + }); + + it('re-applies the import-time property exclusions to merged data', async () => { + startNeo4jSession(CONFIG, [], [], { + excludedNodeProps: new Set(['embedding']), + excludedEdgeProps: new Set(), + }); + const apply = vi.fn().mockResolvedValue(true); + await mergeAndApply( + makeCache(), + [rawNode('1', 'Person', { name: 'Ada', embedding: [1, 2, 3] })], + [], + { apply }, + ); + expect(apply.mock.calls[0][1].nodes[0].D4Data['Node filters'].Person).toEqual({ + name: 'Ada', + }); + }); +}); + +describe('session lifecycle and UI gating', () => { + const mountButtons = (labelText) => { + document.body.innerHTML = ` + ${labelText} + + + `; + }; + + it('shows the buttons while the session is active and the label is Neo4j', () => { + mountButtons('Neo4j: movies'); + startNeo4jSession(CONFIG, [], [], NO_EXCLUSIONS); + expect(document.getElementById('neo4jExpandBtn').style.display).toBe(''); + expect(document.getElementById('neo4jJoinBtn').style.display).toBe(''); + }); + + it('hides the buttons when another source labels the graph', () => { + mountButtons('Neo4j: movies'); + startNeo4jSession(CONFIG, [], [], NO_EXCLUSIONS); + document.getElementById('dataSourceLabel').textContent = 'Live (API)'; + refreshNeo4jSessionUI(); + expect(document.getElementById('neo4jExpandBtn').style.display).toBe('none'); + expect(document.getElementById('neo4jJoinBtn').style.display).toBe('none'); + }); + + it('hides the buttons without a session', () => { + mountButtons('Neo4j: movies'); + refreshNeo4jSessionUI(); + expect(document.getElementById('neo4jExpandBtn').style.display).toBe('none'); + }); + + it('executeNeo4jImport starts the session; the password never reaches localStorage', async () => { + mountButtons(''); + const cache = makeCache(); + cache.ui.setDataSourceLabel = (text) => { + document.getElementById('dataSourceLabel').textContent = text; + }; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [{ row: [1] }] }], errors: [] })) + .mockResolvedValueOnce(graphResponse([rawNode('1', 'Person', { name: 'Ada' })], [])); + + const rendered = await executeNeo4jImport( + cache, + { ...CONFIG, query: 'MATCH (n) RETURN n' }, + { + fetchImpl, + checklist: vi.fn().mockResolvedValue(NO_EXCLUSIONS), + apply: vi.fn().mockResolvedValue(true), + }, + ); + + expect(rendered).toBe(true); + expect(getNeo4jSession()).not.toBeNull(); + expect(getNeo4jSession().rawNodes.has('1')).toBe(true); + expect(document.getElementById('neo4jExpandBtn').style.display).toBe(''); + for (const key of Object.keys(localStorage)) { + expect(localStorage.getItem(key)).not.toContain(CONFIG.password); + } + expect(localStorage.getItem(SETTINGS_STORAGE_KEY) ?? '').not.toContain(CONFIG.password); + }); +}); + +describe('expandNeo4jSelection', () => { + const preflightResponse = jsonResponse({ + results: [ + { + data: [ + { row: ['ACTED_IN', 'Movie', 12] }, + { row: ['KNOWS', 'Person', 3] }, + ], + }, + ], + errors: [], + }); + + it('no-ops without a session or without a selection', async () => { + expect(await expandNeo4jSelection(makeCache([], ['1']))).toBe(false); + startNeo4jSession(CONFIG, [], [], NO_EXCLUSIONS); + expect(await expandNeo4jSelection(makeCache([], []))).toBe(false); + }); + + it('preflights, filters by the checked pairs, fetches, and merges', async () => { + startNeo4jSession( + CONFIG, + [rawNode('1', 'Person', {}, { elementId: '4:abc:1' })], + [], + NO_EXCLUSIONS, + ); + const cache = makeCache([{ id: '1', style: { x: 5, y: 5 } }], ['1']); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(preflightResponse) + .mockResolvedValueOnce( + graphResponse( + [rawNode('1', 'Person'), rawNode('9', 'Movie', { title: 'M' })], + [rawRel('r1', '1', '9', 'ACTED_IN')], + ), + ); + const checklist = vi + .fn() + .mockImplementation(async (pairs) => ({ pairs: [pairs[0]], stitch: false })); + const apply = vi.fn().mockResolvedValue(true); + + const rendered = await expandNeo4jSelection(cache, { fetchImpl, checklist, apply }); + + expect(rendered).toBe(true); + expect(checklist).toHaveBeenCalledWith([ + { relType: 'ACTED_IN', neighborLabel: 'Movie', count: 12 }, + { relType: 'KNOWS', neighborLabel: 'Person', count: 3 }, + ]); + const preflightBody = JSON.parse(fetchImpl.mock.calls[0][1].body); + expect(preflightBody.statements[0].parameters.ids).toEqual(['4:abc:1']); + const fetchBody = JSON.parse(fetchImpl.mock.calls[1][1].body); + expect(fetchBody.statements[0].parameters.pairs).toEqual([['ACTED_IN', 'Movie']]); + const merged = apply.mock.calls[0][1]; + expect(merged.nodes.map((n) => n.id).sort()).toEqual(['1', '9']); + // The anchor kept its position; the new node was seeded near it. + expect(merged.nodes.find((n) => n.id === '1').style.x).toBe(5); + expect(merged.nodes.find((n) => n.id === '9').style.x).toBeDefined(); + }); + + it('informs instead of merging when the selection has no neighbors', async () => { + startNeo4jSession(CONFIG, [rawNode('1')], [], NO_EXCLUSIONS); + const cache = makeCache([], ['1']); + const fetchImpl = vi + .fn() + .mockResolvedValue(jsonResponse({ results: [{ data: [] }], errors: [] })); + + expect(await expandNeo4jSelection(cache, { fetchImpl })).toBe(false); + expect(cache.ui.info).toHaveBeenCalledWith(expect.stringContaining('no neighbors')); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('informs instead of merging when the fetch returns no elements (data changed under us)', async () => { + startNeo4jSession(CONFIG, [rawNode('1')], [], NO_EXCLUSIONS); + const cache = makeCache([], ['1']); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(preflightResponse) + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [] }], errors: [] })); + const apply = vi.fn(); + + const rendered = await expandNeo4jSelection(cache, { + fetchImpl, + checklist: vi.fn().mockImplementation(async (pairs) => ({ pairs, stitch: false })), + apply, + }); + + expect(rendered).toBe(false); + expect(cache.ui.info).toHaveBeenCalledWith(expect.stringContaining('no graph elements')); + expect(apply).not.toHaveBeenCalled(); + }); + + it('runs the stitch after the fetch when the checklist opted in', async () => { + startNeo4jSession( + CONFIG, + [rawNode('1', 'Person', {}, { elementId: '4:abc:1' })], + [], + NO_EXCLUSIONS, + ); + const cache = makeCache([{ id: '1', style: { x: 0, y: 0 } }], ['1']); + const stitchRel = rawRel('r9', '9', '1', 'REGULATES'); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(preflightResponse) + .mockResolvedValueOnce( + graphResponse([rawNode('9', 'Movie', {}, { elementId: '4:abc:9' })], []), + ) + .mockResolvedValueOnce(graphResponse([], [stitchRel])); + const apply = vi.fn().mockResolvedValue(true); + + const rendered = await expandNeo4jSelection(cache, { + fetchImpl, + checklist: vi.fn().mockImplementation(async (pairs) => ({ pairs, stitch: true })), + apply, + }); + + expect(rendered).toBe(true); + const stitchBody = JSON.parse(fetchImpl.mock.calls[2][1].body); + expect(stitchBody.statements[0].statement).toContain('USING JOIN ON m'); + expect(stitchBody.statements[0].parameters.ids.sort()).toEqual(['4:abc:1', '4:abc:9']); + expect(apply.mock.calls[0][1].edges.map((e) => e.id)).toContain('r9'); + }); + + it('aborts silently when the checklist is cancelled', async () => { + startNeo4jSession(CONFIG, [rawNode('1')], [], NO_EXCLUSIONS); + const cache = makeCache([], ['1']); + const fetchImpl = vi.fn().mockResolvedValue(preflightResponse); + const apply = vi.fn(); + + const rendered = await expandNeo4jSelection(cache, { + fetchImpl, + checklist: vi.fn().mockResolvedValue(null), + apply, + }); + + expect(rendered).toBe(false); + expect(apply).not.toHaveBeenCalled(); + expect(fetchImpl).toHaveBeenCalledTimes(1); // preflight only + }); + + it('surfaces errors and clears the busy overlay', async () => { + startNeo4jSession(CONFIG, [rawNode('1')], [], NO_EXCLUSIONS); + const cache = makeCache([], ['1']); + const rendered = await expandNeo4jSelection(cache, { + fetchImpl: vi.fn().mockRejectedValue(new Error('boom')), + }); + + expect(rendered).toBe(false); + expect(cache.ui.error).toHaveBeenCalledWith(expect.stringContaining('boom')); + expect(cache.ui.hideLoading).toHaveBeenCalled(); + }); +}); + +describe('showExpandChecklist', () => { + const pairs = [ + { relType: 'ACTED_IN', neighborLabel: 'Movie', count: 12 }, + { relType: 'KNOWS', neighborLabel: '', count: 3 }, + ]; + + it('resolves the checked pairs and stitch flag (all preselected), labels blanks as Node', async () => { + const promise = showExpandChecklist(pairs); + const names = [...document.querySelectorAll('.neo4j-prop-name')].map((el) => el.textContent); + expect(names).toEqual(['ACTED_IN → Movie', 'KNOWS → Node']); + + const boxes = [...document.querySelectorAll('.p-custom input[data-index]')]; + boxes[1].checked = false; + boxes[1].dispatchEvent(new Event('change', { bubbles: true })); + + const stitchBox = document.querySelector('.p-custom .neo4j-stitch-row input'); + expect(stitchBox.checked).toBe(true); + stitchBox.checked = false; + + const buttons = [...document.querySelectorAll('.p-custom button')]; + buttons.find((b) => b.textContent === 'Fetch & merge').click(); + expect(await promise).toEqual({ pairs: [pairs[0]], stitch: false }); + }); + + it('disables fetch at zero checked and toggle-all restores', async () => { + const promise = showExpandChecklist(pairs); + const toggleAll = document.querySelector('.neo4j-props-heading input'); + const fetchBtn = [...document.querySelectorAll('.p-custom button')].find( + (b) => b.textContent === 'Fetch & merge', + ); + + toggleAll.checked = false; + toggleAll.dispatchEvent(new Event('change')); + expect(fetchBtn.disabled).toBe(true); + + toggleAll.checked = true; + toggleAll.dispatchEvent(new Event('change')); + expect(fetchBtn.disabled).toBe(false); + + fetchBtn.click(); + expect(await promise).toEqual({ pairs, stitch: true }); + }); + + it('warns when the checked counts sum past the row threshold', async () => { + const bigPairs = [ + { relType: 'A', neighborLabel: 'X', count: LARGE_RESULT_ROW_THRESHOLD }, + { relType: 'B', neighborLabel: 'Y', count: 1 }, + ]; + const promise = showExpandChecklist(bigPairs); + const warning = document.querySelector('.neo4j-warning'); + expect(warning.hidden).toBe(false); + expect(warning.textContent).toContain('slow'); + + // Unchecking drops the sum back under the threshold. + const boxes = [...document.querySelectorAll('.p-custom input[data-index]')]; + boxes[0].checked = false; + boxes[0].dispatchEvent(new Event('change', { bubbles: true })); + expect(warning.hidden).toBe(true); + + [...document.querySelectorAll('.p-custom button')] + .find((b) => b.textContent === 'Cancel') + .click(); + expect(await promise).toBeNull(); + }); +}); + +describe('openNeo4jJoinPopup', () => { + // The Popup relocates the .p-footer out of the content element, so buttons + // must be driven through the live document (footer-relocation regression). + it('resolves false immediately without a session', async () => { + expect(await openNeo4jJoinPopup(makeCache())).toBe(false); + expect(document.querySelector('.p-custom')).toBeNull(); + }); + + it('requires a query inline and cancels cleanly', async () => { + startNeo4jSession(CONFIG, [], [], NO_EXCLUSIONS); + const promise = openNeo4jJoinPopup(makeCache()); + + document.getElementById('neo4j-join-fetch-btn').click(); + const errorBox = document.getElementById('neo4j-join-error'); + expect(errorBox.hidden).toBe(false); + expect(errorBox.textContent).toContain('required'); + + document.getElementById('neo4j-join-cancel-btn').click(); + expect(await promise).toBe(false); + }); + + it('counts, confirms above the threshold, and aborts on decline', async () => { + startNeo4jSession(CONFIG, [], [], NO_EXCLUSIONS); + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + results: [{ data: [{ row: [LARGE_RESULT_ROW_THRESHOLD + 1] }] }], + errors: [], + }), + ); + const confirm = vi.fn().mockResolvedValue(false); + const promise = openNeo4jJoinPopup(makeCache(), { fetchImpl, confirm }); + + document.getElementById('neo4j-join-query').value = 'MATCH (n) RETURN n'; + document.getElementById('neo4j-join-fetch-btn').click(); + await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce()); + expect(fetchImpl).toHaveBeenCalledTimes(1); // count only + + document.getElementById('neo4j-join-cancel-btn').click(); + expect(await promise).toBe(false); + }); + + it('fetches, merges, and resolves the render result', async () => { + startNeo4jSession(CONFIG, [rawNode('1', 'Person')], [], NO_EXCLUSIONS); + const cache = makeCache([{ id: '1', style: { x: 1, y: 2 } }]); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [{ row: [2] }] }], errors: [] })) + .mockResolvedValueOnce(graphResponse([rawNode('7', 'Gene', { symbol: 'TP53' })], [])); + const apply = vi.fn().mockResolvedValue(true); + const promise = openNeo4jJoinPopup(cache, { fetchImpl, apply }); + + document.getElementById('neo4j-join-stitch').checked = false; // plain path — stitch has its own tests + document.getElementById('neo4j-join-query').value = 'MATCH (g:Gene) RETURN g LIMIT 2'; + document.getElementById('neo4j-join-fetch-btn').click(); + + expect(await promise).toBe(true); + const merged = apply.mock.calls[0][1]; + expect(merged.nodes.map((n) => n.id).sort()).toEqual(['1', '7']); + // Join results may be disconnected — seeded at the centroid, not left bare. + expect(merged.nodes.find((n) => n.id === '7').style.x).toBeDefined(); + expect(cache.ui.setDataSourceLabel).toHaveBeenCalledWith('Neo4j: movies'); + }); + + it('shows fetch failures inline and re-enables the buttons', async () => { + startNeo4jSession(CONFIG, [], [], NO_EXCLUSIONS); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [{ row: [1] }] }], errors: [] })) + .mockRejectedValueOnce(new Error('Neo.ClientError.Statement.SyntaxError: bad')); + const promise = openNeo4jJoinPopup(makeCache(), { fetchImpl }); + + document.getElementById('neo4j-join-query').value = 'MATCH oops'; + const fetchBtn = document.getElementById('neo4j-join-fetch-btn'); + fetchBtn.click(); + + await vi.waitFor(() => { + expect(document.getElementById('neo4j-join-error').hidden).toBe(false); + }); + expect(document.getElementById('neo4j-join-error').textContent).toContain('SyntaxError'); + expect(fetchBtn.disabled).toBe(false); + + document.getElementById('neo4j-join-cancel-btn').click(); + expect(await promise).toBe(false); + }); + + it('stitches by default: fetches relationships among ALL loaded nodes and merges them', async () => { + startNeo4jSession( + CONFIG, + [rawNode('1', 'Person', {}, { elementId: '4:abc:1' })], + [], + NO_EXCLUSIONS, + ); + const cache = makeCache([{ id: '1', style: { x: 1, y: 2 } }]); + const stitchRel = rawRel('r9', '1', '7', 'REGULATES'); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [{ row: [1] }] }], errors: [] })) + .mockResolvedValueOnce(graphResponse([rawNode('7', 'Gene', {}, { elementId: '4:abc:7' })], [])) + .mockResolvedValueOnce(graphResponse([], [stitchRel])); + const apply = vi.fn().mockResolvedValue(true); + const promise = openNeo4jJoinPopup(cache, { fetchImpl, apply }); + + document.getElementById('neo4j-join-query').value = 'MATCH (g:Gene) RETURN g'; + expect(document.getElementById('neo4j-join-stitch').checked).toBe(true); + document.getElementById('neo4j-join-fetch-btn').click(); + + expect(await promise).toBe(true); + // Third call is the stitch: both accumulated and new element ids, both endpoints constrained. + const stitchBody = JSON.parse(fetchImpl.mock.calls[2][1].body); + expect(stitchBody.statements[0].statement).toContain('elementId(n) IN $ids AND elementId(m) IN $ids'); + expect(stitchBody.statements[0].parameters.ids.sort()).toEqual(['4:abc:1', '4:abc:7']); + // The stitched relationship made it into the merged payload. + expect(apply.mock.calls[0][1].edges.map((e) => e.id)).toContain('r9'); + }); + + it('skips the stitch roundtrip when the checkbox is unchecked', async () => { + startNeo4jSession(CONFIG, [], [], NO_EXCLUSIONS); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [{ row: [1] }] }], errors: [] })) + .mockResolvedValueOnce(graphResponse([rawNode('7', 'Gene')], [])); + const apply = vi.fn().mockResolvedValue(true); + const promise = openNeo4jJoinPopup(makeCache(), { fetchImpl, apply }); + + document.getElementById('neo4j-join-stitch').checked = false; + document.getElementById('neo4j-join-query').value = 'MATCH (g:Gene) RETURN g'; + document.getElementById('neo4j-join-fetch-btn').click(); + + expect(await promise).toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(2); // count + fetch, no stitch + }); + + it('shows an inline error when the query returns no graph elements', async () => { + startNeo4jSession(CONFIG, [], [], NO_EXCLUSIONS); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [{ row: [0] }] }], errors: [] })) + .mockResolvedValueOnce(jsonResponse({ results: [{ data: [] }], errors: [] })); + const promise = openNeo4jJoinPopup(makeCache(), { fetchImpl }); + + document.getElementById('neo4j-join-query').value = 'MATCH (n) RETURN count(n)'; + const fetchBtn = document.getElementById('neo4j-join-fetch-btn'); + fetchBtn.click(); + + await vi.waitFor(() => { + expect(document.getElementById('neo4j-join-error').hidden).toBe(false); + }); + expect(document.getElementById('neo4j-join-error').textContent).toContain( + 'no graph elements', + ); + expect(fetchBtn.disabled).toBe(false); // busy state cleared, popup still open + + document.getElementById('neo4j-join-cancel-btn').click(); + expect(await promise).toBe(false); + }); +}); From db873cac6631cf83d42b28f98713c5be47dc9ced Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 15 Jul 2026 20:03:19 +0200 Subject: [PATCH 012/181] fix(ui): disambiguate same-named properties in the pie slice picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Property ids hash as mainGroup::subGroup::propName, but the pie picker stripped everything before the last '::' — so same-named properties on different subGroups (Cell::score vs Document::score, common after Neo4j imports) rendered as identical rows with no way to tell them apart. Colliding names now get a '(subGroup)' suffix in the property list and the numeric slice-color rows (labelsFor builds the collision map once per render), each property row carries a 'subGroup > propName' tooltip, and availableProperties sorts by short name first so twins sit adjacent. Display-only; unique names and the propId format are unchanged. --- src/utilities/pie_chart_picker.js | 43 +++++++++++-- tests/pie-chart-picker.test.js | 102 ++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 tests/pie-chart-picker.test.js diff --git a/src/utilities/pie_chart_picker.js b/src/utilities/pie_chart_picker.js index 61147fd..2683d9d 100644 --- a/src/utilities/pie_chart_picker.js +++ b/src/utilities/pie_chart_picker.js @@ -197,7 +197,11 @@ class PieChartPicker { if (filter && Boolean(filter.isCategory) === wantCategory) available.add(propId); }); } - return Array.from(available).sort(); + // Sort by short name first so colliding names (score (Cell) / score + // (Document)) end up adjacent, then by full id for a stable tiebreak. + return Array.from(available).sort( + (a, b) => this.displayName(a).localeCompare(this.displayName(b)) || a.localeCompare(b), + ); } /** Distinct categorical values the selected nodes carry across `props`. */ @@ -223,8 +227,36 @@ class PieChartPicker { return propId.includes("::") ? propId.split("::").pop() : propId; } + /** subGroup segment of a hashed propId (`main::sub::name`), or null for bare names. */ + subGroupOf(propId) { + return propId.includes("::") ? propId.split("::")[1] : null; + } + + /** + * Map propId → display label, appending "(subGroup)" only when two or more + * listed ids collapse to the same short name (e.g. Cell::score vs + * Document::score both display as "score"). + * @param {string[]} propIds + * @returns {Map} + */ + labelsFor(propIds) { + const nameCount = new Map(); + for (const id of propIds) { + const name = this.displayName(id); + nameCount.set(name, (nameCount.get(name) ?? 0) + 1); + } + return new Map( + propIds.map((id) => { + const name = this.displayName(id); + const sub = this.subGroupOf(id); + return [id, nameCount.get(name) > 1 && sub ? `${name} (${sub})` : name]; + }), + ); + } + renderProperties() { const props = this.availableProperties(); + const labels = this.labelsFor(props); const list = this.dom.propList; list.innerHTML = ""; @@ -248,7 +280,9 @@ class PieChartPicker { this.renderColors(); }); const text = document.createElement("span"); - text.textContent = this.displayName(prop); + text.textContent = labels.get(prop); + const sub = this.subGroupOf(prop); + row.title = sub ? `${sub} > ${this.displayName(prop)}` : this.displayName(prop); row.append(cb, text); list.appendChild(row); } @@ -275,7 +309,8 @@ class PieChartPicker { if (this.mode === "categorical") { sources = this.distinctValues(selectedProps).map((v) => ({ key: v, label: v })); } else { - sources = selectedProps.map((p) => ({ key: p, label: this.displayName(p) })); + const labels = this.labelsFor(selectedProps); + sources = selectedProps.map((p) => ({ key: p, label: labels.get(p) })); } // Assign palette colors to any source that doesn't have one yet. @@ -318,7 +353,7 @@ class PieChartPicker { row.className = "picker-category-row pie-color-row"; if (dropped) row.classList.add("pie-color-row--dropped"); const label = document.createElement("span"); - label.textContent = dropped ? `${src.label} (not shown)` : src.label; + label.textContent = dropped ? `${src.label} — not shown` : src.label; const color = document.createElement("input"); color.type = "color"; color.className = "picker-color-swatch"; diff --git a/tests/pie-chart-picker.test.js b/tests/pie-chart-picker.test.js new file mode 100644 index 0000000..5608ef4 --- /dev/null +++ b/tests/pie-chart-picker.test.js @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { PieChartPicker } from "../src/utilities/pie_chart_picker.js"; + +// ========================================================================== +// Collision-aware labels in the pie picker (PLAN_FIX_PIE_MODAL.md): propIds +// are `mainGroup::subGroup::propName` hashes, and same-named properties on +// different subGroups (Cell::score vs Document::score) used to both render +// as a bare "score". Colliding names now get a "(subGroup)" suffix in the +// property list and the numeric slice-color rows, plus a title tooltip. +// ========================================================================== + +/** Minimal cache: nodes is Map, filters Map. */ +function makeCache(nodes, filters) { + return { + DEFAULTS: { + NODE: { + PIE: { SLICE_PALETTE: ["#111111", "#222222"], DEFAULT_COLOR: "#999999", MAX_SLICES: 6 }, + }, + }, + selectedNodes: Array.from(nodes.keys()), + nodeRef: nodes, + data: { selectedLayout: "L", layouts: { L: { filters } } }, + ui: { warning: vi.fn() }, + }; +} + +function makeNumericPicker(propIds) { + const filters = new Map(propIds.map((id) => [id, { isCategory: false }])); + const nodes = new Map([ + ["n1", { features: propIds, featureValues: new Map(propIds.map((id) => [id, 1])) }], + ]); + const picker = new PieChartPicker(makeCache(nodes, filters)); + picker.mode = "numeric"; + picker.buildContent(); // wires this.dom without opening a Popup + return picker; +} + +describe("PieChartPicker.labelsFor", () => { + const picker = new PieChartPicker({}); + + it("disambiguates colliding names with the subGroup, leaves unique names bare", () => { + const labels = picker.labelsFor(["A::Cell::score", "A::Document::score", "A::Cell::size"]); + expect(labels.get("A::Cell::score")).toBe("score (Cell)"); + expect(labels.get("A::Document::score")).toBe("score (Document)"); + expect(labels.get("A::Cell::size")).toBe("size"); + }); + + it("keeps a bare propId bare when it collides with a hashed one", () => { + const labels = picker.labelsFor(["score", "A::Cell::score"]); + expect(labels.get("score")).toBe("score"); + expect(labels.get("A::Cell::score")).toBe("score (Cell)"); + }); +}); + +describe("PieChartPicker.renderProperties", () => { + it("renders disambiguated labels adjacent, with subGroup > propName titles", () => { + const picker = makeNumericPicker(["A::Document::score", "A::Cell::size", "A::Cell::score"]); + picker.renderProperties(); + + const rows = Array.from(picker.dom.propList.querySelectorAll(".pie-prop-row")); + expect(rows.map((r) => r.textContent)).toEqual([ + "score (Cell)", + "score (Document)", + "size", + ]); + expect(rows[0].title).toBe("Cell > score"); + expect(rows[1].title).toBe("Document > score"); + expect(rows[2].title).toBe("Cell > size"); + }); +}); + +describe("PieChartPicker.renderColors (numeric)", () => { + it("uses disambiguated labels on slice-color rows", () => { + const picker = makeNumericPicker(["A::Cell::score", "A::Document::score"]); + picker.selected = new Set(["A::Cell::score", "A::Document::score"]); + picker.renderColors(); + + const labels = Array.from( + picker.dom.colorSection.querySelectorAll(".pie-color-row span"), + (el) => el.textContent, + ); + expect(labels).toEqual(["score (Cell)", "score (Document)"]); + }); + + it("still marks rows past the slice cap as not shown", () => { + const propIds = Array.from({ length: 8 }, (_, i) => `A::Cell::p${i}`); + const picker = makeNumericPicker(propIds); + picker.selected = new Set(propIds); + picker.renderColors(); + + const labels = Array.from( + picker.dom.colorSection.querySelectorAll(".pie-color-row span"), + (el) => el.textContent, + ); + expect(labels[5]).toBe("p5"); + expect(labels[6]).toBe("p6 — not shown"); + expect(labels[7]).toBe("p7 — not shown"); + const dropped = picker.dom.colorSection.querySelectorAll(".pie-color-row--dropped"); + expect(dropped).toHaveLength(2); + }); +}); From 771c508485d10efdadbad82a354a29e6edc72d2a Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 15 Jul 2026 20:03:36 +0200 Subject: [PATCH 013/181] =?UTF-8?q?chore(release):=201.16.2=20=E2=80=94=20?= =?UTF-8?q?pie=20picker=20property=20disambiguation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/config.js | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 349789d..843ada2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.16.2 — 2026-07-15 + +Saved graph files load unchanged — this release is bug fixes only. + +### Fixes + +* **Same-named properties are distinguishable in the pie-slice picker.** When properties with the same name exist on different groups (common after a Neo4j import, where every label can carry `name`, `score`, …), the **Map Properties to Pie Slices** dialog showed identical rows with no way to tell them apart. Colliding names now show their group in parentheses — `score (Cell)` vs `score (Document)` — in both the property list and the numeric slice-color rows, every property row gets a hover tooltip with its full `group > name` path, and the list is sorted by name so ambiguous twins sit next to each other. Unique names stay short as before. + ## 1.16.1 — 2026-07-15 Saved graph files load unchanged — this release is bug fixes only. diff --git a/package-lock.json b/package-lock.json index 67945a3..d31418f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "graph-lens-lite", - "version": "1.16.1", + "version": "1.16.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "graph-lens-lite", - "version": "1.16.1", + "version": "1.16.2", "license": "MIT", "dependencies": { "@antv/layout": "^2.0.0", diff --git a/package.json b/package.json index ebe0052..f2e25cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graph-lens-lite", - "version": "1.16.1", + "version": "1.16.2", "main": "src/package/electron_app.js", "description": "Visualise and explore property graphs in a lightweight desktop app.", "homepage": "https://github.com/Delta4AI/GraphLensLite", diff --git a/src/config.js b/src/config.js index 59d96fe..0cc4fc3 100644 --- a/src/config.js +++ b/src/config.js @@ -1,7 +1,7 @@ /** * Defaults for the graph, layouts and UI */ -const VERSION = "1.16.1"; +const VERSION = "1.16.2"; const DEFAULTS = { NODE: { From 4c5392da827fb48439c50cb32490f2aa2ef3cbec Mon Sep 17 00:00:00 2001 From: Mnikley Date: Thu, 16 Jul 2026 10:37:42 +0200 Subject: [PATCH 014/181] feat(neo4j): add Stack Overflow demo prefill and legible edge palette A link in the connection dialog fills in Neo4j Labs' public read-only demo server (published credentials) with a query fetching the best-answered neo4j-tagged Stack Overflow questions and their 1-hop neighborhood (~1.5k nodes / ~1.8k rels, sized under the row-count confirm threshold), with CC BY-SA attribution links. Edge auto-colors no longer reuse the node pie palette, whose pale tones (#EFB0AA, #8CA6D9) vanished at the 90 alpha over light backgrounds: edges now walk the golden-angle hue wheel at mid HSL lightness, legible on both themes. Landing Neo4j card mentions the demo; .neo4j-info links use --brand-text for dark-theme legibility. --- CHANGELOG.md | 3 ++- src/graph_lens_lite.html | 2 +- src/style.css | 5 ++++ src/utilities/neo4j_loader.js | 48 +++++++++++++++++++++++++++++++++-- tests/neo4j-loader.test.js | 25 ++++++++++++++++++ 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 843ada2..aa4788a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,8 @@ Saved graph files load unchanged — this release adds a new data source. ### Features -* **Neo4j connector.** Fetch a graph straight from a Neo4j server: a new **🛢️ Neo4j Database** card on the landing page (and a sidebar button) opens a connection dialog for server URL, credentials, optional database name, and a Cypher query returning nodes, relationships, or paths. Before fetching, the connector counts the matching rows and asks for confirmation above 2,000; after fetching, a property checklist shows each property's type and example values and lets you drop unwanted ones — long arrays such as embeddings start deselected. Nodes are colored per entity label and edges per relationship type (when there is more than one), property groups use the most specific label of a stored class hierarchy, and list properties become pipe-separated multi-value categories and booleans true/false categories so the regular filters work on them. The connection settings (never the password) are remembered locally. Uses the Neo4j HTTP API on port 7474/7473 with no driver dependency; Neo4j Aura (Bolt-only) is not supported. +* **Neo4j connector.** Fetch a graph straight from a Neo4j server: a new **🛢️ Neo4j Database** card on the landing page (and a sidebar button) opens a connection dialog for server URL, credentials, optional database name, and a Cypher query returning nodes, relationships, or paths. Before fetching, the connector counts the matching rows and asks for confirmation above 2,000; after fetching, a property checklist shows each property's type and example values and lets you drop unwanted ones — long arrays such as embeddings start deselected. Nodes are colored per entity label and edges per relationship type (when there is more than one) — edge colors use a dedicated mid-lightness palette that stays legible over both the light and dark theme backgrounds. Property groups use the most specific label of a stored class hierarchy, and list properties become pipe-separated multi-value categories and booleans true/false categories so the regular filters work on them. The connection settings (never the password) are remembered locally. Uses the Neo4j HTTP API on port 7474/7473 with no driver dependency; Neo4j Aura (Bolt-only) is not supported. +* **Stack Overflow demo.** No Neo4j server at hand? The connection dialog can fill itself in: a link loads the settings for [Neo4j Labs' public read-only demo server](https://github.com/neo4j-graph-examples) with a query fetching the best-answered `neo4j`-tagged Stack Overflow questions and their askers, answers, and tags (~1,500 nodes / ~1,800 relationships) — review and press **Fetch** to try the connector, expand, and join queries without any setup. Content from [Stack Overflow](https://stackoverflow.com) contributors, licensed [CC BY-SA](https://creativecommons.org/licenses/by-sa/4.0/). * **Growing a Neo4j graph in place.** After a Neo4j import, the graph can be extended without starting over — both features live behind the active session (the password is kept in memory only and never persisted) and disappear when another data source replaces the graph: * **🛢️ Expand** (selection panel): with nodes selected, a checklist shows what surrounds them — one row per relationship type and neighbor label, with counts, everything preselected — and fetches the checked groups. New neighbors appear next to the node they connect to and float into place with a short force animation that feels the whole network but moves only them; existing nodes never move. The same **Stitch** checkbox as the join-query dialog (on by default) additionally fetches the relationships between the new neighbors and everything already loaded — including among the new neighbors themselves, which the expansion pattern alone never returns. * **🛢️ Add query** (workspace toolbar): runs an additional Cypher query against the connected server and merges the results into the current graph, even when they are disconnected from it. The same row-count confirmation as the initial import applies. A **Stitch** checkbox (on by default) runs one extra query that fetches all relationships between the new results and everything already loaded — individual queries only return the relationships their own pattern matched, so without it two queries about different entities never reveal how their neighborhoods interconnect. diff --git a/src/graph_lens_lite.html b/src/graph_lens_lite.html index f3c1b1b..2bb9377 100644 --- a/src/graph_lens_lite.html +++ b/src/graph_lens_lite.html @@ -44,7 +44,7 @@

Graph Lens Lite

+
+ No server at hand? Fill in the Stack Overflow demo — + Neo4j Labs' + public read-only server; content from + Stack Overflow + contributors, licensed under + CC BY-SA. +
Uses the Neo4j HTTP API (port 7474/7473). Credentials are sent only to the server above and are not stored; the URL, username, database, and query @@ -586,6 +622,12 @@ function buildConnectionForm(saved) { form.querySelector('#neo4j-username').value = saved.username ?? ''; form.querySelector('#neo4j-database').value = saved.database ?? ''; if (saved.query) form.querySelector('#neo4j-query').value = saved.query; + form.querySelector('#neo4j-demo-link').addEventListener('click', (event) => { + event.preventDefault(); + for (const [field, value] of Object.entries(DEMO_SETTINGS)) { + form.querySelector(`#neo4j-${field}`).value = value; + } + }); return form; } @@ -779,6 +821,7 @@ export { nodeDisplayLabel, primaryLabel, categoryColor, + edgeCategoryColor, buildCategoryColors, hslToHex, readSavedSettings, @@ -789,6 +832,7 @@ export { clearNeo4jSession, refreshNeo4jSessionUI, DEFAULT_DATABASE, + DEMO_SETTINGS, LARGE_RESULT_ROW_THRESHOLD, LARGE_ARRAY_THRESHOLD, SETTINGS_STORAGE_KEY, diff --git a/tests/neo4j-loader.test.js b/tests/neo4j-loader.test.js index 9768872..ba19a0e 100644 --- a/tests/neo4j-loader.test.js +++ b/tests/neo4j-loader.test.js @@ -11,6 +11,7 @@ import { toAppFormat, coerceValue, categoryColor, + edgeCategoryColor, buildCategoryColors, primaryLabel, sanitizeForAST, @@ -21,6 +22,7 @@ import { saveSettings, showPropertyChecklist, DEFAULT_DATABASE, + DEMO_SETTINGS, LARGE_ARRAY_THRESHOLD, SETTINGS_STORAGE_KEY, LARGE_RESULT_ROW_THRESHOLD, @@ -328,6 +330,17 @@ describe('toAppFormat', () => { ); expect(data.edges[0].style.stroke).toMatch(/^#[0-9A-Fa-f]{6}90$/); expect(data.edges[0].style.stroke).not.toBe(data.edges[1].style.stroke); + expect(data.edges[0].style.stroke).toBe(`${edgeCategoryColor(0)}90`); + }); + + it('keeps edge auto-colors legible on light backgrounds (mid HSL lightness)', () => { + for (let i = 0; i < 8; i++) { + const hex = edgeCategoryColor(i); + const [r, g, b] = [1, 3, 5].map((o) => parseInt(hex.slice(o, o + 2), 16) / 255); + const lightness = (Math.max(r, g, b) + Math.min(r, g, b)) / 2; + expect(lightness).toBeGreaterThan(0.3); + expect(lightness).toBeLessThan(0.6); + } }); it('builds deduplicated headers without synthetic type properties', () => { @@ -570,6 +583,18 @@ describe('buildConnectionForm', () => { expect(form.querySelector('img')).toBeNull(); expect(form.querySelector('#neo4j-password').value).toBe(''); }); + + it('fills the public demo settings when the demo link is clicked', () => { + const form = buildConnectionForm({ url: 'http://db:7474', username: 'me' }); + + form.querySelector('#neo4j-demo-link').click(); + + expect(form.querySelector('#neo4j-url').value).toBe(DEMO_SETTINGS.url); + expect(form.querySelector('#neo4j-username').value).toBe(DEMO_SETTINGS.username); + expect(form.querySelector('#neo4j-password').value).toBe(DEMO_SETTINGS.password); + expect(form.querySelector('#neo4j-database').value).toBe(DEMO_SETTINGS.database); + expect(form.querySelector('#neo4j-query').value).toBe(DEMO_SETTINGS.query); + }); }); describe('openNeo4jPopup', () => { From 9b87d3a3baace159b6c73c342d18584ce254052b Mon Sep 17 00:00:00 2001 From: Mnikley Date: Thu, 16 Jul 2026 13:57:05 +0200 Subject: [PATCH 015/181] feat(neo4j): log executed Cypher queries in the status log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every statement passing through runCypher (fetch, preflights, expand, join, stitch) now emits a grey 🛢️-prefixed line in the sidebar status log, whitespace-collapsed and truncated at 160 chars. No-op outside the app (globalThis.cache guard keeps tests silent). --- src/utilities/neo4j_loader.js | 16 ++++++++++++++++ tests/neo4j-loader.test.js | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/utilities/neo4j_loader.js b/src/utilities/neo4j_loader.js index 4e95d30..f4d5a97 100644 --- a/src/utilities/neo4j_loader.js +++ b/src/utilities/neo4j_loader.js @@ -63,6 +63,21 @@ function basicAuth(username, password) { return 'Basic ' + btoa(String.fromCharCode(...bytes)); } +// Status-log lines for executed queries are truncated to keep the log legible. +const QUERY_LOG_MAX_LENGTH = 160; + +/** One grey status-log line per executed Cypher statement (no-op outside the app). */ +function logStatements(statements) { + const ui = globalThis.cache?.ui; + if (!ui) return; + for (const { statement } of statements) { + const oneLine = statement.replace(/\s+/g, ' ').trim(); + const text = + oneLine.length > QUERY_LOG_MAX_LENGTH ? `${oneLine.slice(0, QUERY_LOG_MAX_LENGTH)}…` : oneLine; + ui.logMessage(text, 'grey', false, '🛢️'); + } +} + /** * POST one or more Cypher statements to the tx/commit endpoint. * @@ -73,6 +88,7 @@ function basicAuth(username, password) { * @throws {Error} on network failure, non-2xx status, or Cypher errors */ async function runCypher(config, statements, opts = {}) { + logStatements(statements); const fetchImpl = opts.fetchImpl ?? fetch; const response = await fetchImpl(buildTxUrl(config.url, config.database), { method: 'POST', diff --git a/tests/neo4j-loader.test.js b/tests/neo4j-loader.test.js index ba19a0e..819af65 100644 --- a/tests/neo4j-loader.test.js +++ b/tests/neo4j-loader.test.js @@ -105,6 +105,25 @@ describe('runCypher', () => { /SyntaxError: bad query/, ); }); + + it('logs each executed statement to the status log, collapsed and truncated', async () => { + const logMessage = vi.fn(); + globalThis.cache = { ui: { logMessage } }; + try { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ results: [], errors: [] })); + const long = 'MATCH (n)\n WHERE n.name = "' + 'x'.repeat(200) + '" RETURN n'; + await runCypher(CONFIG, [{ statement: 'RETURN 1' }, { statement: long }], { fetchImpl }); + + expect(logMessage).toHaveBeenCalledTimes(2); + expect(logMessage.mock.calls[0][0]).toBe('RETURN 1'); + const truncated = logMessage.mock.calls[1][0]; + expect(truncated).toHaveLength(161); // 160 chars + ellipsis + expect(truncated.endsWith('…')).toBe(true); + expect(truncated).not.toContain('\n'); + } finally { + delete globalThis.cache; + } + }); }); describe('countQueryRows', () => { From c409a1561891f840bfe608d524a7330663e97a37 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Thu, 16 Jul 2026 13:57:50 +0200 Subject: [PATCH 016/181] =?UTF-8?q?chore(release):=201.16.3=20=E2=80=94=20?= =?UTF-8?q?Cypher=20query=20visibility=20in=20status=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/config.js | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa4788a..7507f65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.16.3 — 2026-07-16 + +Saved graph files load unchanged — this release is a small usability addition. + +### Features + +* **Neo4j queries are visible in the status log.** Every Cypher statement the connector sends — the initial fetch, row-count preflights, expansions, additional queries, and stitch passes — now appears as a grey 🛢️-prefixed line in the sidebar status log, so it's always clear what was asked of the server. Long queries are collapsed to one line and truncated to keep the log legible. + ## 1.16.2 — 2026-07-15 Saved graph files load unchanged — this release is bug fixes only. diff --git a/package-lock.json b/package-lock.json index d31418f..5ec27b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "graph-lens-lite", - "version": "1.16.2", + "version": "1.16.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "graph-lens-lite", - "version": "1.16.2", + "version": "1.16.3", "license": "MIT", "dependencies": { "@antv/layout": "^2.0.0", diff --git a/package.json b/package.json index f2e25cf..04258ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graph-lens-lite", - "version": "1.16.2", + "version": "1.16.3", "main": "src/package/electron_app.js", "description": "Visualise and explore property graphs in a lightweight desktop app.", "homepage": "https://github.com/Delta4AI/GraphLensLite", diff --git a/src/config.js b/src/config.js index 0cc4fc3..dccdb04 100644 --- a/src/config.js +++ b/src/config.js @@ -1,7 +1,7 @@ /** * Defaults for the graph, layouts and UI */ -const VERSION = "1.16.2"; +const VERSION = "1.16.3"; const DEFAULTS = { NODE: { From 6d3f1cf9b5c558f05f2e0eff13983cee33de0af1 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Mon, 27 Jul 2026 16:01:30 +0200 Subject: [PATCH 017/181] feat(ui): keyboard cheat sheet, Tools open by default, drop Details toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concept C phase 1 (redesign_and_mocks/ui-redesign-concept-c.md §8): - "?" hotkey opens a keyboard cheat sheet popup listing every global shortcut; acts as a toggle and mirrors the hotkey switch in graph/core.js. - Selection HUD's Tools panel starts expanded (spec: no hidden surfaces). - The filter panel's ⚙ Details toggle is deleted (§6.3): exact value inputs, per-row group/selection actions, and the OR/AND join cluster are always rendered; the gll.filterDetails localStorage preference is retired. (SVG as a visible export row already shipped with the export rework.) --- src/graph/core.js | 3 + src/graph_lens_lite.html | 6 +- src/managers/ui.js | 102 +++++++++--------- src/style.css | 66 ++++-------- ...t.js => filter-collapsible-groups.test.js} | 59 +--------- tests/keyboard-sheet.test.js | 53 +++++++++ 6 files changed, 137 insertions(+), 152 deletions(-) rename tests/{filter-details-toggle.test.js => filter-collapsible-groups.test.js} (50%) create mode 100644 tests/keyboard-sheet.test.js diff --git a/src/graph/core.js b/src/graph/core.js index 0c1bfc2..c8ca65b 100644 --- a/src/graph/core.js +++ b/src/graph/core.js @@ -816,6 +816,9 @@ class GraphCoreManager { case 'a': this.cache.assistant.togglePanel(); break; + case '?': + this.cache.ui.toggleKeyboardSheet(); + break; default: break; } diff --git a/src/graph_lens_lite.html b/src/graph_lens_lite.html index c9a6c53..509f2d9 100644 --- a/src/graph_lens_lite.html +++ b/src/graph_lens_lite.html @@ -145,13 +145,13 @@
Shown:
-
+
Selection + title="Hide selection tools" + aria-expanded="true" onclick="cache.ui.toggleSelectionEditor()">Tools ▴
diff --git a/src/managers/ui.js b/src/managers/ui.js index 07ab8ef..b0c661c 100644 --- a/src/managers/ui.js +++ b/src/managers/ui.js @@ -7,11 +7,6 @@ import { EXPORT_SCALES } from '../utilities/export_scale.js'; import { clampPopoverLeft } from '../utilities/popover_position.js'; import { refreshNeo4jSessionUI } from '../utilities/neo4j_loader.js'; -// Persisted preference: whether the filter panel reveals exact numeric inputs -// and the per-row group / selection actions. Off keeps rows scannable when a -// dataset has 30-50 properties. -const FILTER_DETAILS_KEY = 'gll.filterDetails'; - // Persisted preference: how multiple active filters combine — "OR" (match any) // or "AND" (match every, non-strict: a property an element lacks does not // exclude it). Stored globally as the default for new layouts. @@ -23,6 +18,23 @@ const FILTER_JOIN_KEY = 'gll.filterJoinMode'; // same-type filter. const FILTER_STRICT_KEY = 'gll.filterStrict'; +// The keyboard cheat sheet (opened with "?"). Must mirror the hotkey switch +// in graph/core.js registerHotkeyEvents — update both when a key changes. +const KEYBOARD_SHORTCUTS = [ + ['P', 'Export PNG image (at the remembered resolution)'], + ['S', 'Save graph as JSON'], + ['F', 'Fit view to visible elements'], + ['D', 'Toggle data table'], + ['Q', 'Toggle query editor'], + ['M', 'Toggle metrics panel'], + ['Y', 'Toggle styling panel'], + ['L', 'Toggle lasso selection'], + ['H', 'Toggle hover highlight'], + ['A', 'Toggle assistant'], + ['Esc', 'Exit lasso mode'], + ['?', 'Show this sheet'], +]; + class UIManager { constructor(cache, debugEnabled = false) { this.cache = cache; @@ -534,6 +546,37 @@ class UIManager { this.info(enable ? 'Hover highlight effect enabled' : 'Hover highlight effect disabled'); } + // Keyboard cheat sheet, opened with "?" (and closed by it, acting as a + // toggle). Content is static — one row per KEYBOARD_SHORTCUTS entry. + toggleKeyboardSheet() { + if (this._keyboardSheet) { + this._keyboardSheet.close(); + return; + } + + const content = document.createElement('div'); + content.className = 'keyboard-sheet'; + for (const [key, action] of KEYBOARD_SHORTCUTS) { + const row = document.createElement('div'); + row.className = 'keyboard-sheet-row'; + const kbd = document.createElement('kbd'); + kbd.textContent = key; + const label = document.createElement('span'); + label.textContent = action; + row.append(kbd, label); + content.appendChild(row); + } + + this._keyboardSheet = new Popup(content, { + title: 'Keyboard shortcuts', + width: '340px', + showFullscreenButton: false, + onClose: () => { + this._keyboardSheet = null; + }, + }); + } + /** * Close every anchored popover (graph teardown hook — the popovers outlive * the adapter, but their outside-click document listeners must not). @@ -723,11 +766,8 @@ class UIManager { } // Panel-level control bar. Sits above every section so its controls read - // as global, not scoped to the adjacent section: - // - "Combine filters" OR/AND: how multiple active filters combine. - // - "Details": reveals exact numeric inputs and per-row group / selection - // actions. Compact by default so dense property sets (30-50 properties) - // stay scannable. + // as global, not scoped to the adjacent section: the OR/AND join toggle + // and its "complete cases" modifier. const toolbar = document.createElement('div'); toolbar.className = 'filter-toolbar'; // OR/AND join control (left). "Complete cases only" is a modifier of AND, @@ -740,8 +780,7 @@ class UIManager { const joinCluster = document.createElement('div'); joinCluster.className = 'filter-toolbar-join'; joinCluster.append(joinToggle, strictCheckbox); - // Details (view option) is pushed to the far right. - toolbar.append(joinCluster, this.createFilterDetailsToggle(div)); + toolbar.append(joinCluster); div.appendChild(toolbar); // Each section (and sub-group) is a collapsible accordion so large @@ -839,45 +878,6 @@ class UIManager { this.cache.qm.updateQueryTextArea(); } - // Builds the panel-level "Details" toggle button. Adding/removing - // `show-details` on the filter container drives input/action visibility - // purely via CSS. Returned button is mounted on the first section header. - createFilterDetailsToggle(container) { - const detailsBtn = document.createElement('button'); - detailsBtn.type = 'button'; - detailsBtn.className = 'filter-details-toggle'; - detailsBtn.textContent = '⚙ Details'; - - const apply = (on) => { - container.classList.toggle('show-details', on); - detailsBtn.classList.toggle('active', on); - detailsBtn.setAttribute('aria-pressed', String(on)); - detailsBtn.title = on - ? 'Hide exact value inputs and per-row group / selection actions' - : 'Show exact value inputs and per-row group / selection actions'; - }; - - detailsBtn.addEventListener('click', () => { - const on = !container.classList.contains('show-details'); - try { - window.localStorage.setItem(FILTER_DETAILS_KEY, on ? '1' : '0'); - } catch (err) { - this.debug(`Could not persist filter-details preference: ${err.message}`); - } - apply(on); - }); - - let stored = '0'; - try { - stored = window.localStorage.getItem(FILTER_DETAILS_KEY) ?? '0'; - } catch (err) { - this.debug(`Could not read filter-details preference: ${err.message}`); - } - apply(stored === '1'); - - return detailsBtn; - } - // Builds the segmented OR/AND control that sets how multiple active filters // combine. OR shows elements matching any active filter; AND shows elements // matching every active filter (non-strict — a property an element lacks diff --git a/src/style.css b/src/style.css index 8ea6ab5..7361882 100644 --- a/src/style.css +++ b/src/style.css @@ -1539,17 +1539,6 @@ h5 { gap: 8px; } -.filter-toolbar .filter-details-toggle { - margin-left: auto; -} - -/* The OR/AND join controls are revealed only in Details mode, alongside the - exact value inputs and per-row actions. Compact mode shows just the Details - button so the panel stays minimal. */ -#filterContainer:not(.show-details) .filter-toolbar-join { - display: none; -} - .filter-strict-checkbox { display: inline-flex; align-items: center; @@ -1564,33 +1553,36 @@ h5 { display: none; } -.filter-details-toggle { - display: inline-flex; +/* Keyboard cheat sheet (opened with "?"): one key/action row per shortcut. */ +.keyboard-sheet { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 12px; align-items: center; - gap: 4px; - padding: 2px 9px; +} + +.keyboard-sheet-row { + display: contents; +} + +.keyboard-sheet kbd { + justify-self: center; + min-width: 26px; + padding: 1px 6px; + text-align: center; + font-family: inherit; font-size: 11px; font-weight: 600; color: var(--text-strong); background: var(--surface-3); border: 1px solid var(--border-soft); - border-radius: 12px; - cursor: pointer; -} - -.filter-details-toggle:hover { - border-color: var(--accent-text); -} - -.filter-details-toggle.active { - background: var(--accent-text); - color: #fff; - border-color: var(--accent-text); + border-bottom-width: 2px; + border-radius: 4px; } -/* Segmented OR/AND control: how multiple active filters combine. Mirrors the - details toggle's pill styling; the active segment is filled so the current - mode is always visible (a state, not a morphing action). */ +/* Segmented OR/AND control: how multiple active filters combine. Pill + styling; the active segment is filled so the current mode is always + visible (a state, not a morphing action). */ .filter-join-toggle { display: inline-flex; align-items: stretch; @@ -1623,20 +1615,6 @@ h5 { cursor: default; } -/* Compact mode: numeric inputs and the circle / +- actions are hidden so the - control uses the full row width. Hiding col3 drops the grid to two columns - so the remaining cells stay aligned (a 3-col grid would pull the next row's - label into the empty third cell). The hover value bubbles overflow into the - canvas via position:fixed, so no right inset is needed. */ -#filterContainer:not(.show-details) .filter-subgroup-body { - grid-template-columns: auto minmax(0, 1fr); -} - -#filterContainer:not(.show-details) .filter-input-row, -#filterContainer:not(.show-details) .filter-row-col3 { - display: none; -} - /* Collapsible filter sections and sub-groups (accordion). Properties within a sub-group stay compact (grid row-gap); sub-groups get a bottom margin so they read as distinct blocks. */ diff --git a/tests/filter-details-toggle.test.js b/tests/filter-collapsible-groups.test.js similarity index 50% rename from tests/filter-details-toggle.test.js rename to tests/filter-collapsible-groups.test.js index a4b29ff..adbdf1d 100644 --- a/tests/filter-details-toggle.test.js +++ b/tests/filter-collapsible-groups.test.js @@ -1,12 +1,12 @@ // @vitest-environment jsdom -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { UIManager } from "../src/managers/ui.js"; // ========================================================================== -// Filter panel compact-by-default disclosure (Details toggle) + collapsible -// groups. These keep dense property sets (30-50 properties) scannable: exact -// numeric inputs and per-row group/selection actions hide behind one panel -// toggle, and each section/sub-group folds independently. +// Collapsible filter groups: each section/sub-group folds independently so +// dense property sets (30-50 properties) stay scannable. (The former panel +// ⚙ Details toggle was deleted — exact inputs and per-row actions are +// always rendered now.) // ========================================================================== function makeUI() { @@ -14,55 +14,6 @@ function makeUI() { return new UIManager({}, false); } -describe("UIManager.createFilterDetailsToggle", () => { - let ui, container; - - beforeEach(() => { - window.localStorage.clear(); - ui = makeUI(); - container = document.createElement("div"); - }); - - it("renders a labeled Details toggle button", () => { - const btn = ui.createFilterDetailsToggle(container); - - expect(btn).not.toBeNull(); - expect(btn.classList.contains("filter-details-toggle")).toBe(true); - expect(btn.textContent).toContain("Details"); - expect(btn.getAttribute("aria-pressed")).toBe("false"); - }); - - it("defaults to compact (no show-details) when nothing is stored", () => { - ui.createFilterDetailsToggle(container); - - expect(container.classList.contains("show-details")).toBe(false); - }); - - it("restores the on state from localStorage", () => { - window.localStorage.setItem("gll.filterDetails", "1"); - - const btn = ui.createFilterDetailsToggle(container); - - expect(container.classList.contains("show-details")).toBe(true); - expect(btn.classList.contains("active")).toBe(true); - expect(btn.getAttribute("aria-pressed")).toBe("true"); - }); - - it("toggles details on click and persists the choice", () => { - const btn = ui.createFilterDetailsToggle(container); - - btn.click(); - expect(container.classList.contains("show-details")).toBe(true); - expect(btn.classList.contains("active")).toBe(true); - expect(window.localStorage.getItem("gll.filterDetails")).toBe("1"); - - btn.click(); - expect(container.classList.contains("show-details")).toBe(false); - expect(btn.classList.contains("active")).toBe(false); - expect(window.localStorage.getItem("gll.filterDetails")).toBe("0"); - }); -}); - describe("UIManager.makeFilterGroupCollapsible", () => { let ui, wrapper, header, badge; diff --git a/tests/keyboard-sheet.test.js b/tests/keyboard-sheet.test.js new file mode 100644 index 0000000..554bdd3 --- /dev/null +++ b/tests/keyboard-sheet.test.js @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from "vitest"; +import { UIManager } from "../src/managers/ui.js"; + +// ========================================================================== +// Keyboard cheat sheet ("?" hotkey): a Popup listing every global shortcut. +// The list must mirror the hotkey switch in graph/core.js. +// ========================================================================== + +function makeUI() { + return new UIManager({}, false); +} + +describe("UIManager.toggleKeyboardSheet", () => { + let ui; + + beforeEach(() => { + document.body.innerHTML = ""; + ui = makeUI(); + }); + + it("opens a popup listing every registered hotkey", () => { + ui.toggleKeyboardSheet(); + + const keys = [...document.querySelectorAll(".keyboard-sheet kbd")].map( + (el) => el.textContent + ); + // One row per hotkey in graph/core.js registerHotkeyEvents, plus Esc and ?. + for (const key of ["P", "S", "F", "D", "Q", "M", "Y", "L", "H", "A", "Esc", "?"]) { + expect(keys).toContain(key); + } + // Every row pairs the key with a non-empty action label. + document.querySelectorAll(".keyboard-sheet-row").forEach((row) => { + expect(row.querySelector("span").textContent.length).toBeGreaterThan(0); + }); + }); + + it("acts as a toggle: a second ? closes the sheet", () => { + ui.toggleKeyboardSheet(); + expect(document.querySelector(".keyboard-sheet")).not.toBeNull(); + + ui.toggleKeyboardSheet(); + expect(document.querySelector(".keyboard-sheet")).toBeNull(); + }); + + it("reopens cleanly after the popup is closed by its own close button", () => { + ui.toggleKeyboardSheet(); + document.querySelector('.p-icon[title="Close popup"]').click(); + + ui.toggleKeyboardSheet(); + expect(document.querySelector(".keyboard-sheet")).not.toBeNull(); + }); +}); From 6a91eef4e550f99dde9b71c47b387ccd3d358b8e Mon Sep 17 00:00:00 2001 From: Mnikley Date: Mon, 27 Jul 2026 16:24:52 +0200 Subject: [PATCH 018/181] feat(filters): boolean property inference and visible mixed-type columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concept C phase 2 (redesign_and_mocks/ui-redesign-concept-c.md §6.1/§6.2): - Two-stage type classification: populateFilterPropsLowsAndHighs only accumulates (numeric bounds, categories, boolean candidacy, per-type counts); a new finalizeFilterClassification pass resolves each property to numeric, categorical, boolean, or unusable after all values are seen. - Boolean columns (true/TRUE/1 vs false/FALSE/0, per the Excel template's encoding) become a refined categorical with canonical categories {'true','false'} and render as a three-state Any/True/False segment (new BooleanToggle in ui_components.js). - New query DSL forms IS TRUE / IS FALSE evaluate against raw D4Data with encoding normalization; generated boolean conditions use them, manual queries and the assistant AST (new IS_TRUE/IS_FALSE ops) can too. - 0/1-numeric columns are override-eligible ('treat as 0/1 numbers' link); the choice lives in cache.data.booleanTypeOverrides, persists in the workspace JSON, and survives data-table/Excel-merge/Neo4j rebuilds. - Mixed numeric+text columns are no longer deleted: they stay visible as disabled rows stating the reason and per-type counts, and are skipped by query generation, scale/pie pickers, and the assistant context. - Saved workspaces migrate: pre-inference categorical booleans get their categories canonicalized, stale-typed filters reset to the new default. 42 new tests (classification, override round-trip, migration, DSL evaluation, widget behavior, query generation); 1617 green. --- src/gll.js | 1 + src/managers/assistant/context.js | 2 + .../assistant/query_generator_prompt.js | 4 +- .../assistant/query_generator_prompt.md | 4 +- src/managers/assistant/query_schema.js | 4 +- src/managers/io.js | 193 ++++++++-- src/managers/query.js | 43 ++- src/managers/ui.js | 72 +++- src/managers/ui_components.js | 100 +++++- src/style.css | 47 ++- src/utilities/color_scale_picker.js | 2 +- src/utilities/data_editor.js | 2 + src/utilities/neo4j_session.js | 5 + src/utilities/numeric_scale_picker.js | 2 +- src/utilities/pie_chart_picker.js | 3 +- src/utilities/static.js | 14 + tests/assistant-query-schema.test.js | 4 +- tests/boolean-filter-inference.test.js | 338 ++++++++++++++++++ tests/boolean-toggle-widget.test.js | 167 +++++++++ 19 files changed, 958 insertions(+), 49 deletions(-) create mode 100644 tests/boolean-filter-inference.test.js create mode 100644 tests/boolean-toggle-widget.test.js diff --git a/src/gll.js b/src/gll.js index 2c4086d..a77e30f 100644 --- a/src/gll.js +++ b/src/gll.js @@ -139,6 +139,7 @@ class Cache { this.propIDToDropdownChecklists = new Map(); this.propIDToInvertibleRangeSliders = new Map(); + this.propIDToBooleanToggles = new Map(); this.lastBubbleSetMembers = new Map(); this.bubbleSetChanged = false; diff --git a/src/managers/assistant/context.js b/src/managers/assistant/context.js index 328cccb..edbef7d 100644 --- a/src/managers/assistant/context.js +++ b/src/managers/assistant/context.js @@ -100,6 +100,8 @@ function readRecentActions(maxLines) { function describeProperty(filterDefaults, propID) { const def = filterDefaults?.get?.(propID) if (!def) return {type: 'unknown'} + if (def.unusable) return {type: 'unusable'} + if (def.isBoolean) return {type: 'boolean'} if (def.isCategory) { const allValues = def.categories ? [...def.categories] : [] diff --git a/src/managers/assistant/query_generator_prompt.js b/src/managers/assistant/query_generator_prompt.js index 1cb4db5..7004bfb 100644 --- a/src/managers/assistant/query_generator_prompt.js +++ b/src/managers/assistant/query_generator_prompt.js @@ -20,11 +20,12 @@ A phantom query that references a non-existent field is worse than no query — - A query is \`{title, expr}\`. The title is a short human-readable label. - \`expr\` is either a condition leaf or a binary AND/OR/NOT. -### Conditions (exactly three operators exist) +### Conditions (exactly five operators exist) - **BETWEEN** — numeric range, inclusive: \`{kind:"condition", field, op:"BETWEEN", min, max}\` - **LT_OR_GT** — numeric exclusion (keep values outside a range): \`{kind:"condition", field, op:"LT_OR_GT", lt, gt}\` - **IN** — categorical set membership: \`{kind:"condition", field, op:"IN", values:[...]}\` +- **IS_TRUE** / **IS_FALSE** — boolean test, no operands: \`{kind:"condition", field, op:"IS_TRUE"}\` ### Binary @@ -51,6 +52,7 @@ Same-scope (Node AND Node, Edge AND Edge, Node NOT Node, Edge NOT Edge) is alway - Field type **numeric** → BETWEEN (for "between 0 and 1", "above 0.5", "at most 100") or LT_OR_GT (for "outside range", "not around 0.5"). - Field type **categorical** → IN with values drawn from the field's declared values list. +- Field type **boolean** → IS_TRUE or IS_FALSE (never IN — the stored encodings vary: true/TRUE/1). - "above X" with numeric max available → BETWEEN X AND \`\`. "below X" → BETWEEN \`\` AND X. - Never emit \`=\`, \`==\`, \`!=\`, \`<\`, \`>\`, \`<=\`, \`>=\`, \`CONTAINS\`, \`LIKE\`, \`MATCHES\` — they do not exist in GLL. diff --git a/src/managers/assistant/query_generator_prompt.md b/src/managers/assistant/query_generator_prompt.md index a80fadb..31018ea 100644 --- a/src/managers/assistant/query_generator_prompt.md +++ b/src/managers/assistant/query_generator_prompt.md @@ -18,11 +18,12 @@ A phantom query that references a non-existent field is worse than no query — - A query is `{title, expr}`. The title is a short human-readable label. - `expr` is either a condition leaf or a binary AND/OR/NOT. -### Conditions (exactly three operators exist) +### Conditions (exactly five operators exist) - **BETWEEN** — numeric range, inclusive: `{kind:"condition", field, op:"BETWEEN", min, max}` - **LT_OR_GT** — numeric exclusion (keep values outside a range): `{kind:"condition", field, op:"LT_OR_GT", lt, gt}` - **IN** — categorical set membership: `{kind:"condition", field, op:"IN", values:[...]}` +- **IS_TRUE** / **IS_FALSE** — boolean test, no operands: `{kind:"condition", field, op:"IS_TRUE"}` ### Binary @@ -49,6 +50,7 @@ Same-scope (Node AND Node, Edge AND Edge, Node NOT Node, Edge NOT Edge) is alway - Field type **numeric** → BETWEEN (for "between 0 and 1", "above 0.5", "at most 100") or LT_OR_GT (for "outside range", "not around 0.5"). - Field type **categorical** → IN with values drawn from the field's declared values list. +- Field type **boolean** → IS_TRUE or IS_FALSE (never IN — the stored encodings vary: true/TRUE/1). - "above X" with numeric max available → BETWEEN X AND ``. "below X" → BETWEEN `` AND X. - Never emit `=`, `==`, `!=`, `<`, `>`, `<=`, `>=`, `CONTAINS`, `LIKE`, `MATCHES` — they do not exist in GLL. diff --git a/src/managers/assistant/query_schema.js b/src/managers/assistant/query_schema.js index 27aaf2b..ea3fcc7 100644 --- a/src/managers/assistant/query_schema.js +++ b/src/managers/assistant/query_schema.js @@ -71,7 +71,7 @@ export const QUERY_RESPONSE_SCHEMA = { field: {type: 'string', pattern: FIELD_PATTERN}, // ↑ shape only. `buildQuerySchema` replaces this with an `enum` of // real paths drawn from the current graph's hierarchy. - op: {enum: ['BETWEEN', 'LT_OR_GT', 'IN']}, + op: {enum: ['BETWEEN', 'LT_OR_GT', 'IN', 'IS_TRUE', 'IS_FALSE']}, min: {type: 'number'}, max: {type: 'number'}, lt: {type: 'number'}, @@ -170,6 +170,8 @@ function renderCondition(cond) { }) return `${field} IN [${rendered.join(', ')}]` } + if (op === 'IS_TRUE') return `${field} IS TRUE` + if (op === 'IS_FALSE') return `${field} IS FALSE` throw new QueryShapeError(`unknown condition.op: ${op}`) } diff --git a/src/managers/io.js b/src/managers/io.js index 97187b2..40ce555 100644 --- a/src/managers/io.js +++ b/src/managers/io.js @@ -1262,6 +1262,11 @@ class IOManager { this.cache.nodePositionsFromExcelImport = new Map(); + // Per-property boolean-type overrides (§6.1): props the user forced back + // to numeric. Lives on cache.data so it round-trips through the workspace + // JSON (Set → array via the export replacer). + this.cache.data.booleanTypeOverrides = new Set(fileData.booleanTypeOverrides || []); + this.populateCacheHeaders(fileData); this.cache.data.nodes = fileData.nodes.map((node) => { @@ -1378,6 +1383,8 @@ class IOManager { return processedEdge; }); + this.finalizeFilterClassification(); + const excelHasCoordinates = this.cache.nodePositionsFromExcelImport.size > 0; this.cache.data.selectedLayout = fileData.selectedLayout || @@ -1412,8 +1419,14 @@ class IOManager { // Rebuild filters in the order they appear in filterDefaults for (const [propId, defaultFilter] of this.cache.data.filterDefaults.entries()) { if (oldFilters.has(propId)) { - // Preserve existing filter values (user's slider positions, etc.) - layout.filters.set(propId, oldFilters.get(propId)); + // Preserve existing filter values (user's slider positions, etc.), + // reconciled against the property's freshly-derived type — a loaded + // filter saved before boolean inference (or before a type override) + // may no longer match the widget the property gets now. + layout.filters.set( + propId, + this.reconcileLoadedFilterType(oldFilters.get(propId), defaultFilter) + ); } else { // Add new property with default filter layout.filters.set(propId, structuredClone(defaultFilter)); @@ -1438,6 +1451,20 @@ class IOManager { upperThreshold: -Infinity, isInverted: false, isCategory: false, + // Boolean classification (§6.1): boolCandidate stays true while every + // observed value is a boolean encoding (true/TRUE/1 vs false/FALSE/0); + // finalizeFilterClassification then promotes candidates to isBoolean. + // numericBoolSource marks candidates whose values were all numeric + // (0/1) — the only ones eligible for the "treat as numbers" override. + isBoolean: false, + boolCandidate: true, + numericBoolSource: false, + // Mixed-type accounting (§6.2): a column holding both numeric and text + // values becomes an unusable (disabled) filter row instead of being + // deleted; the counts feed the row's explanation. + unusable: false, + numericCount: 0, + textCount: 0, hasFloatValues: false, categories: new Set(), }; @@ -1450,6 +1477,10 @@ class IOManager { return obj; } + // Accumulates one observed value into a property's filter defaults. Type + // conflicts are NOT resolved here — numeric bounds and categorical values + // both accumulate, and finalizeFilterClassification decides afterwards + // whether the property is numeric, categorical, boolean, or unusable. populateFilterPropsLowsAndHighs(propHash, nodeOrEdgeValue) { if (!this.cache.data.filterDefaults.get(propHash)) { this.cache.data.filterDefaults.set(propHash, this.getDefaultFilterObject()); @@ -1459,34 +1490,146 @@ class IOManager { return; } + const fo = this.cache.data.filterDefaults.get(propHash); + if (fo.boolCandidate && StaticUtilities.booleanTokenValue(nodeOrEdgeValue) === null) { + fo.boolCandidate = false; + } + if (isNaN(nodeOrEdgeValue)) { - if (this.cache.data.filterDefaults.get(propHash).lowerThreshold !== Infinity) { - let [section, subSection, prop] = StaticUtilities.decodePropHashId(propHash); - this.cache.ui - .warning(`Property ${prop} (section ${section} sub-section ${subSection} contains both numeric and - categorical values. To proceed, please use a single data type. Property has been excluded.`); - this.cache.data.filterDefaults.delete(propHash); - return; - } - this.cache.data.filterDefaults.get(propHash).isCategory = true; - this.cache.data.filterDefaults.get(propHash).categories.add(nodeOrEdgeValue); + fo.textCount += 1; + fo.isCategory = true; + fo.categories.add(nodeOrEdgeValue); return; } - if ( - !this.cache.data.filterDefaults.get(propHash).hasFloatValues && - !StaticUtilities.isInteger(nodeOrEdgeValue) - ) { - this.cache.data.filterDefaults.get(propHash).hasFloatValues = true; + fo.numericCount += 1; + if (!fo.hasFloatValues && !StaticUtilities.isInteger(nodeOrEdgeValue)) { + fo.hasFloatValues = true; + } + fo.lowerThreshold = Math.min(nodeOrEdgeValue, fo.lowerThreshold); + fo.upperThreshold = Math.max(nodeOrEdgeValue, fo.upperThreshold); + } + + // Reconcile a filter loaded from a saved workspace with the property's + // freshly-derived default. Boolean props canonicalize the saved categories + // (pre-inference files stored raw spellings like 'TRUE'); a type mismatch + // in either direction falls back to a clone of the new default. Non-boolean + // props keep the loaded filter untouched (today's behavior). + reconcileLoadedFilterType(loaded, defaultFilter) { + if (defaultFilter.isBoolean) { + const canonical = new Set( + [...(loaded.categories ?? [])] + .map((c) => StaticUtilities.booleanTokenValue(c)) + .filter((c) => c !== null) + ); + if (loaded.isCategory && canonical.size > 0) { + return { + ...structuredClone(defaultFilter), + active: loaded.active, + categories: canonical, + }; + } + return structuredClone(defaultFilter); // numeric-era or empty → reset + } + if (defaultFilter.unusable || loaded.isBoolean) { + return structuredClone(defaultFilter); + } + return loaded; + } + + /** + * Resolve every property's final filter type once all values are collected + * (§6.1/§6.2). Runs after the node/edge preprocessing loops and before the + * per-layout filter rebuild, so cloned layout filters inherit the result. + * + * - Boolean: every value is a boolean encoding → isBoolean (a refined + * categorical with canonical categories {'true','false'}), unless the + * user overrode a 0/1-numeric column back to numeric (persisted in + * cache.data.booleanTypeOverrides, round-trips through workspace JSON). + * - Mixed numeric+text: kept visible but marked unusable (disabled row) + * instead of the pre-1.17 silent delete. + */ + finalizeFilterClassification() { + const overrides = this.cache.data.booleanTypeOverrides; + for (const [propHash, fo] of this.cache.data.filterDefaults.entries()) { + const hasText = fo.categories.size > 0; + const hasNumeric = fo.lowerThreshold !== Infinity; + if (!hasText && !hasNumeric) continue; // header-only property, no values + + if (fo.boolCandidate) { + fo.numericBoolSource = !hasText; + if (!(fo.numericBoolSource && overrides.has(propHash))) { + fo.isBoolean = true; + fo.isCategory = true; + fo.categories = new Set(['true', 'false']); + this.#canonicalizeBooleanFeatureValues(propHash); + } + continue; // an overridden 0/1 column falls through as plain numeric + } + + if (hasText && hasNumeric) { + fo.unusable = true; + fo.active = false; + const [section, subSection, prop] = StaticUtilities.decodePropHashId(propHash); + this.cache.ui.warning( + `Property ${prop} (section ${section}, sub-section ${subSection}) mixes ` + + `${fo.numericCount} numeric and ${fo.textCount} text values — its filter is disabled.` + ); + } + } + } + + // Rewrite a boolean property's derived featureValues to canonical + // Set{'true'|'false'} on every carrier, so categorical consumers (color + // ramps, pies, IN queries) see one value space regardless of the source + // encoding (TRUE vs 1). Raw D4Data is never touched — exports stay + // byte-faithful and the numeric override can restore the original values. + #canonicalizeBooleanFeatureValues(propHash) { + for (const element of [...this.cache.data.nodes, ...this.cache.data.edges]) { + if (!element.features?.has(propHash)) continue; + const raw = element.featureValues.get(propHash); + const rawValues = raw instanceof Set ? [...raw] : [raw]; + const canonical = new Set(); + for (const value of rawValues) { + canonical.add(StaticUtilities.booleanTokenValue(value) ?? 'false'); + } + element.featureValues.set(propHash, canonical); + } + } + + /** + * User-facing type override for boolean-classified 0/1 columns (§6.1 risk + * mitigation): flips one property between the inferred boolean segment and + * a plain numeric range slider. The choice is stored in + * cache.data.booleanTypeOverrides and therefore persists in the workspace + * JSON. Resets the property's per-layout filter state (a type switch + * invalidates any narrowed state) and rebuilds featureValues from D4Data. + */ + applyBooleanTypeOverride(propHash, toNumeric) { + const fo = this.cache.data.filterDefaults.get(propHash); + if (!fo?.numericBoolSource) return; + + if (toNumeric) { + this.cache.data.booleanTypeOverrides.add(propHash); + fo.isBoolean = false; + fo.isCategory = false; + fo.categories = new Set(); + const [main, sub, prop] = StaticUtilities.decodePropHashId(propHash); + for (const element of [...this.cache.data.nodes, ...this.cache.data.edges]) { + if (!element.features?.has(propHash)) continue; + element.featureValues.set(propHash, element.D4Data?.[main]?.[sub]?.[prop]); + } + } else { + this.cache.data.booleanTypeOverrides.delete(propHash); + fo.isBoolean = true; + fo.isCategory = true; + fo.categories = new Set(['true', 'false']); + this.#canonicalizeBooleanFeatureValues(propHash); + } + + for (const layoutName in this.cache.data.layouts) { + this.cache.data.layouts[layoutName].filters.set(propHash, structuredClone(fo)); } - this.cache.data.filterDefaults.get(propHash).lowerThreshold = Math.min( - nodeOrEdgeValue, - this.cache.data.filterDefaults.get(propHash).lowerThreshold - ); - this.cache.data.filterDefaults.get(propHash).upperThreshold = Math.max( - nodeOrEdgeValue, - this.cache.data.filterDefaults.get(propHash).upperThreshold - ); } populateCacheHeaders(fileData) { diff --git a/src/managers/query.js b/src/managers/query.js index aa9165d..da3343e 100644 --- a/src/managers/query.js +++ b/src/managers/query.js @@ -140,6 +140,15 @@ class QueryAST { validated = values.some((val) => set.includes(val)); } + // --- IS TRUE / IS FALSE ---------------------------------------------- + // Boolean predicate (§6.1): matches every encoding of the wanted truth + // value (true/TRUE/1 vs false/FALSE/0, string or number) so it works on + // raw D4Data regardless of how the source file spelled its booleans. + if (op === 'IS TRUE' || op === 'IS FALSE') { + const want = op === 'IS TRUE' ? 'true' : 'false'; + validated = StaticUtilities.booleanTokenValue(propVal) === want; + } + element.featureIsWithinThreshold.set(tokens[0].propID, validated); return validated; } @@ -193,7 +202,7 @@ class QueryManager { /* 4. Encode Property names (main group::sub group::property) */ /* ------------------------------------------------------------------ */ asciiStr = asciiStr.replace( - /(Node filters|Edge filters)::([^:]+)::([^:]+)(?=\s(?:IN|BETWEEN|LOWER\sTHAN|IS\sMISSING|\)))/g, + /(Node filters|Edge filters)::([^:]+)::([^:]+)(?=\s(?:IN|BETWEEN|LOWER\sTHAN|IS\sMISSING|IS\sTRUE|IS\sFALSE|\)))/g, (match, mainGroup, subGroup, prop) => { const mgok = mainGroup in this.cache.uniquePropHierarchy; const sgok = mgok && subGroup in this.cache.uniquePropHierarchy[mainGroup]; @@ -269,6 +278,13 @@ class QueryManager { () => `IS MISSING` ); + /* 5-5 Boolean predicates (§6.1): "IS TRUE" / "IS FALSE" ------------- */ + asciiStr = asciiStr.replace( + /\bIS\s+(TRUE|FALSE)\b/gi, + (_m, word) => + `IS ${word.toUpperCase()}` + ); + /* ------------------------------------------------------------------ */ /* 6. Top-level connectors ") OR (" / ") AND (" / ") NOT (" */ /* ------------------------------------------------------------------ */ @@ -457,9 +473,19 @@ class QueryManager { const joinMode = layout.filterJoinMode === 'AND' ? 'AND' : 'OR'; let queryEntries = []; for (const [propID, fo] of layout.filters.entries()) { - if (fo.active) { + if (fo.active && !fo.unusable) { let cond; - if (fo.isCategory) { + if (fo.isBoolean) { + // Three-state boolean segment. "Any" (both selected) still emits a + // condition — like a fully-selected categorical, it matches every + // carrier of the property under an OR join. + const wantTrue = fo.categories.has('true'); + const wantFalse = fo.categories.has('false'); + cond = + wantTrue && wantFalse + ? `(${propID} IS TRUE) OR (${propID} IS FALSE)` + : `${propID} IS ${wantTrue ? 'TRUE' : 'FALSE'}`; + } else if (fo.isCategory) { cond = `${propID} IN [${[...fo.categories].map((cat) => StaticUtilities.escapeQueryValue(cat)).join(',')}]`; } else if (fo.isInverted) { cond = `${propID} LOWER THAN ${fo.upperThreshold} OR GREATER THAN ${fo.lowerThreshold}`; @@ -727,6 +753,8 @@ class QueryManager { 'q-or-greater-than': () => ({ type: 'KW', value: 'OR GREATER THAN' }), 'q-in-cat-bracket-open': () => ({ type: 'KW', value: 'IN [' }), 'q-kw-ismissing': () => ({ type: 'KW', value: 'IS MISSING' }), + 'q-kw-istrue': () => ({ type: 'KW', value: 'IS TRUE' }), + 'q-kw-isfalse': () => ({ type: 'KW', value: 'IS FALSE' }), // category strings 'q-string': (el) => ({ @@ -823,6 +851,14 @@ class QueryManager { .setTo(obj[2].value, obj[4].value, true); } + // boolean predicate + else if (obj[1].type === 'KW' && (obj[1].value === 'IS TRUE' || obj[1].value === 'IS FALSE')) { + this.cache.ui.checkCheckbox(obj[0].propID, true); + this.cache.propIDToBooleanToggles + .get(obj[0].propID) + ?.applyFromQuery(obj[1].value === 'IS TRUE' ? 'true' : 'false'); + } + // category else if (obj[1].type === 'KW' && obj[1].value === 'IN [') { this.cache.ui.checkCheckbox(obj[0].propID, true); @@ -972,6 +1008,7 @@ class QueryManager {
  • LOWER THAN 0.2 OR GREATER THAN 0.8 - Keep numerical values ≤ 0.2 or ≥ 0.8
  • IN [foo, bar] - Keep specific categorical values
  • IS MISSING - True when the property is absent/empty or belongs to the other element type. Auto-added by the filter panel's AND join so a property an element lacks doesn't exclude it; rarely hand-written.
  • +
  • IS TRUE / IS FALSE - Boolean test; matches every encoding (true/TRUE/1 vs false/FALSE/0)
  • 3. Logical Operators

    diff --git a/src/managers/ui.js b/src/managers/ui.js index b0c661c..18b241d 100644 --- a/src/managers/ui.js +++ b/src/managers/ui.js @@ -1,5 +1,5 @@ import { StaticUtilities } from '../utilities/static.js'; -import { DropdownChecklist, InvertibleRangeSlider } from './ui_components.js'; +import { BooleanToggle, DropdownChecklist, InvertibleRangeSlider } from './ui_components.js'; import { createStyleDiv } from './ui_style_div.js'; import { Popup } from '../utilities/popup.js'; import { applyTheme, currentTheme, nodeLabelColorForTheme } from '../utilities/theme.js'; @@ -793,8 +793,6 @@ class UIManager { for (let propID of sortedPropIDs) { let [section, subSection, prop] = StaticUtilities.decodePropHashId(propID); - let isCategoricalProperty = this.cache.data.filterDefaults.get(propID).isCategory; - if (!sectionBodies.has(section)) { const sectionWrap = document.createElement('div'); sectionWrap.className = 'filter-section'; @@ -844,6 +842,8 @@ class UIManager { } const subBody = subBodies.get(subKey); + const filterDefault = this.cache.data.filterDefaults.get(propID); + const row = document.createElement('div'); row.className = 'filter-row'; const col1 = document.createElement('div'); @@ -854,11 +854,39 @@ class UIManager { col2.className = 'filter-row-col2'; row.appendChild(col2); - const sliderOrDropdown = isCategoricalProperty - ? new DropdownChecklist(propID, this.cache) - : new InvertibleRangeSlider(propID, this.cache); + // Mixed-type property (§6.2): rendered, but disabled with the reason — + // no widget, no per-row actions, checkbox inert via the row class. + if (filterDefault.unusable) { + row.classList.add('filter-row-unusable'); + const reason = document.createElement('div'); + reason.className = 'filter-unusable-reason'; + reason.textContent = + `Mixes ${filterDefault.numericCount} numeric and ` + + `${filterDefault.textCount} text values — filter disabled`; + reason.title = + 'This column holds both numbers and text, so neither a range slider nor a ' + + 'category list fits it. Clean the column to a single type to filter by it.'; + // ponytail: "jump to offending rows in the data table" (spec §6.2) + // needs data-editor search/filter support that does not exist yet; + // add the link here once the data editor can focus a row subset. + col2.appendChild(reason); + row.appendChild(document.createElement('div')); + subBody.append(row); + continue; + } - sliderOrDropdown.appendTo(col2); + const widget = filterDefault.isBoolean + ? new BooleanToggle(propID, this.cache) + : filterDefault.isCategory + ? new DropdownChecklist(propID, this.cache) + : new InvertibleRangeSlider(propID, this.cache); + + widget.appendTo(col2); + // 0/1-encoded columns can be genuine numeric measures misclassified as + // boolean (§6.1 risk) — offer the type switch in both directions. + if (filterDefault.numericBoolSource) { + col2.appendChild(this.createBooleanTypeOverrideLink(propID, filterDefault.isBoolean)); + } const col3 = document.createElement('div'); col3.className = 'filter-row-col3'; if (this.cache.nodeExclusiveProps.has(propID) || this.cache.mixedProps.has(propID)) { @@ -871,13 +899,36 @@ class UIManager { col3.appendChild(this.cache.uiComponents.createAddOrRemoveToSelectionGroup(propID)); row.appendChild(col3); subBody.append(row); - sliderOrDropdown.appendListeners(); + widget.appendListeners(); } this.manageDynamicWidgets(); this.cache.qm.updateQueryTextArea(); } + // Small type-switch link under the widget of a 0/1-encoded column: inferred + // boolean ↔ plain numeric slider (§6.1 misclassification override). The + // choice persists in the workspace JSON via cache.data.booleanTypeOverrides. + createBooleanTypeOverrideLink(propID, isCurrentlyBoolean) { + const link = document.createElement('button'); + link.type = 'button'; + link.className = 'filter-type-override'; + link.textContent = isCurrentlyBoolean ? 'treat as 0/1 numbers' : 'treat as true/false'; + link.title = isCurrentlyBoolean + ? 'This column only holds 0 and 1 — switch to a numeric range slider if they are measures, not booleans' + : 'Switch back to the inferred true/false toggle'; + link.addEventListener('click', async () => { + if (this.cache.EVENT_LOCKS.FILTERS_LOCKED_BY_MANUAL_QUERY) return; + this.cache.io.applyBooleanTypeOverride(propID, isCurrentlyBoolean); + this.buildFilterUI(); + await this.cache.fm.handleFilterEvent( + 'Filtering Elements', + `${propID} type switched to ${isCurrentlyBoolean ? 'numeric' : 'boolean'}` + ); + }); + return link; + } + // Builds the segmented OR/AND control that sets how multiple active filters // combine. OR shows elements matching any active filter; AND shows elements // matching every active filter (non-strict — a property an element lacks @@ -1053,6 +1104,11 @@ class UIManager { for (const propID of this.cache.propIDs) { this.checkCheckbox(propID, false); } + // Boolean toggles resync from the manual query starting from Any, so a + // query with only IS TRUE (or only IS FALSE) lands on the right segment. + for (const toggle of this.cache.propIDToBooleanToggles.values()) { + toggle.resetToAny(); + } } checkCheckbox(propID, enable = true) { diff --git a/src/managers/ui_components.js b/src/managers/ui_components.js index db47159..99c9acd 100644 --- a/src/managers/ui_components.js +++ b/src/managers/ui_components.js @@ -248,6 +248,104 @@ class DropdownChecklist { } } +/** + * Three-state Any / True / False segment for boolean-classified properties + * (§6.1). State lives in the layout filter's `categories` Set (mutated in + * place, like DropdownChecklist): Any = {'true','false'}, True = {'true'}, + * False = {'false'} — so query generation, narrowing checks, and JSON + * persistence reuse the categorical machinery unchanged. + */ +class BooleanToggle { + static STATES = [ + ['any', 'Any', ['true', 'false']], + ['true', 'True', ['true']], + ['false', 'False', ['false']], + ]; + + constructor(propID, cache) { + this.propID = propID; + this.cache = cache; + this.selectedCategories = + this.cache.data.layouts[this.cache.data.selectedLayout].filters.get(propID).categories; + this.cache.propIDToBooleanToggles.set(propID, this); + } + + state() { + const hasTrue = this.selectedCategories.has('true'); + const hasFalse = this.selectedCategories.has('false'); + if (hasTrue && !hasFalse) return 'true'; + if (hasFalse && !hasTrue) return 'false'; + return 'any'; + } + + appendTo(parent) { + this.container = document.createElement('div'); + this.container.id = this.propID + '-bool-toggle'; + this.container.className = 'filter-join-toggle filter-bool-toggle'; + this.segments = new Map(); + + for (const [key, label] of BooleanToggle.STATES) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'filter-join-segment'; + btn.textContent = label; + btn.title = `Show elements where this property is ${key === 'any' ? 'true or false' : key}`; + btn.addEventListener('click', async () => await this.handleSelection(key)); + this.segments.set(key, btn); + this.container.appendChild(btn); + } + + this.updateSegments(); + parent.appendChild(this.container); + } + + // Interface parity with DropdownChecklist / InvertibleRangeSlider — + // BooleanToggle wires its listeners in appendTo. + appendListeners() {} + + updateSegments() { + if (!this.segments) return; // not rendered (e.g. sync before appendTo) + const current = this.state(); + for (const [key, btn] of this.segments.entries()) { + btn.classList.toggle('active', key === current); + } + } + + #setState(key) { + const values = BooleanToggle.STATES.find(([k]) => k === key)[2]; + this.selectedCategories.clear(); + for (const value of values) this.selectedCategories.add(value); + this.updateSegments(); + } + + async handleSelection(key) { + if (this.cache.EVENT_LOCKS.FILTERS_LOCKED_BY_MANUAL_QUERY) return; + if (key === this.state()) return; + this.#setState(key); + await this.cache.fm.handleFilterEvent( + 'Filtering Elements', + `${this.propID} is ${key === 'any' ? 'true or false' : key}` + ); + } + + // Sync from a manual query (no filter event — the query drives rendering). + // Called once per IS TRUE / IS FALSE leaf; two leaves for the same property + // union to Any. resetToAny() runs before each sync pass, so the sequence + // any → first leaf narrows → second leaf widens back is always correct. + applyFromQuery(value) { + const current = this.state(); + if (current === 'any') { + this.#setState(value); + } else if (current !== value) { + this.#setState('any'); + } + } + + resetToAny() { + this.#setState('any'); + } +} + class InvertibleRangeSlider { constructor(propID, cache) { this.propID = propID; @@ -1130,4 +1228,4 @@ class UIComponentManager { } } -export { DropdownChecklist, InvertibleRangeSlider, UIComponentManager }; +export { BooleanToggle, DropdownChecklist, InvertibleRangeSlider, UIComponentManager }; diff --git a/src/style.css b/src/style.css index 7361882..8b34d2e 100644 --- a/src/style.css +++ b/src/style.css @@ -1027,6 +1027,11 @@ h5 { color: var(--brand-text); } +.q-kw-istrue, +.q-kw-isfalse { + color: var(--brand-text); +} + .q-connector-opening-bracket { } @@ -1522,10 +1527,9 @@ h5 { align-items: center; } -/* Panel-level control bar above all filter sections: the "Details" disclosure - on the left, the OR/AND join control (plus its "Complete cases only" option) - pushed to the right. Sitting above every section makes clear the join - control is global, not scoped to the adjacent section. */ +/* Panel-level control bar above all filter sections: the OR/AND join control + plus its "Complete cases only" option. Sitting above every section makes + clear the join control is global, not scoped to the adjacent section. */ .filter-toolbar { display: flex; align-items: center; @@ -1615,6 +1619,41 @@ h5 { cursor: default; } +/* Boolean filter rows (§6.1): the Any/True/False segment reuses the join + toggle's pill styling; the type-override link sits under it as fine print. */ +.filter-bool-toggle { + justify-self: start; +} + +.filter-type-override { + display: block; + margin-top: 2px; + padding: 0; + border: none; + background: none; + font-size: 10px; + color: var(--text-muted); + text-decoration: underline dotted; + cursor: pointer; +} + +.filter-type-override:hover { + color: var(--accent-text); +} + +/* Mixed-type rows (§6.2): visible but inert, with the reason inline. */ +.filter-row-unusable .filter-row-col1 { + opacity: 0.55; + pointer-events: none; +} + +.filter-unusable-reason { + font-size: 10px; + font-style: italic; + color: var(--text-muted); + padding-top: 3px; +} + /* Collapsible filter sections and sub-groups (accordion). Properties within a sub-group stay compact (grid row-gap); sub-groups get a bottom margin so they read as distinct blocks. */ diff --git a/src/utilities/color_scale_picker.js b/src/utilities/color_scale_picker.js index 3883a62..a10ed02 100644 --- a/src/utilities/color_scale_picker.js +++ b/src/utilities/color_scale_picker.js @@ -153,7 +153,7 @@ class ColorScalePicker { : this.cache.edgeRef.get(elementId); element?.features.forEach((f) => { - if (filters.has(f)) available.add(f); + if (filters.has(f) && !filters.get(f).unusable) available.add(f); }); }); diff --git a/src/utilities/data_editor.js b/src/utilities/data_editor.js index d38d244..08ecbf1 100644 --- a/src/utilities/data_editor.js +++ b/src/utilities/data_editor.js @@ -1075,6 +1075,7 @@ class DataTable { ...plan.fileData, layouts: this.cache.data.layouts, selectedLayout: this.cache.data.selectedLayout, + booleanTypeOverrides: this.cache.data.booleanTypeOverrides, }); const s = plan.stats; const ignored = s.ignoredNodes.length + s.ignoredEdges.length; @@ -1158,6 +1159,7 @@ class DataTable { // Preserve existing layouts and selected layout to maintain per-view configurations layouts: this.cache.data.layouts, selectedLayout: this.cache.data.selectedLayout, + booleanTypeOverrides: this.cache.data.booleanTypeOverrides, // filterDefaults will be rebuilt by preProcessData() from the headers }; diff --git a/src/utilities/neo4j_session.js b/src/utilities/neo4j_session.js index 39a639a..28356b8 100644 --- a/src/utilities/neo4j_session.js +++ b/src/utilities/neo4j_session.js @@ -257,6 +257,11 @@ async function mergeAndApply(cache, newNodes, newRels, deps = {}) { session.exclusions, ); seedMergedPositions(data, positions); + // Same in-memory carry-over as layouts below: a graph rebuild must not + // drop the user's boolean-type overrides (§6.1). + if (cache.data?.booleanTypeOverrides?.size) { + data.booleanTypeOverrides = [...cache.data.booleanTypeOverrides]; + } // Declare the (single, current) workspace in the payload so preProcessData // takes the JSON-import path: with a layout whose positions cover every diff --git a/src/utilities/numeric_scale_picker.js b/src/utilities/numeric_scale_picker.js index cc3de22..79d83e5 100644 --- a/src/utilities/numeric_scale_picker.js +++ b/src/utilities/numeric_scale_picker.js @@ -190,7 +190,7 @@ class NumericScalePicker { element?.features.forEach(f => { const filterObj = filters.get(f); - if (filterObj && !filterObj.isCategory) { + if (filterObj && !filterObj.isCategory && !filterObj.unusable) { available.add(f); } }); diff --git a/src/utilities/pie_chart_picker.js b/src/utilities/pie_chart_picker.js index 2683d9d..5e89a77 100644 --- a/src/utilities/pie_chart_picker.js +++ b/src/utilities/pie_chart_picker.js @@ -194,7 +194,8 @@ class PieChartPicker { const node = this.cache.nodeRef.get(nodeId); node?.features.forEach((propId) => { const filter = filters.get(propId); - if (filter && Boolean(filter.isCategory) === wantCategory) available.add(propId); + if (filter && !filter.unusable && Boolean(filter.isCategory) === wantCategory) + available.add(propId); }); } // Sort by short name first so colliding names (score (Cell) / score diff --git a/src/utilities/static.js b/src/utilities/static.js index 3cf9b62..de20792 100644 --- a/src/utilities/static.js +++ b/src/utilities/static.js @@ -64,6 +64,20 @@ class StaticUtilities { return false; } + /** + * Canonical boolean value of a user-data token, per the Excel template's + * stated encoding ("true or TRUE or 1, false or FALSE or 0"): returns + * 'true', 'false', or null when the value is not a boolean encoding. + * Unlike isBoolean above (reserved style columns), this also accepts the + * string forms '1'/'0', which is how spreadsheet cells usually arrive. + */ + static booleanTokenValue(value) { + const norm = String(value).trim().toLowerCase(); + if (norm === 'true' || norm === '1') return 'true'; + if (norm === 'false' || norm === '0') return 'false'; + return null; + } + static isHexColor(value) { if (!this.isString(value)) return false; const hexRegex = /^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/; diff --git a/tests/assistant-query-schema.test.js b/tests/assistant-query-schema.test.js index ed331f9..f32d459 100644 --- a/tests/assistant-query-schema.test.js +++ b/tests/assistant-query-schema.test.js @@ -24,9 +24,9 @@ describe("QUERY_RESPONSE_SCHEMA", () => { expect(re.test("Node filters::only_two")).toBe(false); }); - it("enumerates only the three permitted operators", () => { + it("enumerates only the five permitted operators", () => { expect(QUERY_RESPONSE_SCHEMA.$defs.Expr.properties.op.enum) - .toEqual(["BETWEEN", "LT_OR_GT", "IN"]); + .toEqual(["BETWEEN", "LT_OR_GT", "IN", "IS_TRUE", "IS_FALSE"]); }); it("enumerates only the three permitted binary connectors", () => { diff --git a/tests/boolean-filter-inference.test.js b/tests/boolean-filter-inference.test.js new file mode 100644 index 0000000..7668fc8 --- /dev/null +++ b/tests/boolean-filter-inference.test.js @@ -0,0 +1,338 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { IOManager } from '../src/managers/io.js'; +import { QueryAST } from '../src/managers/query.js'; +import { StaticUtilities } from '../src/utilities/static.js'; +import { CFG, DEFAULTS } from '../src/config.js'; + +// ========================================================================== +// Boolean property inference (spec §6.1) + mixed-type visibility (§6.2). +// Classification runs in two stages: populateFilterPropsLowsAndHighs +// accumulates values, finalizeFilterClassification resolves the type. +// ========================================================================== + +function createMockCache() { + return { + CFG, + DEFAULTS, + data: { + filterDefaults: new Map(), + booleanTypeOverrides: new Set(), + nodes: [], + edges: [], + layouts: {}, + selectedLayout: 'Default', + }, + bs: { + traverseBubbleSets: function* () { + for (const group of Object.keys(DEFAULTS.BUBBLE_GROUP_QUADRANT_POSITIONS)) { + yield group; + } + }, + }, + ui: { warning: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }; +} + +function feed(io, propId, values) { + for (const v of values) io.populateFilterPropsLowsAndHighs(propId, v); +} + +const PROP = 'Node filters::group::flag'; + +describe('boolean classification (§6.1)', () => { + let io, cache; + + beforeEach(() => { + cache = createMockCache(); + io = new IOManager(cache); + }); + + it('classifies TRUE/FALSE strings as boolean with canonical categories', () => { + feed(io, PROP, ['TRUE', 'FALSE', 'true']); + io.finalizeFilterClassification(); + + const fd = cache.data.filterDefaults.get(PROP); + expect(fd.isBoolean).toBe(true); + expect(fd.isCategory).toBe(true); + expect([...fd.categories].sort()).toEqual(['false', 'true']); + expect(fd.numericBoolSource).toBe(false); + expect(fd.unusable).toBe(false); + }); + + it('classifies a pure 0/1 numeric column as boolean and override-eligible', () => { + feed(io, PROP, [1, 0, 1, 1]); + io.finalizeFilterClassification(); + + const fd = cache.data.filterDefaults.get(PROP); + expect(fd.isBoolean).toBe(true); + expect(fd.numericBoolSource).toBe(true); + // numeric bounds survive so the override can switch back without rescanning + expect(fd.lowerThreshold).toBe(0); + expect(fd.upperThreshold).toBe(1); + }); + + it('classifies mixed encodings (TRUE + 1) as boolean, not unusable', () => { + feed(io, PROP, ['TRUE', 1, 'false']); + io.finalizeFilterClassification(); + + const fd = cache.data.filterDefaults.get(PROP); + expect(fd.isBoolean).toBe(true); + expect(fd.numericBoolSource).toBe(false); // text present → no numeric override + expect(fd.unusable).toBe(false); + expect(cache.ui.warning).not.toHaveBeenCalled(); + }); + + it('keeps a 0/0.5/1 column numeric — 0.5 is not a boolean encoding', () => { + feed(io, PROP, [0, 0.5, 1]); + io.finalizeFilterClassification(); + + const fd = cache.data.filterDefaults.get(PROP); + expect(fd.isBoolean).toBe(false); + expect(fd.isCategory).toBe(false); + expect(fd.hasFloatValues).toBe(true); + }); + + it('keeps other integer columns numeric', () => { + feed(io, PROP, [0, 1, 2]); + io.finalizeFilterClassification(); + + expect(cache.data.filterDefaults.get(PROP).isBoolean).toBe(false); + }); + + it('single-valued boolean columns still get both canonical categories', () => { + feed(io, PROP, ['TRUE', 'TRUE']); + io.finalizeFilterClassification(); + + const fd = cache.data.filterDefaults.get(PROP); + expect(fd.isBoolean).toBe(true); + expect([...fd.categories].sort()).toEqual(['false', 'true']); + }); + + it('leaves header-only (valueless) properties untouched', () => { + io.populateFilterPropsLowsAndHighs(PROP, ''); + io.finalizeFilterClassification(); + + const fd = cache.data.filterDefaults.get(PROP); + expect(fd.isBoolean).toBe(false); + expect(fd.unusable).toBe(false); + }); + + it('honors a persisted numeric override for 0/1 columns', () => { + cache.data.booleanTypeOverrides.add(PROP); + feed(io, PROP, [1, 0]); + io.finalizeFilterClassification(); + + const fd = cache.data.filterDefaults.get(PROP); + expect(fd.isBoolean).toBe(false); + expect(fd.numericBoolSource).toBe(true); // still eligible to switch back + expect(fd.lowerThreshold).toBe(0); + expect(fd.upperThreshold).toBe(1); + }); + + it('ignores an override on text-encoded booleans (numeric makes no sense)', () => { + cache.data.booleanTypeOverrides.add(PROP); + feed(io, PROP, ['TRUE', 'FALSE']); + io.finalizeFilterClassification(); + + expect(cache.data.filterDefaults.get(PROP).isBoolean).toBe(true); + }); + + it('canonicalizes featureValues on carriers to Set{true|false}', () => { + const node = { + features: new Set([PROP]), + featureValues: new Map([[PROP, 1]]), + }; + const edge = { + features: new Set([PROP]), + featureValues: new Map([[PROP, new Set(['FALSE'])]]), + }; + cache.data.nodes = [node]; + cache.data.edges = [edge]; + feed(io, PROP, [1, 'FALSE']); + io.finalizeFilterClassification(); + + expect([...node.featureValues.get(PROP)]).toEqual(['true']); + expect([...edge.featureValues.get(PROP)]).toEqual(['false']); + }); +}); + +describe('mixed-type columns stay visible as unusable (§6.2)', () => { + let io, cache; + + beforeEach(() => { + cache = createMockCache(); + io = new IOManager(cache); + }); + + it('marks a numeric+text column unusable instead of deleting it', () => { + feed(io, PROP, [3, 'apple', 7, 'pear']); + io.finalizeFilterClassification(); + + const fd = cache.data.filterDefaults.get(PROP); + expect(fd).toBeDefined(); // pre-1.17 behavior deleted the property + expect(fd.unusable).toBe(true); + expect(fd.active).toBe(false); + expect(fd.numericCount).toBe(2); + expect(fd.textCount).toBe(2); + expect(cache.ui.warning).toHaveBeenCalledTimes(1); + expect(cache.ui.warning.mock.calls[0][0]).toContain('2 numeric'); + expect(cache.ui.warning.mock.calls[0][0]).toContain('2 text'); + }); + + it('pure categorical and pure numeric columns stay usable', () => { + feed(io, 'Node filters::g::cat', ['apple', 'pear']); + feed(io, 'Node filters::g::num', [1, 2, 3]); + io.finalizeFilterClassification(); + + expect(cache.data.filterDefaults.get('Node filters::g::cat').unusable).toBe(false); + expect(cache.data.filterDefaults.get('Node filters::g::num').unusable).toBe(false); + }); +}); + +describe('applyBooleanTypeOverride', () => { + let io, cache, node; + + beforeEach(() => { + cache = createMockCache(); + io = new IOManager(cache); + node = { + features: new Set([PROP]), + featureValues: new Map(), + D4Data: { 'Node filters': { group: { flag: 1 } } }, + }; + cache.data.nodes = [node]; + node.featureValues.set(PROP, 1); + feed(io, PROP, [1, 0]); + io.finalizeFilterClassification(); + cache.data.layouts = { + Default: { filters: new Map([[PROP, { placeholder: true }]]) }, + }; + }); + + it('switches to numeric: flags, override set, featureValues from D4Data', () => { + io.applyBooleanTypeOverride(PROP, true); + + const fd = cache.data.filterDefaults.get(PROP); + expect(fd.isBoolean).toBe(false); + expect(fd.isCategory).toBe(false); + expect(cache.data.booleanTypeOverrides.has(PROP)).toBe(true); + expect(node.featureValues.get(PROP)).toBe(1); + // layout filter reset to a clone of the new default + expect(cache.data.layouts.Default.filters.get(PROP).isBoolean).toBe(false); + }); + + it('switches back to boolean: canonical categories and featureValues', () => { + io.applyBooleanTypeOverride(PROP, true); + io.applyBooleanTypeOverride(PROP, false); + + const fd = cache.data.filterDefaults.get(PROP); + expect(fd.isBoolean).toBe(true); + expect(cache.data.booleanTypeOverrides.has(PROP)).toBe(false); + expect([...node.featureValues.get(PROP)]).toEqual(['true']); + expect(cache.data.layouts.Default.filters.get(PROP).isBoolean).toBe(true); + }); + + it('is a no-op for properties that are not 0/1-encoded', () => { + feed(io, 'Node filters::g::text', ['TRUE', 'FALSE']); + io.finalizeFilterClassification(); + + io.applyBooleanTypeOverride('Node filters::g::text', true); + + expect(cache.data.filterDefaults.get('Node filters::g::text').isBoolean).toBe(true); + expect(cache.data.booleanTypeOverrides.has('Node filters::g::text')).toBe(false); + }); +}); + +describe('reconcileLoadedFilterType (saved-workspace migration)', () => { + let io, cache; + + beforeEach(() => { + cache = createMockCache(); + io = new IOManager(cache); + }); + + const booleanDefault = () => ({ + active: true, + isBoolean: true, + isCategory: true, + unusable: false, + categories: new Set(['true', 'false']), + }); + + it('canonicalizes raw-cased categories from pre-inference files', () => { + const loaded = { active: true, isCategory: true, categories: new Set(['TRUE']) }; + + const merged = io.reconcileLoadedFilterType(loaded, booleanDefault()); + + expect(merged.isBoolean).toBe(true); + expect([...merged.categories]).toEqual(['true']); // narrowed state preserved + }); + + it('resets numeric-era filters when the property is boolean now', () => { + const loaded = { active: false, isCategory: false, lowerThreshold: 0, upperThreshold: 1 }; + + const merged = io.reconcileLoadedFilterType(loaded, booleanDefault()); + + expect(merged.isBoolean).toBe(true); + expect([...merged.categories].sort()).toEqual(['false', 'true']); + }); + + it('resets a stale boolean filter when the property is not boolean anymore', () => { + const loaded = { isBoolean: true, isCategory: true, categories: new Set(['true']) }; + const numericDefault = { active: true, isBoolean: false, isCategory: false, unusable: false }; + + const merged = io.reconcileLoadedFilterType(loaded, numericDefault); + + expect(merged.isBoolean).toBe(false); + }); + + it('keeps non-boolean filters untouched', () => { + const loaded = { active: true, isCategory: false, lowerThreshold: 3, upperThreshold: 9 }; + const numericDefault = { active: true, isBoolean: false, isCategory: false, unusable: false }; + + expect(io.reconcileLoadedFilterType(loaded, numericDefault)).toBe(loaded); + }); +}); + +describe('QueryAST IS TRUE / IS FALSE evaluation', () => { + const leaf = (op) => [ + [ + { type: 'property', main: 'Node filters', sub: 'group', prop: 'flag', propID: PROP }, + { type: 'KW', value: op }, + ], + ]; + + const nodeWith = (value) => ({ + D4Data: { 'Node filters': { group: { flag: value } } }, + featureIsWithinThreshold: new Map(), + }); + + it.each([[true], ['true'], ['TRUE'], [1], ['1']])('IS TRUE matches %j', (v) => { + expect(new QueryAST(leaf('IS TRUE')).testNode(nodeWith(v))).toBe(true); + expect(new QueryAST(leaf('IS FALSE')).testNode(nodeWith(v))).toBe(false); + }); + + it.each([[false], ['false'], ['FALSE'], [0], ['0']])('IS FALSE matches %j', (v) => { + expect(new QueryAST(leaf('IS FALSE')).testNode(nodeWith(v))).toBe(true); + expect(new QueryAST(leaf('IS TRUE')).testNode(nodeWith(v))).toBe(false); + }); + + it('neither matches non-boolean or missing values', () => { + expect(new QueryAST(leaf('IS TRUE')).testNode(nodeWith('maybe'))).toBe(false); + expect(new QueryAST(leaf('IS TRUE')).testNode(nodeWith(undefined))).toBe(false); + }); +}); + +describe('StaticUtilities.booleanTokenValue', () => { + it('canonicalizes every documented encoding', () => { + for (const v of [true, 'true', 'TRUE', ' True ', 1, '1']) { + expect(StaticUtilities.booleanTokenValue(v)).toBe('true'); + } + for (const v of [false, 'false', 'FALSE', 0, '0']) { + expect(StaticUtilities.booleanTokenValue(v)).toBe('false'); + } + for (const v of ['yes', 2, '', '10', 0.5]) { + expect(StaticUtilities.booleanTokenValue(v)).toBe(null); + } + }); +}); diff --git a/tests/boolean-toggle-widget.test.js b/tests/boolean-toggle-widget.test.js new file mode 100644 index 0000000..b029016 --- /dev/null +++ b/tests/boolean-toggle-widget.test.js @@ -0,0 +1,167 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { BooleanToggle } from '../src/managers/ui_components.js'; +import { QueryManager } from '../src/managers/query.js'; + +// ========================================================================== +// BooleanToggle (§6.1): three-state Any/True/False segment for boolean- +// classified properties. State lives in the layout filter's categories Set +// (Any = {'true','false'}) so query generation and persistence reuse the +// categorical machinery. Plus: query generation emits IS TRUE / IS FALSE +// and skips unusable (§6.2) filters. +// ========================================================================== + +const PROP = 'Node filters::group::flag'; + +function makeCache(categories) { + const handleFilterEvent = vi.fn().mockResolvedValue(undefined); + return { + data: { + selectedLayout: 'L', + layouts: { L: { filters: new Map([[PROP, { active: true, categories }]]) } }, + }, + propIDToBooleanToggles: new Map(), + EVENT_LOCKS: { FILTERS_LOCKED_BY_MANUAL_QUERY: false }, + fm: { handleFilterEvent }, + }; +} + +describe('BooleanToggle', () => { + let cache, toggle, parent; + + beforeEach(() => { + document.body.innerHTML = ''; + cache = makeCache(new Set(['true', 'false'])); + toggle = new BooleanToggle(PROP, cache); + parent = document.createElement('div'); + toggle.appendTo(parent); + }); + + it('renders three segments with Any active for the default state', () => { + const segments = [...parent.querySelectorAll('.filter-join-segment')]; + expect(segments.map((s) => s.textContent)).toEqual(['Any', 'True', 'False']); + expect(segments[0].classList.contains('active')).toBe(true); + expect(cache.propIDToBooleanToggles.get(PROP)).toBe(toggle); + }); + + it('narrows to True on click, mutating the layout filter in place', async () => { + const [, trueBtn] = parent.querySelectorAll('.filter-join-segment'); + trueBtn.click(); + await Promise.resolve(); + + expect([...cache.data.layouts.L.filters.get(PROP).categories]).toEqual(['true']); + expect(toggle.state()).toBe('true'); + expect(cache.fm.handleFilterEvent).toHaveBeenCalledOnce(); + }); + + it('ignores clicks on the active segment and while filters are locked', async () => { + const [anyBtn, trueBtn] = parent.querySelectorAll('.filter-join-segment'); + anyBtn.click(); // already active + cache.EVENT_LOCKS.FILTERS_LOCKED_BY_MANUAL_QUERY = true; + trueBtn.click(); + await Promise.resolve(); + + expect(cache.fm.handleFilterEvent).not.toHaveBeenCalled(); + expect(toggle.state()).toBe('any'); + }); + + it('derives state from a loaded single-value categories set', () => { + const loadedCache = makeCache(new Set(['false'])); + const loadedToggle = new BooleanToggle(PROP, loadedCache); + expect(loadedToggle.state()).toBe('false'); + }); + + it('applyFromQuery: one leaf narrows, the complementary leaf widens to Any', () => { + toggle.resetToAny(); + toggle.applyFromQuery('true'); + expect(toggle.state()).toBe('true'); + + toggle.applyFromQuery('false'); // second leaf of a generated Any condition + expect(toggle.state()).toBe('any'); + + toggle.resetToAny(); + toggle.applyFromQuery('false'); + expect(toggle.state()).toBe('false'); + toggle.applyFromQuery('false'); // idempotent + expect(toggle.state()).toBe('false'); + }); +}); + +describe('updateQueryTextArea with boolean and unusable filters', () => { + let qm, cache; + + function makeQueryCache(filters, filterDefaults) { + document.body.innerHTML = + ''; + return { + data: { + selectedLayout: 'L', + layouts: { L: { filters, filterJoinMode: 'OR' } }, + filterDefaults, + }, + uniquePropHierarchy: { + 'Node filters': { group: new Set(['flag', 'mix']) }, + }, + query: { + text: document.createElement('div'), + overlay: document.createElement('div'), + valid: true, + }, + }; + } + + it('emits IS TRUE for a narrowed boolean and a TRUE/FALSE pair for Any', () => { + const anyFilter = { + active: true, + isBoolean: true, + isCategory: true, + unusable: false, + categories: new Set(['true', 'false']), + }; + const trueFilter = { ...anyFilter, categories: new Set(['true']) }; + + cache = makeQueryCache(new Map([[PROP, trueFilter]]), new Map([[PROP, anyFilter]])); + qm = new QueryManager(cache); + qm.updateQueryTextArea(); + expect(cache.query.text.textContent).toBe(`(${PROP} IS TRUE)`); + + cache = makeQueryCache(new Map([[PROP, anyFilter]]), new Map([[PROP, anyFilter]])); + qm = new QueryManager(cache); + qm.updateQueryTextArea(); + expect(cache.query.text.textContent).toBe(`((${PROP} IS TRUE) OR (${PROP} IS FALSE))`); + }); + + it('skips unusable filters entirely (§6.2)', () => { + const unusableFilter = { + active: true, // even if something re-activated it + unusable: true, + isCategory: true, + categories: new Set(['apple']), + }; + cache = makeQueryCache( + new Map([['Node filters::group::mix', unusableFilter]]), + new Map([['Node filters::group::mix', unusableFilter]]) + ); + qm = new QueryManager(cache); + qm.updateQueryTextArea(); + + expect(cache.query.text.textContent).toBe(''); + }); + + it('round-trips IS TRUE through encode without validation errors', () => { + const boolFilter = { + active: true, + isBoolean: true, + isCategory: true, + unusable: false, + categories: new Set(['true']), + }; + cache = makeQueryCache(new Map([[PROP, boolFilter]]), new Map([[PROP, boolFilter]])); + qm = new QueryManager(cache); + qm.updateQueryTextArea(); + + expect(cache.query.valid).toBe(true); + expect(cache.query.overlay.innerHTML).toContain('q-kw-istrue'); + expect(cache.query.overlay.innerHTML).not.toContain('q-error'); + }); +}); From 9f04f6991c4e672f8dab24b4d09f2183d6ad096c Mon Sep 17 00:00:00 2001 From: Mnikley Date: Mon, 27 Jul 2026 17:07:38 +0200 Subject: [PATCH 019/181] refactor(filters): drop boolean numeric override; min width for filter widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback on phase 2: - The 'treat as 0/1 numbers' override is gone, including its workspace-JSON persistence and the rebuild-path carry-overs. Classification is purely data-driven: two distinct boolean-encoded values → boolean toggle; a rebuild that sees a third distinct value (e.g. added via the data editor) reclassifies the column back to a numeric slider automatically (covered by a new test). Spec §6.1/§7.2 updated to match. - Filter control column gets a 150px floor and long property names wrap (grid minmax + overflow-wrap), so sliders/dropdowns stay readable next to very long labels now that details are always rendered. --- src/managers/io.js | 66 +++---------------- src/managers/ui.js | 28 -------- src/style.css | 25 ++------ src/utilities/data_editor.js | 2 - src/utilities/neo4j_session.js | 5 -- tests/boolean-filter-inference.test.js | 89 ++++---------------------- 6 files changed, 30 insertions(+), 185 deletions(-) diff --git a/src/managers/io.js b/src/managers/io.js index 40ce555..23349c2 100644 --- a/src/managers/io.js +++ b/src/managers/io.js @@ -1262,11 +1262,6 @@ class IOManager { this.cache.nodePositionsFromExcelImport = new Map(); - // Per-property boolean-type overrides (§6.1): props the user forced back - // to numeric. Lives on cache.data so it round-trips through the workspace - // JSON (Set → array via the export replacer). - this.cache.data.booleanTypeOverrides = new Set(fileData.booleanTypeOverrides || []); - this.populateCacheHeaders(fileData); this.cache.data.nodes = fileData.nodes.map((node) => { @@ -1454,11 +1449,11 @@ class IOManager { // Boolean classification (§6.1): boolCandidate stays true while every // observed value is a boolean encoding (true/TRUE/1 vs false/FALSE/0); // finalizeFilterClassification then promotes candidates to isBoolean. - // numericBoolSource marks candidates whose values were all numeric - // (0/1) — the only ones eligible for the "treat as numbers" override. + // Classification is purely data-driven — when the data changes (e.g. a + // third distinct number appears via the data editor), the rebuild + // reclassifies the column automatically. isBoolean: false, boolCandidate: true, - numericBoolSource: false, // Mixed-type accounting (§6.2): a column holding both numeric and text // values becomes an unusable (disabled) filter row instead of being // deleted; the counts feed the row's explanation. @@ -1543,28 +1538,22 @@ class IOManager { * per-layout filter rebuild, so cloned layout filters inherit the result. * * - Boolean: every value is a boolean encoding → isBoolean (a refined - * categorical with canonical categories {'true','false'}), unless the - * user overrode a 0/1-numeric column back to numeric (persisted in - * cache.data.booleanTypeOverrides, round-trips through workspace JSON). + * categorical with canonical categories {'true','false'}). * - Mixed numeric+text: kept visible but marked unusable (disabled row) * instead of the pre-1.17 silent delete. */ finalizeFilterClassification() { - const overrides = this.cache.data.booleanTypeOverrides; for (const [propHash, fo] of this.cache.data.filterDefaults.entries()) { const hasText = fo.categories.size > 0; const hasNumeric = fo.lowerThreshold !== Infinity; if (!hasText && !hasNumeric) continue; // header-only property, no values if (fo.boolCandidate) { - fo.numericBoolSource = !hasText; - if (!(fo.numericBoolSource && overrides.has(propHash))) { - fo.isBoolean = true; - fo.isCategory = true; - fo.categories = new Set(['true', 'false']); - this.#canonicalizeBooleanFeatureValues(propHash); - } - continue; // an overridden 0/1 column falls through as plain numeric + fo.isBoolean = true; + fo.isCategory = true; + fo.categories = new Set(['true', 'false']); + this.#canonicalizeBooleanFeatureValues(propHash); + continue; } if (hasText && hasNumeric) { @@ -1583,7 +1572,7 @@ class IOManager { // Set{'true'|'false'} on every carrier, so categorical consumers (color // ramps, pies, IN queries) see one value space regardless of the source // encoding (TRUE vs 1). Raw D4Data is never touched — exports stay - // byte-faithful and the numeric override can restore the original values. + // byte-faithful. #canonicalizeBooleanFeatureValues(propHash) { for (const element of [...this.cache.data.nodes, ...this.cache.data.edges]) { if (!element.features?.has(propHash)) continue; @@ -1597,41 +1586,6 @@ class IOManager { } } - /** - * User-facing type override for boolean-classified 0/1 columns (§6.1 risk - * mitigation): flips one property between the inferred boolean segment and - * a plain numeric range slider. The choice is stored in - * cache.data.booleanTypeOverrides and therefore persists in the workspace - * JSON. Resets the property's per-layout filter state (a type switch - * invalidates any narrowed state) and rebuilds featureValues from D4Data. - */ - applyBooleanTypeOverride(propHash, toNumeric) { - const fo = this.cache.data.filterDefaults.get(propHash); - if (!fo?.numericBoolSource) return; - - if (toNumeric) { - this.cache.data.booleanTypeOverrides.add(propHash); - fo.isBoolean = false; - fo.isCategory = false; - fo.categories = new Set(); - const [main, sub, prop] = StaticUtilities.decodePropHashId(propHash); - for (const element of [...this.cache.data.nodes, ...this.cache.data.edges]) { - if (!element.features?.has(propHash)) continue; - element.featureValues.set(propHash, element.D4Data?.[main]?.[sub]?.[prop]); - } - } else { - this.cache.data.booleanTypeOverrides.delete(propHash); - fo.isBoolean = true; - fo.isCategory = true; - fo.categories = new Set(['true', 'false']); - this.#canonicalizeBooleanFeatureValues(propHash); - } - - for (const layoutName in this.cache.data.layouts) { - this.cache.data.layouts[layoutName].filters.set(propHash, structuredClone(fo)); - } - } - populateCacheHeaders(fileData) { if (fileData.nodeDataHeaders) { for (const nodeHeader of fileData.nodeDataHeaders) { diff --git a/src/managers/ui.js b/src/managers/ui.js index 18b241d..49e9377 100644 --- a/src/managers/ui.js +++ b/src/managers/ui.js @@ -882,11 +882,6 @@ class UIManager { : new InvertibleRangeSlider(propID, this.cache); widget.appendTo(col2); - // 0/1-encoded columns can be genuine numeric measures misclassified as - // boolean (§6.1 risk) — offer the type switch in both directions. - if (filterDefault.numericBoolSource) { - col2.appendChild(this.createBooleanTypeOverrideLink(propID, filterDefault.isBoolean)); - } const col3 = document.createElement('div'); col3.className = 'filter-row-col3'; if (this.cache.nodeExclusiveProps.has(propID) || this.cache.mixedProps.has(propID)) { @@ -906,29 +901,6 @@ class UIManager { this.cache.qm.updateQueryTextArea(); } - // Small type-switch link under the widget of a 0/1-encoded column: inferred - // boolean ↔ plain numeric slider (§6.1 misclassification override). The - // choice persists in the workspace JSON via cache.data.booleanTypeOverrides. - createBooleanTypeOverrideLink(propID, isCurrentlyBoolean) { - const link = document.createElement('button'); - link.type = 'button'; - link.className = 'filter-type-override'; - link.textContent = isCurrentlyBoolean ? 'treat as 0/1 numbers' : 'treat as true/false'; - link.title = isCurrentlyBoolean - ? 'This column only holds 0 and 1 — switch to a numeric range slider if they are measures, not booleans' - : 'Switch back to the inferred true/false toggle'; - link.addEventListener('click', async () => { - if (this.cache.EVENT_LOCKS.FILTERS_LOCKED_BY_MANUAL_QUERY) return; - this.cache.io.applyBooleanTypeOverride(propID, isCurrentlyBoolean); - this.buildFilterUI(); - await this.cache.fm.handleFilterEvent( - 'Filtering Elements', - `${propID} type switched to ${isCurrentlyBoolean ? 'numeric' : 'boolean'}` - ); - }); - return link; - } - // Builds the segmented OR/AND control that sets how multiple active filters // combine. OR shows elements matching any active filter; AND shows elements // matching every active filter (non-strict — a property an element lacks diff --git a/src/style.css b/src/style.css index 8b34d2e..1ac3fce 100644 --- a/src/style.css +++ b/src/style.css @@ -1426,7 +1426,9 @@ h5 { and the slider's min/max inputs all line up). */ .filter-subgroup-body { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; + /* Col2 keeps a readable floor for sliders/dropdowns/inputs; col1 gives up + width instead (long property names wrap via .checkboxLabel). */ + grid-template-columns: minmax(0, auto) minmax(150px, 1fr) auto; column-gap: 8px; row-gap: 6px; align-items: start; @@ -1620,27 +1622,11 @@ h5 { } /* Boolean filter rows (§6.1): the Any/True/False segment reuses the join - toggle's pill styling; the type-override link sits under it as fine print. */ + toggle's pill styling. */ .filter-bool-toggle { justify-self: start; } -.filter-type-override { - display: block; - margin-top: 2px; - padding: 0; - border: none; - background: none; - font-size: 10px; - color: var(--text-muted); - text-decoration: underline dotted; - cursor: pointer; -} - -.filter-type-override:hover { - color: var(--accent-text); -} - /* Mixed-type rows (§6.2): visible but inert, with the reason inline. */ .filter-row-unusable .filter-row-col1 { opacity: 0.55; @@ -2380,6 +2366,9 @@ hr { padding-left: 2px; flex-grow: 1; font-size: 12px; + /* Long property names wrap (breaking mid-word if unavoidable) so the + control column never drops below its readable minimum. */ + overflow-wrap: anywhere; } .checkbox { diff --git a/src/utilities/data_editor.js b/src/utilities/data_editor.js index 08ecbf1..d38d244 100644 --- a/src/utilities/data_editor.js +++ b/src/utilities/data_editor.js @@ -1075,7 +1075,6 @@ class DataTable { ...plan.fileData, layouts: this.cache.data.layouts, selectedLayout: this.cache.data.selectedLayout, - booleanTypeOverrides: this.cache.data.booleanTypeOverrides, }); const s = plan.stats; const ignored = s.ignoredNodes.length + s.ignoredEdges.length; @@ -1159,7 +1158,6 @@ class DataTable { // Preserve existing layouts and selected layout to maintain per-view configurations layouts: this.cache.data.layouts, selectedLayout: this.cache.data.selectedLayout, - booleanTypeOverrides: this.cache.data.booleanTypeOverrides, // filterDefaults will be rebuilt by preProcessData() from the headers }; diff --git a/src/utilities/neo4j_session.js b/src/utilities/neo4j_session.js index 28356b8..39a639a 100644 --- a/src/utilities/neo4j_session.js +++ b/src/utilities/neo4j_session.js @@ -257,11 +257,6 @@ async function mergeAndApply(cache, newNodes, newRels, deps = {}) { session.exclusions, ); seedMergedPositions(data, positions); - // Same in-memory carry-over as layouts below: a graph rebuild must not - // drop the user's boolean-type overrides (§6.1). - if (cache.data?.booleanTypeOverrides?.size) { - data.booleanTypeOverrides = [...cache.data.booleanTypeOverrides]; - } // Declare the (single, current) workspace in the payload so preProcessData // takes the JSON-import path: with a layout whose positions cover every diff --git a/tests/boolean-filter-inference.test.js b/tests/boolean-filter-inference.test.js index 7668fc8..6abcdaf 100644 --- a/tests/boolean-filter-inference.test.js +++ b/tests/boolean-filter-inference.test.js @@ -16,7 +16,6 @@ function createMockCache() { DEFAULTS, data: { filterDefaults: new Map(), - booleanTypeOverrides: new Set(), nodes: [], edges: [], layouts: {}, @@ -55,20 +54,14 @@ describe('boolean classification (§6.1)', () => { expect(fd.isBoolean).toBe(true); expect(fd.isCategory).toBe(true); expect([...fd.categories].sort()).toEqual(['false', 'true']); - expect(fd.numericBoolSource).toBe(false); expect(fd.unusable).toBe(false); }); - it('classifies a pure 0/1 numeric column as boolean and override-eligible', () => { + it('classifies a pure 0/1 numeric column as boolean', () => { feed(io, PROP, [1, 0, 1, 1]); io.finalizeFilterClassification(); - const fd = cache.data.filterDefaults.get(PROP); - expect(fd.isBoolean).toBe(true); - expect(fd.numericBoolSource).toBe(true); - // numeric bounds survive so the override can switch back without rescanning - expect(fd.lowerThreshold).toBe(0); - expect(fd.upperThreshold).toBe(1); + expect(cache.data.filterDefaults.get(PROP).isBoolean).toBe(true); }); it('classifies mixed encodings (TRUE + 1) as boolean, not unusable', () => { @@ -77,7 +70,6 @@ describe('boolean classification (§6.1)', () => { const fd = cache.data.filterDefaults.get(PROP); expect(fd.isBoolean).toBe(true); - expect(fd.numericBoolSource).toBe(false); // text present → no numeric override expect(fd.unusable).toBe(false); expect(cache.ui.warning).not.toHaveBeenCalled(); }); @@ -117,24 +109,23 @@ describe('boolean classification (§6.1)', () => { expect(fd.unusable).toBe(false); }); - it('honors a persisted numeric override for 0/1 columns', () => { - cache.data.booleanTypeOverrides.add(PROP); + it('reclassifies to numeric when a rebuild sees a third distinct value', () => { + // First load: 0/1 only → boolean. feed(io, PROP, [1, 0]); io.finalizeFilterClassification(); + expect(cache.data.filterDefaults.get(PROP).isBoolean).toBe(true); + + // Data-editor edit adds a 2; the rebuild reruns classification from + // scratch (preProcessData resets filterDefaults) → plain numeric slider. + cache.data.filterDefaults = new Map(); + feed(io, PROP, [1, 0, 2]); + io.finalizeFilterClassification(); const fd = cache.data.filterDefaults.get(PROP); expect(fd.isBoolean).toBe(false); - expect(fd.numericBoolSource).toBe(true); // still eligible to switch back + expect(fd.isCategory).toBe(false); expect(fd.lowerThreshold).toBe(0); - expect(fd.upperThreshold).toBe(1); - }); - - it('ignores an override on text-encoded booleans (numeric makes no sense)', () => { - cache.data.booleanTypeOverrides.add(PROP); - feed(io, PROP, ['TRUE', 'FALSE']); - io.finalizeFilterClassification(); - - expect(cache.data.filterDefaults.get(PROP).isBoolean).toBe(true); + expect(fd.upperThreshold).toBe(2); }); it('canonicalizes featureValues on carriers to Set{true|false}', () => { @@ -189,60 +180,6 @@ describe('mixed-type columns stay visible as unusable (§6.2)', () => { }); }); -describe('applyBooleanTypeOverride', () => { - let io, cache, node; - - beforeEach(() => { - cache = createMockCache(); - io = new IOManager(cache); - node = { - features: new Set([PROP]), - featureValues: new Map(), - D4Data: { 'Node filters': { group: { flag: 1 } } }, - }; - cache.data.nodes = [node]; - node.featureValues.set(PROP, 1); - feed(io, PROP, [1, 0]); - io.finalizeFilterClassification(); - cache.data.layouts = { - Default: { filters: new Map([[PROP, { placeholder: true }]]) }, - }; - }); - - it('switches to numeric: flags, override set, featureValues from D4Data', () => { - io.applyBooleanTypeOverride(PROP, true); - - const fd = cache.data.filterDefaults.get(PROP); - expect(fd.isBoolean).toBe(false); - expect(fd.isCategory).toBe(false); - expect(cache.data.booleanTypeOverrides.has(PROP)).toBe(true); - expect(node.featureValues.get(PROP)).toBe(1); - // layout filter reset to a clone of the new default - expect(cache.data.layouts.Default.filters.get(PROP).isBoolean).toBe(false); - }); - - it('switches back to boolean: canonical categories and featureValues', () => { - io.applyBooleanTypeOverride(PROP, true); - io.applyBooleanTypeOverride(PROP, false); - - const fd = cache.data.filterDefaults.get(PROP); - expect(fd.isBoolean).toBe(true); - expect(cache.data.booleanTypeOverrides.has(PROP)).toBe(false); - expect([...node.featureValues.get(PROP)]).toEqual(['true']); - expect(cache.data.layouts.Default.filters.get(PROP).isBoolean).toBe(true); - }); - - it('is a no-op for properties that are not 0/1-encoded', () => { - feed(io, 'Node filters::g::text', ['TRUE', 'FALSE']); - io.finalizeFilterClassification(); - - io.applyBooleanTypeOverride('Node filters::g::text', true); - - expect(cache.data.filterDefaults.get('Node filters::g::text').isBoolean).toBe(true); - expect(cache.data.booleanTypeOverrides.has('Node filters::g::text')).toBe(false); - }); -}); - describe('reconcileLoadedFilterType (saved-workspace migration)', () => { let io, cache; From 5c5673b86770981f81e7d366276652bd062106fe Mon Sep 17 00:00:00 2001 From: Mnikley Date: Mon, 27 Jul 2026 20:03:02 +0200 Subject: [PATCH 020/181] =?UTF-8?q?feat(ui):=20the=20rail=20=E2=80=94=20co?= =?UTF-8?q?nsolidate=20header,=20toolbar,=20workspace=20bar=20and=20select?= =?UTF-8?q?ion=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concept C phase 3 (redesign_and_mocks/ui-redesign-concept-c.md §8): - Permanent 52px rail replaces the sidebar header-row, app toolbar, workspace bar and the selection HUD's counts/undo-redo/lasso: ◆ app menu, workspace chip (live counts + switch/new/rename/delete), ⛶, ↻ layout menu labelled with the current algorithm (algorithms + remove overlaps + hide disconnected), ➰ lasso, ✨ hover, selection chip (one focus/clear pair per decision 3, undo/redo, 'N hidden' warning per decision 1), Data/Query/Metrics/Assist tabs, 🛢 join, ⤓ export menu (PNG 1×/2×/4×, SVG, JSON, Excel), theme, ? sheet. - New src/managers/rail.js: RailMenu disclosure popovers (aria-expanded, Escape closes, aria-disabled rows inert). - relayoutWorkspace(layoutType) now requires a type; the rail menu is the picker — Popup.layoutSelectDialog deleted with its tests. - Workspace rename added (lm.renameSelectedLayout, Default protected). - gcm.focusSelection / sm.clearSelection back the chip's single 🔍/×. - showOnLoad/hideOnLoad mechanism + duplicate launch block retired (decision 6); body is a flex column, bottom bar no longer manages #mainContent heights; export-resolution popover and manageDynamicWidgets deleted; tour steps rewritten for the rail. - HUD keeps Style/groups/Tools until phase 4 absorbs them. 1622 tests green (rail.test.js, rail-selection-chip.test.js; relayout-workspace.test.js rewritten). --- src/gll.js | 2 + src/graph/core.js | 7 + src/graph/layout.js | 59 ++- src/graph/selection.js | 39 +- src/graph_lens_lite.html | 163 ++++---- src/managers/rail.js | 391 ++++++++++++++++++ src/managers/ui.js | 164 +------- src/style.css | 642 +++++++++++++++++++----------- src/utilities/data_editor.js | 8 +- src/utilities/popup.js | 127 ------ src/utilities/tour.js | 63 +-- tests/popup-layout-select.test.js | 172 -------- tests/rail-selection-chip.test.js | 192 +++++++++ tests/rail.test.js | 217 ++++++++++ tests/relayout-workspace.test.js | 131 +++--- 15 files changed, 1467 insertions(+), 910 deletions(-) create mode 100644 src/managers/rail.js delete mode 100644 tests/popup-layout-select.test.js create mode 100644 tests/rail-selection-chip.test.js create mode 100644 tests/rail.test.js diff --git a/src/gll.js b/src/gll.js index a77e30f..0f4c12b 100644 --- a/src/gll.js +++ b/src/gll.js @@ -26,6 +26,7 @@ import {Popup} from "./utilities/popup.js"; import {StaticUtilities} from "./utilities/static.js"; import {generateTourData, GuidedTour} from "./utilities/tour.js"; import {initApiClient} from "./managers/api_client.js"; +import {initRail} from "./managers/rail.js"; import {initTheme} from "./utilities/theme.js"; import {initSelectionHud} from "./utilities/selection_hud.js"; @@ -475,6 +476,7 @@ window.addEventListener("DOMContentLoaded", () => { // Stored preference wins; prefers-color-scheme is only the first-run default. initTheme(document, window); cache.reset(); + cache.rail = initRail(cache); cache.ui.updateDarkModeButton(); initSelectionHud(); // cache.initialize(); diff --git a/src/graph/core.js b/src/graph/core.js index c8ca65b..21e19ce 100644 --- a/src/graph/core.js +++ b/src/graph/core.js @@ -393,6 +393,13 @@ class GraphCoreManager { await this.focusElements(edgeIDs); } + /** Center and zoom to the whole selection (nodes and edges) — the rail chip's 🔍. */ + async focusSelection() { + const ids = [...this.cache.selectedNodes, ...this.cache.selectedEdges]; + if (ids.length === 0) return; + await this.focusElements(ids); + } + async focusElements(elementIDs, isNode) { const zoom = await this.cache.graph.getZoom(); if (zoom < 2) { diff --git a/src/graph/layout.js b/src/graph/layout.js index 412e83e..c58cf10 100644 --- a/src/graph/layout.js +++ b/src/graph/layout.js @@ -418,6 +418,29 @@ class GraphLayoutManager { } } + /** Rename the current workspace (the Default workspace keeps its name). */ + async renameSelectedLayout() { + const current = this.cache.data.selectedLayout; + if (current === 'Default') { + this.cache.ui.error('Cannot rename the Default workspace.'); + return; + } + + const name = await Popup.prompt(`Rename workspace "${current}" to:`); + if (!name || name === current) return; + if (Object.keys(this.cache.data.layouts).includes(name)) { + this.cache.ui.error(`Workspace with name "${name}" already exists.`); + return; + } + + this.cache.data.layouts[name] = this.cache.data.layouts[current]; + delete this.cache.data.layouts[current]; + this.cache.data.selectedLayout = name; + this.cache.uiComponents.buildDropdownOptions(); + this.cache.rail?.refresh(); + this.cache.ui.info(`Renamed workspace "${current}" to "${name}"`); + } + async removeSelectedLayout() { // Protect the "Default" layout from deletion if (this.cache.data.selectedLayout === 'Default') { @@ -578,28 +601,32 @@ class GraphLayoutManager { * Styles, filters, query and bubble-group membership are untouched — only * positions change. Mirrors the template branch of addLayout (setLayout → * layout → persist → animated transition) but stays on the current workspace - * instead of creating a new one. Defaults the picker to the workspace's - * original layout type so it doubles as "redo the layout I started with". + * instead of creating a new one. The algorithm comes from the rail's Layout + * menu, which marks the workspace's current type. */ - async relayoutWorkspace() { + async relayoutWorkspace(layoutType) { const currentName = this.cache.data.selectedLayout; const currentLayout = this.cache.data.layouts[currentName]; - if (!currentLayout) return; + if (!currentLayout || !layoutType) return; const nodeCount = this.cache.graphData?.order ?? this.cache.nodeRef.size; - const result = await Popup.layoutSelectDialog(this.cache.DEFAULTS.LAYOUT_INTERNALS, { - defaultType: currentLayout.layoutType || this.cache.DEFAULTS.LAYOUT, - hasPositions: currentLayout.positions?.size > 0, - nodeCount, - expensiveLayouts: this.cache.DEFAULTS.EXPENSIVE_LAYOUTS, - warningThreshold: this.cache.DEFAULTS.LAYOUT_NODE_WARNING_THRESHOLD, - }); - if (!result) { - this.cache.ui.info('Re-layout canceled'); - return; - } - const layoutType = result.templateType; + // Chosen from the rail's Layout menu — the expensive-layout guard mirrors + // the addLayout template branch. + if ( + this.cache.DEFAULTS.EXPENSIVE_LAYOUTS.includes(layoutType) && + nodeCount > this.cache.DEFAULTS.LAYOUT_NODE_WARNING_THRESHOLD + ) { + const proceed = await Popup.confirm( + `The "${layoutType}" layout is computationally intensive and may ` + + `take several minutes on ${nodeCount.toLocaleString()} nodes. The UI stays ` + + `blocked until it finishes. Continue?` + ); + if (!proceed) { + this.cache.ui.info('Re-layout canceled'); + return; + } + } await this.cache.ui.showLoading('Re-layouting Workspace', `Applying ${layoutType} layout`); // Pin the overlay up across the whole re-layout so the inner render's diff --git a/src/graph/selection.js b/src/graph/selection.js index 5cd674e..55a645e 100644 --- a/src/graph/selection.js +++ b/src/graph/selection.js @@ -97,6 +97,12 @@ class GraphSelectionManager { await this.updateSelectedState(edges, enable); } + /** Clear the whole selection — the rail chip's ×. */ + async clearSelection() { + await this.toggleSelectionForAllNodes(false); + await this.toggleSelectionForAllEdges(false); + } + async syncSelectionCacheAndElementStates() { const snapshot = this.cache.selectionMemory[this.cache.selectedMemoryIndex]; @@ -370,13 +376,17 @@ class GraphSelectionManager { } async updateSelectedNodesAndEdges() { - this.cache.selectedNodes = await this.cache.graph - .getNodeData() - .filter((n) => n.states?.includes('selected') && this.cache.nodeIDsToBeShown.has(n.id)) + const selectedNodeStates = (await this.cache.graph.getNodeData()).filter((n) => + n.states?.includes('selected') + ); + const selectedEdgeStates = (await this.cache.graph.getEdgeData()).filter((e) => + e.states?.includes('selected') + ); + this.cache.selectedNodes = selectedNodeStates + .filter((n) => this.cache.nodeIDsToBeShown.has(n.id)) .map((n) => n.id); - this.cache.selectedEdges = await this.cache.graph - .getEdgeData() - .filter((e) => e.states?.includes('selected') && this.cache.edgeIDsToBeShown.has(e.id)) + this.cache.selectedEdges = selectedEdgeStates + .filter((e) => this.cache.edgeIDsToBeShown.has(e.id)) .map((e) => e.id); const selectedNodesCount = this.cache.selectedNodes?.length || 0; @@ -389,8 +399,23 @@ class GraphSelectionManager { const atLeastOneEdgeSelected = selectedEdgesCount > 0; const atLeastOneNodeOrEdgeSelected = atLeastOneNodeSelected || atLeastOneEdgeSelected; + // Rail selection chip: swap "Nothing selected" for the live counts, and + // warn when filters hide part of the selection (filters and selection are + // orthogonal — hidden elements stay selected but actions skip them). + document + .getElementById('selectionChip') + ?.classList.toggle('live', atLeastOneNodeOrEdgeSelected); + const hiddenSelectedCount = + selectedNodeStates.length - selectedNodesCount + + (selectedEdgeStates.length - selectedEdgesCount); + const hiddenWarning = document.getElementById('selectionHiddenWarning'); + if (hiddenWarning) { + hiddenWarning.style.display = hiddenSelectedCount > 0 ? '' : 'none'; + hiddenWarning.textContent = hiddenSelectedCount > 0 ? `${hiddenSelectedCount} hidden` : ''; + } + // Swap the HUD between its empty state (instructions) and its active - // state (counts + actions). CSS keys off the `has-selection` class. + // state (actions). CSS keys off the `has-selection` class. document .getElementById('selectedElementsContainer') ?.classList.toggle('has-selection', atLeastOneNodeOrEdgeSelected); diff --git a/src/graph_lens_lite.html b/src/graph_lens_lite.html index 509f2d9..7eeaca5 100644 --- a/src/graph_lens_lite.html +++ b/src/graph_lens_lite.html @@ -76,76 +76,91 @@

    Loading ..

    +
    + + + + +
    + + + + +
    +
    + Nothing selected + + 0 nodes · + 0 edges + + + + + + +
    + +
    + + + + +
    +
    + + + + +
    -
    +
    Selection @@ -157,13 +172,7 @@
    Shown:
    -
    - - - - +
    @@ -172,20 +181,6 @@
    Shown:
    -
    - 0 nodes - - - - 0 edges - - -
    -
    Add to group: @@ -211,7 +206,7 @@
    Shown:
    - +
    -

    Overlays

    + +