diff --git a/cypress/e2e/view-state/twod-collapse.cy.ts b/cypress/e2e/view-state/twod-collapse.cy.ts new file mode 100644 index 00000000..a3359975 --- /dev/null +++ b/cypress/e2e/view-state/twod-collapse.cy.ts @@ -0,0 +1,500 @@ +/// + +import { ensureTwoDNetworkView, setGlobalDistanceMetric, setTN93DistanceDisplayFormat, visitAppAndAcceptEula } from '../../support/journey-helpers'; +import { byTestId, testIds } from '../../support/selectors'; + +const selectors = { + canvas: '#cy', + settingsBtn: byTestId(testIds.twodSettingsButton), + collapseToggle: '#network-node-collapse-enabled', + collapseThreshold: '#network-node-collapse-threshold', + collapseThresholdInput: '#network-node-collapse-threshold-input', +}; + +const collapseGroupField = '__twodCollapseGroup'; +const collapseShapeWarningText = 'All collapsed nodes will be displayed as circles'; + +function linkEndpointId(endpoint: any): string { + if (endpoint === undefined || endpoint === null) return ''; + if (typeof endpoint === 'object') return String(endpoint._id ?? endpoint.id ?? endpoint.data?.id ?? ''); + return String(endpoint); +} + +function metricValue(link: any, metric: string): number | null { + const value = Number(link?.[metric]); + return Number.isFinite(value) ? value : null; +} + +function distanceLinksForMetric(win: any, metric: string): any[] { + return (win.commonService.session.data.links || []) + .filter((link: any) => { + const value = metricValue(link, metric); + const source = linkEndpointId(link.source); + const target = linkEndpointId(link.target); + const distanceOrigins = win.commonService.getLinkDistanceOrigins?.(link) || []; + const origins = Array.isArray(link.origin) ? link.origin : []; + const hasDistanceOrigin = distanceOrigins.length > 0 + || origins.some((origin: any) => String(origin || '').toLowerCase().includes('distance')); + + return Boolean(source && target && source !== target && link.hasDistance === true && value !== null && hasDistanceOrigin); + }) + .sort((a: any, b: any) => Number(a[metric]) - Number(b[metric])); +} + +function internalDistanceSummaryForMembers( + win: any, + memberIds: string[], + metric: string, +): { mean: number | null; pairCount: number } { + const members = new Set(memberIds.map((id) => String(id))); + const pairValues = new Map(); + + distanceLinksForMetric(win, metric).forEach((link: any) => { + const source = linkEndpointId(link.source); + const target = linkEndpointId(link.target); + + if (source === target || !members.has(source) || !members.has(target)) { + return; + } + + const value = metricValue(link, metric); + if (value === null) { + return; + } + + const pairKey = JSON.stringify(source < target ? [source, target] : [target, source]); + const values = pairValues.get(pairKey) || []; + values.push(value); + pairValues.set(pairKey, values); + }); + + const pairMeans = Array.from(pairValues.values()) + .map((values) => values.reduce((sum, value) => sum + value, 0) / values.length); + + if (pairMeans.length === 0) { + return { mean: null, pairCount: 0 }; + } + + return { + mean: pairMeans.reduce((sum, value) => sum + value, 0) / pairMeans.length, + pairCount: pairMeans.length, + }; +} + +function configureDeterministicCollapsePie(win: any): number { + const commonService = win.commonService; + const twoD = commonService.visuals.twoD; + const widgets = commonService.session.style.widgets; + const threshold = 0.001; + const aboveThresholdDistance = 0.2; + const [firstNode, secondNode, thirdNode] = (commonService.session.data.nodes || []) + .filter((node: any) => String(node._id ?? node.id ?? '').length > 0); + + expect(firstNode, 'first collapse fixture node').to.exist; + expect(secondNode, 'second collapse fixture node').to.exist; + expect(thirdNode, 'third collapse fixture node').to.exist; + + const firstId = String(firstNode._id ?? firstNode.id); + const secondId = String(secondNode._id ?? secondNode.id); + const thirdId = String(thirdNode._id ?? thirdNode.id); + const targetIds = new Set([firstId, secondId, thirdId]); + const syntheticLinks = [ + { id: 'cypress-collapse-distance-ab', source: firstId, target: secondId, distance: threshold }, + { id: 'cypress-collapse-distance-bc', source: secondId, target: thirdId, distance: threshold }, + { id: 'cypress-collapse-distance-ac', source: firstId, target: thirdId, distance: aboveThresholdDistance }, + ]; + + commonService.session.data.links = (commonService.session.data.links || []) + .filter((link: any) => !String(link.id || '').startsWith('cypress-collapse-distance-')); + syntheticLinks.forEach((link, index) => { + commonService.session.data.links.push({ + index: commonService.session.data.links.length + index, + ...link, + visible: true, + origin: ['Cypress Collapse Mean Distance', 'Genetic Distance'], + hasDistance: true, + distanceOrigin: 'Genetic Distance', + directed: false, + }); + }); + + commonService.session.data.nodes.forEach((node: any) => { + const id = String(node._id ?? node.id ?? ''); + node[collapseGroupField] = targetIds.has(id) + ? `Pair ${id === firstId ? 'A' : id === secondId ? 'B' : 'C'}` + : 'Outside Pair'; + }); + commonService.session.data.nodeFilteredValues.forEach((node: any) => { + const id = String(node._id ?? node.id ?? ''); + node[collapseGroupField] = targetIds.has(id) + ? `Pair ${id === firstId ? 'A' : id === secondId ? 'B' : 'C'}` + : 'Outside Pair'; + }); + + if (!commonService.session.data.nodeFields.includes(collapseGroupField)) { + commonService.session.data.nodeFields.push(collapseGroupField); + } + + widgets['node-color-variable'] = collapseGroupField; + commonService.createNodeColorMap(); + twoD.updateNodeColors(); + + return threshold; +} + +function getCollapseRenderSummary(win: any) { + const cyInstance = win.commonService.visuals.twoD.cy; + const aggregateNodes = cyInstance.nodes(':visible') + .filter((node: any) => node.data('isCollapsedAggregate') === true); + const pieNodes = aggregateNodes + .map((node: any) => { + const counts = node.data('counts') || []; + const pieBackgroundImage = String(node.data('pieBackgroundImage') || ''); + const collapsedMemberIds = node.data('collapsedMemberIds') || []; + const meanInternalDistance = node.data('meanInternalDistance'); + + return { + id: node.id(), + totalCount: Number(node.data('totalCount') || 0), + collapsedMemberCount: collapsedMemberIds.length, + collapsedMemberIds: collapsedMemberIds.map((id: any) => String(id)), + counts: counts.map((count: any) => ({ + label: String(count.label), + count: Number(count.count || 0), + })), + meanInternalDistance: meanInternalDistance === undefined || meanInternalDistance === null + ? null + : Number(meanInternalDistance), + internalDistancePairCount: Number(node.data('internalDistancePairCount') || 0), + labels: counts.map((count: any) => String(count.label)), + pieBackgroundImage, + renderedBackgroundImage: String(node.style('background-image') || ''), + }; + }) + .filter((node: any) => node.labels.length > 1 && node.pieBackgroundImage.startsWith('data:image/svg+xml;base64,')); + const selfEdgeIds = cyInstance.edges(':visible') + .filter((edge: any) => edge.source().id() === edge.target().id()) + .map((edge: any) => edge.id()); + + return { + aggregateCount: aggregateNodes.length, + pieNodes, + selfEdgeIds, + }; +} + +describe('2D Network - Collapse Related Nodes', () => { + beforeEach(() => { + visitAppAndAcceptEula({ skipDemoSession: false }); + ensureTwoDNetworkView(); + cy.get(selectors.canvas, { timeout: 15000 }).should('be.visible'); + cy.openGlobalSettings(); + cy.contains('#global-settings-modal .nav-link', 'Filtering').click({ force: true }); + setGlobalDistanceMetric('tn93'); + setTN93DistanceDisplayFormat('percentage'); + cy.closeGlobalSettings(); + + cy.window().then((win: any) => { + const app = win.commonService.visuals.microbeTrace; + const selectedShape = app.getNodeShapeTreeSelection('ellipse'); + + expect(selectedShape, 'circle node shape selection').to.exist; + app.onNodeShapeByChanged(true, false, 'None'); + app.onNodeShapeTreeChange(selectedShape); + }); + }); + + it('collapses threshold-connected nodes into aggregate pie nodes and restores individual nodes', () => { + cy.get(selectors.settingsBtn).click(); + + cy.contains('.p-dialog-title', '2D Network Settings') + .should('be.visible') + .parents('.p-dialog') + .as('dialogContainer'); + + cy.get('@dialogContainer').contains('.nav-link', 'Nodes').click(); + cy.get('@dialogContainer').contains('p-accordion-panel', 'Collapse Related Nodes').click(); + cy.get('@dialogContainer').find(selectors.collapseToggle).should('exist'); + cy.get('@dialogContainer').find(selectors.collapseThreshold).should('exist'); + cy.get('@dialogContainer').find(selectors.collapseThresholdInput).should('exist'); + cy.get('@dialogContainer').find('#network-node-collapse-threshold-readout').should('not.exist'); + + cy.window().then((win: any) => { + const commonService = win.commonService; + const twoD = commonService.visuals.twoD; + const threshold = configureDeterministicCollapsePie(win); + const metric = 'distance'; + const displayedThreshold = commonService.toDisplayedDistanceValue(threshold, metric); + + twoD.SelectedNodeCollapseThresholdDisplayedVariable = displayedThreshold; + twoD.onNodeCollapseThresholdDisplayedChange(displayedThreshold); + twoD.onNodeCollapseEnabledChange(true); + + expect(commonService.session.style.widgets['network-node-collapse-enabled']).to.equal(true); + expect(commonService.session.style.widgets['network-node-collapse-threshold']).to.equal(threshold); + expect(twoD.SelectedNodeCollapseMetricLabel).to.equal('TN93 (%)'); + + cy.get('@dialogContainer').find(selectors.collapseThresholdInput).should('have.value', String(displayedThreshold)); + }); + + cy.closeSettingsPane('2D Network Settings'); + + cy.window().then((win: any) => { + cy.wrap(null, { timeout: 20000 }).should(() => { + const summary = getCollapseRenderSummary(win); + const pieNode = summary.pieNodes[0]; + + expect(summary.aggregateCount, 'visible aggregate nodes').to.be.greaterThan(0); + expect(summary.pieNodes.length, 'aggregate nodes with pie backgrounds').to.be.greaterThan(0); + expect(pieNode.collapsedMemberCount, 'collapsed member ids').to.be.greaterThan(1); + expect(pieNode.totalCount, 'total count').to.equal(pieNode.collapsedMemberCount); + expect(pieNode.labels, 'pie labels') + .to.include.members(['Pair A', 'Pair B', 'Pair C']); + expect(pieNode.renderedBackgroundImage, 'rendered pie background').to.include('data:image'); + expect(summary.selfEdgeIds, 'self edges').to.deep.equal([]); + }); + }); + + cy.window().then((win: any) => { + win.commonService.visuals.twoD.onNodeCollapseEnabledChange(false); + expect(win.commonService.session.style.widgets['network-node-collapse-enabled']).to.equal(false); + }); + + cy.window().then((win: any) => { + cy.wrap(null, { timeout: 20000 }).should(() => { + expect(getCollapseRenderSummary(win).aggregateCount, 'visible aggregate nodes after disable').to.equal(0); + }); + }); + }); + + it('warns that collapsed nodes render as circles when non-circle node shapes are active', () => { + cy.get(selectors.settingsBtn).click(); + + cy.contains('.p-dialog-title', '2D Network Settings') + .should('be.visible') + .parents('.p-dialog') + .as('dialogContainer'); + + cy.get('@dialogContainer').contains('.nav-link', 'Nodes').click(); + cy.get('@dialogContainer').contains('p-accordion-panel', 'Collapse Related Nodes').click(); + + cy.window().then((win: any) => { + const commonService = win.commonService; + const app = commonService.visuals.microbeTrace; + const twoD = commonService.visuals.twoD; + const selectedShape = app.getNodeShapeTreeSelection('triangle'); + const threshold = configureDeterministicCollapsePie(win); + const displayedThreshold = commonService.toDisplayedDistanceValue(threshold, 'distance'); + + expect(selectedShape, 'triangle node shape selection').to.exist; + app.onNodeShapeByChanged(true, false, 'None'); + app.onNodeShapeTreeChange(selectedShape); + twoD.SelectedNodeCollapseThresholdDisplayedVariable = displayedThreshold; + twoD.onNodeCollapseThresholdDisplayedChange(displayedThreshold); + + expect(commonService.session.style.widgets['node-symbol']).to.equal('triangle'); + expect(commonService.session.style.widgets['network-node-collapse-enabled']).to.equal(false); + }); + + cy.get('@dialogContainer').find(selectors.collapseToggle).contains('span', 'Show').click({ force: true }); + cy.contains('.p-dialog:visible', collapseShapeWarningText, { timeout: 15000 }).as('collapseShapeConfirmDialog'); + cy.get('@collapseShapeConfirmDialog').contains('button', 'Cancel').click({ force: true }); + cy.contains('.p-dialog:visible', collapseShapeWarningText).should('not.exist'); + cy.window().its('commonService.session.style.widgets.network-node-collapse-enabled').should('equal', false); + cy.get('@dialogContainer').find(selectors.collapseThresholdInput).should('be.disabled'); + cy.wait(250); + + cy.get('@dialogContainer').find(selectors.collapseToggle).contains('span', 'Show').click({ force: true }); + cy.contains('.p-dialog:visible', collapseShapeWarningText, { timeout: 15000 }).as('collapseShapeConfirmDialog'); + cy.get('@collapseShapeConfirmDialog').contains('button', 'Confirm').click({ force: true }); + cy.contains('.p-dialog:visible', collapseShapeWarningText).should('not.exist'); + cy.window().its('commonService.session.style.widgets.network-node-collapse-enabled').should('equal', true); + cy.closeSettingsPane('2D Network Settings'); + + cy.window().then((win: any) => { + cy.wrap(null, { timeout: 20000 }).should(() => { + const aggregateNodes = win.commonService.visuals.twoD.cy.nodes(':visible') + .filter((node: any) => node.data('isCollapsedAggregate') === true); + + expect(aggregateNodes.length, 'visible aggregate nodes').to.be.greaterThan(0); + aggregateNodes.forEach((node: any) => { + expect(String(node.data('shapeKey') || '').trim(), 'aggregate node shape key').to.equal('ellipse'); + expect(String(node.style('shape') || '').trim(), 'aggregate rendered shape').to.equal('ellipse'); + }); + }); + }); + }); + + it('shows Bubble-style table content for collapsed aggregate node tooltips', () => { + cy.window().then((win: any) => { + const commonService = win.commonService; + const twoD = commonService.visuals.twoD; + const threshold = configureDeterministicCollapsePie(win); + const displayedThreshold = commonService.toDisplayedDistanceValue(threshold, 'distance'); + + twoD.SelectedNodeCollapseThresholdDisplayedVariable = displayedThreshold; + twoD.onNodeCollapseThresholdDisplayedChange(displayedThreshold); + twoD.onNodeCollapseEnabledChange(true); + }); + + cy.window().then((win: any) => { + cy.wrap(null, { timeout: 20000 }).should(() => { + expect(getCollapseRenderSummary(win).pieNodes.length, 'aggregate nodes with pie backgrounds').to.be.greaterThan(0); + }); + }); + + cy.window().then((win: any) => { + const commonService = win.commonService; + const summary = getCollapseRenderSummary(win); + const pieNode = summary.pieNodes[0]; + const metric = 'distance'; + const collapseThreshold = Number(commonService.session.style.widgets['network-node-collapse-threshold']); + const expectedInternalDistance = internalDistanceSummaryForMembers(win, pieNode.collapsedMemberIds, metric); + const expectedHeaders = [ + commonService.capitalize(commonService.session.style.widgets['node-color-variable']), + 'Count', + '%', + ]; + const expectedRows = pieNode.counts.map((count: any) => [ + count.label, + String(count.count), + `${(count.count / pieNode.totalCount * 100).toFixed(1)}%`, + ]); + expectedRows.push(['Total', String(pieNode.totalCount), '']); + expectedRows.push([ + `Mean Distance (${win.commonService.visuals.twoD.SelectedNodeCollapseMetricLabel})`, + commonService.formatDisplayedDistanceValue(expectedInternalDistance.mean, metric), + '', + ]); + + expect(pieNode.internalDistancePairCount, 'internal distance pair count') + .to.equal(expectedInternalDistance.pairCount); + expect(expectedInternalDistance.pairCount, 'all pairwise member distances').to.be.at.least(3); + if (expectedInternalDistance.mean === null) { + expect(pieNode.meanInternalDistance, 'mean internal distance').to.equal(null); + } else { + expect(expectedInternalDistance.mean, 'mean includes above-threshold internal pair') + .to.be.greaterThan(collapseThreshold); + expect(pieNode.meanInternalDistance, 'mean internal distance') + .to.be.closeTo(expectedInternalDistance.mean, 1e-12); + } + + win.Cypress.test.tooltip('show', pieNode.id); + + cy.get('#tooltip #tooltip-table', { timeout: 1000 }).should('be.visible').within(() => { + cy.get('thead th').then(($headers) => { + expect($headers.toArray().map((header) => header.textContent?.trim() || '')).to.deep.equal(expectedHeaders); + }); + cy.get('tbody tr').should('have.length', expectedRows.length).each(($row, index) => { + const cells = $row.find('td').toArray().map((cell) => cell.textContent?.trim() || ''); + expect(cells).to.deep.equal(expectedRows[index]); + }); + }); + + win.Cypress.test.tooltip('hide', pieNode.id); + }); + }); + + it('preserves collapsed pie images in SVG export content', () => { + cy.window().then((win: any) => { + const commonService = win.commonService; + const twoD = commonService.visuals.twoD; + const threshold = configureDeterministicCollapsePie(win); + const displayedThreshold = commonService.toDisplayedDistanceValue(threshold, 'distance'); + + twoD.SelectedNodeCollapseThresholdDisplayedVariable = displayedThreshold; + twoD.onNodeCollapseThresholdDisplayedChange(displayedThreshold); + twoD.onNodeCollapseEnabledChange(true); + }); + + cy.window().then((win: any) => { + cy.wrap(null, { timeout: 20000 }).should(() => { + expect(getCollapseRenderSummary(win).pieNodes.length, 'aggregate nodes with pie backgrounds').to.be.greaterThan(0); + }); + }); + + cy.window().then((win: any) => { + const twoD = win.commonService.visuals.twoD; + cy.stub(twoD.exportService, 'requestSVGExport').as('requestSVGExport'); + twoD.SelectedNetworkExportFileTypeListVariable = 'svg'; + twoD.exportVisualization(new win.Event('click')); + }); + + cy.get('@requestSVGExport').should('have.been.calledOnce'); + cy.get('@requestSVGExport').then((stub: any) => { + const svgContent = String(stub.getCall(0).args[1] || ''); + const doc = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); + const pieImages = Array.from(doc.getElementsByTagName('image')) + .filter((image: any) => { + const href = String(image.getAttribute('href') || image.getAttribute('xlink:href') || ''); + return href.startsWith('data:image/'); + }); + const pieOutlines = Array.from(doc.querySelectorAll('circle[data-microbetrace-collapsed-pie-outline="true"]')); + + expect(svgContent, 'svg export contains image elements').to.include(' { + cy.get(selectors.settingsBtn).click(); + + cy.contains('.p-dialog-title', '2D Network Settings') + .should('be.visible') + .parents('.p-dialog') + .as('dialogContainer'); + + cy.get('@dialogContainer').contains('.nav-link', 'Nodes').click(); + cy.get('@dialogContainer').contains('p-accordion-panel', 'Collapse Related Nodes').click(); + cy.get('@dialogContainer').find('label[for="network-node-collapse-threshold-input"]').should('have.text', 'TN93 (%)'); + cy.get('@dialogContainer').find(selectors.collapseThresholdInput).should('have.value', '0'); + cy.get('@dialogContainer').find(selectors.collapseThreshold).should('have.attr', 'step', '0.1'); + cy.get('@dialogContainer').find('#network-node-collapse-threshold-readout').should('not.exist'); + cy.closeSettingsPane('2D Network Settings'); + + cy.openGlobalSettings(); + cy.contains('#global-settings-modal .nav-link', 'Filtering').click({ force: true }); + setTN93DistanceDisplayFormat('decimal'); + cy.closeGlobalSettings(); + + cy.get(selectors.settingsBtn).click(); + cy.contains('.p-dialog-title', '2D Network Settings') + .should('be.visible') + .parents('.p-dialog') + .as('dialogContainer'); + + cy.get('@dialogContainer').contains('.nav-link', 'Nodes').click(); + cy.get('@dialogContainer').contains('p-accordion-panel', 'Collapse Related Nodes').click(); + cy.get('@dialogContainer').find('label[for="network-node-collapse-threshold-input"]').should('have.text', 'TN93'); + cy.get('@dialogContainer').find(selectors.collapseThresholdInput).should('have.value', '0'); + cy.get('@dialogContainer').find(selectors.collapseThreshold).should('have.attr', 'step', '0.001'); + cy.get('@dialogContainer').find('#network-node-collapse-threshold-readout').should('not.exist'); + cy.closeSettingsPane('2D Network Settings'); + + cy.openGlobalSettings(); + cy.contains('#global-settings-modal .nav-link', 'Filtering').click({ force: true }); + setGlobalDistanceMetric('snps'); + cy.closeGlobalSettings(); + + cy.window() + .its('commonService.session.style.widgets.network-node-collapse-threshold', { timeout: 20000 }) + .should('equal', 0); + + cy.get(selectors.settingsBtn).click(); + cy.contains('.p-dialog-title', '2D Network Settings') + .should('be.visible') + .parents('.p-dialog') + .as('dialogContainer'); + + cy.get('@dialogContainer').contains('.nav-link', 'Nodes').click(); + cy.get('@dialogContainer').contains('p-accordion-panel', 'Collapse Related Nodes').click(); + cy.get('@dialogContainer').find('label[for="network-node-collapse-threshold-input"]').should('have.text', 'SNPs'); + cy.get('@dialogContainer').find(selectors.collapseThresholdInput).should('have.value', '0'); + cy.get('@dialogContainer').find(selectors.collapseThreshold).should('have.attr', 'step', '1'); + cy.get('@dialogContainer').find('#network-node-collapse-threshold-readout').should('not.exist'); + }); +}); diff --git a/cypress/fixtures/CollapsedMeanDistanceLinks.csv b/cypress/fixtures/CollapsedMeanDistanceLinks.csv new file mode 100644 index 00000000..64a17fce --- /dev/null +++ b/cypress/fixtures/CollapsedMeanDistanceLinks.csv @@ -0,0 +1,7 @@ +source,target,distance +A,B,0.001 +B,C,0.001 +A,C,0.100 +A,D,0.250 +B,D,0.250 +C,D,0.250 diff --git a/cypress/fixtures/CollapsedMeanDistanceNodes.csv b/cypress/fixtures/CollapsedMeanDistanceNodes.csv new file mode 100644 index 00000000..cef890ab --- /dev/null +++ b/cypress/fixtures/CollapsedMeanDistanceNodes.csv @@ -0,0 +1,5 @@ +_id,category,label +A,Alpha,Node A +B,Beta,Node B +C,Gamma,Node C +D,Outside,Node D diff --git a/src/app/contactTraceCommonServices/common.service.ts b/src/app/contactTraceCommonServices/common.service.ts index 027958e9..9fc35b8e 100644 --- a/src/app/contactTraceCommonServices/common.service.ts +++ b/src/app/contactTraceCommonServices/common.service.ts @@ -452,6 +452,8 @@ export class CommonService extends AppComponentBase implements OnInit { 'network-friction': 0.4, 'network-gravity': 0.05, 'network-link-strength': 0.124, + 'network-node-collapse-enabled': false, + 'network-node-collapse-threshold': 0, 'node-charge': 200, 'node-border-width' : 2.0, 'node-color': '#1f77b4', diff --git a/src/app/contactTraceCommonServices/pie-chart-utils.ts b/src/app/contactTraceCommonServices/pie-chart-utils.ts new file mode 100644 index 00000000..27c571c2 --- /dev/null +++ b/src/app/contactTraceCommonServices/pie-chart-utils.ts @@ -0,0 +1,117 @@ +export interface PieChartSlice { + label: string; + count: number; + color: string; + alpha?: number; +} + +export interface PieChartPathSlice extends PieChartSlice { + path: string; +} + +export function getPieChartTotalCount(slices: PieChartSlice[]): number { + return (slices || []).reduce((total, slice) => { + const count = Number(slice?.count); + return Number.isFinite(count) && count > 0 ? total + count : total; + }, 0); +} + +export function getValidPieChartSlices(slices: PieChartSlice[]): PieChartSlice[] { + return (slices || []).filter(slice => { + const count = Number(slice?.count); + return Number.isFinite(count) && count > 0; + }); +} + +export function buildPieChartPathSlices( + slices: PieChartSlice[], + centerX: number, + centerY: number, + radius: number +): PieChartPathSlice[] { + const totalCount = getPieChartTotalCount(slices); + const validSlices = getValidPieChartSlices(slices); + const safeRadius = Math.max(0, Number(radius) || 0); + + if (totalCount <= 0 || safeRadius <= 0) { + return []; + } + + if (validSlices.length === 1) { + const topY = centerY - safeRadius; + const bottomY = centerY + safeRadius; + return [{ + ...validSlices[0], + path: `M ${centerX} ${topY} A ${safeRadius} ${safeRadius} 0 1 1 ${centerX} ${bottomY} A ${safeRadius} ${safeRadius} 0 1 1 ${centerX} ${topY} Z` + }]; + } + + let cumulative = 0; + let previousAngle = -Math.PI / 2; + + return validSlices.map(slice => { + const proportion = Number(slice.count) / totalCount; + cumulative += proportion; + const endAngle = (-Math.PI / 2) + (2 * Math.PI * cumulative); + const startX = centerX + (safeRadius * Math.cos(previousAngle)); + const startY = centerY + (safeRadius * Math.sin(previousAngle)); + const endX = centerX + (safeRadius * Math.cos(endAngle)); + const endY = centerY + (safeRadius * Math.sin(endAngle)); + const largeArcFlag = proportion > 0.5 ? 1 : 0; + previousAngle = endAngle; + + return { + ...slice, + path: `M ${centerX} ${centerY} L ${startX} ${startY} A ${safeRadius} ${safeRadius} 0 ${largeArcFlag} 1 ${endX} ${endY} Z` + }; + }); +} + +export function buildPieChartPatternDef(patternId: string, slices: PieChartSlice[]): string { + const totalCount = getPieChartTotalCount(slices); + const validSlices = getValidPieChartSlices(slices); + + if (!patternId || totalCount <= 0 || validSlices.length < 2) { + return ''; + } + + const coordinates: Array<[number, number]> = []; + const proportions: number[] = []; + let cumulative = 0; + + validSlices.forEach(slice => { + const proportion = Number(slice.count) / totalCount; + cumulative += proportion; + proportions.push(proportion); + coordinates.push([ + Math.cos(2 * Math.PI * cumulative), + Math.sin(2 * Math.PI * cumulative) + ]); + }); + + let patternString = ``; + + for (let i = 0; i < coordinates.length; i++) { + const arcStart = i === 0 ? '1 0' : `${coordinates[i - 1][0]} ${coordinates[i - 1][1]}`; + const largeArcFlag = proportions[i] > 0.5 ? 1 : 0; + const arcEnd = i === coordinates.length - 1 ? '1 0' : `${coordinates[i][0]} ${coordinates[i][1]}`; + const alpha = Number(validSlices[i].alpha); + const fillOpacity = Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1; + patternString += ``; + } + + patternString += ''; + return patternString; +} + +export function buildPieChartSvgDataUri(patternId: string, size: number, slices: PieChartSlice[]): string { + const safeSize = Math.max(1, Number(size) || 1); + const patternDef = buildPieChartPatternDef(patternId, slices); + + if (!patternDef) { + return ''; + } + + const svgPattern = `${patternDef}`; + return 'data:image/svg+xml;base64,' + btoa(svgPattern); +} diff --git a/src/app/contactTraceCommonServices/threshold-analysis.ts b/src/app/contactTraceCommonServices/threshold-analysis.ts index eb131952..3b2862d2 100644 --- a/src/app/contactTraceCommonServices/threshold-analysis.ts +++ b/src/app/contactTraceCommonServices/threshold-analysis.ts @@ -67,6 +67,16 @@ export interface VisibleClusterSummary { clusterCount: number; } +export interface ThresholdConnectedComponent { + nodeIds: string[]; + nodeIndexes: number[]; +} + +export interface ThresholdConnectedComponentSummary { + components: ThresholdConnectedComponent[]; + nodeComponentById: Record; +} + class UnionFind { private readonly parent: number[]; private readonly sizes: number[]; @@ -137,6 +147,80 @@ function getNumericMetricValue(link: ThresholdAnalysisLinkLike, metric: string): return null; } +function getLinkEndpointId(endpoint: any): string { + if (endpoint && typeof endpoint === 'object') { + return String(endpoint._id ?? endpoint.id ?? ''); + } + + return String(endpoint ?? ''); +} + +export function buildThresholdConnectedComponents( + nodes: ThresholdAnalysisNodeLike[], + links: ThresholdAnalysisLinkLike[], + metric: string, + threshold: number +): ThresholdConnectedComponentSummary { + const nodeIds = nodes.map((node) => getNodeId(node)); + const nodeIndexById: Record = Object.create(null); + const uf = new UnionFind(nodeIds.length); + const numericThreshold = Number(threshold); + + nodeIds.forEach((nodeId, index) => { + nodeIndexById[nodeId] = index; + }); + + if (Number.isFinite(numericThreshold)) { + links.forEach((link) => { + const value = getNumericMetricValue(link, metric); + + if (value === null || value > numericThreshold) { + return; + } + + const sourceIndex = nodeIndexById[getLinkEndpointId(link.source)]; + const targetIndex = nodeIndexById[getLinkEndpointId(link.target)]; + + if ( + sourceIndex === undefined || + targetIndex === undefined || + sourceIndex === targetIndex + ) { + return; + } + + uf.union(sourceIndex, targetIndex); + }); + } + + const rootToComponentId = new Map(); + const components: ThresholdConnectedComponent[] = []; + const nodeComponentById: Record = Object.create(null); + + nodeIds.forEach((nodeId, nodeIndex) => { + const root = uf.find(nodeIndex); + let componentId = rootToComponentId.get(root); + + if (componentId === undefined) { + componentId = components.length; + rootToComponentId.set(root, componentId); + components.push({ + nodeIds: [], + nodeIndexes: [] + }); + } + + components[componentId].nodeIds.push(nodeId); + components[componentId].nodeIndexes.push(nodeIndex); + nodeComponentById[nodeId] = componentId; + }); + + return { + components, + nodeComponentById + }; +} + export function buildStoredDistanceEdgeCache( nodes: ThresholdAnalysisNodeLike[], links: ThresholdAnalysisLinkLike[], diff --git a/src/app/microbe-trace-next-plugin.component.ts b/src/app/microbe-trace-next-plugin.component.ts index ec354772..bfe7f040 100644 --- a/src/app/microbe-trace-next-plugin.component.ts +++ b/src/app/microbe-trace-next-plugin.component.ts @@ -1828,6 +1828,26 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A }, ); } + public openNodeCollapseShapeConfirmation(accept: () => void, reject?: () => void): void { + this.confirmationService.confirm({ + message: `All collapsed nodes will be displayed as circles. Custom node shapes are not preserved while nodes are collapsed. + Are you sure that you want to proceed?`, + closable: false, + closeOnEscape: false, + icon: 'pi pi-exclamation-triangle', + rejectButtonProps: { + label: 'Cancel', + severity: 'secondary', + outlined: true, + }, + acceptButtonProps: { + label: 'Confirm', + }, + accept, + reject, + }); + } + onPruneWithTypesChanged(newValue: string) { if (this.userConfirmedNN == false && this.SelectedPruneWithTypesVariable == "Nearest Neighbor") { if (this.commonService.session.data.links.filter(l => l.origin.length > 1 && Array.isArray(l.origin)).length>0) { @@ -5220,6 +5240,7 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A this.commonService.updateThresholdHistogram(this.linkThresholdSparkline.nativeElement); } + this.commonService.visuals.twoD?.refreshDistanceMetricSettings?.(); this.refreshThresholdStabilityPanel(); } diff --git a/src/app/visualizationComponents/BubbleComponent/bubble.component.ts b/src/app/visualizationComponents/BubbleComponent/bubble.component.ts index c9c993ae..9a5616d3 100644 --- a/src/app/visualizationComponents/BubbleComponent/bubble.component.ts +++ b/src/app/visualizationComponents/BubbleComponent/bubble.component.ts @@ -13,6 +13,7 @@ import svg from 'cytoscape-svg'; import { ExportService, ExportOptions } from '@app/contactTraceCommonServices/export.service'; import { Subject, Subscription, takeUntil } from 'rxjs'; import { CommonStoreService } from '@app/contactTraceCommonServices/common-store.services'; +import { buildPieChartPatternDef, buildPieChartSvgDataUri, PieChartSlice } from '@app/contactTraceCommonServices/pie-chart-utils'; type DataRecord = { index: number, id: string, x: number; y: number, color: string, opacity: number, Xgroup: number, Ygroup: number, strokeColor: string, totalCount?: number, counts ?: any }//selected: boolean } @@ -812,14 +813,11 @@ export class BubbleComponent extends BaseComponentDirective implements OnInit, M this.cy.style().resetToDefault(); this.cy.style(this.getCytoscapeStyle()) - this.visibleData.forEach((node, i) => { + this.visibleData.forEach((node) => { if ( node.totalCount == 1 || node.counts.length == 1) { return; } else { - let size = this.nodeSize * Math.sqrt(node.totalCount); - let svgPattern = `${this.svgDefs[`node${i}`]}`; - let b64 = 'data:image/svg+xml;base64,' + btoa(svgPattern); - this.cy.style().selector(`#cNode${i}`).style({ 'background-color': 'transparent', 'background-opacity': 0, 'background-fit': 'cover', 'background-image': b64}) + this.applyCollapsedBubblePieStyle(node); } }) this.cy.style().update(); @@ -901,42 +899,57 @@ export class BubbleComponent extends BaseComponentDirective implements OnInit, M return this.commonService.getNodeFillStyle(syntheticNode); } + private getPieSlicesForCollapsedBubbleNode(node: DataRecord): PieChartSlice[] { + return (node.counts || []).map(count => { + const nodeStyle = this.getNodeFillStyleForColorValue(count.label); + return { + label: count.label, + count: count.count, + color: nodeStyle.color, + alpha: nodeStyle.alpha + }; + }); + } + + private getCollapsedBubblePieDataUri(node: DataRecord, patternId: string): string { + const size = this.nodeSize * Math.sqrt(Math.max(1, Number(node.totalCount) || 1)); + return buildPieChartSvgDataUri(patternId, size, this.getPieSlicesForCollapsedBubbleNode(node)); + } + + private getCollapsedBubblePatternId(node: DataRecord): string { + return `bubble-${String(node.id || node.index).replace(/[^A-Za-z0-9_-]/g, '-')}`; + } + + private applyCollapsedBubblePieStyle(node: DataRecord): void { + const renderedNode = this.cy?.getElementById(String(node.id)); + if (!renderedNode || renderedNode.empty()) { + return; + } + + const b64 = this.getCollapsedBubblePieDataUri(node, this.getCollapsedBubblePatternId(node)); + renderedNode.style({ + 'background-color': 'transparent', + 'background-opacity': 0, + 'background-fit': 'cover', + 'background-image': b64 + }); + } + /** * @returns a string representing the SVG def of the patterns needed to generate the pie chart */ generatePieChartsSVGDefs(changedVisibleNodes) : void { changedVisibleNodes.forEach((indexNumber) => { - let patternString = ''; let node = this.visibleData.find(vNode => vNode.index == indexNumber); - if (node.totalCount < 2 || node.counts.length == 1 || node == undefined) { + if (node == undefined || node.totalCount < 2 || node.counts.length == 1) { return; } - let proportions = [] - let coordinates = [] - let colors = []; - let opacities = []; - node.counts.forEach(x => { - let proportion = proportions.reduce((acc, cv) => acc+cv, 0) + x.count/node.totalCount - let xPos = Math.cos(2 * Math.PI * proportion) - let yPos = Math.sin(2 * Math.PI * proportion) - const nodeStyle = this.getNodeFillStyleForColorValue(x.label); - - proportions.push(x.count/node.totalCount) - coordinates.push([xPos, yPos]) - colors.push(nodeStyle.color) - opacities.push(nodeStyle.alpha) - }) - patternString += `` ; - for (let i = 0; i .5 ? 1: 0 - let arcEnd = i == coordinates.length-1 ? '1 0' : coordinates[i][0] + ' ' + coordinates[i][1] - patternString += `` - } - patternString += '' - this.svgDefs[`node${indexNumber}`] = (patternString); + const slices = this.getPieSlicesForCollapsedBubbleNode(node); + const stablePatternId = this.getCollapsedBubblePatternId(node); + this.svgDefs[stablePatternId] = buildPieChartPatternDef(stablePatternId, slices); + this.svgDefs[`node${indexNumber}`] = buildPieChartPatternDef(`node${indexNumber}`, slices); }) } @@ -1187,7 +1200,7 @@ export class BubbleComponent extends BaseComponentDirective implements OnInit, M this.getData(); this.updateNodes(); - this.visibleData.forEach((node, i) => { + this.visibleData.forEach((node) => { if ( node.totalCount == 1 || node.counts.length == 1) { let currrentVar = node.counts[0].label const nodeStyle = this.getNodeFillStyleForColorValue(currrentVar); @@ -1197,10 +1210,7 @@ export class BubbleComponent extends BaseComponentDirective implements OnInit, M }) return; } else { - let size = this.nodeSize * Math.sqrt(node.totalCount); - let svgPattern = `${this.svgDefs[`node${i}`]}`; - let b64 = 'data:image/svg+xml;base64,' + btoa(svgPattern); - this.cy.style().selector(`#cNode${i}`).style({ 'background-color': 'transparent', 'background-opacity': 0, 'background-fit': 'cover', 'background-image': b64}) + this.applyCollapsedBubblePieStyle(node); } }) this.cy.style().update(); diff --git a/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.html b/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.html index a8df0d7b..d78d8098 100644 --- a/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.html +++ b/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.html @@ -14,7 +14,7 @@ @@ -164,12 +164,35 @@ Please add data files to load... + + + + + Collapse Related Nodes + + + Collapse + + + + + + Distance ({{ SelectedNodeCollapseMetricLabel }}) + + - - - - Colors - + + + {{ SelectedNodeCollapseMetricLabel }} + + + + + + + + Colors + diff --git a/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.scss b/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.scss index 5538778e..fbafd240 100644 --- a/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.scss +++ b/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.scss @@ -40,6 +40,7 @@ position: relative; width: 100%; height: 100%; + z-index: 0; } /* Grid Overlay Styling */ @@ -64,11 +65,22 @@ } #cy { - // position: absolute; + position: relative; width: 100%; height: 100%; top: 0; left: 0; + z-index: 0; +} + +#tool-btn-container, +#network-statistics-wrapper, +.view-controls { + z-index: 10; +} + +.view-controls { + position: relative; } @@ -118,11 +130,12 @@ /* Button appearance on hover */ .btn-icon:hover { transform: scale(1.1); /* Slight enlarge on hover */ - background-color: rgba(0, 0, 0, 0.1); /* Subtle background color on hover */ + background-color: #eef2f6; /* Subtle background color on hover */ } .btn-icon { margin-right: 10px; + background-color: #ffffff; box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.2); } diff --git a/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts b/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts index 02299988..38536001 100644 --- a/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts +++ b/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts @@ -23,6 +23,8 @@ import * as d3f from 'd3-force'; import { CommonStoreService } from '@app/contactTraceCommonServices/common-store.services'; import { ExportService, ExportOptions } from '@app/contactTraceCommonServices/export.service'; import { NgZone } from '@angular/core'; +import { buildThresholdConnectedComponents } from '@app/contactTraceCommonServices/threshold-analysis'; +import { buildPieChartSvgDataUri, PieChartSlice } from '@app/contactTraceCommonServices/pie-chart-utils'; interface CustomNodeSvgExportReplacement { exportHeight: number; @@ -74,6 +76,9 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic links: [] }; selectedNodeId = undefined; + private readonly collapsedNodeIdPrefix = 'twod-collapse-'; + private nodeCollapseShapeWarningConfirmed = false; + private nodeCollapseShapeWarningPending = false; private getPerformanceNow(): number { return typeof performance !== 'undefined' && performance.now @@ -204,9 +209,17 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic selected: node.selected, degree: node.degree, label: node.label, + isCollapsedAggregate: node.isCollapsedAggregate, + collapsedMemberIds: node.collapsedMemberIds, + totalCount: node.totalCount, + counts: node.counts, + meanInternalDistance: node.meanInternalDistance, + internalDistancePairCount: node.internalDistancePairCount, nodeSize: node.nodeSize, + aggregateRenderedSize: node.aggregateRenderedSize, nodeColor: node.nodeColor, bgOpacity: node.bgOpacity, + pieBackgroundImage: node.pieBackgroundImage, borderWidth: node.borderWidth, selectedBorderColor: this.widgets['selected-color'], fontSize: this.getNodeFontSize(node), @@ -376,6 +389,12 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic SelectedNodeTooltipVariable: any = "None"; SelectedNodeRadiusVariable: string = "None"; SelectedNodeRadiusSizeVariable: number = 50; + SelectedNodeCollapseTypeVariable: boolean = false; + SelectedNodeCollapseThresholdDisplayedVariable: number = 0; + SelectedNodeCollapseMetricLabel: string = 'TN93'; + NodeCollapseThresholdMinDisplayed: number = 0; + NodeCollapseThresholdMaxDisplayed: number = 1; + NodeCollapseThresholdStepDisplayed: number = 0.001; SelectedNetworkTableTypeVariable: PolygonColorTableDisplayMode = "Dock"; @@ -496,6 +515,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic // this.setExpanded(this.mainSite); this.widgets = this.commonService.session.style.widgets; + this.ensureNodeCollapseWidgetDefaults(); this.container.on('resize', () => { setTimeout(() => this.fit(), 200)}) this.container.on('hide', () => { @@ -544,6 +564,71 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic return String(endpoint ?? ''); } + private getNodeId(node: any): string { + const id = node?._id ?? node?.id ?? ''; + return typeof id === 'string' ? id : String(id); + } + + private getSessionNetworkNodes(): any[] { + const networkNodes = this.commonService.session?.network?.nodes; + if (Array.isArray(networkNodes) && networkNodes.length > 0) { + return networkNodes; + } + + const filteredNodes = this.commonService.session?.data?.nodeFilteredValues; + if (Array.isArray(filteredNodes) && filteredNodes.length > 0) { + return filteredNodes; + } + + const dataNodes = this.commonService.session?.data?.nodes; + return Array.isArray(dataNodes) ? dataNodes : []; + } + + private getSessionNetworkNodeByEndpoint(nodes: any[], endpoint: any): any { + const endpointId = this.getLinkEndpointId(endpoint); + return nodes.find(node => this.getNodeId(node) === endpointId); + } + + private isNetworkRendering(): boolean { + return this.commonService.session?.network?.rendering === true; + } + + private setNetworkRendering(rendering: boolean): void { + const network = this.commonService.session?.network; + if (network) { + network.rendering = rendering; + } + } + + private normalizeNetworkDataForCytoscape( + networkData: { nodes: any[]; links: any[] }, + warnInvalidLinks = true + ): Set { + networkData.nodes.forEach(node => { + node.id = this.getNodeId(node); + }); + + const nodeIds = new Set(networkData.nodes.map(node => this.getNodeId(node))); + + networkData.links.forEach(link => { + link.source = this.getLinkEndpointId(link.source); + link.target = this.getLinkEndpointId(link.target); + }); + + if (warnInvalidLinks) { + networkData.links.forEach(link => { + if (!nodeIds.has(link.source)) { + console.warn('Link source not found in nodes:', link.source, link); + } + if (!nodeIds.has(link.target)) { + console.warn('Link target not found in nodes:', link.target, link); + } + }); + } + + return nodeIds; + } + private getVisibleNetworkDataForRender(filterLinksByVisibleNodes = this.isTimelineFilteringActive()) { const nodes = this.commonService.getVisibleNodes(); let links = this.commonService.getVisibleLinks(true); @@ -559,6 +644,540 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic return { nodes, links }; } + private ensureNodeCollapseWidgetDefaults(): void { + if (!this.widgets) return; + if (this.widgets['network-node-collapse-enabled'] === undefined || this.widgets['network-node-collapse-enabled'] === null) { + this.widgets['network-node-collapse-enabled'] = false; + } + if (!Number.isFinite(Number(this.widgets['network-node-collapse-threshold']))) { + this.widgets['network-node-collapse-threshold'] = 0; + } + } + + private getNodeCollapseMetric(): string { + return String(this.widgets?.['link-sort-variable'] || this.widgets?.['default-distance-metric'] || 'distance'); + } + + private getNodeCollapseMetricLabel(metric: string): string { + const normalizedMetric = String(metric || 'distance').toLowerCase(); + const effectiveMetric = normalizedMetric === 'distance' + ? String(this.widgets?.['default-distance-metric'] || 'distance').toLowerCase() + : normalizedMetric; + + if ( + effectiveMetric === 'tn93' + && String(this.widgets?.['tn93-distance-display-format'] || 'decimal').toLowerCase() === 'percentage' + ) { + return 'TN93 (%)'; + } + + return this.commonService.titleize(effectiveMetric); + } + + private getNodeCollapseRawStep(metric: string): number { + return String(metric || '').toLowerCase() === 'snps' || + String(this.widgets?.['default-distance-metric'] || '').toLowerCase() === 'snps' + ? 1 + : 0.001; + } + + private getNodeCollapseThresholdRaw(): number { + this.ensureNodeCollapseWidgetDefaults(); + return Number(this.widgets['network-node-collapse-threshold']); + } + + private isNodeCollapseEnabled(): boolean { + this.ensureNodeCollapseWidgetDefaults(); + return this.widgets['network-node-collapse-enabled'] === true; + } + + private getNumericMetricValue(link: any, metric: string): number | null { + const raw = link?.[metric]; + if (typeof raw === 'number') { + return Number.isFinite(raw) ? raw : null; + } + + if (typeof raw === 'string' && raw.trim().length > 0) { + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; + } + + private getNumericNodeCollapseDistanceValue(link: any, metric: string): number | null { + const primaryValue = this.getNumericMetricValue(link, metric); + if (primaryValue !== null) { + return primaryValue; + } + + const selectedMetric = String(this.widgets?.['default-distance-metric'] || '').toLowerCase(); + return selectedMetric !== metric + ? this.getNumericMetricValue(link, selectedMetric) + : null; + } + + private isNodeCollapseDistanceLink(link: any, metric: string): boolean { + if (link?.hasDistance !== true || this.getNumericNodeCollapseDistanceValue(link, metric) === null) { + return false; + } + + const distanceOrigins = this.commonService.getLinkDistanceOrigins?.(link) || []; + if (distanceOrigins.length > 0) { + return true; + } + + const origins = Array.isArray(link?.origin) ? link.origin : []; + return origins.some((origin: any) => String(origin || '').toLowerCase().includes('distance')); + } + + private getNodeCollapseDistanceLinks(nodeIds: Set, metric: string): any[] { + return (this.commonService.session.data.links || []).reduce((acc, link) => { + const source = this.getLinkEndpointId(link.source); + const target = this.getLinkEndpointId(link.target); + const value = this.getNumericNodeCollapseDistanceValue(link, metric); + + if ( + nodeIds.has(source) + && nodeIds.has(target) + && this.isNodeCollapseDistanceLink(link, metric) + && value !== null + ) { + acc.push(link[metric] === value ? link : { ...link, [metric]: value }); + } + + return acc; + }, [] as any[]); + } + + private updateNodeCollapseThresholdDisplayBounds(): void { + this.ensureNodeCollapseWidgetDefaults(); + const metric = this.getNodeCollapseMetric(); + const nodeIds = new Set((this.commonService.session.data.nodes || []).map(node => this.getNodeId(node))); + const values = this.getNodeCollapseDistanceLinks(nodeIds, metric) + .map(link => this.getNumericNodeCollapseDistanceValue(link, metric)) + .filter((value): value is number => value !== null) + .sort((a, b) => a - b); + + const storedThreshold = this.getNodeCollapseThresholdRaw(); + const rawStep = this.getNodeCollapseRawStep(metric); + const rawMin = values.length ? Math.min(0, values[0]) : 0; + const rawMaxFromData = values.length ? values[values.length - 1] : rawStep; + const rawMax = Math.max(rawMaxFromData, storedThreshold, rawStep); + + this.SelectedNodeCollapseMetricLabel = this.getNodeCollapseMetricLabel(metric); + this.NodeCollapseThresholdMinDisplayed = this.commonService.toDisplayedDistanceValue(rawMin, metric); + this.NodeCollapseThresholdMaxDisplayed = this.commonService.toDisplayedDistanceValue(rawMax, metric); + this.NodeCollapseThresholdStepDisplayed = this.commonService.toDisplayedDistanceValue(rawStep, metric); + this.SelectedNodeCollapseThresholdDisplayedVariable = this.commonService.toDisplayedDistanceValue(storedThreshold, metric); + this.syncNodeCollapseThresholdDomControls(); + } + + private syncNodeCollapseThresholdDomControls(): void { + const controls = $('#network-node-collapse-threshold, #network-node-collapse-threshold-input'); + controls + .attr('min', this.NodeCollapseThresholdMinDisplayed) + .attr('max', this.NodeCollapseThresholdMaxDisplayed) + .attr('step', this.NodeCollapseThresholdStepDisplayed) + .val(this.SelectedNodeCollapseThresholdDisplayedVariable); + } + + private syncNodeCollapseControlsFromWidgets(): void { + this.ensureNodeCollapseWidgetDefaults(); + this.SelectedNodeCollapseTypeVariable = this.widgets['network-node-collapse-enabled'] === true; + this.updateNodeCollapseThresholdDisplayBounds(); + } + + private refreshNodeCollapseRender(): void { + if (!this.viewActive) { + this.rerenderOnActive = true; + return; + } + + void this._rerender(false) + .finally(() => { + if (!this.isDestroyed) { + this.setNetworkRendering(false); + } + }); + } + + private isNonCircleNodeShape(shapeKey: any): boolean { + const rawShape = String(shapeKey ?? '').trim(); + if (!rawShape) { + return false; + } + + return resolveNodeShapeKey(rawShape, rawShape) !== 'ellipse'; + } + + private hasActiveNonCircleNodeShapes(): boolean { + if (this.cy) { + const renderedNodes = this.cy.nodes(':visible') + .filter((node: any) => !this.isGroupNode(node) && !node.data('isCollapsedAggregate')); + + if (renderedNodes.toArray().some((node: any) => ( + this.isNonCircleNodeShape(node.data('shapeKey') || node.data('shape') || node.style('shape')) + ))) { + return true; + } + } + + const visibleNodes = this.getVisibleNetworkDataForRender().nodes || []; + return visibleNodes.some((node: any) => ( + !node?.isCollapsedAggregate && this.isNonCircleNodeShape(this.getNodeShape(node)) + )); + } + + private setNodeCollapseEnabled(enabled: boolean, refreshRender = true): void { + this.ensureNodeCollapseWidgetDefaults(); + this.widgets['network-node-collapse-enabled'] = enabled === true; + this.SelectedNodeCollapseTypeVariable = this.widgets['network-node-collapse-enabled']; + this.updateNodeCollapseThresholdDisplayBounds(); + this.cdref.markForCheck(); + this.cdref.detectChanges(); + + if (refreshRender) { + this.refreshNodeCollapseRender(); + } + } + + private syncNodeCollapseDisabledControlState(): void { + this.ensureNodeCollapseWidgetDefaults(); + this.widgets['network-node-collapse-enabled'] = false; + this.SelectedNodeCollapseTypeVariable = null as any; + this.updateNodeCollapseThresholdDisplayBounds(); + this.cdref.markForCheck(); + this.cdref.detectChanges(); + + setTimeout(() => { + if (!this.isDestroyed && this.widgets['network-node-collapse-enabled'] !== true) { + this.setNodeCollapseEnabled(false, false); + } + }, 0); + } + + public onNodeCollapseEnabledChange(enabled: boolean, warnOnNonCircleShapes = false): void { + this.ensureNodeCollapseWidgetDefaults(); + const shouldCheckShapeWarning = enabled === true + && warnOnNonCircleShapes + && !this.nodeCollapseShapeWarningConfirmed; + const shouldWarnForNonCircleShapes = shouldCheckShapeWarning + && (this.nodeCollapseShapeWarningPending || this.hasActiveNonCircleNodeShapes()); + + if (shouldWarnForNonCircleShapes) { + this.nodeCollapseShapeWarningPending = true; + this.syncNodeCollapseDisabledControlState(); + + const confirmationHost = this.commonService.visuals.microbeTrace; + if (confirmationHost?.openNodeCollapseShapeConfirmation) { + confirmationHost.openNodeCollapseShapeConfirmation( + () => { + this.nodeCollapseShapeWarningConfirmed = true; + this.nodeCollapseShapeWarningPending = false; + this.setNodeCollapseEnabled(true); + }, + () => this.syncNodeCollapseDisabledControlState() + ); + return; + } + } + + if (shouldCheckShapeWarning && !shouldWarnForNonCircleShapes) { + this.nodeCollapseShapeWarningPending = false; + } + + this.setNodeCollapseEnabled(enabled); + } + + public onNodeCollapseThresholdDisplayedChange(value: any): void { + this.ensureNodeCollapseWidgetDefaults(); + const metric = this.getNodeCollapseMetric(); + const rawDisplayedValue = value && typeof value === 'object' && 'target' in value + ? (value.target as HTMLInputElement)?.value + : value; + const displayedValue = Number(rawDisplayedValue); + + if (!Number.isFinite(displayedValue)) { + return; + } + + const clampedDisplayedValue = Math.min( + Math.max(displayedValue, this.NodeCollapseThresholdMinDisplayed), + this.NodeCollapseThresholdMaxDisplayed + ); + const rawThreshold = this.commonService.fromDisplayedDistanceValue(clampedDisplayedValue, metric); + + if (!Number.isFinite(rawThreshold)) { + return; + } + + this.widgets['network-node-collapse-threshold'] = rawThreshold; + this.SelectedNodeCollapseThresholdDisplayedVariable = this.commonService.toDisplayedDistanceValue(rawThreshold, metric); + this.syncNodeCollapseThresholdDomControls(); + + if (this.isNodeCollapseEnabled()) { + this.refreshNodeCollapseRender(); + } + } + + private getCollapsedNodeBaseSize(): number { + const rawSize = Number(this.widgets?.['node-radius']); + return Number.isFinite(rawSize) && rawSize > 0 ? rawSize : 20; + } + + private getCollapsedNodeRenderedSize(totalCount: number): number { + return this.mapNodeSize(this.getCollapsedNodeBaseSize()) * Math.sqrt(Math.max(1, Number(totalCount) || 1)); + } + + private buildCollapsedNodeCounts(memberNodes: any[]): Array<{ label: string; count: number }> { + const colorVariable = this.widgets['node-color-variable']; + + if (colorVariable === 'None') { + return [{ label: 'All Nodes', count: memberNodes.length }]; + } + + const counts = new Map(); + memberNodes.forEach(node => { + const rawLabel = node?.[colorVariable]; + const label = rawLabel === undefined || rawLabel === null ? '' : String(rawLabel); + counts.set(label, (counts.get(label) || 0) + 1); + }); + + return Array.from(counts.entries()).map(([label, count]) => ({ label, count })); + } + + private getCollapsedPieSlices(counts: Array<{ label: string; count: number }>): PieChartSlice[] { + const colorVariable = this.widgets['node-color-variable']; + const fixedColor = this.widgets['node-color']; + + return counts.map(count => ({ + label: count.label, + count: count.count, + color: colorVariable === 'None' + ? fixedColor + : this.commonService.temp.style.nodeColorMap(count.label), + alpha: colorVariable === 'None' + ? 1 - Number(this.widgets['node-opacity'] || 0) + : this.commonService.temp.style.nodeAlphaMap(count.label) + })); + } + + private getCollapsedSolidNodeColor(counts: Array<{ label: string; count: number }>): [string, number] { + const colorVariable = this.widgets['node-color-variable']; + + if (colorVariable === 'None') { + return [this.widgets['node-color'], 1 - Number(this.widgets['node-opacity'] || 0)]; + } + + const label = counts[0]?.label ?? ''; + return [ + this.commonService.temp.style.nodeColorMap(label), + this.commonService.temp.style.nodeAlphaMap(label) + ]; + } + + private getCollapsedNodeDistanceSummary( + memberNodes: any[], + metric: string + ): { meanInternalDistance: number | null; internalDistancePairCount: number } { + const memberIds = new Set(memberNodes.map(node => this.getNodeId(node))); + // Collapse membership is threshold-driven; this summary uses every available member-pair distance. + const distanceLinks = this.getNodeCollapseDistanceLinks(memberIds, metric); + const pairValues = new Map(); + + distanceLinks.forEach(link => { + const source = this.getLinkEndpointId(link.source); + const target = this.getLinkEndpointId(link.target); + + if ( + source === target || + !memberIds.has(source) || + !memberIds.has(target) + ) { + return; + } + + const value = this.getNumericNodeCollapseDistanceValue(link, metric); + if (value === null) { + return; + } + + const pairKey = JSON.stringify(source < target ? [source, target] : [target, source]); + const values = pairValues.get(pairKey) || []; + values.push(value); + pairValues.set(pairKey, values); + }); + + const pairMeans = Array.from(pairValues.values()) + .map(values => values.reduce((sum, value) => sum + value, 0) / values.length); + + if (pairMeans.length === 0) { + return { + meanInternalDistance: null, + internalDistancePairCount: 0 + }; + } + + return { + meanInternalDistance: pairMeans.reduce((sum, value) => sum + value, 0) / pairMeans.length, + internalDistancePairCount: pairMeans.length + }; + } + + private createCollapsedAggregateNode( + memberNodes: any[], + componentIndex: number, + metric: string + ): any { + const aggregateId = `${this.collapsedNodeIdPrefix}${componentIndex}`; + const firstMember = memberNodes[0] || {}; + const finiteX = memberNodes.map(node => Number(node.x)).filter(value => Number.isFinite(value)); + const finiteY = memberNodes.map(node => Number(node.y)).filter(value => Number.isFinite(value)); + const cachedPosition = this.nodePositions.get(aggregateId); + const cachedX = Number(cachedPosition?.x); + const cachedY = Number(cachedPosition?.y); + const x = Number.isFinite(cachedX) + ? cachedX + : finiteX.length + ? finiteX.reduce((sum, value) => sum + value, 0) / finiteX.length + : 0; + const y = Number.isFinite(cachedY) + ? cachedY + : finiteY.length + ? finiteY.reduce((sum, value) => sum + value, 0) / finiteY.length + : 0; + const totalCount = memberNodes.length; + const nodeSize = this.getCollapsedNodeBaseSize() * Math.sqrt(totalCount); + const aggregateRenderedSize = this.getCollapsedNodeRenderedSize(totalCount); + const counts = this.buildCollapsedNodeCounts(memberNodes); + const slices = this.getCollapsedPieSlices(counts); + const hasPie = this.widgets['node-color-variable'] !== 'None' && slices.length > 1; + const [solidColor, solidOpacity] = this.getCollapsedSolidNodeColor(counts); + const distanceSummary = this.getCollapsedNodeDistanceSummary(memberNodes, metric); + + return { + ...firstMember, + id: aggregateId, + _id: aggregateId, + index: firstMember.index, + cluster: undefined, + group: undefined, + x, + y, + vx: 0, + vy: 0, + visible: true, + selected: false, + isCollapsedAggregate: true, + collapsedMemberIds: memberNodes.map(node => this.getNodeId(node)), + totalCount, + counts, + meanInternalDistance: distanceSummary.meanInternalDistance, + internalDistancePairCount: distanceSummary.internalDistancePairCount, + label: `${totalCount} nodes`, + nodeSize, + aggregateRenderedSize, + nodeColor: hasPie ? 'transparent' : solidColor, + bgOpacity: hasPie ? 0 : solidOpacity, + borderWidth: this.getNodeBorderWidth(firstMember), + pieBackgroundImage: hasPie + ? buildPieChartSvgDataUri(`twod-collapse-pie-${componentIndex}`, aggregateRenderedSize, slices) + : undefined + }; + } + + private applyNodeCollapseToNetworkData(networkData: { nodes: any[]; links: any[] }): { nodes: any[]; links: any[] } { + if (!this.isNodeCollapseEnabled()) { + return networkData; + } + + const nodes = networkData.nodes || []; + if (nodes.length === 0) { + return networkData; + } + + const metric = this.getNodeCollapseMetric(); + const threshold = this.getNodeCollapseThresholdRaw(); + if (!Number.isFinite(threshold)) { + return networkData; + } + + const nodeById = new Map(); + nodes.forEach(node => { + const nodeId = this.getNodeId(node); + node.id = nodeId; + nodeById.set(nodeId, node); + }); + + const nodeIds = new Set(nodeById.keys()); + const distanceLinks = this.getNodeCollapseDistanceLinks(nodeIds, metric); + const summary = buildThresholdConnectedComponents(nodes, distanceLinks, metric, threshold); + const collapsedComponentIds = new Set(); + + summary.components.forEach((component, componentIndex) => { + if (component.nodeIds.length > 1) { + collapsedComponentIds.add(componentIndex); + } + }); + + if (collapsedComponentIds.size === 0) { + return networkData; + } + + const renderedNodeIdByOriginalId = new Map(); + const renderedNodes: any[] = []; + const renderedNodeIds = new Set(); + + summary.components.forEach((component, componentIndex) => { + if (!collapsedComponentIds.has(componentIndex)) { + const node = nodeById.get(component.nodeIds[0]); + if (node) { + renderedNodes.push(node); + renderedNodeIds.add(this.getNodeId(node)); + renderedNodeIdByOriginalId.set(this.getNodeId(node), this.getNodeId(node)); + } + return; + } + + const memberNodes = component.nodeIds + .map(nodeId => nodeById.get(nodeId)) + .filter(Boolean); + const aggregateNode = this.createCollapsedAggregateNode(memberNodes, componentIndex, metric); + renderedNodes.push(aggregateNode); + renderedNodeIds.add(aggregateNode.id); + component.nodeIds.forEach(nodeId => renderedNodeIdByOriginalId.set(nodeId, aggregateNode.id)); + }); + + const renderedLinks = (networkData.links || []).reduce((acc, link, index) => { + const source = this.getLinkEndpointId(link.source); + const target = this.getLinkEndpointId(link.target); + const collapsedSource = renderedNodeIdByOriginalId.get(source) || source; + const collapsedTarget = renderedNodeIdByOriginalId.get(target) || target; + + if ( + collapsedSource === collapsedTarget || + !renderedNodeIds.has(collapsedSource) || + !renderedNodeIds.has(collapsedTarget) + ) { + return acc; + } + + acc.push({ + ...link, + id: link.id ?? `collapsed-link-${index}`, + source: collapsedSource, + target: collapsedTarget + }); + return acc; + }, [] as any[]); + + return { + nodes: renderedNodes, + links: renderedLinks + }; + } + ngOnInit() { this.commonService.visuals.twoD = this; @@ -775,6 +1394,13 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic 'height': 'mapData(nodeSize, 0, 100, 10, 50)' } }, + { + selector: 'node[!isParent][isCollapsedAggregate][aggregateRenderedSize]', + css: { + 'width': 'data(aggregateRenderedSize)', + 'height': 'data(aggregateRenderedSize)' + } + }, { selector: 'node[bgOpacity]', css: { @@ -798,6 +1424,20 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic 'background-opacity': 0, 'border-width': 0 } + }, + { + selector: 'node[!isParent][pieBackgroundImage]', + css: { + // @ts-ignore + 'background-image': 'data(pieBackgroundImage)', + 'background-fit': 'cover', + 'background-clip': 'node', + 'background-position-x': '50%', + 'background-position-y': '50%', + 'background-repeat': 'no-repeat', + 'background-color': 'transparent', + 'background-opacity': 0 + } }, { selector: '.hidden', @@ -889,7 +1529,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic } }, { - selector: 'node:selected[!isParent][!iconBackgroundImage]', + selector: 'node:selected[!isParent][!iconBackgroundImage][!pieBackgroundImage]', css: { 'background-color': 'data(nodeColor)', 'border-color': 'data(selectedBorderColor)', @@ -906,6 +1546,15 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic 'border-width': 3 } }, + { + selector: 'node:selected[!isParent][pieBackgroundImage]', + css: { + 'background-color': 'transparent', + 'background-opacity': 0, + 'border-color': 'data(selectedBorderColor)', + 'border-width': 3 + } + }, { selector: 'edge:selected', css: { @@ -931,9 +1580,13 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic // Debounced function to sync Cytoscape selections with the common service. const syncCySelectionToService = _.debounce(() => { - const selectedNodes = this.cy.nodes(':selected'); + const selectedNodes = this.cy.nodes(':selected').filter((node: any) => !node.data('isCollapsedAggregate')); const selectedIds = new Set(selectedNodes.map(node => node.id())); + if (this.isNodeCollapseEnabled() && selectedIds.size === 0) { + return; + } + let selectionChanged = false; // Sync with the main nodes array this.commonService.session.data.nodes.forEach(n => { @@ -980,7 +1633,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic this.cy.on('cxttap', 'node', (evt) => { const node = evt.target; - if (node.data('isParent')) { + if (node.data('isParent') || node.data('isCollapsedAggregate')) { return; } @@ -1365,6 +2018,10 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic const nodeId = node.id(); // This is for REAL user events. It reads the now-updated position from Cytoscape. const newPosition = node.position(); + if (node.data('isCollapsedAggregate')) { + this.nodePositions.set(nodeId, newPosition); + return; + } this.commonService.updateNodePosition(nodeId, newPosition); } /** Initializes the view. @@ -1391,6 +2048,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic this.widgets['link-threshold'] = this.commonService.GlobalSettingsModel.SelectedLinkThresholdVariable; } + this.ensureNodeCollapseWidgetDefaults(); // Subscribe to style file applied event this.styleFileSub = this.store.styleFileApplied$.subscribe(() => { @@ -1812,6 +2470,54 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic }); } + private addCollapsedPieSvgExportOutlines(doc: XMLDocument): void { + const svgNamespace = 'http://www.w3.org/2000/svg'; + const borderWidth = Math.max(0, Number(this.widgets?.['node-border-width']) || 0); + if (borderWidth <= 0) { + return; + } + + const images = Array.from(doc.getElementsByTagName('image')) + .filter(image => { + const href = this.getSvgImageHref(image); + return !!href + && href.startsWith('data:image/') + && this.hasClipPathAncestor(image); + }); + + images.forEach(image => { + if (!image.parentNode) { + return; + } + + const imageWidth = this.getSvgLengthAttribute(image, 'width'); + const imageHeight = this.getSvgLengthAttribute(image, 'height'); + if (imageWidth === null || imageHeight === null || imageWidth <= 0 || imageHeight <= 0) { + return; + } + + const imageX = this.getSvgLengthAttribute(image, 'x') ?? 0; + const imageY = this.getSvgLengthAttribute(image, 'y') ?? 0; + const radius = Math.max(0.1, (Math.min(imageWidth, imageHeight) / 2) - (borderWidth / 2)); + const outline = doc.createElementNS(svgNamespace, 'circle'); + outline.setAttribute('cx', `${imageX + (imageWidth / 2)}`); + outline.setAttribute('cy', `${imageY + (imageHeight / 2)}`); + outline.setAttribute('r', `${radius}`); + outline.setAttribute('fill', 'none'); + outline.setAttribute('stroke', '#000000'); + outline.setAttribute('stroke-width', `${borderWidth}`); + outline.setAttribute('stroke-opacity', '1'); + outline.setAttribute('data-microbetrace-collapsed-pie-outline', 'true'); + + const imageTransform = image.getAttribute('transform'); + if (imageTransform) { + outline.setAttribute('transform', imageTransform); + } + + image.parentNode.insertBefore(outline, image.nextSibling); + }); + } + /** * Hides export pane, sets isExporting variable to true and calls exportWork2 to export the twoD network image */ @@ -1839,6 +2545,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic const parser = new DOMParser(); const doc = parser.parseFromString(content, 'image/svg+xml'); this.replaceExportedCustomNodeImagesWithVectorShapes(doc); + this.addCollapsedPieSvgExportOutlines(doc); const svg1 = doc.documentElement; svg1.setAttribute('height', (parseFloat(svg1.getAttribute('height'))+20).toString()); svg1.setAttribute('width', (parseFloat(svg1.getAttribute('width'))+20).toString()); @@ -1917,7 +2624,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic display: "none" }); - this.commonService.session.network.nodes.forEach(node => { + this.getSessionNetworkNodes().forEach(node => { node.selected = false; }); } @@ -2522,7 +3229,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic let vlinks = this.commonService.getVisibleLinks(true); let output = []; let n = vlinks.length; - let nodes = this.commonService.session.network.nodes; + let nodes = this.getSessionNetworkNodes(); for (let i = 0; i < n; i++) { if (vlinks[i].origin) { if (typeof vlinks[i].origin === 'object') { @@ -2533,8 +3240,8 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic origin: o, oNum: j, origins: l.length, - source: nodes.find(d => d._id === vlinks[i].source || d.id === vlinks[i].source), - target: nodes.find(d => d._id === vlinks[i].target || d.id === vlinks[i].target) + source: this.getSessionNetworkNodeByEndpoint(nodes, vlinks[i].source), + target: this.getSessionNetworkNodeByEndpoint(nodes, vlinks[i].target) }); output.push(holder); }); @@ -2542,8 +3249,8 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic const holder = Object.assign({}, vlinks[i], { oNum: 0, origins: 1, - source: nodes.find(d => d._id === vlinks[i].source || d.id === vlinks[i].source), - target: nodes.find(d => d._id === vlinks[i].target || d.id === vlinks[i].target) + source: this.getSessionNetworkNodeByEndpoint(nodes, vlinks[i].source), + target: this.getSessionNetworkNodeByEndpoint(nodes, vlinks[i].target) }); output.push(holder); } @@ -2551,8 +3258,8 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic const holder = Object.assign({}, vlinks[i], { oNum: 0, origins: 1, - source: nodes.find(d => d._id === vlinks[i].source || d.id === vlinks[i].source), - target: nodes.find(d => d._id === vlinks[i].target || d.id === vlinks[i].target) + source: this.getSessionNetworkNodeByEndpoint(nodes, vlinks[i].source), + target: this.getSessionNetworkNodeByEndpoint(nodes, vlinks[i].target) }); //console.log(holder); output.push(holder); @@ -2562,8 +3269,8 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic origin: 'Unknown', oNum: 0, origins: 1, - source: nodes.find(d => d._id === vlinks[i].source || d.id === vlinks[i].source), - target: nodes.find(d => d._id === vlinks[i].target || d.id === vlinks[i].target) + source: this.getSessionNetworkNodeByEndpoint(nodes, vlinks[i].source), + target: this.getSessionNetworkNodeByEndpoint(nodes, vlinks[i].target) }); output.push(holder); } @@ -2584,9 +3291,10 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic getLLinks() { let vlinks = this.commonService.getVisibleLinks(true); let n = vlinks.length; + const nodes = this.getSessionNetworkNodes(); for (let i = 0; i < n; i++) { - vlinks[i].source = this.commonService.session.network.nodes.find(d => d._id == vlinks[i].source); - vlinks[i].target = this.commonService.session.network.nodes.find(d => d._id == vlinks[i].target); + vlinks[i].source = this.getSessionNetworkNodeByEndpoint(nodes, vlinks[i].source); + vlinks[i].target = this.getSessionNetworkNodeByEndpoint(nodes, vlinks[i].target); } return vlinks; }; @@ -2741,9 +3449,10 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic * Generate a tabular HTML string from the data array * @param data [ [Col1, ...], ...] - An Array of arrays where arrays within outer array represent different rows and * values within inner array represent the cells within that row + * @param headers Optional table header cells * @returns an HTML string with a table representation of the data */ - tabulate(data: any[]) { + tabulate(data: any[], headers: any[] = []) { let tableHtml = ` - `; + `; + + if (headers.length > 0) { + tableHtml += ''; + for (let header of headers) { + tableHtml += '' + header + ''; + } + tableHtml += ''; + } + + tableHtml += ''; for (let row of data) { tableHtml += ''; @@ -2801,15 +3520,35 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic this.selectedNodeId = d.id; } - let tt_var_len = this.widgets['node-tooltip-variable'].length let tooltipHtml: string; - if (tt_var_len == 0) { - return null; - } else if (tt_var_len == 1) { - tooltipHtml = `${d[this.widgets['node-tooltip-variable'][0]]}` + if (d.isCollapsedAggregate) { + const totalCount = Number(d.totalCount || 0); + const colorVariable = this.commonService.capitalize(this.widgets['node-color-variable']); + const collapseMetric = this.getNodeCollapseMetric(); + const meanDistanceLabel = `Mean Distance (${this.getNodeCollapseMetricLabel(collapseMetric)})`; + const meanDistanceValue = this.formatNodeCollapseDistanceForDisplay(d.meanInternalDistance, collapseMetric); + const countRows = (d.counts || []).map(count => { + const countValue = Number(count.count || 0); + const percent = totalCount > 0 ? (countValue / totalCount * 100).toFixed(1) + '%' : '0.0%'; + return [count.label, countValue, percent]; + }); + + tooltipHtml = this.tabulate([ + ...countRows, + ['Total', totalCount, ''], + [meanDistanceLabel, meanDistanceValue, ''] + ], [colorVariable, 'Count', '%']); } else { - tooltipHtml = this.tabulate(this.widgets['node-tooltip-variable'].map(variable => [this.titleize(variable), d[variable]])) + let tt_var_len = this.widgets['node-tooltip-variable'].length + + if (tt_var_len == 0) { + return null; + } else if (tt_var_len == 1) { + tooltipHtml = `${d[this.widgets['node-tooltip-variable'][0]]}` + } else { + tooltipHtml = this.tabulate(this.widgets['node-tooltip-variable'].map(variable => [this.titleize(variable), d[variable]])) + } } let [X, Y] = this.getRelativeMousePosition(event); @@ -3393,6 +4132,10 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic getNodeSize(node: any) { + if (node?.isCollapsedAggregate) { + return Number(node.nodeSize); + } + let sizeVariable = this.widgets['node-radius-variable']; if (sizeVariable == 'None') { @@ -3423,6 +4166,13 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic } getNodeColor(node: any): [string, number] { + if (node?.isCollapsedAggregate) { + return [ + node.nodeColor || this.widgets['node-color'], + Number.isFinite(Number(node.bgOpacity)) ? Number(node.bgOpacity) : 1 + ]; + } + // If this node is a parent (polygon/group), keep using polygonColorMap if (node.isParent) { if (!this.commonService.session.style.widgets['polygons-color-show']) { @@ -3512,6 +4262,9 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic getNodeLabel(node: any) { // If no label variable then should be none + if (node?.isCollapsedAggregate) { + return (this.widgets['node-label-variable'] == 'None') ? '' : (node.label || `${node.totalCount || 0} nodes`); + } return (this.widgets['node-label-variable'] == 'None') ? '' : (String(node[this.widgets['node-label-variable']]) || ''); } @@ -3544,6 +4297,14 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic return usePercentageDisplay ? `${formattedValue}%` : formattedValue; } + private formatNodeCollapseDistanceForDisplay(value: any, metric: string): string { + if (String(metric || '').toLowerCase() === 'snps') { + return this.commonService.formatDisplayedDistanceValue(value, 'distance', { decimals: 0 }); + } + + return this.commonService.formatDisplayedDistanceValue(value, metric); + } + /** * Gets the label for a link based on link label variable * @param link the link we retrieve to get the value of the variable @@ -3757,6 +4518,10 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic public onNodeRadiusChange(e) { this.widgets['node-radius'] = this.SelectedNodeRadiusSizeVariable; + if (this.isNodeCollapseEnabled() && this.cy) { + this.refreshNodeCollapseRender(); + return; + } this.updateNodeSizes(); // Update node sizes without rerendering the entire network } @@ -3783,7 +4548,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic if (!timelineTick) { // If the network is in the middle of rendering, don't rerender - if(this.commonService.session.network.rendering) { + if(this.isNetworkRendering()) { this.recordTwoDRenderTiming('twoDRerenderSkipped', rerenderStart, { reason: 'already-rendering', timelineTick @@ -3792,7 +4557,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic } // Set rendering to true to prevent actions during rerendering - this.commonService.session.network.rendering = true; + this.setNetworkRendering(true); // Set rendered to false so to prevent other changes. Needed to check to differentiate network has rendered for the first time vs checking if rendering is false this.store.setNetworkRendered(false); @@ -3800,46 +4565,33 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic const collectDataStart = this.getPerformanceNow(); let networkData = this.getVisibleNetworkDataForRender(timelineTick || this.isTimelineFilteringActive()); + this.normalizeNetworkDataForCytoscape(networkData, false); + networkData = this.applyNodeCollapseToNetworkData(networkData); + this.normalizeNetworkDataForCytoscape(networkData, false); this.recordTwoDRenderTiming('twoDCollectVisibleGraphData', collectDataStart, { timelineTick, nodes: networkData.nodes.length, links: networkData.links.length }); - - // Need to convert source and target to string ids for cytoscape - networkData.links.forEach((link) => { - // If link.source is an object, grab its _id and convert to string - if (typeof link.source === 'object') { - link.source = link.source._id.toString(); - } - - // Same for link.target - if (typeof link.target === 'object') { - link.target = link.target._id.toString(); - } - }); - - const nodeIds = new Set(networkData.nodes.map(n => n.id)); - - // Instead of calling synchronously, await the precomputation: if (!this.cy) { const precomputeStart = this.getPerformanceNow(); const initialLayout = await this.precomputePositionsWithD3(networkData.nodes, networkData.links, 300); - const refinementLayout = await this.precomputePositionsWithD3(initialLayout.nodes, initialLayout.links, 5, false); + const refinementTicks = this.isNodeCollapseEnabled() ? 60 : 5; + const refinementLayout = await this.precomputePositionsWithD3(initialLayout.nodes, initialLayout.links, refinementTicks, false); const { nodes: laidOutNodes, links: laidOutLinks } = refinementLayout; this.recordTwoDRenderTiming('twoDPrecomputePositions', precomputeStart, { nodes: laidOutNodes.length, links: laidOutLinks.length, - ticks: 305, + ticks: 300 + refinementTicks, tickBatches: initialLayout.tickBatches + refinementLayout.tickBatches, initialTicksPerYield: initialLayout.ticksPerYield, refinementTicksPerYield: refinementLayout.ticksPerYield }); if (this.isDestroyed || !this.cyContainer?.nativeElement) { - this.commonService.session.network.rendering = false; + this.setNetworkRendering(false); return; } @@ -3850,36 +4602,19 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic // Update networkData with the precomputed positions networkData.nodes = laidOutNodes; networkData.links = laidOutLinks; - - - networkData.links.forEach((link, i) => { - // If link.source is an object, grab its _id and convert to string - if (typeof link.source === 'object') { - link.source = link.source._id.toString(); - } - - // Same for link.target - if (typeof link.target === 'object') { - link.target = link.target._id.toString(); - } - }); - - - networkData.links.forEach(link => { - if (!nodeIds.has(link.source)) { - console.warn('Link source not found in nodes:', link.source, link); - } - if (!nodeIds.has(link.target)) { - console.warn('Link target not found in nodes:', link.target, link); - } - }); } + this.normalizeNetworkDataForCytoscape(networkData); + // Update Cytoscape visualization if it exists if (this.cy && !timelineTick) { await this._partialUpdate(); + if (!this.cy || this.isDestroyed || !this.isCytoscapeUsable(this.cy)) { + this.setNetworkRendering(false); + return; + } this.ensurePolygon(); this.recordTwoDRenderTiming('twoDRerender', rerenderStart, { mode: 'partial', @@ -4277,7 +5012,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic // Mark as rendered this.store.setNetworkRendered(true); this.store.setNetworkUpdated(false); - this.commonService.session.network.rendering = false; + this.setNetworkRendering(false); this.commonService.demoNetworkRendered = true; if (this.pendingPartialUpdate) { @@ -4327,6 +5062,10 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic } public getNodeShape(node: any) { + if (node?.isCollapsedAggregate) { + return 'ellipse'; + } + return resolveNodeShapeForNode( node, this.commonService.session.style.widgets, @@ -4417,8 +5156,21 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic }); } + public refreshDistanceMetricSettings(): void { + this.ensureNodeCollapseWidgetDefaults(); + this.updateLinkLabels(); + this.syncNodeCollapseControlsFromWidgets(); + this.cdref.detectChanges(); + + if (this.isNodeCollapseEnabled()) { + this.refreshNodeCollapseRender(); + } + } + refreshDistanceDisplayFormat(): void { this.updateLinkLabels(); + this.syncNodeCollapseControlsFromWidgets(); + this.cdref.detectChanges(); } /** @@ -4838,6 +5590,10 @@ private updateArrowStyles(): void { if(!this.cy) return; + if (this.isNodeCollapseEnabled()) { + this.refreshNodeCollapseRender(); + return; + } let variable = this.widgets['node-color-variable']; let color = this.widgets['node-color'] @@ -4965,6 +5721,7 @@ scaleLinkWidth() { (this.Node2DNetworkExportDialogSettings.isVisible) ? this.Node2DNetworkExportDialogSettings.setVisibility(false) : this.Node2DNetworkExportDialogSettings.setVisibility(true); this.ShowStatistics = !this.Show2DSettingsPane; this.updateLinkWidthRows(this.SelectedLinkWidthByVariable); + this.syncNodeCollapseControlsFromWidgets(); } /** @@ -5062,22 +5819,20 @@ scaleLinkWidth() { // Retrieve fresh node/link data. Timeline renders only links whose // endpoints are currently timeline-visible, matching the statistics panel. const collectDataStart = this.getPerformanceNow(); - const networkData = this.getVisibleNetworkDataForRender(); + let networkData = this.getVisibleNetworkDataForRender(); this.recordTwoDRenderTiming('twoDPartialCollectVisibleGraphData', collectDataStart, { nodes: networkData.nodes.length, links: networkData.links.length }); - // Add nodeSize to each node so that infomration can be used with calcuating node position - if (this.SelectedNodeRadiusVariable == 'None') { - networkData.nodes.forEach(node => { - node.nodeSize = Number(this.widgets['node-radius']); - }) - } else { - networkData.nodes.forEach(node => { - node.nodeSize = Number(cy.nodes().getElementById(node._id).data('nodeSize')); - }) - } + // Keep layout collision sizing in sync with current style widgets. + networkData.nodes.forEach(node => { + node.nodeSize = Number(this.getNodeSize(node)); + }); + this.normalizeNetworkDataForCytoscape(networkData, false); + networkData = this.applyNodeCollapseToNetworkData(networkData); + this.normalizeNetworkDataForCytoscape(networkData); + const precomputeStart = this.getPerformanceNow(); const partialLayout = await this.precomputePositionsWithD3(networkData.nodes, networkData.links, 30, false); const { nodes: laidOutNodes, links: laidOutLinks } = partialLayout; @@ -5088,6 +5843,7 @@ scaleLinkWidth() { networkData.nodes = laidOutNodes; networkData.links = laidOutLinks; + this.normalizeNetworkDataForCytoscape(networkData); this.recordTwoDRenderTiming('twoDPartialPrecomputePositions', precomputeStart, { nodes: laidOutNodes.length, links: laidOutLinks.length, @@ -5099,35 +5855,6 @@ scaleLinkWidth() { // Use batch mode to disable auto-panning during updates const batchStart = this.getPerformanceNow(); cy.batch(() => { - - networkData.nodes.forEach(node => { - node.id = node._id.toString(); - }); - networkData.links.forEach((link, i) => { - // Set a unique link id if desired - //link.id = i.toString(); // or link.index.toString() - // If link.source is an object, grab its _id and convert to string - if (typeof link.source === 'object') { - link.source = link.source._id.toString(); - } - - // Same for link.target - if (typeof link.target === 'object') { - link.target = link.target._id.toString(); - } - }); - - const nodeIds = new Set(networkData.nodes.map(n => n.id)); - - networkData.links.forEach(link => { - if (!nodeIds.has(link.source)) { - console.warn('Link source not found in nodes:', link.source, link); - } - if (!nodeIds.has(link.target)) { - console.warn('Link target not found in nodes:', link.target, link); - } - }); - if (this.debugMode) { console.log('--- TwoDComponent _partialUpdate called: ', networkData.links); } @@ -5142,10 +5869,8 @@ scaleLinkWidth() { // @ts-ignore const newLinkIds = new Set(newElements.edges.map(l => l.data.id)); - let cyNodeCount = 0; // Update node visibility and restore positions cy.nodes().forEach(node => { - if (!node.hasClass('parent')) { cyNodeCount += 1;} if (!newNodeIds.has(node.id()) && !node.hasClass('parent')) { // Hide node but keep its cached position node.addClass('hidden'); @@ -5162,21 +5887,14 @@ scaleLinkWidth() { } }); - // some series of operations (ie. min-cluster size set to 2, then playing timeline, then setting min-cluster size back to) led to nodes being removed from - // this.cy.nodes, this checks and adds them back - if (cyNodeCount < newElements.nodes.length) { - let countd = 0; - newElements.nodes.forEach(n => { - const cyNode = cy.getElementById(n.data.id); - if (!cyNode || !cyNode.length) { - countd += 1; - cy.add(n); // Add node - } else { - return - } - - }); - } + // Always add missing render IDs. Collapse can reduce total nodes while + // introducing new aggregate IDs, so count-based checks are insufficient. + newElements.nodes.forEach(n => { + const cyNode = cy.getElementById(n.data.id); + if (!cyNode || !cyNode.length) { + cy.add(n); + } + }); // Remove old edges cy.edges().forEach(edge => { @@ -5190,9 +5908,13 @@ scaleLinkWidth() { // Add/Update new edges newElements.edges.forEach(e => { - const cyEdge = cy.getElementById(e.data.id); + let cyEdge = cy.getElementById(e.data.id); if (!cyEdge || !cyEdge.length) { - cy.add(e); // Add edge + cyEdge = cy.add(e); // Add edge + } else if (cyEdge.source().id() !== e.data.source || cyEdge.target().id() !== e.data.target) { + // Cytoscape edge endpoints are not retargeted by mutating data. + cy.remove(cyEdge); + cyEdge = cy.add(e); } else { cyEdge.data({ ...cyEdge.data(), ...e.data }); // Update edge data } @@ -5235,7 +5957,7 @@ scaleLinkWidth() { this.store.setNetworkRendered(true); // Now we can set network update to false after its been updated fully this.store.setNetworkUpdated(false); - this.commonService.session.network.rendering = false; + this.setNetworkRendering(false); this.recordTwoDRenderTiming('twoDPartialUpdate', partialUpdateStart, { nodes: cy.nodes().length, edges: cy.edges().length @@ -5245,6 +5967,7 @@ scaleLinkWidth() { applyStyleFileSettings() { this.widgets = this.commonService.session.style.widgets; + this.ensureNodeCollapseWidgetDefaults(); this.loadSettings(); this._partialUpdate(); } @@ -5256,7 +5979,7 @@ scaleLinkWidth() { this.pendingPartialUpdate = false; this.destroy$.next(); this.destroy$.complete(); - this.commonService.session.network.rendering = false; + this.setNetworkRendering(false); this.styleFileSub.unsubscribe(); @@ -5291,6 +6014,7 @@ scaleLinkWidth() { console.log('onLoadNewData'); this.widgets = this.commonService.session.style.widgets; + this.ensureNodeCollapseWidgetDefaults(); this.IsDataAvailable = (this.commonService.session.data.nodes.length > 0); if (!this.IsDataAvailable) { @@ -5343,6 +6067,7 @@ scaleLinkWidth() { * (ie. onPolygonLabelVariableChange, onPolygonLabelVariableChange, onPolygonLabelOrientationChange all call redrawPolygonLabels) XXXXX */ loadSettings() { + this.ensureNodeCollapseWidgetDefaults(); //Polygons|Label Size this.SelectedPolygonLabelSizeVariable = this.widgets['polygons-label-size']; @@ -5396,6 +6121,7 @@ scaleLinkWidth() { this.onNodeRadiusChange(this.SelectedNodeRadiusSizeVariable); this.nodeBorderWidth = this.widgets['node-border-width'] + this.syncNodeCollapseControlsFromWidgets(); //Links|Tooltip this.SelectedLinkTooltipVariable = this.widgets['link-tooltip-variable']; @@ -5596,6 +6322,13 @@ scaleLinkWidth() { return; } const fullNode = this.getFullNodeDataForCyNode(node); + if (fullNode?.isCollapsedAggregate) { + node.data('shapeKey', 'ellipse'); + node.data('shape', 'ellipse'); + node.removeData('iconBackgroundImage'); + node.removeData('customIconKey'); + return; + } const shapeKey = this.getNodeShape(fullNode); node.data('shapeKey', shapeKey); node.data('shape', resolveCustomNodeIconCytoscapeShape(shapeKey));