diff --git a/cypress/e2e/journeys/flows/heatmap-background-uploaded.cy.ts b/cypress/e2e/journeys/flows/heatmap-background-uploaded.cy.ts index b4719643..bf4f7dcc 100644 --- a/cypress/e2e/journeys/flows/heatmap-background-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/heatmap-background-uploaded.cy.ts @@ -24,6 +24,7 @@ const hexToRgb = (hex: string): string => { }; const openHeatmapAccordion = (panelValue: HeatmapAccordionPanel): void => { + cy.get('@heatmapSettings').contains('.nav-link', 'Appearance').click({ force: true }); cy.get('@heatmapSettings') .find(`p-accordion-panel[value="${panelValue}"] .p-accordionheader`) .first() diff --git a/cypress/e2e/journeys/flows/heatmap-controls-uploaded.cy.ts b/cypress/e2e/journeys/flows/heatmap-controls-uploaded.cy.ts index 74120d07..49714d82 100644 --- a/cypress/e2e/journeys/flows/heatmap-controls-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/heatmap-controls-uploaded.cy.ts @@ -13,6 +13,7 @@ type HeatmapAccordionPanel = 'heatmap-invert' | 'heatmap-labels' | 'heatmap-colo type HeatmapImageFileType = 'png' | 'jpeg' | 'svg'; function openHeatmapAccordion(panelValue: HeatmapAccordionPanel): void { + cy.get('@heatmapSettings').contains('.nav-link', 'Appearance').click({ force: true }); cy.get('@heatmapSettings') .find(`p-accordion-panel[value="${panelValue}"] .p-accordionheader`) .first() diff --git a/cypress/e2e/journeys/flows/heatmap-custom-config-uploaded.cy.ts b/cypress/e2e/journeys/flows/heatmap-custom-config-uploaded.cy.ts new file mode 100644 index 00000000..c40e7676 --- /dev/null +++ b/cypress/e2e/journeys/flows/heatmap-custom-config-uploaded.cy.ts @@ -0,0 +1,333 @@ +/// + +import type { DatasetProfile } from '../datasets/profile'; +import { + assertHeatmapReady, + ensureHeatmapView, + launchProfileToHeatmap, + openHeatmapSettingsDialog, + saveSessionFromFileMenu, + visitAppAndAcceptEula, + waitForProcessingDialogToClear, +} from '../../../support/journey-helpers'; + +type HeatmapImageFileType = 'png' | 'jpeg' | 'svg'; + +const nodeProfile: DatasetProfile = { + id: 'heatmap-custom-hi-node', + title: 'Heatmap: custom HI node rows render selected X/Y/value fields', + tags: ['heatmap', 'custom-config', 'node'], + files: [ + { + name: 'Heatmap_Custom_HI_Node.csv', + datatype: 'node', + field1: 'row_id', + field2: 'None', + }, + ], + preLaunch: { + metric: 'snps', + threshold: 16, + defaultView: '2D Network', + }, + expectations: {}, +}; + +const linkProfile: DatasetProfile = { + id: 'heatmap-custom-assay-link', + title: 'Heatmap: custom link rows render selected value field', + tags: ['heatmap', 'custom-config', 'link'], + files: [ + { + name: 'Heatmap_Custom_Assay_Links.csv', + datatype: 'link', + field1: 'source', + field2: 'target', + field3: 'None', + }, + ], + preLaunch: { + metric: 'snps', + threshold: 16, + defaultView: '2D Network', + }, + expectations: {}, +}; + +function escapeForRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function openHeatmapConfigurationTab(): void { + cy.get('@heatmapSettings').contains('.nav-link', 'Configuration').click({ force: true }); +} + +function openHeatmapAppearanceTab(): void { + cy.get('@heatmapSettings').contains('.nav-link', 'Appearance').click({ force: true }); +} + +function openHeatmapAccordion(panelValue: 'heatmap-labels' | 'heatmap-color'): void { + openHeatmapAppearanceTab(); + cy.get('@heatmapSettings') + .find(`p-accordion-panel[value="${panelValue}"] .p-accordionheader`) + .first() + .then(($header) => { + if ($header.attr('aria-expanded') !== 'true') { + cy.wrap($header).click({ force: true }); + } + }); +} + +function selectHeatmapOption(selectId: string, optionLabel: string): void { + cy.get('@heatmapSettings').find(selectId).click({ force: true }); + cy.contains('li[role="option"]', new RegExp(`^${escapeForRegex(optionLabel)}$`), { timeout: 15000 }) + .click({ force: true }); +} + +function setSelectButtonValue(controlSelector: string, value: 'Yes' | 'No'): void { + const targetIndex = value === 'Yes' ? 0 : 1; + + cy.get('@heatmapSettings') + .find(controlSelector) + .find('p-togglebutton') + .eq(targetIndex) + .click({ force: true }); +} + +function setColorInput(inputSelector: string, color: string): void { + cy.get('@heatmapSettings').find(inputSelector).then(($input) => { + const input = $input.get(0) as HTMLInputElement; + input.value = color; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }); +} + +function configureNodeHeatmap(options: { + sortBy?: string; + value: string; +}): void { + openHeatmapSettingsDialog(); + openHeatmapConfigurationTab(); + selectHeatmapOption('#heatmap-x-variable', 'Serum'); + selectHeatmapOption('#heatmap-y-variable', 'Isolate ID'); + selectHeatmapOption('#heatmap-value-variable', options.value); + + if (options.sortBy) { + selectHeatmapOption('#heatmap-sort-by', options.sortBy); + } + + cy.closeSettingsPane('Heatmap Settings'); + assertHeatmapReady(); +} + +function openHeatmapExportDialog(): void { + cy.get('heatmapcomponent #tool-btn-container a[title="Export Screen"]:visible').click({ force: true }); + cy.contains('.p-dialog-title', 'Export Heatmap') + .should('be.visible') + .parents('.p-dialog') + .as('heatmapExportDialog'); +} + +function setHeatmapExportFileType(fileType: HeatmapImageFileType): void { + cy.get('@heatmapExportDialog').find('#network-export-filetype').click({ force: true }); + cy.contains('li[role="option"]', new RegExp(`^${fileType}$`, 'i')).click({ force: true }); +} + +function captureHeatmapDownloadBlobs(): void { + cy.window().then((win: any) => { + const originalCreateObjectURL = win.URL.createObjectURL.bind(win.URL); + win.__lastSavedBlob = null; + + cy.stub(win.URL, 'createObjectURL').callsFake((blob: Blob) => { + win.__lastSavedBlob = blob; + return originalCreateObjectURL(blob); + }).as('customHeatmapCreateObjectUrl'); + }); +} + +function assertCapturedCsvRows(expectedRows: string[][]): void { + cy.window().then((win: any) => { + const csvBlob = win.__lastSavedBlob as Blob | null; + + expect(csvBlob, 'captured heatmap CSV blob').to.exist; + return csvBlob!.text().then((csvText: string) => { + const rows = csvText.trim().split(/\r?\n/).map((row) => row.split(',')); + expect(rows, 'custom heatmap CSV rows').to.deep.equal(expectedRows); + }); + }); +} + +describe('Journey Flow - Custom Heatmap configuration on uploaded data', () => { + it('renders node-backed numeric values with sorting, missing color, duplicate warning, and configured export', () => { + const missingColor = '#123456'; + + launchProfileToHeatmap(nodeProfile); + configureNodeHeatmap({ + value: 'HI Titer', + sortBy: 'Sort Order', + }); + + openHeatmapSettingsDialog(); + openHeatmapAccordion('heatmap-labels'); + setSelectButtonValue('#show-labels', 'Yes'); + openHeatmapAccordion('heatmap-color'); + setColorInput('#missing-color', missingColor); + cy.closeSettingsPane('Heatmap Settings'); + + cy.window().should((win: any) => { + const heatmap = win.commonService.visuals.heatmap; + const trace = heatmap.heatmapData[0]; + const missingTrace = heatmap.heatmapData[1]; + const duplicateWarning = win.commonService.session.warnings + .find((warning: any) => warning.id === 'heatmap-duplicate-cells'); + + expect(win.commonService.session.style.widgets['heatmap-x-variable']).to.equal('Serum'); + expect(win.commonService.session.style.widgets['heatmap-y-variable']).to.equal('Isolate ID'); + expect(win.commonService.session.style.widgets['heatmap-value-source']).to.equal('node'); + expect(win.commonService.session.style.widgets['heatmap-value-variable']).to.equal('HI_Titer'); + expect(win.commonService.session.style.widgets['heatmap-sort-by']).to.equal('Sort_Order'); + expect(win.commonService.session.style.widgets['heatmap-summary-statistic']).to.equal('None'); + expect(win.commonService.session.style.widgets['heatmap-color-missing']).to.equal(missingColor); + + expect(trace.x, 'node-backed sorted x labels').to.deep.equal(['S2', 'S1']); + expect(trace.y, 'node-backed sorted y labels').to.deep.equal(['Virus B', 'Virus A']); + expect(trace.z, 'node-backed numeric z values').to.deep.equal([ + [20, 80], + [null, 40], + ]); + expect(trace.customdata, 'node-backed hover values').to.deep.equal([ + ['20', '80'], + ['No Data', '40'], + ]); + expect(trace.hoverongaps, 'node-backed hover skips missing main cells').to.equal(false); + expect(trace.hovertemplate, 'node-backed hover uses native axis labels') + .to.equal('X: %{x}
Y: %{y}
HI Titer: %{customdata}'); + expect(trace.colorbar.ticktext, 'node-backed numeric legend labels').to.include.members(['20', '40', '80']); + expect(trace.colorbar.ticktext, 'node-backed numeric legend excludes literal null').to.not.include('null'); + expect(trace.colorbar.y, 'node-backed colorbar is lowered when No Data legend is present').to.be.lessThan(0.5); + expect(missingTrace.name, 'missing trace legend label').to.equal('No Data'); + expect(missingTrace.hoverongaps, 'missing hover skips non-missing overlay gaps').to.equal(false); + expect(missingTrace.hovertemplate, 'missing hover uses native axis labels') + .to.equal('X: %{x}
Y: %{y}
HI Titer: No Data'); + expect(missingTrace.legendrank, 'missing trace legend order').to.equal(0); + expect(missingTrace.colorscale, 'missing trace colorscale').to.deep.equal([ + [0, missingColor], + [1, missingColor], + ]); + expect(duplicateWarning?.duplicateCount, 'duplicate heatmap cells').to.equal(1); + }); + + captureHeatmapDownloadBlobs(); + openHeatmapExportDialog(); + cy.get('@heatmapExportDialog') + .find('#distance-matrix-filename') + .clear({ force: true }) + .type('custom_heatmap_matrix.csv', { force: true }); + setHeatmapExportFileType('png'); + cy.get('@heatmapExportDialog').find('#export-distance-matrix').click({ force: true }); + + assertCapturedCsvRows([ + ['', 'S2', 'S1'], + ['Virus B', '20', '80'], + ['Virus A', '', '40'], + ]); + }); + + it('renders node-backed categorical values with categorical legend labels', () => { + launchProfileToHeatmap(nodeProfile); + configureNodeHeatmap({ + value: 'Phenotype', + }); + + cy.window().should((win: any) => { + const heatmap = win.commonService.visuals.heatmap; + const trace = heatmap.heatmapData[0]; + const duplicateWarning = win.commonService.session.warnings + .find((warning: any) => warning.id === 'heatmap-duplicate-cells'); + + expect(trace.x, 'categorical x labels').to.deep.equal(['S1', 'S2']); + expect(trace.y, 'categorical y labels').to.deep.equal(['Virus A', 'Virus B']); + expect(trace.z, 'categorical z values').to.deep.equal([ + [0, null], + [1, 0], + ]); + expect(trace.colorbar.ticktext, 'categorical legend labels').to.deep.equal(['Low', 'Medium']); + expect(trace.colorbar.ticktext, 'categorical legend excludes literal null').to.not.include('null'); + expect(heatmap.heatmapData[1]?.name, 'categorical missing trace').to.equal('No Data'); + expect(duplicateWarning?.duplicateCount, 'categorical duplicate heatmap cells').to.equal(1); + }); + }); + + it('renders link-backed custom values using source and target axes', () => { + launchProfileToHeatmap(linkProfile); + + openHeatmapSettingsDialog(); + openHeatmapConfigurationTab(); + selectHeatmapOption('#heatmap-value-variable', 'AssayScore'); + cy.closeSettingsPane('Heatmap Settings'); + assertHeatmapReady(); + + cy.window().should((win: any) => { + const heatmap = win.commonService.visuals.heatmap; + const trace = heatmap.heatmapData[0]; + const missingTrace = heatmap.heatmapData[1]; + + expect(win.commonService.session.style.widgets['heatmap-value-source']).to.equal('link'); + expect(win.commonService.session.style.widgets['heatmap-value-variable']).to.equal('AssayScore'); + expect(trace.x, 'link-backed x labels').to.deep.equal(['Virus A', 'Virus B']); + expect(trace.y, 'link-backed y labels').to.deep.equal(['Serum 1', 'Serum 2']); + expect(trace.z, 'link-backed z values').to.deep.equal([ + [5, 3], + [null, null], + ]); + expect(missingTrace.name, 'link-backed missing trace').to.equal('No Data'); + }); + }); + + it('restores custom Heatmap configuration and missing-data color after a session round trip', () => { + const sessionFileBase = `cypress_heatmap_custom_session_${Date.now()}`; + const sessionFilePath = `${Cypress.config('downloadsFolder')}/${sessionFileBase}.microbetrace`; + const missingColor = '#654321'; + + launchProfileToHeatmap(nodeProfile); + configureNodeHeatmap({ + value: 'HI Titer', + sortBy: 'Sort Order', + }); + + openHeatmapSettingsDialog(); + openHeatmapAccordion('heatmap-color'); + setColorInput('#missing-color', missingColor); + cy.closeSettingsPane('Heatmap Settings'); + + saveSessionFromFileMenu(sessionFileBase); + cy.readFile(sessionFilePath, 'utf8', { timeout: 30000 }).should((savedSession) => { + expect(savedSession, 'saved custom Heatmap session').to.include('"heatmap-value-source":"node"'); + expect(savedSession, 'saved custom Heatmap missing color').to.include(`"heatmap-color-missing":"${missingColor}"`); + }); + + visitAppAndAcceptEula(); + cy.get('#fileDropRef', { timeout: 15000 }).selectFile(sessionFilePath, { force: true }); + waitForProcessingDialogToClear(30000); + ensureHeatmapView(); + + cy.window().should((win: any) => { + const widgets = win.commonService.session.style.widgets; + const trace = win.commonService.visuals.heatmap.heatmapData[0]; + + expect(widgets['heatmap-x-variable']).to.equal('Serum'); + expect(widgets['heatmap-y-variable']).to.equal('Isolate ID'); + expect(widgets['heatmap-value-source']).to.equal('node'); + expect(widgets['heatmap-value-variable']).to.equal('HI_Titer'); + expect(widgets['heatmap-sort-by']).to.equal('Sort_Order'); + expect(widgets['heatmap-color-missing']).to.equal(missingColor); + expect(trace.x, 'restored custom x labels').to.deep.equal(['S2', 'S1']); + expect(trace.y, 'restored custom y labels').to.deep.equal(['Virus B', 'Virus A']); + expect(trace.z, 'restored custom z values').to.deep.equal([ + [20, 80], + [null, 40], + ]); + }); + }); +}); diff --git a/cypress/e2e/journeys/flows/heatmap-session-roundtrip.cy.ts b/cypress/e2e/journeys/flows/heatmap-session-roundtrip.cy.ts index 27d0f3ea..efd5358f 100644 --- a/cypress/e2e/journeys/flows/heatmap-session-roundtrip.cy.ts +++ b/cypress/e2e/journeys/flows/heatmap-session-roundtrip.cy.ts @@ -15,6 +15,7 @@ import { byTestId, testIds } from '../../../support/selectors'; type HeatmapAccordionPanel = 'heatmap-invert' | 'heatmap-labels' | 'heatmap-color'; const openHeatmapAccordion = (panelValue: HeatmapAccordionPanel): void => { + cy.get('@heatmapSettings').contains('.nav-link', 'Appearance').click({ force: true }); cy.get('@heatmapSettings') .find(`p-accordion-panel[value="${panelValue}"] .p-accordionheader`) .first() diff --git a/cypress/e2e/view-state/heatmap-view.cy.ts b/cypress/e2e/view-state/heatmap-view.cy.ts index 1615a52e..7e96cc15 100644 --- a/cypress/e2e/view-state/heatmap-view.cy.ts +++ b/cypress/e2e/view-state/heatmap-view.cy.ts @@ -22,6 +22,7 @@ type WinWithMT = Window & { }; const openHeatmapAccordion = (panelValue: HeatmapAccordionPanel): void => { + cy.get('@heatmapSettings').contains('.nav-link', 'Appearance').click({ force: true }); cy.get('@heatmapSettings') .find(`p-accordion-panel[value="${panelValue}"] .p-accordionheader`) .first() diff --git a/cypress/fixtures/Heatmap_Custom_Assay_Links.csv b/cypress/fixtures/Heatmap_Custom_Assay_Links.csv new file mode 100644 index 00000000..9bee0845 --- /dev/null +++ b/cypress/fixtures/Heatmap_Custom_Assay_Links.csv @@ -0,0 +1,4 @@ +source,target,AssayScore,Relationship +Virus A,Serum 1,5,Reactive +Virus B,Serum 1,3,Weak +Virus A,Serum 2,,No Result diff --git a/cypress/fixtures/Heatmap_Custom_HI_Node.csv b/cypress/fixtures/Heatmap_Custom_HI_Node.csv new file mode 100644 index 00000000..19981567 --- /dev/null +++ b/cypress/fixtures/Heatmap_Custom_HI_Node.csv @@ -0,0 +1,6 @@ +row_id,Serum,Isolate ID,HI_Titer,Phenotype,Sort_Order +row-1,S1,Virus A,40,Low,2 +row-2,S1,Virus A,160,High,9 +row-3,S1,Virus B,80,Medium,1 +row-4,S2,Virus A,null,null,1 +row-5,S2,Virus B,20,Low,1 diff --git a/cypress/support/journey-helpers.ts b/cypress/support/journey-helpers.ts index d745dbb4..cac421fa 100644 --- a/cypress/support/journey-helpers.ts +++ b/cypress/support/journey-helpers.ts @@ -2072,7 +2072,7 @@ export function assertHeatmapMatchesBackingMatrix(options: { }; expect(heatmapView.heatmapMetric, 'heatmap metric label').to.equal(expectedMetric); - expect(heatmapView.heatmapData, 'heatmap traces').to.have.length(1); + expect(heatmapView.heatmapData, 'heatmap traces').to.have.length.greaterThan(0); const trace = heatmapView.heatmapData[0]; expect(trace.type, 'heatmap trace type').to.equal('heatmap'); diff --git a/src/app/contactTraceCommonServices/common.service.ts b/src/app/contactTraceCommonServices/common.service.ts index 3dcfddfe..90cfb846 100644 --- a/src/app/contactTraceCommonServices/common.service.ts +++ b/src/app/contactTraceCommonServices/common.service.ts @@ -400,7 +400,14 @@ export class CommonService extends AppComponentBase implements OnInit { 'heatmap-color-high': '#a50026', 'heatmap-color-medium': '#ffffbf', 'heatmap-color-low': '#313695', + 'heatmap-color-missing': '#d9d9d9', 'heatmap-axislabels-show': false, + 'heatmap-x-variable': '_id', + 'heatmap-y-variable': '_id', + 'heatmap-value-source': 'distance', + 'heatmap-value-variable': 'Distance', + 'heatmap-sort-by': 'None', + 'heatmap-summary-statistic': 'None', 'histogram-axis-x': true, 'histogram-scale-log': false, 'histogram-variable': 'links-distance', diff --git a/src/app/visualizationComponents/HeatmapComponent/heatmap.component.html b/src/app/visualizationComponents/HeatmapComponent/heatmap.component.html index db74d55a..ce88a4c3 100644 --- a/src/app/visualizationComponents/HeatmapComponent/heatmap.component.html +++ b/src/app/visualizationComponents/HeatmapComponent/heatmap.component.html @@ -12,7 +12,7 @@ - Current Distance Metric: {{this.heatmapMetric}} + Current Heatmap Value: {{this.heatmapValueDisplayLabel}}
@@ -26,67 +26,111 @@ [positionLeft]="HeatmapSettingsDialogSettings.left" [positionTop]="HeatmapSettingsDialogSettings.top" [(visible)]="HeatmapSettingsDialogSettings.isVisible" - [contentStyle]="{'overflow': 'visible', 'width': '350px'}" + [contentStyle]="{'overflow': 'visible', 'width': '430px'}" header="Heatmap Settings" appendTo="body" > - - - Invert - -
-
-
-
- -
-
-
-
-
- -
-
+ + +
+
+
+
- - - - Color - -
-
-
-
- +
+
+
+
+ +
+
+
+
+
+ + + {{ group.label }} + + +
+
+
+
+
+ +
+
+ + + + + Invert + +
+
+
+
+ +
+
+
+
+
+ +
+
-
-
-
-
- + + + + Color + +
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
-
-
-
-
- + + + + Labels +
+
+
+
+ +
-
- - - - Labels -
-
-
-
- -
-
-
-
-
- + + + + + }
diff --git a/src/app/visualizationComponents/HeatmapComponent/heatmap.component.ts b/src/app/visualizationComponents/HeatmapComponent/heatmap.component.ts index f765d3c8..550ecdf2 100644 --- a/src/app/visualizationComponents/HeatmapComponent/heatmap.component.ts +++ b/src/app/visualizationComponents/HeatmapComponent/heatmap.component.ts @@ -1,11 +1,9 @@ -import { Injector, Component, Output, EventEmitter, - ElementRef, Renderer2, ChangeDetectorRef, Inject, OnInit, OnDestroy, ViewContainerRef, +import { Injector, Component, Output, EventEmitter, + ElementRef, Renderer2, ChangeDetectorRef, Inject, OnInit, OnDestroy, ViewChild} from '@angular/core'; import { EventManager } from '@angular/platform-browser'; import { CommonService } from '@app/contactTraceCommonServices/common.service'; -import * as _ from 'lodash'; import { saveAs } from 'file-saver'; -import * as domToImage from 'html-to-image'; import { BaseComponentDirective } from '@app/base-component.directive'; import { ComponentContainer } from 'golden-layout'; import { GoogleTagManagerService } from 'angular-google-tag-manager'; @@ -14,13 +12,47 @@ import { PlotlyComponent, PlotlyModule } from 'angular-plotly.js'; import { SelectItem } from 'primeng/api'; import { MicrobeTraceNextVisuals } from '../../microbe-trace-next-plugin-visuals'; import { cloneDeep } from 'lodash'; -import { ExportService } from '@app/contactTraceCommonServices/export.service'; import { buildSafeCsvRow } from '@app/contactTraceCommonServices/export-sanitization'; import { CommonStoreService } from '@app/contactTraceCommonServices/common-store.services'; import { Subject, takeUntil } from 'rxjs'; import * as d3 from 'd3'; -//import * as plotlyjs from 'plotly.js-dist-min'; +type HeatmapValueSource = 'distance' | 'node' | 'link'; +type HeatmapValueKind = 'numeric' | 'categorical'; + +interface HeatmapAxisItem { + index: number; + label: string; + sortValue?: unknown; +} + +interface HeatmapCellInput { + x: unknown; + y: unknown; + value: unknown; + xSort?: unknown; + ySort?: unknown; +} + +interface HeatmapMatrixResult { + categories: string[]; + customdata: string[][]; + duplicateCount: number; + exportValues: unknown[][]; + hasMissing: boolean; + isDistance: boolean; + kind: HeatmapValueKind; + missingZ: (number | null)[][]; + valueLabel: string; + xLabels: string[]; + yLabels: string[]; + z: (number | null)[][]; +} + +const HEATMAP_DISTANCE_LABEL = 'Distance'; +const HEATMAP_NONE = 'None'; +const HEATMAP_VALUE_SEPARATOR = '::'; +const HEATMAP_CELL_SEPARATOR = '\u0000'; @Component({ selector: 'HeatmapComponent', @@ -30,23 +62,21 @@ import * as d3 from 'd3'; }) export class HeatmapComponent extends BaseComponentDirective implements OnInit, OnDestroy { - @ViewChild('heatmapContainer', { read: ElementRef }) heatmapContainerRef: ElementRef; + @ViewChild('heatmapContainer', { read: ElementRef }) heatmapContainerRef: ElementRef; @Output() DisplayGlobalSettingsDialogEvent = new EventEmitter(); - private Plotly: any; - - labels: string[]; - //xLabels: string[]; - //yLabels: string[]; matrix: object; plot: PlotlyComponent; visuals: MicrobeTraceNextVisuals; - nodeIds: string[]; + nodeIds: string[] = []; viewActive: boolean; - heatmapData: object; + heatmapData: any[] = []; FieldList: SelectItem[] = []; - heatmapLayout: object; + AxisFieldList: SelectItem[] = []; + SortFieldList: SelectItem[] = []; + ValueFieldGroups: any[] = []; + heatmapLayout: any; heatmapConfig: object; invertX: boolean; invertY: boolean; @@ -54,122 +84,331 @@ export class HeatmapComponent extends BaseComponentDirective implements OnInit, loColor: string; medColor: string; hiColor: string; + missingColor: string; + selectedXVariable: string; + selectedYVariable: string; + selectedValueKey: string; + selectedSortBy: string; + summaryStatistic: string; HeatmapSettingsDialogSettings: DialogSettings = new DialogSettings('#heatmap-settings-pane', false); - ShowHeatmapExportPane: boolean = false; + ShowHeatmapExportPane = false; invertOptions: object = [ - { label: "Yes", value: true }, - { label: "No", value: false } + { label: 'Yes', value: true }, + { label: 'No', value: false } ]; - SelectedImageFilenameVariable = "default_heatmap"; - SelectedNetworkExportFileTypeVariable: string = "png"; + SelectedImageFilenameVariable = 'default_heatmap'; + SelectedNetworkExportFileTypeVariable = 'png'; NetworkExportFileTypeList: object = [ { label: 'png', value: 'png' }, { label: 'jpeg', value: 'jpeg' }, { label: 'svg', value: 'svg' } ]; - SelectedDistanceMatrixFilenameVariable: string = "distance_matrix.csv"; - heatmapLabels: string[]; - heatmapMetric: string; + SelectedDistanceMatrixFilenameVariable = 'distance_matrix.csv'; + heatmapLabels: string[] = []; + heatmapValueDisplayLabel = HEATMAP_DISTANCE_LABEL; + heatmapValueLabel = HEATMAP_DISTANCE_LABEL; + + private lastHeatmapMatrix: HeatmapMatrixResult | null = null; private destroy$ = new Subject(); - + constructor(injector: Injector, private eventManager: EventManager, public commonService: CommonService, - @Inject(BaseComponentDirective.GoldenLayoutContainerInjectionToken) private container: ComponentContainer, + @Inject(BaseComponentDirective.GoldenLayoutContainerInjectionToken) private container: ComponentContainer, elRef: ElementRef, private cdref: ChangeDetectorRef, private gtmService: GoogleTagManagerService, private renderer: Renderer2, - private exportService: ExportService, private plotlyModule: PlotlyModule, private store: CommonStoreService, ) { super(elRef.nativeElement); this.visuals = commonService.visuals; this.visuals.heatmap = this; - this.invertX = this.commonService.session.style.widgets['heatmap-invertX']; - this.invertY = this.commonService.session.style.widgets['heatmap-invertY']; - this.heatmapShowLabels = this.commonService.session.style.widgets['heatmap-axislabels-show']; - this.loColor = this.commonService.session.style.widgets['heatmap-color-low']; - this.medColor = this.commonService.session.style.widgets['heatmap-color-medium']; - this.hiColor = this.commonService.session.style.widgets['heatmap-color-high'] - this.heatmapMetric = this.commonService.session.style.widgets['default-distance-metric'].toUpperCase(); + this.ensureHeatmapWidgets(); + this.syncLocalSettingsFromWidgets(); } openSettings(): void { - this.visuals.heatmap.HeatmapSettingsDialogSettings.setVisibility(true); + this.HeatmapSettingsDialogSettings.setVisibility(true); + this.cdref.detectChanges(); } - + openExport(): void { this.ShowHeatmapExportPane = true; } - + openCenter(): void { + if (!this.heatmapData || this.heatmapData.length === 0) { + return; + } + const reCenter = { 'xaxis.autorange': true, 'yaxis.autorange': true - } - PlotlyModule.plotlyjs.relayout("heatmap", reCenter); + }; + PlotlyModule.plotlyjs.relayout('heatmap', reCenter); this.plot = PlotlyModule.plotlyjs.newPlot('heatmap', cloneDeep(this.heatmapData), this.heatmapLayout, this.heatmapConfig); } - - ngOnInit(): void { - + ngOnInit(): void { this.viewActive = true; this.gtmService.pushTag({ - event: "page_view", - page_location: "/heatmap", - page_title: "Heatmap View" + event: 'page_view', + page_location: '/heatmap', + page_title: 'Heatmap View' }); - //this.nodeIds = this.getNodeIds(); - this.visuals.heatmap.FieldList.push( - { - label: "None", - value: "", - } - ) - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - this.commonService.session.data['nodeFields'].map((d, i) => { - - this.visuals.heatmap.FieldList.push( - { - label: this.visuals.heatmap.commonService.capitalize(d.replace('_', '')), - value: d - }); - }); - - //this.visuals.microbeTrace.GlobalSettingsNodeColorDialogSettings.setVisibility(false); - //this.visuals.microbeTrace.GlobalSettingsLinkColorDialogSettings.setVisibility(false); - - + this.refreshFieldLists(); + this.syncLocalSettingsFromWidgets(); this.goldenLayoutComponentResize(true); - this.container.on('resize', () => { setTimeout(() => this.goldenLayoutComponentResize(), 200) }) - this.container.on('hide', () => { - this.viewActive = false; + this.container.on('resize', () => { setTimeout(() => this.goldenLayoutComponentResize(), 200); }); + this.container.on('hide', () => { + this.viewActive = false; this.cdref.detectChanges(); - }) - this.container.on('show', () => { - this.viewActive = true; + }); + this.container.on('show', () => { + this.viewActive = true; this.cdref.detectChanges(); this.redrawHeatmap(); - }) + }); this.store.networkUpdated$ .pipe(takeUntil(this.destroy$)) .subscribe((networkUpdated) => { if (this.viewActive && networkUpdated) { + this.refreshFieldLists(); + this.syncLocalSettingsFromWidgets(); this.redrawHeatmap(); this.store.setNetworkUpdated(false); } }); + this.store.styleFileApplied$ + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.applyStyleFileSettings()); + this.redrawHeatmap(); } + private get widgets(): any { + return this.commonService.session.style.widgets; + } + + private ensureHeatmapWidgets(): void { + if (!this.commonService.session.style.widgets) { + this.commonService.session.style.widgets = this.commonService.defaultWidgets(); + } + + const defaults = this.commonService.defaultWidgets(); + [ + 'heatmap-invertX', + 'heatmap-invertY', + 'heatmap-color-high', + 'heatmap-color-medium', + 'heatmap-color-low', + 'heatmap-color-missing', + 'heatmap-axislabels-show', + 'heatmap-x-variable', + 'heatmap-y-variable', + 'heatmap-value-source', + 'heatmap-value-variable', + 'heatmap-sort-by', + 'heatmap-summary-statistic', + ].forEach((key) => { + if (this.widgets[key] === undefined || this.widgets[key] === null) { + this.widgets[key] = defaults[key]; + } + }); + + if (!['distance', 'node', 'link'].includes(String(this.widgets['heatmap-value-source']))) { + this.widgets['heatmap-value-source'] = defaults['heatmap-value-source']; + this.widgets['heatmap-value-variable'] = defaults['heatmap-value-variable']; + } + + this.widgets['heatmap-summary-statistic'] = HEATMAP_NONE; + } + + private syncLocalSettingsFromWidgets(): void { + this.ensureHeatmapWidgets(); + const axisValues = this.AxisFieldList.length + ? this.AxisFieldList.map((option) => option.value) + : this.getNodeFields(); + const sortValues = this.SortFieldList.length + ? this.SortFieldList.map((option) => option.value) + : [HEATMAP_NONE, ...this.getNodeFields()]; + + this.selectedXVariable = axisValues.includes(this.widgets['heatmap-x-variable']) + ? this.widgets['heatmap-x-variable'] + : '_id'; + this.selectedYVariable = axisValues.includes(this.widgets['heatmap-y-variable']) + ? this.widgets['heatmap-y-variable'] + : '_id'; + this.selectedSortBy = sortValues.includes(this.widgets['heatmap-sort-by']) + ? this.widgets['heatmap-sort-by'] + : HEATMAP_NONE; + + const valueSource = String(this.widgets['heatmap-value-source']) as HeatmapValueSource; + const valueVariable = String(this.widgets['heatmap-value-variable'] || HEATMAP_DISTANCE_LABEL); + const selectedValueKey = this.encodeValueKey(valueSource, valueVariable); + const allowedValueKeys = this.flattenValueOptions().map((option) => option.value); + + this.selectedValueKey = allowedValueKeys.length === 0 || allowedValueKeys.includes(selectedValueKey) + ? selectedValueKey + : this.encodeValueKey('distance', HEATMAP_DISTANCE_LABEL); + + const selectedValue = this.parseValueKey(this.selectedValueKey); + this.widgets['heatmap-x-variable'] = this.selectedXVariable; + this.widgets['heatmap-y-variable'] = this.selectedYVariable; + this.widgets['heatmap-sort-by'] = this.selectedSortBy; + this.widgets['heatmap-value-source'] = selectedValue.source; + this.widgets['heatmap-value-variable'] = selectedValue.variable; + this.widgets['heatmap-summary-statistic'] = HEATMAP_NONE; + + this.invertX = Boolean(this.widgets['heatmap-invertX']); + this.invertY = Boolean(this.widgets['heatmap-invertY']); + this.heatmapShowLabels = Boolean(this.widgets['heatmap-axislabels-show']); + this.loColor = this.widgets['heatmap-color-low']; + this.medColor = this.widgets['heatmap-color-medium']; + this.hiColor = this.widgets['heatmap-color-high']; + this.missingColor = this.widgets['heatmap-color-missing']; + this.summaryStatistic = this.widgets['heatmap-summary-statistic']; + this.heatmapValueDisplayLabel = this.getHeatmapValueDisplayLabel(selectedValue); + this.heatmapValueLabel = selectedValue.source === 'distance' + ? HEATMAP_DISTANCE_LABEL + : this.getFieldLabel(selectedValue.variable); + } + + private getNodeFields(): string[] { + return this.uniqueFields(['_id', ...(this.commonService.session.data?.nodeFields || [])]); + } + + private getLinkFields(): string[] { + return this.uniqueFields(this.commonService.session.data?.linkFields || []); + } + + private uniqueFields(fields: string[]): string[] { + const seen = new Set(); + const output: string[] = []; + + fields.forEach((field) => { + const normalizedField = String(field || '').trim(); + const key = normalizedField.toLowerCase(); + if (!normalizedField || seen.has(key)) { + return; + } + seen.add(key); + output.push(normalizedField); + }); + + return output; + } + + private getFieldLabel(field: string): string { + if (field === HEATMAP_NONE) { + return HEATMAP_NONE; + } + if (field === '_id') { + return 'ID'; + } + if (String(field).toLowerCase() === 'tn93') { + return 'TN93'; + } + if (String(field).toLowerCase() === 'snps') { + return 'SNPs'; + } + + return this.commonService.capitalize(String(field || '').replace(/_/g, ' ')); + } + + private refreshFieldLists(): void { + const nodeFields = this.getNodeFields(); + const linkFields = this.getLinkFields(); + + this.AxisFieldList = nodeFields.map((field) => ({ + label: this.getFieldLabel(field), + value: field, + })); + + this.FieldList = [ + { + label: HEATMAP_NONE, + value: '', + }, + ...this.AxisFieldList, + ]; + + this.SortFieldList = [ + { + label: HEATMAP_NONE, + value: HEATMAP_NONE, + }, + ...nodeFields.map((field) => ({ + label: this.getFieldLabel(field), + value: field, + })), + ]; + + this.ValueFieldGroups = [ + { + label: HEATMAP_DISTANCE_LABEL, + value: 'distance', + items: [ + { + label: HEATMAP_DISTANCE_LABEL, + value: this.encodeValueKey('distance', HEATMAP_DISTANCE_LABEL), + }, + ], + }, + { + label: 'Node', + value: 'node', + items: nodeFields.map((field) => ({ + label: this.getFieldLabel(field), + value: this.encodeValueKey('node', field), + })), + }, + { + label: 'Link', + value: 'link', + items: linkFields.map((field) => ({ + label: this.getFieldLabel(field), + value: this.encodeValueKey('link', field), + })), + }, + ]; + } + + private flattenValueOptions(): any[] { + return this.ValueFieldGroups.flatMap((group) => group.items || []); + } + + private encodeValueKey(source: HeatmapValueSource, variable: string): string { + return `${source}${HEATMAP_VALUE_SEPARATOR}${variable}`; + } + + private parseValueKey(key: string): { source: HeatmapValueSource; variable: string } { + const [source, ...variableParts] = String(key || '').split(HEATMAP_VALUE_SEPARATOR); + const parsedSource = ['distance', 'node', 'link'].includes(source) + ? source as HeatmapValueSource + : 'distance'; + const variable = variableParts.join(HEATMAP_VALUE_SEPARATOR) || HEATMAP_DISTANCE_LABEL; + + return { + source: parsedSource, + variable: parsedSource === 'distance' ? HEATMAP_DISTANCE_LABEL : variable, + }; + } + + private getHeatmapValueDisplayLabel(selectedValue = this.parseValueKey(this.selectedValueKey)): string { + if (selectedValue.source === 'distance') { + return this.getFieldLabel(String(this.commonService.session.style.widgets['default-distance-metric'] || HEATMAP_DISTANCE_LABEL)); + } + + return this.getFieldLabel(selectedValue.variable); + } + private usesPercentageDistanceDisplay(): boolean { return this.commonService.tn93PercentageDisplayEnabled('heatmap-distance'); } @@ -185,15 +424,45 @@ export class HeatmapComponent extends BaseComponentDirective implements OnInit, return this.commonService.formatDisplayedDistanceValue(value, 'heatmap-distance', options); } - private buildFormattedHeatmapMatrix(matrix: any[]): string[][] { - return (matrix || []).map((row) => ( - Array.isArray(row) - ? row.map((value) => this.formatHeatmapDistanceValue(Number(value))) - : [] - )); + private formatGenericNumericValue(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(Number(value))) { + return 'No Data'; + } + + const numericValue = Number(value); + if (Math.abs(numericValue - Math.round(numericValue)) < 1e-9) { + return Math.round(numericValue).toLocaleString(); + } + + return numericValue.toLocaleString(undefined, { + maximumFractionDigits: 6, + }); + } + + private isMissingValue(value: unknown): boolean { + const missingStringValues = new Set(['', 'null', 'undefined', 'nan']); + const normalizedStringValue = typeof value === 'string' + ? value.trim().toLowerCase() + : undefined; + + return value === null + || value === undefined + || (normalizedStringValue !== undefined && missingStringValues.has(normalizedStringValue)) + || (typeof value === 'number' && Number.isNaN(value)); + } + + private normalizeAxisLabel(value: unknown): string | null { + if (this.isMissingValue(value)) { + return null; + } + + return String(value).trim(); } - private buildHeatmapColorbar(matrix: any[]): any { + private buildNumericHeatmapColorbar( + matrix: any[], + formatter: (value: number) => string, + ): any { const numericValues = (matrix || []) .flatMap((row) => Array.isArray(row) ? row : []) .map((value) => Number(value)) @@ -215,66 +484,518 @@ export class HeatmapComponent extends BaseComponentDirective implements OnInit, return { tickmode: 'array', tickvals: colorbarTickValues, - ticktext: colorbarTickValues.map((value) => this.formatHeatmapDistanceValue(value)), + ticktext: colorbarTickValues.map((value) => formatter(value)), + }; + } + + private buildCategoricalColorbar(categories: string[]): any { + if (categories.length === 0) { + return undefined; + } + + return { + tickmode: 'array', + tickvals: categories.map((_, index) => index), + ticktext: categories, + }; + } + + private buildHeatmapAxisConfig(labels: string[]): any { + const config: any = { + type: 'category', + categoryorder: 'array', + categoryarray: labels, + tickmode: 'array', + tickvals: labels, + ticktext: labels, + showticklabels: this.heatmapShowLabels, + automargin: true, + }; + + if (!config.showticklabels) { + config.ticks = ''; + } + + return config; + } + + private buildNodeLookup(): Map { + const lookup = new Map(); + (this.commonService.session.data?.nodes || []).forEach((node) => { + const id = node?._id ?? node?.id; + if (id !== undefined && id !== null) { + lookup.set(String(id), node); + } + }); + + return lookup; + } + + private resolveNodeAxisValue(nodeId: unknown, field: string, nodeLookup: Map): unknown { + const id = this.normalizeAxisLabel(nodeId); + if (id === null) { + return null; + } + + if (!field || field === '_id') { + return id; + } + + const node = nodeLookup.get(id); + const value = node?.[field]; + + return this.isMissingValue(value) ? id : value; + } + + private compareSortValues(a: unknown, b: unknown): number { + const aMissing = this.isMissingValue(a); + const bMissing = this.isMissingValue(b); + if (aMissing && bMissing) return 0; + if (aMissing) return 1; + if (bMissing) return -1; + + const aNumber = Number(a); + const bNumber = Number(b); + if (Number.isFinite(aNumber) && Number.isFinite(bNumber)) { + return aNumber - bNumber; + } + + return String(a).localeCompare(String(b), undefined, { + numeric: true, + sensitivity: 'base', + }); + } + + private sortAxisItems(items: HeatmapAxisItem[]): HeatmapAxisItem[] { + if (!this.selectedSortBy || this.selectedSortBy === HEATMAP_NONE) { + return items; + } + + return [...items].sort((a, b) => { + const sortComparison = this.compareSortValues(a.sortValue, b.sortValue); + return sortComparison !== 0 ? sortComparison : a.index - b.index; + }); + } + + private sortLabels(labels: string[], sortValuesByLabel: Map): string[] { + if (!this.selectedSortBy || this.selectedSortBy === HEATMAP_NONE) { + return labels; + } + + return labels + .map((label, index) => ({ + index, + label, + sortValue: sortValuesByLabel.get(label), + })) + .sort((a, b) => { + const sortComparison = this.compareSortValues(a.sortValue, b.sortValue); + return sortComparison !== 0 ? sortComparison : a.index - b.index; + }) + .map((item) => item.label); + } + + private getVisibleNodeRows(): any[] { + if (typeof this.commonService.getVisibleNodesIgnoringTimeline === 'function') { + return this.commonService.getVisibleNodesIgnoringTimeline(); + } + + return this.commonService.session.data?.nodeFilteredValues || this.commonService.session.data?.nodes || []; + } + + private getVisibleLinkRows(): any[] { + if (typeof this.commonService.getVisibleLinksIgnoringTimeline === 'function') { + return this.commonService.getVisibleLinksIgnoringTimeline(); + } + + return this.commonService.session.data?.links || []; + } + + private async buildDistanceMatrix(): Promise { + const { dm, labels } = await this.commonService.getDM(); + const nodeLookup = this.buildNodeLookup(); + const baseLabels = (labels || []).map((label) => String(label)); + const matrix = cloneDeep(dm || []); + + const xItems = baseLabels.map((label, index) => { + const axisValue = this.resolveNodeAxisValue(label, this.selectedXVariable, nodeLookup); + return { + index, + label: this.normalizeAxisLabel(axisValue) || label, + sortValue: this.selectedSortBy === HEATMAP_NONE + ? undefined + : this.resolveNodeAxisValue(label, this.selectedSortBy, nodeLookup), + }; + }); + const yItems = baseLabels.map((label, index) => { + const axisValue = this.resolveNodeAxisValue(label, this.selectedYVariable, nodeLookup); + return { + index, + label: this.normalizeAxisLabel(axisValue) || label, + sortValue: this.selectedSortBy === HEATMAP_NONE + ? undefined + : this.resolveNodeAxisValue(label, this.selectedSortBy, nodeLookup), + }; + }); + + const sortedXItems = this.sortAxisItems(xItems); + const sortedYItems = this.sortAxisItems(yItems); + const sortedMatrix = sortedYItems.map((yItem) => ( + sortedXItems.map((xItem) => matrix?.[yItem.index]?.[xItem.index] ?? null) + )); + + this.nodeIds = baseLabels; + + return this.finalizeMatrix({ + rawMatrix: sortedMatrix, + xLabels: sortedXItems.map((item) => item.label), + yLabels: sortedYItems.map((item) => item.label), + isDistance: true, + valueLabel: HEATMAP_DISTANCE_LABEL, + duplicateCount: 0, + }); + } + + private buildNodeBackedMatrix(valueVariable: string): HeatmapMatrixResult { + const rows = this.getVisibleNodeRows(); + const cells = rows.map((row): HeatmapCellInput => ({ + x: row?.[this.selectedXVariable], + y: row?.[this.selectedYVariable], + value: row?.[valueVariable], + xSort: this.selectedSortBy === HEATMAP_NONE ? undefined : row?.[this.selectedSortBy], + ySort: this.selectedSortBy === HEATMAP_NONE ? undefined : row?.[this.selectedSortBy], + })); + + return this.buildCustomMatrixFromCells(cells, this.getFieldLabel(valueVariable)); + } + + private buildLinkBackedMatrix(valueVariable: string): HeatmapMatrixResult { + const rows = this.getVisibleLinkRows(); + const nodeLookup = this.buildNodeLookup(); + const cells = rows.map((row): HeatmapCellInput => ({ + x: this.resolveNodeAxisValue(row?.source, this.selectedXVariable, nodeLookup), + y: this.resolveNodeAxisValue(row?.target, this.selectedYVariable, nodeLookup), + value: row?.[valueVariable], + xSort: this.selectedSortBy === HEATMAP_NONE + ? undefined + : this.resolveNodeAxisValue(row?.source, this.selectedSortBy, nodeLookup), + ySort: this.selectedSortBy === HEATMAP_NONE + ? undefined + : this.resolveNodeAxisValue(row?.target, this.selectedSortBy, nodeLookup), + })); + + return this.buildCustomMatrixFromCells(cells, this.getFieldLabel(valueVariable)); + } + + private buildCustomMatrixFromCells(cells: HeatmapCellInput[], valueLabel: string): HeatmapMatrixResult { + const xLabels: string[] = []; + const yLabels: string[] = []; + const xSortValues = new Map(); + const ySortValues = new Map(); + const valuesByCell = new Map(); + const cellSeen = new Set(); + let duplicateCount = 0; + + cells.forEach((cell) => { + const xLabel = this.normalizeAxisLabel(cell.x); + const yLabel = this.normalizeAxisLabel(cell.y); + if (xLabel === null || yLabel === null) { + return; + } + + if (!xLabels.includes(xLabel)) { + xLabels.push(xLabel); + } + if (!yLabels.includes(yLabel)) { + yLabels.push(yLabel); + } + if (!xSortValues.has(xLabel) && !this.isMissingValue(cell.xSort)) { + xSortValues.set(xLabel, cell.xSort); + } + if (!ySortValues.has(yLabel) && !this.isMissingValue(cell.ySort)) { + ySortValues.set(yLabel, cell.ySort); + } + + const cellKey = this.getCellKey(xLabel, yLabel); + if (cellSeen.has(cellKey)) { + duplicateCount++; + if (this.isMissingValue(valuesByCell.get(cellKey)) && !this.isMissingValue(cell.value)) { + valuesByCell.set(cellKey, cell.value); + } + return; + } + + cellSeen.add(cellKey); + valuesByCell.set(cellKey, cell.value); + }); + + const sortedXLabels = this.sortLabels(xLabels, xSortValues); + const sortedYLabels = this.sortLabels(yLabels, ySortValues); + const rawMatrix = sortedYLabels.map((yLabel) => ( + sortedXLabels.map((xLabel) => valuesByCell.get(this.getCellKey(xLabel, yLabel)) ?? null) + )); + + return this.finalizeMatrix({ + rawMatrix, + xLabels: sortedXLabels, + yLabels: sortedYLabels, + isDistance: false, + valueLabel, + duplicateCount, + }); + } + + private getCellKey(xLabel: string, yLabel: string): string { + return `${xLabel}${HEATMAP_CELL_SEPARATOR}${yLabel}`; + } + + private finalizeMatrix(options: { + rawMatrix: unknown[][]; + xLabels: string[]; + yLabels: string[]; + isDistance: boolean; + valueLabel: string; + duplicateCount: number; + }): HeatmapMatrixResult { + const nonMissingValues = options.rawMatrix + .flatMap((row) => Array.isArray(row) ? row : []) + .filter((value) => !this.isMissingValue(value)); + const isNumeric = options.isDistance || nonMissingValues.every((value) => Number.isFinite(Number(value))); + const categoryMap = new Map(); + const categories: string[] = []; + + const z = options.rawMatrix.map((row) => ( + row.map((value) => { + if (this.isMissingValue(value)) { + return null; + } + + if (isNumeric) { + const numericValue = Number(value); + return Number.isFinite(numericValue) ? numericValue : null; + } + + const label = String(value); + if (!categoryMap.has(label)) { + categoryMap.set(label, categoryMap.size); + categories.push(label); + } + + return categoryMap.get(label); + }) + )); + + const customdata = options.rawMatrix.map((row) => ( + row.map((value) => this.formatCellDisplayValue(value, options.isDistance)) + )); + + const exportValues = options.rawMatrix.map((row) => ( + row.map((value) => this.formatCellExportValue(value, options.isDistance)) + )); + const missingZ = z.map((row) => row.map((value) => value === null ? 1 : null)); + const result: HeatmapMatrixResult = { + categories, + customdata, + duplicateCount: options.duplicateCount, + exportValues, + hasMissing: missingZ.some((row) => row.some((value) => value !== null)), + isDistance: options.isDistance, + kind: isNumeric ? 'numeric' : 'categorical', + missingZ, + valueLabel: options.valueLabel, + xLabels: options.xLabels, + yLabels: options.yLabels, + z, }; + + return this.applyConfiguredAxisTransforms(result); } - drawHeatmap(config: object): void { - this.commonService.getDM().then(({dm, labels}) => { - this.nodeIds = labels; - const xLabels = labels.map(d => d); - const yLabels = xLabels.slice(); + private applyConfiguredAxisTransforms(matrix: HeatmapMatrixResult): HeatmapMatrixResult { + const result = cloneDeep(matrix); + + if (this.invertX) { + result.xLabels.reverse(); + result.z = result.z.map((row) => [...row].reverse()); + result.exportValues = result.exportValues.map((row) => [...row].reverse()); + result.customdata = result.customdata.map((row) => [...row].reverse()); + result.missingZ = result.missingZ.map((row) => [...row].reverse()); + } + + if (this.invertY) { + result.yLabels.reverse(); + result.z = [...result.z].reverse(); + result.exportValues = [...result.exportValues].reverse(); + result.customdata = [...result.customdata].reverse(); + result.missingZ = [...result.missingZ].reverse(); + } + + return result; + } + + private formatCellDisplayValue(value: unknown, isDistance: boolean): string { + if (this.isMissingValue(value)) { + return 'No Data'; + } + + if (isDistance) { + return this.formatHeatmapDistanceValue(Number(value)); + } + + const numericValue = Number(value); + return Number.isFinite(numericValue) && String(value).trim() !== '' + ? this.formatGenericNumericValue(numericValue) + : String(value); + } + + private formatCellExportValue(value: unknown, isDistance: boolean): unknown { + if (this.isMissingValue(value)) { + return ''; + } + + if (isDistance && this.usesPercentageDistanceDisplay()) { + return this.formatHeatmapDistanceValue(Number(value)); + } - if (this.invertX) { - dm.forEach(l => l.reverse()); - xLabels.reverse(); + return value; + } + + private buildHeatmapColorbar(matrix: HeatmapMatrixResult): any { + const missingColorbarPosition = matrix.hasMissing + ? { + len: 0.82, + y: 0.41, + yanchor: 'middle', } - this.heatmapLabels = xLabels; - if (this.invertY) { - dm.reverse(); - yLabels.reverse(); + : {}; + + if (matrix.kind === 'categorical') { + const colorbar = this.buildCategoricalColorbar(matrix.categories); + return colorbar ? { ...colorbar, ...missingColorbarPosition } : undefined; + } + + const colorbar = this.buildNumericHeatmapColorbar( + matrix.z, + (value) => matrix.isDistance + ? this.formatHeatmapDistanceValue(value) + : this.formatGenericNumericValue(value) + ); + return colorbar ? { ...colorbar, ...missingColorbarPosition } : undefined; + } + + private buildMissingLegendLayout(hasMissing: boolean): any { + return hasMissing + ? { + x: 1.02, + y: 1, + xanchor: 'left', + yanchor: 'top', + traceorder: 'normal', } + : undefined; + } + + private async buildHeatmapMatrix(): Promise { + this.refreshFieldLists(); + this.syncLocalSettingsFromWidgets(); + + const selectedValue = this.parseValueKey(this.selectedValueKey); + if (selectedValue.source === 'node') { + return this.buildNodeBackedMatrix(selectedValue.variable); + } + if (selectedValue.source === 'link') { + return this.buildLinkBackedMatrix(selectedValue.variable); + } + + return this.buildDistanceMatrix(); + } + + private recordHeatmapDuplicateWarning(duplicateCount: number): void { + const warnings = Array.isArray(this.commonService.session.warnings) + ? this.commonService.session.warnings + : []; + const filteredWarnings = warnings.filter((warning: any) => warning?.id !== 'heatmap-duplicate-cells'); + + if (duplicateCount > 0) { + filteredWarnings.push({ + id: 'heatmap-duplicate-cells', + type: 'heatmap-duplicate-cells', + severity: 'warning', + message: `Heatmap found ${duplicateCount} duplicate X/Y cell ${duplicateCount === 1 ? 'entry' : 'entries'} for X=${this.getFieldLabel(this.selectedXVariable)}, Y=${this.getFieldLabel(this.selectedYVariable)}, Value=${this.heatmapValueLabel}. The first non-missing value was rendered.`, + duplicateCount, + xVariable: this.selectedXVariable, + yVariable: this.selectedYVariable, + value: this.selectedValueKey, + recordedAt: Date.now(), + }); + } + + this.commonService.session.warnings = filteredWarnings; + } + + drawHeatmap(): void { + this.buildHeatmapMatrix().then((matrix) => { + this.lastHeatmapMatrix = matrix; + this.heatmapLabels = matrix.xLabels; + this.heatmapValueLabel = matrix.valueLabel; + this.recordHeatmapDuplicateWarning(matrix.duplicateCount); const heatmapTrace: any = { - x: xLabels, - y: yLabels, - z: dm, + x: matrix.xLabels, + y: matrix.yLabels, + z: matrix.z, type: 'heatmap', colorscale: [ [0, this.loColor], [0.5, this.medColor], [1, this.hiColor] - ] + ], + hoverongaps: false, + customdata: matrix.customdata, + hovertemplate: `X: %{x}
Y: %{y}
${matrix.valueLabel}: %{customdata}`, }; - heatmapTrace.colorbar = this.buildHeatmapColorbar(dm); - - if (this.usesPercentageDistanceDisplay()) { - heatmapTrace.customdata = this.buildFormattedHeatmapMatrix(dm); - heatmapTrace.hovertemplate = 'X: %{x}
Y: %{y}
Distance: %{customdata}'; + heatmapTrace.colorbar = this.buildHeatmapColorbar(matrix); + + const traces = [heatmapTrace]; + if (matrix.hasMissing) { + traces.push({ + x: matrix.xLabels, + y: matrix.yLabels, + z: matrix.missingZ, + type: 'heatmap', + colorscale: [ + [0, this.missingColor], + [1, this.missingColor], + ], + zmin: 0, + zmax: 1, + hoverongaps: false, + showscale: false, + showlegend: true, + legendrank: 0, + name: 'No Data', + customdata: matrix.customdata, + hovertemplate: `X: %{x}
Y: %{y}
${matrix.valueLabel}: No Data`, + }); } - this.heatmapData = [heatmapTrace] + this.heatmapData = traces; -/* const parentElement = this.heatmapContainerRef.nativeElement.parentElement; - const width = parentElement.clientWidth; - const height = parentElement.clientHeight; -*/ - let marginLeft = this.heatmapShowLabels ? 90 : 10 - let marginBottom = this.heatmapShowLabels ? 75 : 10 + const marginLeft = this.heatmapShowLabels ? 90 : 10; + const marginBottom = this.heatmapShowLabels ? 75 : 10; this.heatmapLayout = { - xaxis: config, - yaxis: config, + xaxis: this.buildHeatmapAxisConfig(matrix.xLabels), + yaxis: this.buildHeatmapAxisConfig(matrix.yLabels), width: $('#heatmap').parent().width() - 35, height: $('#heatmap').parent().height() - 90, - margin: { t: 0, l: marginLeft, b: marginBottom, r: 0 } - } + margin: { t: 0, l: marginLeft, b: marginBottom, r: 0 }, + legend: this.buildMissingLegendLayout(matrix.hasMissing), + }; this.heatmapConfig = { displaylogo: false, displayModeBar: false - } - - // this.Plotly.newPlot('heatmap', this.heatmapData, this.heatmapLayout, this.heatmapConfig); + }; const plot = PlotlyModule.plotlyjs.newPlot('heatmap', cloneDeep(this.heatmapData), this.heatmapLayout, this.heatmapConfig); this.plot = plot; @@ -286,75 +1007,38 @@ export class HeatmapComponent extends BaseComponentDirective implements OnInit, }); } - goldenLayoutComponentResize(initial=false): void { + goldenLayoutComponentResize(initial = false): void { const height = $('heatmapcomponent').height() - 72; const width = $('heatmapcomponent').width() - 32; - if (height) + if (height) { $('#heatmap').height(height); - if (width) - $('#heatmap').width(width) - - if ( !initial) { - const config = { - autotick: false, - showticklabels: this.heatmapShowLabels - }; - if (!config.showticklabels) { - config["ticks"] = ''; - } + } + if (width) { + $('#heatmap').width(width); + } - let marginLeft = this.heatmapShowLabels ? 90 : 10 - let marginBottom = this.heatmapShowLabels ? 75 : 10 + if (!initial) { + const marginLeft = this.heatmapShowLabels ? 90 : 10; + const marginBottom = this.heatmapShowLabels ? 75 : 10; + const xLabels = this.lastHeatmapMatrix?.xLabels || []; + const yLabels = this.lastHeatmapMatrix?.yLabels || []; this.heatmapLayout = { - xaxis: config, - yaxis: config, + xaxis: this.buildHeatmapAxisConfig(xLabels), + yaxis: this.buildHeatmapAxisConfig(yLabels), width: $('#heatmap').parent().width() - 35, height: $('#heatmap').parent().height() - 90, - margin: { t: 0, l: marginLeft, b: marginBottom, r: 0 } - } - this.openCenter() - } - -/* const heatmapElement = this.heatmapContainerRef.nativeElement; - const parentElement = heatmapElement.parentElement; - - const height = parentElement.clientHeight; - const width = parentElement.clientWidth; - if (height) { - this.renderer.setStyle(heatmapElement, 'height', `${height - 19}px`); - } - if (width) { - this.renderer.setStyle(heatmapElement, 'width', `${width - 1}px`); + margin: { t: 0, l: marginLeft, b: marginBottom, r: 0 }, + legend: this.buildMissingLegendLayout(Boolean(this.lastHeatmapMatrix?.hasMissing)), + }; + this.openCenter(); } -*/ } - // getNodeIds(): string[] { - // const idSet: string[] = this.visuals.heatmap.commonService.session.data.nodes.map(x=>x._id); - // return idSet; - // } - redrawHeatmap(): void { - //if (!this.heatmapContainerRef.nativeElement.length) return; if (!$('#heatmap').length) return; if (this.plot) PlotlyModule.plotlyjs.purge('heatmap'); - // const labels = this.nodeIds; - // const xLabels = labels.map(d => 'N' + d); - // const yLabels = xLabels.slice(); - // console.log(this.heatmapShowLabels, xLabels.length, xLabels); - this.heatmapMetric = this.commonService.session.style.widgets['default-distance-metric'].toUpperCase(); - - - const config = { - autotick: false, - showticklabels: this.heatmapShowLabels - }; - - if (!config.showticklabels) { - config["ticks"] = ''; - } - - this.drawHeatmap(config); + this.heatmapValueDisplayLabel = this.getHeatmapValueDisplayLabel(); + this.drawHeatmap(); } setBackground(): void { @@ -366,68 +1050,77 @@ export class HeatmapComponent extends BaseComponentDirective implements OnInit, $('#heatmap .xtitle, .ytitle').css('fill', contrast); $('#heatmap .xaxislayer-above text').css('fill', contrast); $('#heatmap .yaxislayer-above text').css('fill', contrast); - /*const heatmapElement: HTMLElement = this.heatmapContainerRef.nativeElement; - const col = this.commonService.session.style.widgets['background-color']; - const contrast = this.commonService.session.style.widgets['background-color-contrast']; - - // Set background color of the main SVG - const mainSvg = heatmapElement.querySelector('svg.main-svg'); - if (mainSvg) { - this.renderer.setStyle(mainSvg, 'background', col); - } - - // Set fill for rect.bg - const rectBg = heatmapElement.querySelector('rect.bg'); - if (rectBg) { - this.renderer.setStyle(rectBg, 'fill', col); - } - - // Set fill for titles - const titles = heatmapElement.querySelectorAll('.xtitle, .ytitle'); - titles.forEach(title => { - this.renderer.setStyle(title, 'fill', contrast); - }); - - // Set fill for axis layer texts - const axisTexts = heatmapElement.querySelectorAll('.xaxislayer-above text, .yaxislayer-above text'); - axisTexts.forEach(text => { - this.renderer.setStyle(text, 'fill', contrast); - });*/ } updateLoColor(color: string): void { - this.commonService.session.style.widgets["heatmap-color-low"] = color; + this.commonService.session.style.widgets['heatmap-color-low'] = color; this.loColor = color; this.redrawHeatmap(); } updateMedColor(color: string): void { - this.commonService.session.style.widgets["heatmap-color-medium"] = color; + this.commonService.session.style.widgets['heatmap-color-medium'] = color; this.medColor = color; this.redrawHeatmap(); } updateHiColor(color: string): void { - this.commonService.session.style.widgets["heatmap-color-high"] = color; + this.commonService.session.style.widgets['heatmap-color-high'] = color; this.hiColor = color; this.redrawHeatmap(); } + updateMissingColor(color: string): void { + this.commonService.session.style.widgets['heatmap-color-missing'] = color; + this.missingColor = color; + this.redrawHeatmap(); + } + updateInvertX(direction: boolean): void { this.invertX = direction; - this.commonService.session.style.widgets["heatmap-invertX"] = this.invertX; + this.commonService.session.style.widgets['heatmap-invertX'] = this.invertX; this.redrawHeatmap(); } updateInvertY(direction: boolean): void { this.invertY = direction; - this.commonService.session.style.widgets["heatmap-invertY"] = this.invertY; + this.commonService.session.style.widgets['heatmap-invertY'] = this.invertY; this.redrawHeatmap(); } updateShowLabels(showLabels: boolean): void { this.heatmapShowLabels = showLabels; - this.commonService.session.style.widgets["heatmap-axislabels-show"] = this.heatmapShowLabels; + this.commonService.session.style.widgets['heatmap-axislabels-show'] = this.heatmapShowLabels; + this.redrawHeatmap(); + } + + updateXVariable(variable: string): void { + this.selectedXVariable = variable; + this.commonService.session.style.widgets['heatmap-x-variable'] = variable; + this.redrawHeatmap(); + } + + updateYVariable(variable: string): void { + this.selectedYVariable = variable; + this.commonService.session.style.widgets['heatmap-y-variable'] = variable; + this.redrawHeatmap(); + } + + updateValueVariable(valueKey: string): void { + this.selectedValueKey = valueKey; + const selectedValue = this.parseValueKey(valueKey); + this.commonService.session.style.widgets['heatmap-value-source'] = selectedValue.source; + this.commonService.session.style.widgets['heatmap-value-variable'] = selectedValue.variable; + this.heatmapValueDisplayLabel = this.getHeatmapValueDisplayLabel(selectedValue); + this.heatmapValueLabel = selectedValue.source === 'distance' + ? HEATMAP_DISTANCE_LABEL + : this.getFieldLabel(selectedValue.variable); + this.redrawHeatmap(); + } + + updateSortBy(variable: string): void { + this.selectedSortBy = variable; + this.commonService.session.style.widgets['heatmap-sort-by'] = variable; this.redrawHeatmap(); } @@ -439,83 +1132,66 @@ export class HeatmapComponent extends BaseComponentDirective implements OnInit, this.redrawHeatmap(); } - saveImage(): void { + applyStyleFileSettings(): void { + this.refreshFieldLists(); + this.syncLocalSettingsFromWidgets(); + this.redrawHeatmap(); + } + + onLoadNewData(): void { + this.refreshFieldLists(); + this.syncLocalSettingsFromWidgets(); + this.redrawHeatmap(); + } + + onFilterDataChange(): void { + this.redrawHeatmap(); + } + + async saveImage(): Promise { const fileName = this.SelectedImageFilenameVariable; const domId = 'heatmap'; - const exportImageType = this.SelectedNetworkExportFileTypeVariable; + const exportImageType = this.SelectedNetworkExportFileTypeVariable as 'png' | 'jpeg' | 'svg'; const content = document.getElementById(domId); - if (content) { - const fixedContent = this.fixGradient(content); - if (exportImageType === 'png') { - domToImage.toPng(content).then( - dataUrl => { - saveAs(dataUrl, fileName+"."+exportImageType); - }); - } else if (exportImageType === 'jpeg') { - domToImage.toJpeg(content, { quality: 0.85 }).then( - dataUrl => { - saveAs(dataUrl, fileName+"."+exportImageType); - }); - } else if (exportImageType === 'svg') { - const svgContent = this.exportService.unparseSVG(fixedContent); - const blob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' }); - saveAs(blob, fileName+"."+exportImageType); - } - } - } - fixGradient(el: HTMLElement): HTMLElement { - const insertionPoint = el.getElementsByClassName("gradient_filled"); - if (!insertionPoint.length) { - return el; + if (!content) { + return; } - const startingUrl = insertionPoint[0]["style"]["fill"]; - if (!startingUrl || !startingUrl.includes("#")) { - return el; + try { + const dataUrl = await PlotlyModule.plotlyjs.toImage(content, { + format: exportImageType, + width: null, + height: null, + }); + saveAs(dataUrl, fileName + '.' + exportImageType); + } catch (error) { + console.error('Error exporting heatmap image:', error); } - - const idVal = startingUrl.substring(startingUrl.indexOf("#")); - insertionPoint[0]["style"]["fill"] = 'url("'+idVal; - return el; } saveDistanceMatrix(): void { - const fileName = this.SelectedDistanceMatrixFilenameVariable; - this.commonService.getDM().then(({dm, labels}) => { - const xLabels = (labels || []).map((label) => String(label)); - const yLabels = cloneDeep(xLabels); - let matrix = cloneDeep(dm); - - if (this.invertX) { - matrix = matrix.map((row) => Array.isArray(row) ? [...row].reverse() : row); - xLabels.reverse(); - } - - if (this.invertY) { - matrix = [...matrix].reverse(); - yLabels.reverse(); - } + const matrix = this.lastHeatmapMatrix; + if (!matrix) { + return; + } - const exportedMatrix = this.usesPercentageDistanceDisplay() - ? matrix.map((row) => Array.isArray(row) - ? row.map((value) => this.formatHeatmapDistanceValue(Number(value))) - : row) - : matrix; - - let csvContent = ""; - if (this.heatmapShowLabels) { - csvContent += buildSafeCsvRow(["", ...xLabels]) + "\n"; - for (let i = 0; i < exportedMatrix.length; i++) { - csvContent += buildSafeCsvRow([yLabels[i], ...exportedMatrix[i]]) + "\n"; - } - } else { - csvContent += exportedMatrix.map((row) => buildSafeCsvRow(row)).join("\n"); + const fileName = this.SelectedDistanceMatrixFilenameVariable; + const xLabels = (matrix.xLabels || []).map((label) => String(label)); + const yLabels = (matrix.yLabels || []).map((label) => String(label)); + const exportedMatrix = matrix.exportValues || []; + + let csvContent = ''; + if (this.heatmapShowLabels) { + csvContent += buildSafeCsvRow(['', ...xLabels]) + '\n'; + for (let i = 0; i < exportedMatrix.length; i++) { + csvContent += buildSafeCsvRow([yLabels[i], ...exportedMatrix[i]]) + '\n'; } - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' }); - saveAs(blob, fileName); - }); - + } else { + csvContent += exportedMatrix.map((row) => buildSafeCsvRow(row)).join('\n'); + } + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' }); + saveAs(blob, fileName); } ngOnDestroy(): void {