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
-
-