From 0cc55dadecbb1534d851327b30a8de03077959d3 Mon Sep 17 00:00:00 2001 From: "Guppy L. Stott" Date: Thu, 27 Aug 2026 14:31:45 -0400 Subject: [PATCH 1/8] Continuous color ramp draft feature --- .../bubble-cluster-recompute-uploaded.cy.ts | 2 + .../journeys/flows/color-by-uploaded.cy.ts | 4 +- .../dashboard-global-styling-uploaded.cy.ts | 27 +- .../flows/epi-curve-styling-uploaded.cy.ts | 1 + .../map-cluster-recompute-uploaded.cy.ts | 2 + .../flows/node-color-history-scoping.cy.ts | 2 + .../phylogenetic-metadata-uploaded.cy.ts | 2 + ...timeline-key-tables-refresh-uploaded.cy.ts | 27 +- .../twod-timeline-controls-uploaded.cy.ts | 19 +- cypress/e2e/view-state/bubble-view.cy.ts | 7 + .../view-state/continuous-color-ramps.cy.ts | 152 ++++++++ cypress/e2e/view-state/map-view.cy.ts | 8 + .../e2e/view-state/phylogenetic-view.cy.ts | 2 + .../view-state/timeline-epicurve-view.cy.ts | 4 +- cypress/support/commands.ts | 24 +- .../integrations/partner-embed-integration.md | 17 +- scripts/embed-security.test.js | 53 +++ src/app/app.module.ts | 2 + .../color-mapping.service.spec.ts | 13 +- .../color-mapping.service.ts | 88 ++++- .../common.service.ts | 127 ++++++- .../export.service.ts | 51 ++- .../variable-color-scale.spec.ts | 130 +++++++ .../variable-color-scale.ts | 333 ++++++++++++++++++ src/app/embed/embed-handoff.service.spec.ts | 134 +++++++ src/app/embed/embed-handoff.service.ts | 82 +++++ src/app/embed/embed-handoff.types.ts | 17 + .../filesComponent/files-plugin.component.ts | 24 +- .../microbe-trace-next-plugin.component.html | 101 +++++- .../microbe-trace-next-plugin.component.ts | 147 +++++++- .../continuous-color-ramp.component.spec.ts | 74 ++++ .../continuous-color-ramp.component.ts | 333 ++++++++++++++++++ .../key-tables.component.html | 36 +- .../key-tables.component.ts | 24 ++ .../timeline-component.component.html | 13 + .../timeline-component.component.ts | 144 +++++++- src/assets/embed/receiver.js | 76 ++++ 37 files changed, 2195 insertions(+), 107 deletions(-) create mode 100644 cypress/e2e/view-state/continuous-color-ramps.cy.ts create mode 100644 src/app/contactTraceCommonServices/variable-color-scale.spec.ts create mode 100644 src/app/contactTraceCommonServices/variable-color-scale.ts create mode 100644 src/app/visualizationComponents/KeyTablesComponent/continuous-color-ramp.component.spec.ts create mode 100644 src/app/visualizationComponents/KeyTablesComponent/continuous-color-ramp.component.ts diff --git a/cypress/e2e/journeys/flows/bubble-cluster-recompute-uploaded.cy.ts b/cypress/e2e/journeys/flows/bubble-cluster-recompute-uploaded.cy.ts index a2fcbe3ce..af8a068d7 100644 --- a/cypress/e2e/journeys/flows/bubble-cluster-recompute-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/bubble-cluster-recompute-uploaded.cy.ts @@ -186,6 +186,7 @@ describe('Journey Flow - Bubble uploaded cluster recompute', () => { openGlobalStylingTab(); selectPrimeOption('#node-color-variable', 'Cluster'); + selectPrimeOption('#node-color-scale-mode', 'Categorical'); cy.window().its('commonService.session.style.widgets.node-color-variable').should('equal', 'cluster'); cy.get('#key-tables-node-table', { timeout: 15000 }).then(($table) => { clusterColorsBefore = extractColorTable($table); @@ -262,6 +263,7 @@ describe('Journey Flow - Bubble uploaded cluster recompute', () => { openGlobalStylingTab(); selectPrimeOption('#node-color-variable', 'Cluster'); + selectPrimeOption('#node-color-scale-mode', 'Categorical'); cy.window().its('commonService.session.style.widgets.node-color-variable').should('equal', 'cluster'); cy.closeGlobalSettings(); diff --git a/cypress/e2e/journeys/flows/color-by-uploaded.cy.ts b/cypress/e2e/journeys/flows/color-by-uploaded.cy.ts index a932d81c8..314055375 100644 --- a/cypress/e2e/journeys/flows/color-by-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/color-by-uploaded.cy.ts @@ -175,7 +175,7 @@ describe('Journey Flow - Uploaded color-by controls', () => { selectPrimeOption('#node-color-variable', 'Profession'); cy.window().its('commonService.session.style.widgets.node-color-variable').should('equal', 'Profession'); - cy.get('#node-color-table-row').should('be.visible'); + cy.get('#node-color-table-row').scrollIntoView().should('be.visible'); cy.get('#key-tables-node-table', { timeout: 15000 }).should('be.visible'); cy.get('#key-tables-node-table tr').should(($rows) => { expect($rows.length, 'node color table rows').to.be.greaterThan(2); @@ -185,7 +185,7 @@ describe('Journey Flow - Uploaded color-by controls', () => { selectPrimeOption('#link-tooltip-variable', 'Contact type'); cy.window().its('commonService.session.style.widgets.link-color-variable').should('equal', 'Contact type'); - cy.get('#link-color-table-row').should('be.visible'); + cy.get('#link-color-table-row').scrollIntoView().should('be.visible'); cy.get('#key-tables-link-table', { timeout: 15000 }).should('be.visible'); cy.get('#key-tables-link-table tr').should(($rows) => { expect($rows.length, 'link color table rows').to.be.greaterThan(2); diff --git a/cypress/e2e/journeys/flows/dashboard-global-styling-uploaded.cy.ts b/cypress/e2e/journeys/flows/dashboard-global-styling-uploaded.cy.ts index f02b84e18..f561304ae 100644 --- a/cypress/e2e/journeys/flows/dashboard-global-styling-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/dashboard-global-styling-uploaded.cy.ts @@ -64,21 +64,29 @@ const matchesExpectedColor = (actual: string, expectedHex: string): boolean => { normalizedActual === normalizeColor(hexToRgbString(expectedHex)); }; +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const getBubbleDataNodes = (bubble: any) => bubble.cy.nodes().filter((node: any) => !node.hasClass('X_axis') && !node.hasClass('Y_axis')); const selectPrimeOption = (selector: string, label: string): void => { - cy.get(selector).click({ force: true }).wait(100); - cy.get('body').then(($body) => { - const overlay = $body.find('.p-select-overlay:visible').last(); - - expect(overlay.length, `visible PrimeNG overlay for ${selector}`).to.be.greaterThan(0); - - cy.wrap(overlay) - .contains('li[role="option"]', label, { timeout: 15000 }) + cy.get(selector, { timeout: 15000 }) + .scrollIntoView() + .should('be.visible') + .click({ force: true }); + cy.get('.p-select-overlay:visible', { timeout: 15000 }) + .last() + .should('be.visible') + .within(() => { + cy.contains( + 'li[role="option"]', + new RegExp(`^\\s*${escapeRegExp(label)}\\s*$`), + { timeout: 15000 }, + ) .scrollIntoView() .click({ force: true }); - }); + }); + cy.get('body').find('.p-select-overlay:visible').should('have.length', 0); }; const changeColorTableEntry = (tableSelector: string, value: string, nextColor: string): void => { @@ -640,6 +648,7 @@ describe('Journey Flow - Dashboard global styling propagation', () => { openGlobalStylingTab(); selectPrimeOption('#link-tooltip-variable', 'Cluster'); cy.window().its('commonService.session.style.widgets.link-color-variable').should('equal', 'cluster'); + selectPrimeOption('#link-color-scale-mode', 'Categorical'); cy.get('#key-tables-link-table', { timeout: 15000 }).should('be.visible'); getCommonLinkClusters().then(([targetCluster, controlCluster]) => { diff --git a/cypress/e2e/journeys/flows/epi-curve-styling-uploaded.cy.ts b/cypress/e2e/journeys/flows/epi-curve-styling-uploaded.cy.ts index aa459a928..2e409934c 100644 --- a/cypress/e2e/journeys/flows/epi-curve-styling-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/epi-curve-styling-uploaded.cy.ts @@ -322,6 +322,7 @@ describe('Journey Flow - Epi Curve styling on uploaded data', () => { cy.openGlobalSettings(); switchGlobalSettingsTab('Styling'); selectVisiblePrimeOption('#node-color-variable', 'Cluster'); + selectVisiblePrimeOption('#node-color-scale-mode', 'Categorical'); cy.window() .its('commonService.session.style.widgets.node-color-variable') diff --git a/cypress/e2e/journeys/flows/map-cluster-recompute-uploaded.cy.ts b/cypress/e2e/journeys/flows/map-cluster-recompute-uploaded.cy.ts index 55a2aee69..e550a7cfb 100644 --- a/cypress/e2e/journeys/flows/map-cluster-recompute-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/map-cluster-recompute-uploaded.cy.ts @@ -159,6 +159,8 @@ describe('Journey Flow - Map uploaded cluster-color recompute', () => { openGlobalStylingTab(); selectPrimeOption('#node-color-variable', 'Cluster'); selectPrimeOption('#link-tooltip-variable', 'Cluster'); + selectPrimeOption('#node-color-scale-mode', 'Categorical'); + selectPrimeOption('#link-color-scale-mode', 'Categorical'); cy.window().its('commonService.session.style.widgets.node-color-variable').should('equal', 'cluster'); cy.window().its('commonService.session.style.widgets.link-color-variable').should('equal', 'cluster'); diff --git a/cypress/e2e/journeys/flows/node-color-history-scoping.cy.ts b/cypress/e2e/journeys/flows/node-color-history-scoping.cy.ts index 6f42fd999..c91489253 100644 --- a/cypress/e2e/journeys/flows/node-color-history-scoping.cy.ts +++ b/cypress/e2e/journeys/flows/node-color-history-scoping.cy.ts @@ -46,6 +46,7 @@ describe('Journey Flow - Node color history is scoped by field', () => { openGlobalStylingTab(); selectPrimeOption('#node-color-variable', 'Seedgroups'); + selectPrimeOption('#node-color-scale-mode', 'Categorical'); cy.window().should((win: any) => { const style = win.commonService.session.style as NodeColorStyle; @@ -59,6 +60,7 @@ describe('Journey Flow - Node color history is scoped by field', () => { }); selectPrimeOption('#node-color-variable', 'Raremutations'); + selectPrimeOption('#node-color-scale-mode', 'Categorical'); cy.window().should((win: any) => { const style = win.commonService.session.style as NodeColorStyle; diff --git a/cypress/e2e/journeys/flows/phylogenetic-metadata-uploaded.cy.ts b/cypress/e2e/journeys/flows/phylogenetic-metadata-uploaded.cy.ts index bd95f8add..df20a22e0 100644 --- a/cypress/e2e/journeys/flows/phylogenetic-metadata-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/phylogenetic-metadata-uploaded.cy.ts @@ -618,6 +618,7 @@ describe('Journey Flow - Phylogenetic Tree metadata-backed controls', () => { const expectedSelectedStroke = normalizeColor(hexToRgbString(selectedColor)); openGlobalStylingTab(); + cy.get('#selected-color').scrollIntoView().should('be.visible'); setColorInputValue('#selected-color', selectedColor); cy.window().its('commonService.session.style.widgets.selected-color').should('equal', selectedColor); @@ -646,6 +647,7 @@ describe('Journey Flow - Phylogenetic Tree metadata-backed controls', () => { openGlobalStylingTab(); selectPrimeOption('#node-color-variable', 'Cluster'); + selectPrimeOption('#node-color-scale-mode', 'Categorical'); cy.window() .its('commonService.session.style.widgets.node-color-variable') diff --git a/cypress/e2e/journeys/flows/timeline-key-tables-refresh-uploaded.cy.ts b/cypress/e2e/journeys/flows/timeline-key-tables-refresh-uploaded.cy.ts index d6c43efc8..1b822c56a 100644 --- a/cypress/e2e/journeys/flows/timeline-key-tables-refresh-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/timeline-key-tables-refresh-uploaded.cy.ts @@ -21,18 +21,26 @@ type WinWithMT = Window & { type CountMap = Record; -const selectPrimeOption = (selector: string, label: string): void => { - cy.get(selector).click({ force: true }); - cy.get('body').then(($body) => { - const overlay = $body.find('.p-select-overlay:visible').last(); - - expect(overlay.length, `visible PrimeNG overlay for ${selector}`).to.be.greaterThan(0); +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - cy.wrap(overlay) - .contains('li[role="option"]', label, { timeout: 15000 }) +const selectPrimeOption = (selector: string, label: string): void => { + cy.get(selector, { timeout: 15000 }) + .scrollIntoView() + .should('be.visible') + .click({ force: true }); + cy.get('.p-select-overlay:visible', { timeout: 15000 }) + .last() + .should('be.visible') + .within(() => { + cy.contains( + 'li[role="option"]', + new RegExp(`^\\s*${escapeRegExp(label)}\\s*$`), + { timeout: 15000 }, + ) .scrollIntoView() .click({ force: true }); - }).wait(50); + }); + cy.get('body').find('.p-select-overlay:visible').should('have.length', 0); }; const readColorTableCounts = ($table: JQuery): CountMap => { @@ -135,6 +143,7 @@ describe('Journey Flow - Timeline key table refresh', () => { selectPrimeOption('#link-tooltip-variable', 'Cluster'); cy.window().its('commonService.session.style.widgets.node-color-variable').should('equal', nodeColorField); cy.window().its('commonService.session.style.widgets.link-color-variable').should('equal', linkColorField); + selectPrimeOption('#link-color-scale-mode', 'Categorical'); cy.get('#key-tables-node-table', { timeout: 15000 }).should('be.visible'); cy.get('#key-tables-link-table', { timeout: 15000 }).should('be.visible'); assertNodeColorTableMatchesVisibleNodes(nodeColorField, 'timeline max'); diff --git a/cypress/e2e/journeys/flows/twod-timeline-controls-uploaded.cy.ts b/cypress/e2e/journeys/flows/twod-timeline-controls-uploaded.cy.ts index 91665b48a..16cedf802 100644 --- a/cypress/e2e/journeys/flows/twod-timeline-controls-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/twod-timeline-controls-uploaded.cy.ts @@ -40,13 +40,22 @@ const normalizeLogicalLinkId = (value: string): string => String(value || '').re const clickVisiblePrimeOption = (label: string): void => { cy.get('.p-select-overlay:visible', { timeout: 15000 }) .last() - .find('p-selectitem') - .contains('li', new RegExp(`^${escapeRegExp(label)}$`)) - .click({ force: true }); + .should('be.visible') + .within(() => { + cy.contains( + 'li[role="option"]', + new RegExp(`^\\s*${escapeRegExp(label)}\\s*$`), + { timeout: 15000 }, + ).click({ force: true }); + }); + cy.get('body').find('.p-select-overlay:visible').should('have.length', 0); }; const selectPrimeOption = (selector: string, label: string): void => { - cy.get(selector).click({ force: true }); + cy.get(selector, { timeout: 15000 }) + .scrollIntoView() + .should('be.visible') + .click({ force: true }); clickVisiblePrimeOption(label); }; @@ -279,6 +288,7 @@ describe('Journey Flow - 2D uploaded timeline controls', () => { openGlobalStylingTab(); selectPrimeOption('#node-color-variable', 'Cluster'); cy.window().its('commonService.session.style.widgets.node-color-variable').should('equal', 'cluster'); + selectPrimeOption('#node-color-scale-mode', 'Categorical'); cy.window().then((win: unknown) => { const cyInstance = (win as WinWithCy).cytoscapeInstance; @@ -375,6 +385,7 @@ describe('Journey Flow - 2D uploaded timeline controls', () => { openGlobalStylingTab(); selectPrimeOption('#link-tooltip-variable', 'Cluster'); cy.window().its('commonService.session.style.widgets.link-color-variable').should('equal', 'cluster'); + selectPrimeOption('#link-color-scale-mode', 'Categorical'); cy.get('#key-tables-link-table tr', { timeout: 15000 }) .then(($rows) => { diff --git a/cypress/e2e/view-state/bubble-view.cy.ts b/cypress/e2e/view-state/bubble-view.cy.ts index 9d1d47d39..756d568d3 100644 --- a/cypress/e2e/view-state/bubble-view.cy.ts +++ b/cypress/e2e/view-state/bubble-view.cy.ts @@ -3,6 +3,11 @@ import { visitAppAndAcceptEula } from '../../support/journey-helpers'; let takeScreenshots = false; const getCy = () => cy.window().then(win => win.commonService.visuals.bubble.cy) +const selectCategoricalColorScale = (target: 'node' | 'link'): void => { + cy.get(`#${target}-color-scale-mode`).click(); + cy.contains('li[role="option"]', 'Categorical', { timeout: 10000 }).click(); +}; + describe('Bubble View', () => { const selectors = { container: '#cyBubble', @@ -254,6 +259,7 @@ describe('Bubble View', () => { cy.openGlobalSettings(); cy.get('#node-color-variable').click() cy.get('li[role="option"]').contains('Cluster').click() + selectCategoricalColorScale('node') cy.contains('#global-settings-modal .nav-link', 'Filtering').click(); for (let i = 0; i < 6; i++) { cy.get('#link-threshold').type('{uparrow}'); @@ -310,6 +316,7 @@ describe('Bubble View', () => { cy.openGlobalSettings(); cy.get('#node-color-variable').click() cy.get('li[role="option"]').contains('Cluster').click() + selectCategoricalColorScale('node') cy.wait(500) let initialDef; let initialBGImage; diff --git a/cypress/e2e/view-state/continuous-color-ramps.cy.ts b/cypress/e2e/view-state/continuous-color-ramps.cy.ts new file mode 100644 index 000000000..908af5d82 --- /dev/null +++ b/cypress/e2e/view-state/continuous-color-ramps.cy.ts @@ -0,0 +1,152 @@ +/// + +import { visitAppAndAcceptEula } from '../../support/journey-helpers'; +import { selectEpiCurveDropdown } from '../../support/epi-curve-helpers'; + +const launchSample = (): void => { + visitAppAndAcceptEula({ skipDemoSession: false, dismissWelcomeOverlay: true }); + cy.window().its('commonService.session.data.nodes').should('have.length.greaterThan', 0); +}; + +const useNodeColorField = (field: string): void => { + cy.window().then((win: any) => { + const app = win.commonService.visuals.microbeTrace; + app.SelectedColorNodesByVariable = field; + app.onColorNodesByChanged(); + }); +}; + +const useLinkColorField = (field: string): void => { + cy.window().then((win: any) => { + const app = win.commonService.visuals.microbeTrace; + app.SelectedColorLinksByVariable = field; + app.onColorLinksByChanged(); + }); +}; + +describe('Continuous numeric color ramps', () => { + beforeEach(() => { + launchSample(); + }); + + it('uses a full-data node ramp, renders a docked legend, and restores categorical history', () => { + useNodeColorField('degree'); + + cy.window().should((win: any) => { + const common = win.commonService; + const resolved = common.temp.style.nodeColorScale; + const allDegrees = common.session.data.nodes.map((node: any) => Number(node.degree)); + + expect(resolved.mode).to.equal('continuous'); + expect(resolved.requestedMode).to.equal('auto'); + expect(resolved.domain.min).to.equal(Math.min(...allDegrees)); + expect(resolved.domain.max).to.equal(Math.max(...allDegrees)); + expect(resolved.colorMap(resolved.domain.min)).not.to.equal(resolved.colorMap(resolved.domain.max)); + expect(resolved.colorMap('not numeric').toLowerCase()).to.equal('#eae553'); + }); + + cy.get('#key-tables-node-legend', { timeout: 15000 }).should('be.visible'); + cy.get('#key-tables-node-legend [data-testid="continuous-color-gradient"]') + .should('have.attr', 'role', 'img') + .and('have.attr', 'aria-label') + .and('contain', 'Continuous color ramp'); + cy.get('#key-tables-node-legend').should('contain.text', 'Missing / invalid'); + cy.get('#key-tables-node-table').should('not.exist'); + + cy.window().then((win: any) => { + const app = win.commonService.visuals.microbeTrace; + const common = win.commonService; + app.onVariableColorModeChanged('node', 'categorical'); + const savedHistory = JSON.stringify(common.session.style.nodeColorsTableHistory.degree); + expect(savedHistory).to.not.equal('{}'); + + app.onVariableColorModeChanged('node', 'continuous'); + expect(JSON.stringify(common.session.style.nodeColorsTableHistory.degree)).to.equal(savedHistory); + + app.onVariableColorModeChanged('node', 'categorical'); + expect(JSON.stringify(common.session.style.nodeColorsTableHistory.degree)).to.equal(savedHistory); + const firstNode = common.session.data.nodes[0]; + expect(common.temp.style.nodeColorMap(firstNode.degree)) + .to.equal(common.session.style.nodeColorsTableHistory.degree[String(firstNode.degree)]); + }); + + cy.get('#key-tables-node-table', { timeout: 15000 }).should('be.visible'); + }); + + it('applies custom arbitrary stops to links while keeping the domain stable across visibility changes', () => { + useLinkColorField('distance'); + + cy.window().then((win: any) => { + const app = win.commonService.visuals.microbeTrace; + app.onVariableColorConfigChanged('link', { + mode: 'continuous', + domain: { kind: 'custom', min: 0, max: 20 }, + stops: [ + { value: 0, color: '#000000' }, + { value: 4, color: '#ff0000' }, + { value: 20, color: '#ffffff' }, + ], + missingColor: '#123456', + }); + }); + + cy.window().should((win: any) => { + const common = win.commonService; + const before = common.temp.style.linkColorScale; + expect(before.domain).to.deep.equal({ min: 0, max: 20 }); + expect(before.stops.map((stop: any) => stop.value)).to.deep.equal([0, 4, 20]); + expect(before.colorMap(-10).replace(/\s/g, '')).to.match(/rgb\(0,0,0\)|#000000/); + expect(before.colorMap(100).replace(/\s/g, '')).to.match(/rgb\(255,255,255\)|#ffffff/); + expect(before.colorMap('invalid')).to.equal('#123456'); + + common.session.data.links.slice(0, 5).forEach((link: any) => { link.visible = false; }); + common.createLinkColorMap(); + expect(common.temp.style.linkColorScale.domain).to.deep.equal({ min: 0, max: 20 }); + }); + + cy.get('#key-tables-link-legend', { timeout: 15000 }).should('be.visible'); + cy.get('#key-tables-link-legend [data-testid="continuous-color-gradient"]') + .should('have.attr', 'aria-label') + .and('contain', '0 #000000') + .and('contain', '4 #ff0000') + .and('contain', '20 #ffffff'); + }); + + it('uses fixed equal-width continuous Node Color bins in the Epi Curve', () => { + useNodeColorField('degree'); + + cy.contains('button', 'View').click(); + cy.contains('button[mat-menu-item]', 'Epi Curve').click(); + cy.get('#epiCurve', { timeout: 15000 }).should('be.visible'); + + selectEpiCurveDropdown('Date Field', 'Date of symptom onset Date'); + selectEpiCurveDropdown('Color By', 'Node Color'); + + cy.get('#epi-continuous-bin-count', { timeout: 15000 }) + .scrollIntoView() + .should('be.visible') + .clear() + .type('7') + .trigger('change'); + + cy.window().then((win: any) => { + expect(win.commonService.visuals.epiCurve.widgets['epiCurve-continuousBinCount']).to.equal(7); + }); + + cy.get('#epi-continuous-bin-count', { timeout: 15000 }) + .should('be.visible') + .and('have.value', '7'); + cy.get('#epi-stack-order-list').should('not.exist'); + cy.get('#epiCurveSVG .epiCurve-epi-curve text') + .invoke('text') + .should('match', /\[[^\]]+,\s*[^\]]+[\)\]]/); + + cy.window().should((win: any) => { + const epi = win.commonService.visuals.epiCurve; + expect(epi.widgets['epiCurve-continuousBinCount']).to.equal(7); + epi.widgets['epiCurve-cumulative'] = true; + epi.setCumulative(); + expect(epi.widgets['epiCurve-continuousBinCount']).to.equal(7); + }); + }); +}); diff --git a/cypress/e2e/view-state/map-view.cy.ts b/cypress/e2e/view-state/map-view.cy.ts index b3c27bd7b..6a8431276 100644 --- a/cypress/e2e/view-state/map-view.cy.ts +++ b/cypress/e2e/view-state/map-view.cy.ts @@ -5,6 +5,11 @@ import { getRenderedMapNodeContainerPoint, readRenderedMapNodeStyle } from '../. import { visitAppAndAcceptEula } from '../../support/journey-helpers'; const takeScreenshots: boolean = false; +const selectCategoricalColorScale = (target: 'node' | 'link'): void => { + cy.get(`#${target}-color-scale-mode`).click(); + cy.contains('li[role="option"]', 'Categorical', { timeout: 10000 }).click(); +}; + const findClusteredMapNodeTarget = (mapView: any, excludedNodeIds: string[] = []): string => { const markerClusterGroup = mapView.layers.markerClusterGroup; const excluded = new Set(excludedNodeIds); @@ -979,6 +984,7 @@ describe('Map View', () => { it('should update link colors variable to Cluster and then change one of the colors', () => { cy.get('#link-tooltip-variable').click() cy.get('li[role="option"]').contains('Cluster').click() + selectCategoricalColorScale('link') cy.wait(250); cy.get('#key-tables-link-table td input').first().invoke('val', '#777777').trigger('input').trigger('change'); @@ -1031,9 +1037,11 @@ describe('Map View', () => { cy.get('#node-color-variable').click() cy.get('li[role="option"]').contains('Cluster').click() + selectCategoricalColorScale('node') cy.get('#link-tooltip-variable').click() cy.get('li[role="option"]').contains('Cluster').click() + selectCategoricalColorScale('link') cy.contains('#global-settings-modal .nav-link', 'Filtering').click(); for (let i = 0; i < 6; i++) { diff --git a/cypress/e2e/view-state/phylogenetic-view.cy.ts b/cypress/e2e/view-state/phylogenetic-view.cy.ts index ffb071357..4649ee7e1 100644 --- a/cypress/e2e/view-state/phylogenetic-view.cy.ts +++ b/cypress/e2e/view-state/phylogenetic-view.cy.ts @@ -117,6 +117,8 @@ describe('Phylogenetic Tree View', () => { cy.openGlobalSettings(); cy.get('#node-color-variable').click() cy.get('li[role="option"]').contains('Cluster').click() + cy.get('#node-color-scale-mode').click() + cy.get('li[role="option"]').contains('Categorical').click() cy.get(selectors.treeSvg) .find('g.tidytree-node-leaf circle[title="MZ740979"]') diff --git a/cypress/e2e/view-state/timeline-epicurve-view.cy.ts b/cypress/e2e/view-state/timeline-epicurve-view.cy.ts index 63d031fd1..8cd5232d7 100644 --- a/cypress/e2e/view-state/timeline-epicurve-view.cy.ts +++ b/cypress/e2e/view-state/timeline-epicurve-view.cy.ts @@ -194,7 +194,9 @@ describe('Epi Curve / Timeline View', () => { cy.openGlobalSettings(); cy.get('#node-color-variable').click() cy.get('li[role="option"]').contains('Cluster').click() - cy.get('#node-color-table-row', { timeout: 10000 }).should('be.visible'); + cy.get('#node-color-scale-mode').click() + cy.get('li[role="option"]').contains('Categorical').click() + cy.get('#node-color-table-row', { timeout: 10000 }).scrollIntoView().should('be.visible'); cy.get('#node-color-table-row').contains('.p-selectbutton .p-togglebutton-label', 'Show').click({ force: true }); cy.window() diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index f4ba17fce..3dba82777 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -200,13 +200,13 @@ Cypress.Commands.add('closeGlobalSettings', () => { }); }); -Cypress.Commands.add('enableTimelineMode', (variableLabel = 'Date of symptom onset') => { +Cypress.Commands.add('enableTimelineMode', (variableLabel = 'Date of symptom onset Date') => { + closeVisibleSelectOverlays(); cy.openGlobalSettings(); cy.contains('.p-dialog:visible .nav-link', 'Timeline').click({ force: true }); cy.get('.p-dialog:visible #timeline-config').should('exist').and('be.visible'); - closeVisibleSelectOverlays(); cy.get('.p-dialog:visible #node-timeline-variable').click({ force: true }); cy.get(visibleSelectOverlay, { timeout: 15000 }) .last() @@ -225,21 +225,13 @@ Cypress.Commands.add('enableTimelineMode', (variableLabel = 'Date of symptom ons }); cy.get(visibleSelectOverlay, { timeout: 15000 }) .last() - .find('p-selectitem') - .find('li') - .then(($options) => { - const exactMatch = $options - .filter((_, option) => String(option.textContent || '').trim() === variableLabel) - .first(); - const partialMatch = $options - .filter((_, option) => String(option.textContent || '').includes(variableLabel)) - .first(); - const match = exactMatch.length ? exactMatch : partialMatch; - - expect(match.length, `timeline option matching "${variableLabel}"`).to.be.greaterThan(0); - cy.wrap(match).click({ force: true }); + .within(() => { + cy.contains( + 'li[role="option"]', + new RegExp(`^\\s*${Cypress._.escapeRegExp(variableLabel)}\\s*$`), + { timeout: 15000 }, + ).click({ force: true }); }); - closeVisibleSelectOverlays(); cy.get('.p-dialog:visible #node-timeline-variable .p-select-label').should('contain', variableLabel); }); diff --git a/docs/integrations/partner-embed-integration.md b/docs/integrations/partner-embed-integration.md index 34b0e85ec..0f6e3c551 100644 --- a/docs/integrations/partner-embed-integration.md +++ b/docs/integrations/partner-embed-integration.md @@ -79,6 +79,16 @@ Wire it up: globalSettings: { nodeColorBy: 'group', linkColorBy: 'distance', + linkColorScale: { + mode: 'continuous', + domain: { min: 0, max: 0.05 }, + stops: [ + { value: 0, color: '#440154' }, + { value: 0.025, color: '#21918c' }, + { value: 0.05, color: '#fde725' } + ], + missingColor: '#eae553' + }, nodeColor: '#1f77b4', linkColor: '#a6cee3', nodeShape: 'ellipse', @@ -158,6 +168,7 @@ Optional fields: globalSettings: { nodeColorBy: 'cluster', linkColorBy: 'distance', + linkColorScale: { mode: 'auto' }, nodeShapeBy: 'None', nodeColor: '#1f77b4', linkColor: '#a6cee3', @@ -182,6 +193,8 @@ Supported launch settings: | `launch.ambiguityThreshold` | non-negative number | Sets the ambiguity threshold used by applicable TN93 ambiguity strategies. | | `launch.globalSettings.nodeColorBy` | `None`, generated node field such as `cluster`, or imported node field | Colors nodes by a field. | | `launch.globalSettings.linkColorBy` | `None`, generated link field such as `origin` or `distance`, or imported link field | Colors links by a field. | +| `launch.globalSettings.nodeColorScale` | `{ mode, domain?, stops?, missingColor? }` | Configures `nodeColorBy` as `auto`, `categorical`, or `continuous`. Requires `nodeColorBy`. | +| `launch.globalSettings.linkColorScale` | `{ mode, domain?, stops?, missingColor? }` | Configures `linkColorBy` as `auto`, `categorical`, or `continuous`. Requires `linkColorBy`. | | `launch.globalSettings.nodeShapeBy` | `None`, generated node field such as `cluster`, or imported node field | Assigns node shapes by a field. | | `launch.globalSettings.nodeColor` | 6-digit hex color, for example `#1f77b4` | Sets the fallback node color when `nodeColorBy` is `None`. | | `launch.globalSettings.linkColor` | 6-digit hex color, for example `#a6cee3` | Sets the fallback link color when `linkColorBy` is `None`. | @@ -191,7 +204,9 @@ Supported launch settings: | `launch.globalSettings.backgroundColor` | 6-digit hex color, for example `#ffffff` | Sets the initial visualization background. | | `launch.globalSettings.tn93DistanceDisplayFormat` | `decimal` or `percentage` | Controls TN93 distance display formatting. | -If `distanceMetric` is supplied without `linkThreshold`, MicrobeTrace applies the metric's normal default threshold (`snps`: `16`, `tn93`: `0.015`). Field-based settings are validated after MicrobeTrace normalizes the imported files. If a requested node or link field is missing, the handoff fails instead of silently launching with a different setting. These launch defaults affect only this handoff. They are not persisted as the user's future MicrobeTrace defaults. +If `distanceMetric` is supplied without `linkThreshold`, MicrobeTrace applies the metric's normal default threshold (`snps`: `16`, `tn93`: `0.015`). Field-based settings are validated after MicrobeTrace normalizes the imported files. If a requested node or link field is missing, the handoff fails instead of silently launching with a different setting. + +Color scale domains require finite `min < max` values. Custom stops require a custom domain, at least two strictly increasing finite values that begin and end at the domain bounds, and six-digit hex colors. `missingColor` also uses six-digit hex notation. These launch defaults affect only this handoff. They are not persisted as the user's future MicrobeTrace defaults. - `onSuccess(result)` - `onError(error)` diff --git a/scripts/embed-security.test.js b/scripts/embed-security.test.js index 11ba18502..8aca78e8b 100644 --- a/scripts/embed-security.test.js +++ b/scripts/embed-security.test.js @@ -177,6 +177,19 @@ test('receiver stores normalized launch options and includes them in the receipt globalSettings: { nodeColorBy: 'id', linkColorBy: 'distance', + nodeColorScale: { + mode: 'auto', + missingColor: '#eae553' + }, + linkColorScale: { + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [ + { value: 0, color: '#440154' }, + { value: 5, color: '#21918c' }, + { value: 10, color: '#fde725' } + ] + }, nodeShapeBy: 'seq', nodeColor: '#123456', linkColor: '#654321', @@ -202,6 +215,19 @@ test('receiver stores normalized launch options and includes them in the receipt globalSettings: { nodeColorBy: 'id', linkColorBy: 'distance', + nodeColorScale: { + mode: 'auto', + missingColor: '#eae553', + }, + linkColorScale: { + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [ + { value: 0, color: '#440154' }, + { value: 5, color: '#21918c' }, + { value: 10, color: '#fde725' }, + ], + }, nodeShapeBy: 'seq', nodeColor: '#123456', linkColor: '#654321', @@ -233,6 +259,33 @@ test('receiver rejects invalid launch options before storage', async () => { assert.match(errorMessage.message.message, /defaultView/); }); +test('receiver rejects malformed continuous color scales before storage', async () => { + const context = createReceiverContext(); + + await waitForReceiverReady(context); + await sendReceiverTransfer(context, ` + launch: { + globalSettings: { + nodeColorBy: 'id', + nodeColorScale: { + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [ + { value: 0, color: '#440154' }, + { value: 8, color: '#21918c' }, + { value: 7, color: '#fde725' } + ] + } + } + }, + `); + await settle(); + + const errorMessage = context.__openerMessages.find((message) => message.message.type === 'MT_HANDOFF_ERROR'); + assert.equal(context.__store.has('handoff:handoff-test-id'), false); + assert.match(errorMessage.message.message, /increase strictly/); +}); + test('receiver continues to reject full session payloads', async () => { const context = createReceiverContext(); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 1b80ba7aa..4e3793a51 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -84,6 +84,7 @@ import { GoogleTagManagerModule } from 'angular-google-tag-manager'; import { SankeyComponent } from './visualizationComponents/SankeyComponent/sankey.component'; import { KeyTablesComponent } from './visualizationComponents/KeyTablesComponent/key-tables.component'; import { StyleKeyTableComponent } from './visualizationComponents/KeyTablesComponent/style-key-table.component'; +import { ContinuousColorRampComponent } from './visualizationComponents/KeyTablesComponent/continuous-color-ramp.component'; import * as PlotlyJS from 'plotly.js-dist-min'; import { PlotlyModule } from 'angular-plotly.js'; import { GlobalErrorHandler } from './runtime-security/global-error-handler'; @@ -138,6 +139,7 @@ export class TestedComponent { SankeyComponent, KeyTablesComponent, StyleKeyTableComponent, + ContinuousColorRampComponent, ], exports: [ SelectButtonModule diff --git a/src/app/contactTraceCommonServices/color-mapping.service.spec.ts b/src/app/contactTraceCommonServices/color-mapping.service.spec.ts index 261b855ab..4610f619e 100644 --- a/src/app/contactTraceCommonServices/color-mapping.service.spec.ts +++ b/src/app/contactTraceCommonServices/color-mapping.service.spec.ts @@ -1,4 +1,5 @@ import { ColorMappingService } from './color-mapping.service'; +import { createDefaultVariableColorScaleConfig } from './variable-color-scale'; describe('ColorMappingService node assignments', () => { let service: ColorMappingService; @@ -18,7 +19,8 @@ describe('ColorMappingService node assignments', () => { { MLST: ['8'] }, history, { '8': '#444444' }, - false + false, + createDefaultVariableColorScaleConfig('categorical') ); expect(result.colorMap('8')).toBe('#444444'); @@ -39,7 +41,8 @@ describe('ColorMappingService node assignments', () => { { MLST: ['8', '84'] }, {}, { '8': '#cccccc' }, - false + false, + createDefaultVariableColorScaleConfig('categorical') ); expect(result.colorMap('8')).toBe('#cccccc'); @@ -57,7 +60,8 @@ describe('ColorMappingService node assignments', () => { {}, history, { '8': '#cc9999' }, - false + false, + createDefaultVariableColorScaleConfig('categorical') ); expect(history.MLST['8']).toBe('#cc9999'); @@ -70,7 +74,8 @@ describe('ColorMappingService node assignments', () => { {}, history, {}, - false + false, + createDefaultVariableColorScaleConfig('categorical') ); expect(otherResult.colorMap('8')).toBe('#123456'); diff --git a/src/app/contactTraceCommonServices/color-mapping.service.ts b/src/app/contactTraceCommonServices/color-mapping.service.ts index d0dd9a379..9f5b3f2cf 100644 --- a/src/app/contactTraceCommonServices/color-mapping.service.ts +++ b/src/app/contactTraceCommonServices/color-mapping.service.ts @@ -1,5 +1,12 @@ import { Injectable } from '@angular/core'; import * as d3 from 'd3'; +import { + buildContinuousGradientCss, + createDefaultVariableColorScaleConfig, + ResolvedVariableColorScale, + resolveVariableColorScale, + VariableColorScaleConfig +} from './variable-color-scale'; /** * A dedicated service for node, link, polygon color mapping. @@ -14,6 +21,18 @@ export class ColorMappingService { constructor() {} + public resolveVariableColorScale( + items: any[], + field: string, + config?: VariableColorScaleConfig + ): ResolvedVariableColorScale { + return resolveVariableColorScale(items, field, config); + } + + public buildContinuousGradientCss(resolved: ResolvedVariableColorScale): string { + return buildContinuousGradientCss(resolved); + } + /** * Creates a node color-mapping scale based on a specified "nodeColorVariable" * and a set of node items. Rather than referencing session or temp directly, @@ -48,18 +67,26 @@ export class ColorMappingService { nodeColorsTableKeys: any, nodeColorsTableHistory: any, nodeColorAssignments: Record, - debugMode: boolean + debugMode: boolean, + variableColorScaleConfig?: VariableColorScaleConfig ): { aggregates: Record; - colorMap: d3.ScaleOrdinal; - alphaMap: d3.ScaleOrdinal; + colorMap: (value: unknown) => string; + alphaMap: (value: unknown) => number; updatedNodeColors: string[]; updatedNodeAlphas: number[]; updatedColorsTable: any; updatedColorsTableKeys: any; updatedColorsTableHistory: any; + resolvedScale: ResolvedVariableColorScale; } { + const resolvedScale = resolveVariableColorScale( + nodes, + nodeColorVariable, + variableColorScaleConfig ?? createDefaultVariableColorScaleConfig('categorical') + ); + // If user hasn't chosen a variable, just return a single uniform color mapping if (nodeColorVariable === 'None') { const uniformMap = () => nodeColors[0] || '#1f77b4'; @@ -71,7 +98,22 @@ export class ColorMappingService { updatedNodeAlphas: nodeAlphas, updatedColorsTable: nodeColorsTable, updatedColorsTableKeys: nodeColorsTableKeys, - updatedColorsTableHistory: nodeColorsTableHistory + updatedColorsTableHistory: nodeColorsTableHistory, + resolvedScale + }; + } + + if (resolvedScale.mode === 'continuous') { + return { + aggregates: {}, + colorMap: resolvedScale.colorMap, + alphaMap: () => 1, + updatedNodeColors: nodeColors, + updatedNodeAlphas: nodeAlphas, + updatedColorsTable: nodeColorsTable || {}, + updatedColorsTableKeys: nodeColorsTableKeys || {}, + updatedColorsTableHistory: nodeColorsTableHistory || {}, + resolvedScale }; } @@ -234,7 +276,8 @@ export class ColorMappingService { updatedNodeAlphas, updatedColorsTable, updatedColorsTableKeys, - updatedColorsTableHistory + updatedColorsTableHistory, + resolvedScale }; } @@ -249,17 +292,26 @@ export class ColorMappingService { linkColorsTable: any, linkColorsTableKeys: any, linkColorsTableHistory: any, - debugMode: boolean + debugMode: boolean, + variableColorScaleConfig?: VariableColorScaleConfig, + domainLinks: any[] = links ): { aggregates: Record; - colorMap: d3.ScaleOrdinal; - alphaMap: d3.ScaleOrdinal; + colorMap: (value: unknown) => string; + alphaMap: (value: unknown) => number; updatedLinkColors: string[]; updatedLinkAlphas: number[]; updatedLinkColorsTable: any; updatedLinkColorsTableKeys: any; updatedLinkColorsTableHistory: any; + resolvedScale: ResolvedVariableColorScale; } { + + const resolvedScale = resolveVariableColorScale( + domainLinks, + linkColorVariable, + variableColorScaleConfig ?? createDefaultVariableColorScaleConfig('categorical') + ); // If user hasn't chosen a variable @@ -273,7 +325,22 @@ export class ColorMappingService { updatedLinkAlphas: linkAlphas, updatedLinkColorsTable: linkColorsTable, updatedLinkColorsTableKeys: linkColorsTableKeys, - updatedLinkColorsTableHistory: linkColorsTableHistory + updatedLinkColorsTableHistory: linkColorsTableHistory, + resolvedScale + }; + } + + if (resolvedScale.mode === 'continuous') { + return { + aggregates: {}, + colorMap: resolvedScale.colorMap, + alphaMap: () => 1, + updatedLinkColors: linkColors, + updatedLinkAlphas: linkAlphas, + updatedLinkColorsTable: linkColorsTable || {}, + updatedLinkColorsTableKeys: linkColorsTableKeys || {}, + updatedLinkColorsTableHistory: linkColorsTableHistory || {}, + resolvedScale }; } @@ -498,7 +565,8 @@ export class ColorMappingService { updatedLinkAlphas, updatedLinkColorsTable, updatedLinkColorsTableKeys, - updatedLinkColorsTableHistory + updatedLinkColorsTableHistory, + resolvedScale }; } diff --git a/src/app/contactTraceCommonServices/common.service.ts b/src/app/contactTraceCommonServices/common.service.ts index d2cba9059..cc1307f33 100644 --- a/src/app/contactTraceCommonServices/common.service.ts +++ b/src/app/contactTraceCommonServices/common.service.ts @@ -17,6 +17,18 @@ import { CommonStoreService } from './common-store.services'; import { LayoutConfig } from 'golden-layout'; import { REFERENCE, HBX2, WATERMARK } from '@app/constants/longStrings.constants'; import { ColorMappingService } from './color-mapping.service'; +import { + buildContinuousGradientCss, + ColorScaleMode, + createDefaultVariableColorScaleConfig, + createVariableColorScaleState, + normalizeVariableColorScaleConfig, + normalizeVariableColorScaleState, + ResolvedVariableColorScale, + VariableColorScaleConfig, + VariableColorScaleState, + VariableColorTarget +} from './variable-color-scale'; import { WorkerComputeService } from './worker-compute.service'; import { buildStoredDistanceEdgeCache, @@ -678,6 +690,7 @@ export class CommonService extends AppComponentBase implements OnInit { polygonAlphas: [0.5], polygonColors: this.polygonPalette, polygonValueNames: {}, + variableColorScales: createVariableColorScaleState(), overwrite: {}, widgets: this.defaultWidgets() }, @@ -709,9 +722,11 @@ export class CommonService extends AppComponentBase implements OnInit { linkColorMap: () => this.session.style.widgets['link-color'], nodeAlphaMap: () => 1, nodeColorMap: () => this.session.style.widgets['node-color'], + nodeColorScale: null as ResolvedVariableColorScale | null, nodeSymbolMap: () => this.session.style.widgets['node-symbol'], polygonAlphaMap: () => 0.5, - polygonColorMap: () => this.session.style.widgets['polygon-color'] + polygonColorMap: () => this.session.style.widgets['polygon-color'], + linkColorScale: null as ResolvedVariableColorScale | null }, trees: {} }; @@ -729,6 +744,90 @@ export class CommonService extends AppComponentBase implements OnInit { return Math.min(1, Math.max(0, numericValue)); } + public ensureVariableColorScaleState( + style: any = this.session?.style, + preserveLegacySelections = false + ): VariableColorScaleState { + if (!style || typeof style !== 'object') { + return createVariableColorScaleState(); + } + + const existingState = style.variableColorScales; + const hasVersionedState = existingState + && typeof existingState === 'object' + && !Array.isArray(existingState) + && existingState.version === 1; + const normalizedState = normalizeVariableColorScaleState(existingState); + + if (!hasVersionedState && preserveLegacySelections) { + const nodeField = style.widgets?.['node-color-variable']; + const linkField = style.widgets?.['link-color-variable']; + if (nodeField && nodeField !== 'None') { + normalizedState.node[nodeField] = createDefaultVariableColorScaleConfig('categorical'); + } + if (linkField && linkField !== 'None') { + normalizedState.link[linkField] = createDefaultVariableColorScaleConfig('categorical'); + } + } + + style.variableColorScales = normalizedState; + return normalizedState; + } + + public getVariableColorScaleConfig( + target: VariableColorTarget, + field: string, + create = true + ): VariableColorScaleConfig { + const state = this.ensureVariableColorScaleState(); + const existingConfig = state[target]?.[field]; + if (existingConfig || !create) { + return normalizeVariableColorScaleConfig(existingConfig); + } + + const config = createDefaultVariableColorScaleConfig(); + state[target][field] = config; + return config; + } + + public setVariableColorScaleConfig( + target: VariableColorTarget, + field: string, + config: VariableColorScaleConfig + ): VariableColorScaleConfig { + const state = this.ensureVariableColorScaleState(); + const normalizedConfig = normalizeVariableColorScaleConfig(config); + state[target][field] = normalizedConfig; + return normalizedConfig; + } + + public setVariableColorScaleMode( + target: VariableColorTarget, + field: string, + mode: ColorScaleMode + ): VariableColorScaleConfig { + const currentConfig = this.getVariableColorScaleConfig(target, field); + return this.setVariableColorScaleConfig(target, field, { ...currentConfig, mode }); + } + + public resolveVariableColorScale( + target: VariableColorTarget, + field: string + ): ResolvedVariableColorScale { + const items = target === 'node' + ? this.session?.data?.nodes || [] + : this.session?.data?.links || []; + return this.colorMappingService.resolveVariableColorScale( + items, + field, + this.getVariableColorScaleConfig(target, field) + ); + } + + public buildVariableColorGradient(target: VariableColorTarget, field: string): string { + return buildContinuousGradientCss(this.resolveVariableColorScale(target, field)); + } + private ensureNodeColorAssignmentState(style: any = this.session?.style): Record> { if (!style || typeof style !== 'object') { return {}; @@ -772,6 +871,7 @@ export class CommonService extends AppComponentBase implements OnInit { }); assignmentState[selectedField] = mergedAssignments; + this.setVariableColorScaleMode('node', selectedField, 'categorical'); this.createNodeColorMap(); return mergedAssignments; } @@ -790,10 +890,11 @@ export class CommonService extends AppComponentBase implements OnInit { const value = node[variable]; const historicalColor = this.session.style.nodeColorsTableHistory?.[variable]?.[String(value)]; + const useContinuousScale = this.temp.style.nodeColorScale?.mode === 'continuous'; let color = fallbackColor; let alpha = 1; - if (typeof historicalColor === 'string' && historicalColor) { + if (!useContinuousScale && typeof historicalColor === 'string' && historicalColor) { // Timeline color tables only contain currently visible categories. Keep // hidden/newly visible nodes on their persisted category color instead // of letting d3 assign a temporary color for a missing scale-domain key. @@ -2746,6 +2847,7 @@ export class CommonService extends AppComponentBase implements OnInit { this.ensureNetworkSubsetFilterState(); this.session.style = oldSession.style; this.ensureNodeColorAssignmentState(this.session.style); + this.ensureVariableColorScaleState(this.session.style, true); this.session.meta.startTime = Date.now(); @@ -2886,6 +2988,7 @@ export class CommonService extends AppComponentBase implements OnInit { console.log('---- applying style: ', style); } this.ensureNodeColorAssignmentState(style); + this.ensureVariableColorScaleState(style, true); this.session.style = style; this.session.style.widgets = Object.assign({}, this.defaultWidgets(), @@ -4265,6 +4368,7 @@ align(params): Promise { const nodeColorsTableKeys = this.session.style.nodeColorsTableKeys; const nodeColorsTableHistory = this.session.style.nodeColorsTableHistory; const nodeColorAssignments = this.ensureNodeColorAssignmentState()[nodeColorVariable] || {}; + const variableColorScaleConfig = this.getVariableColorScaleConfig('node', nodeColorVariable); // 2) Call your new colorMappingService const result = this.colorMappingService.createNodeColorMap( @@ -4276,12 +4380,14 @@ align(params): Promise { nodeColorsTableKeys, nodeColorsTableHistory, nodeColorAssignments, - this.debugMode + this.debugMode, + variableColorScaleConfig ); // 3) Store the results back into session & temp this.temp.style.nodeColorMap = result.colorMap; this.temp.style.nodeAlphaMap = result.alphaMap; + this.temp.style.nodeColorScale = result.resolvedScale; // And also store the updated arrays/tables this.session.style.nodeColors = result.updatedNodeColors; @@ -4312,9 +4418,17 @@ align(params): Promise { } const links = this.getVisibleLinksForCurrentTimeline(); + const variableColorScaleConfig = this.getVariableColorScaleConfig('link', linkColorVariable); + const resolvedScale = this.colorMappingService.resolveVariableColorScale( + this.session.data.links, + linkColorVariable, + variableColorScaleConfig + ); let linkColors; - if( this.session.style.linkColorsTable && this.session.style.linkColorsTable[linkColorVariable]) { + if (resolvedScale.mode === 'continuous') { + linkColors = this.session.style.linkColors || d3.schemePaired; + } else if( this.session.style.linkColorsTable && this.session.style.linkColorsTable[linkColorVariable]) { linkColors = this.session.style.linkColorsTable[linkColorVariable]; } else if (linkColorVariable == 'source' || linkColorVariable == 'target') { this.session.style.linkColorsTable = {}; @@ -4346,13 +4460,16 @@ align(params): Promise { linkColorsTable, linkColorsTableKeys, linkColorsTableHistory, - this.debugMode + this.debugMode, + variableColorScaleConfig, + this.session.data.links ); // 3) Store updated scales back into session & temp this.temp.style.linkColorMap = result.colorMap; this.temp.style.linkAlphaMap = result.alphaMap; + this.temp.style.linkColorScale = result.resolvedScale; // store the updated arrays this.session.style.linkColors = result.updatedLinkColors; diff --git a/src/app/contactTraceCommonServices/export.service.ts b/src/app/contactTraceCommonServices/export.service.ts index 32268aa25..6e1ef4c4b 100644 --- a/src/app/contactTraceCommonServices/export.service.ts +++ b/src/app/contactTraceCommonServices/export.service.ts @@ -30,14 +30,14 @@ export class ExportService { }> = this.exportRequestedSource.asObservable(); private exportSVGSource = new Subject<{ - element: HTMLTableElement[], + element: HTMLElement[], mainSVGString: string, exportNodeTable: boolean, exportLinkTable: boolean, exportNodeShapeTable: boolean }>(); exportSVG$: Observable<{ - element: HTMLTableElement[], + element: HTMLElement[], mainSVGString: string, exportNodeTable: boolean, exportLinkTable: boolean, @@ -94,7 +94,7 @@ export class ExportService { * @param exportNodeShapeTable Flag for exporting the node shape table. */ requestSVGExport( - element: HTMLTableElement[], + element: HTMLElement[], mainSVGString: string, exportNodeTable: boolean, exportLinkTable: boolean, @@ -339,6 +339,51 @@ export class ExportService { return { svg: out, width: tableWidth, height: tableHeight }; } + exportColorLegendAsSVG(legendElement: HTMLElement): { svg: string, width: number, height: number } { + const gradientElement = legendElement.querySelector('.continuous-ramp__gradient'); + const ticks = Array.from(legendElement.querySelectorAll('.continuous-ramp__ticks span')); + const missingSwatch = legendElement.querySelector('.continuous-ramp__missing-swatch'); + const description = gradientElement?.getAttribute('aria-label') || 'Continuous color legend'; + const background = gradientElement?.style.background || ''; + const stopPattern = /(#[0-9a-f]{6}|rgba?\([^)]+\))\s+([\d.]+)%/gi; + const gradientStops: Array<{ color: string; offset: number }> = []; + let match: RegExpExecArray | null; + while ((match = stopPattern.exec(background)) !== null) { + gradientStops.push({ color: match[1], offset: Number(match[2]) }); + } + if (gradientStops.length < 2) { + gradientStops.splice(0, gradientStops.length, + { color: '#440154', offset: 0 }, + { color: '#fde725', offset: 100 }); + } + + const width = Math.max(320, Math.ceil(legendElement.getBoundingClientRect().width || 0)); + const height = 112; + const gradientId = `continuous-gradient-${(legendElement.id || 'legend').replace(/[^a-z0-9_-]/gi, '-')}`; + const tickMarkup = ticks.map((tick, index) => { + const left = Number.parseFloat(tick.style.left); + const x = Number.isFinite(left) + ? 12 + ((width - 24) * left / 100) + : 12 + ((width - 24) * index / Math.max(1, ticks.length - 1)); + const anchor = index === 0 ? 'start' : index === ticks.length - 1 ? 'end' : 'middle'; + return `${this.escapeSVGText(tick.textContent || '')}`; + }).join(''); + const stopMarkup = gradientStops + .map(stop => ``) + .join(''); + const missingColor = missingSwatch?.style.background || '#EAE553'; + + const svg = ` + + ${stopMarkup} + + ${tickMarkup} + + Missing / invalid + `; + return { svg, width, height }; + } + /** * Extracts the SVG from a DOM element with CSS included. * @param svgNode The SVG HTMLElement (for example, obtained via document.getElementById('network')). diff --git a/src/app/contactTraceCommonServices/variable-color-scale.spec.ts b/src/app/contactTraceCommonServices/variable-color-scale.spec.ts new file mode 100644 index 000000000..1a403c166 --- /dev/null +++ b/src/app/contactTraceCommonServices/variable-color-scale.spec.ts @@ -0,0 +1,130 @@ +import * as d3 from 'd3'; +import { + DEFAULT_CONTINUOUS_MISSING_COLOR, + createDefaultVariableColorScaleConfig, + inspectContinuousValues, + normalizeVariableColorScaleConfig, + normalizeVariableColorScaleState, + parseContinuousNumber, + resolveVariableColorScale, +} from './variable-color-scale'; + +describe('variable color scales', () => { + it('accepts finite numbers and fully numeric strings', () => { + expect(parseContinuousNumber(3)).toEqual({ kind: 'number', value: 3 }); + expect(parseContinuousNumber(' -1.25e2 ')).toEqual({ kind: 'number', value: -125 }); + expect(parseContinuousNumber('.5')).toEqual({ kind: 'number', value: 0.5 }); + expect(parseContinuousNumber('1px')).toEqual({ kind: 'invalid' }); + expect(parseContinuousNumber(Number.POSITIVE_INFINITY)).toEqual({ kind: 'invalid' }); + }); + + it('distinguishes blanks and NaN-like values from mixed invalid values', () => { + const summary = inspectContinuousValues([ + { value: 1 }, + { value: '2' }, + { value: '' }, + { value: null }, + { value: 'NaN' }, + { value: 'two' }, + ], 'value'); + + expect(summary).toEqual(jasmine.objectContaining({ + numericCount: 2, + missingCount: 3, + invalidCount: 1, + min: 1, + max: 2, + canUseContinuous: true, + autoUsesContinuous: false, + })); + }); + + it('uses continuous Auto mode for numeric-or-missing fields', () => { + const resolved = resolveVariableColorScale([ + { value: '-2' }, + { value: null }, + { value: 8, visible: false }, + ], 'value', createDefaultVariableColorScaleConfig()); + + expect(resolved.mode).toBe('continuous'); + expect(resolved.domain).toEqual({ min: -2, max: 8 }); + expect(resolved.stops.map(stop => stop.value)).toEqual([-2, 3, 8]); + expect(resolved.colorMap(null)).toBe(DEFAULT_CONTINUOUS_MISSING_COLOR); + }); + + it('keeps mixed fields categorical in Auto and permits a continuous override', () => { + const values = [{ value: 0 }, { value: 'unknown' }, { value: 10 }]; + const automatic = resolveVariableColorScale(values, 'value', createDefaultVariableColorScaleConfig()); + const forced = resolveVariableColorScale(values, 'value', { + ...createDefaultVariableColorScaleConfig(), + mode: 'continuous', + missingColor: '#123456', + }); + + expect(automatic.mode).toBe('categorical'); + expect(forced.mode).toBe('continuous'); + expect(forced.colorMap('unknown')).toBe('#123456'); + }); + + it('honors categorical overrides without changing numeric detection', () => { + const resolved = resolveVariableColorScale([{ value: 1 }, { value: 2 }], 'value', { + ...createDefaultVariableColorScaleConfig(), + mode: 'categorical', + }); + + expect(resolved.mode).toBe('categorical'); + expect(resolved.summary.autoUsesContinuous).toBeTrue(); + }); + + it('uses custom bounds, arbitrary stops, CIELAB interpolation, and clamping', () => { + const resolved = resolveVariableColorScale([{ value: -100 }, { value: 100 }], 'value', { + mode: 'continuous', + domain: { kind: 'custom', min: 0, max: 20 }, + stops: [ + { value: 0, color: '#000000' }, + { value: 5, color: '#ff0000' }, + { value: 20, color: '#ffffff' }, + ], + missingColor: '#abcdef', + }); + + expect(resolved.domain).toEqual({ min: 0, max: 20 }); + expect(d3.color(resolved.colorMap(-5))?.hex()).toBe('#000000'); + expect(d3.color(resolved.colorMap(5))?.hex()).toBe('#ff0000'); + expect(d3.color(resolved.colorMap(25))?.hex()).toBe('#ffffff'); + expect(resolved.colorMap(undefined)).toBe('#abcdef'); + }); + + it('uses the middle ramp color and a single-value domain for constant fields', () => { + const resolved = resolveVariableColorScale([{ value: 7 }, { value: '7' }], 'value'); + + expect(resolved.constant).toBeTrue(); + expect(resolved.domain).toEqual({ min: 7, max: 7 }); + expect(resolved.colorMap(7).toLowerCase()).toBe('#21918c'); + expect(resolved.colorMap(999).toLowerCase()).toBe('#21918c'); + }); + + it('normalizes malformed persisted settings safely', () => { + const config = normalizeVariableColorScaleConfig({ + mode: 'rainbow', + domain: { kind: 'custom', min: 5, max: 1 }, + stops: [ + { value: 1, color: 'red' }, + { value: 1, color: '#123456' }, + ], + missingColor: 'yellow', + }); + const state = normalizeVariableColorScaleState({ + version: 99, + node: { score: config }, + link: [], + }); + + expect(config).toEqual(jasmine.objectContaining(createDefaultVariableColorScaleConfig())); + expect(config.stops).toBeUndefined(); + expect(state.version).toBe(1); + expect(state.node.score).toEqual(jasmine.objectContaining(createDefaultVariableColorScaleConfig())); + expect(state.node.score.stops).toBeUndefined(); + expect(state.link).toEqual({}); + }); +}); diff --git a/src/app/contactTraceCommonServices/variable-color-scale.ts b/src/app/contactTraceCommonServices/variable-color-scale.ts new file mode 100644 index 000000000..483f91ff4 --- /dev/null +++ b/src/app/contactTraceCommonServices/variable-color-scale.ts @@ -0,0 +1,333 @@ +import * as d3 from 'd3'; + +export type ColorScaleMode = 'auto' | 'categorical' | 'continuous'; +export type VariableColorTarget = 'node' | 'link'; + +export interface ContinuousColorStop { + value: number; + color: string; +} + +export type VariableColorScaleDomain = + | { kind: 'auto' } + | { kind: 'custom'; min: number; max: number }; + +export interface VariableColorScaleConfig { + mode: ColorScaleMode; + domain: VariableColorScaleDomain; + stops?: ContinuousColorStop[]; + missingColor: string; +} + +export interface VariableColorScaleState { + version: 1; + node: Record; + link: Record; +} + +export interface ContinuousNumericSummary { + numericCount: number; + missingCount: number; + invalidCount: number; + uniqueCount: number; + min: number | null; + max: number | null; + canUseContinuous: boolean; + autoUsesContinuous: boolean; +} + +export interface ResolvedVariableColorScale { + field: string; + requestedMode: ColorScaleMode; + mode: 'categorical' | 'continuous'; + summary: ContinuousNumericSummary; + domain: { min: number; max: number } | null; + stops: ContinuousColorStop[]; + missingColor: string; + constant: boolean; + colorMap: (value: unknown) => string; +} + +export const VARIABLE_COLOR_SCALE_VERSION = 1 as const; +export const DEFAULT_CONTINUOUS_MISSING_COLOR = '#EAE553'; +export const DEFAULT_CONTINUOUS_COLORS = ['#440154', '#21918c', '#fde725'] as const; + +const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i; +const DECIMAL_NUMBER_PATTERN = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i; + +export function createDefaultVariableColorScaleConfig(mode: ColorScaleMode = 'auto'): VariableColorScaleConfig { + return { + mode, + domain: { kind: 'auto' }, + missingColor: DEFAULT_CONTINUOUS_MISSING_COLOR + }; +} + +export function createVariableColorScaleState(): VariableColorScaleState { + return { + version: VARIABLE_COLOR_SCALE_VERSION, + node: {}, + link: {} + }; +} + +export function normalizeHexColor(value: unknown, fallback: string): string { + const color = typeof value === 'string' ? value.trim() : ''; + return HEX_COLOR_PATTERN.test(color) ? color : fallback; +} + +export function parseContinuousNumber(value: unknown): { kind: 'number'; value: number } | { kind: 'missing' | 'invalid' } { + if (value === null || value === undefined) { + return { kind: 'missing' }; + } + + if (typeof value === 'number') { + if (Number.isFinite(value)) { + return { kind: 'number', value }; + } + return Number.isNaN(value) ? { kind: 'missing' } : { kind: 'invalid' }; + } + + if (typeof value !== 'string') { + return { kind: 'invalid' }; + } + + const trimmed = value.trim(); + const normalized = trimmed.toLowerCase(); + if (!trimmed || normalized === 'nan' || normalized === 'null') { + return { kind: 'missing' }; + } + + if (!DECIMAL_NUMBER_PATTERN.test(trimmed)) { + return { kind: 'invalid' }; + } + + const numericValue = Number(trimmed); + return Number.isFinite(numericValue) + ? { kind: 'number', value: numericValue } + : { kind: 'invalid' }; +} + +export function inspectContinuousValues(items: any[], field: string): ContinuousNumericSummary { + const numericValues: number[] = []; + let missingCount = 0; + let invalidCount = 0; + + (items || []).forEach(item => { + const parsed = parseContinuousNumber(item?.[field]); + if (parsed.kind === 'number') { + numericValues.push(parsed.value); + } else if (parsed.kind === 'missing') { + missingCount += 1; + } else { + invalidCount += 1; + } + }); + + const numericCount = numericValues.length; + const uniqueCount = new Set(numericValues).size; + const min = numericCount ? Math.min(...numericValues) : null; + const max = numericCount ? Math.max(...numericValues) : null; + + return { + numericCount, + missingCount, + invalidCount, + uniqueCount, + min, + max, + canUseContinuous: numericCount > 0, + autoUsesContinuous: numericCount > 0 && invalidCount === 0 + }; +} + +export function normalizeVariableColorScaleConfig(value: unknown): VariableColorScaleConfig { + const candidate = value && typeof value === 'object' && !Array.isArray(value) + ? value as Partial + : {}; + const mode: ColorScaleMode = candidate.mode === 'categorical' || candidate.mode === 'continuous' + ? candidate.mode + : 'auto'; + + let domain: VariableColorScaleDomain = { kind: 'auto' }; + if (candidate.domain?.kind === 'custom') { + const min = Number(candidate.domain.min); + const max = Number(candidate.domain.max); + if (Number.isFinite(min) && Number.isFinite(max) && min < max) { + domain = { kind: 'custom', min, max }; + } + } + + const stops = Array.isArray(candidate.stops) + ? candidate.stops + .map(stop => ({ + value: Number(stop?.value), + color: normalizeHexColor(stop?.color, '') + })) + .filter(stop => Number.isFinite(stop.value) && !!stop.color) + .sort((left, right) => left.value - right.value) + : undefined; + + const hasStrictlyIncreasingStops = !!stops + && stops.length >= 2 + && stops.every((stop, index) => index === 0 || stop.value > stops[index - 1].value); + + return { + mode, + domain, + stops: hasStrictlyIncreasingStops ? stops : undefined, + missingColor: normalizeHexColor(candidate.missingColor, DEFAULT_CONTINUOUS_MISSING_COLOR) + }; +} + +export function normalizeVariableColorScaleState(value: unknown): VariableColorScaleState { + const candidate = value && typeof value === 'object' && !Array.isArray(value) + ? value as Partial + : {}; + const state = createVariableColorScaleState(); + + (['node', 'link'] as VariableColorTarget[]).forEach(target => { + const targetValue = candidate[target]; + if (!targetValue || typeof targetValue !== 'object' || Array.isArray(targetValue)) { + return; + } + + Object.entries(targetValue).forEach(([field, config]) => { + if (field) { + state[target][field] = normalizeVariableColorScaleConfig(config); + } + }); + }); + + return state; +} + +function buildDefaultStops(min: number, max: number): ContinuousColorStop[] { + if (min === max) { + return [ + { value: min, color: DEFAULT_CONTINUOUS_COLORS[0] }, + { value: min, color: DEFAULT_CONTINUOUS_COLORS[1] }, + { value: min, color: DEFAULT_CONTINUOUS_COLORS[2] } + ]; + } + + return [ + { value: min, color: DEFAULT_CONTINUOUS_COLORS[0] }, + { value: min + ((max - min) / 2), color: DEFAULT_CONTINUOUS_COLORS[1] }, + { value: max, color: DEFAULT_CONTINUOUS_COLORS[2] } + ]; +} + +function resolveStops(config: VariableColorScaleConfig, min: number, max: number): ContinuousColorStop[] { + const configuredStops = config.stops; + if (!configuredStops || configuredStops.length < 2 || min === max) { + return buildDefaultStops(min, max); + } + + const sortedStops = configuredStops + .filter(stop => Number.isFinite(stop.value) && HEX_COLOR_PATTERN.test(stop.color)) + .sort((left, right) => left.value - right.value); + if (sortedStops.length < 2) { + return buildDefaultStops(min, max); + } + + const interiorStops = sortedStops + .slice(1, -1) + .filter(stop => stop.value > min && stop.value < max); + + return [ + { value: min, color: sortedStops[0].color }, + ...interiorStops, + { value: max, color: sortedStops[sortedStops.length - 1].color } + ]; +} + +export function resolveVariableColorScale( + items: any[], + field: string, + rawConfig?: VariableColorScaleConfig +): ResolvedVariableColorScale { + const config = normalizeVariableColorScaleConfig(rawConfig); + const summary = inspectContinuousValues(items, field); + const requestedMode = config.mode; + const useContinuous = summary.canUseContinuous && ( + requestedMode === 'continuous' + || (requestedMode === 'auto' && summary.autoUsesContinuous) + ); + const missingColor = config.missingColor; + + if (!useContinuous || summary.min === null || summary.max === null) { + return { + field, + requestedMode, + mode: 'categorical', + summary, + domain: null, + stops: [], + missingColor, + constant: false, + colorMap: () => missingColor + }; + } + + const min = config.domain.kind === 'custom' ? config.domain.min : summary.min; + const max = config.domain.kind === 'custom' ? config.domain.max : summary.max; + const constant = min === max; + const stops = resolveStops(config, min, max); + + if (constant) { + const midpointColor = stops[Math.floor(stops.length / 2)]?.color || DEFAULT_CONTINUOUS_COLORS[1]; + return { + field, + requestedMode, + mode: 'continuous', + summary, + domain: { min, max }, + stops, + missingColor, + constant: true, + colorMap: value => parseContinuousNumber(value).kind === 'number' ? midpointColor : missingColor + }; + } + + const scale = d3.scaleLinear() + .domain(stops.map(stop => stop.value)) + .range(stops.map(stop => stop.color)) + .interpolate(d3.interpolateLab) + .clamp(true); + + return { + field, + requestedMode, + mode: 'continuous', + summary, + domain: { min, max }, + stops, + missingColor, + constant: false, + colorMap: value => { + const parsed = parseContinuousNumber(value); + return parsed.kind === 'number' ? scale(parsed.value) : missingColor; + } + }; +} + +export function buildContinuousGradientCss(resolved: ResolvedVariableColorScale, sampleCount = 32): string { + if (resolved.mode !== 'continuous' || !resolved.domain) { + return resolved.missingColor; + } + + if (resolved.constant) { + return resolved.colorMap(resolved.domain.min); + } + + const count = Math.max(2, sampleCount); + const span = resolved.domain.max - resolved.domain.min; + const samples = Array.from({ length: count }, (_, index) => { + const fraction = index / (count - 1); + const value = resolved.domain.min + span * fraction; + return `${resolved.colorMap(value)} ${(fraction * 100).toFixed(2)}%`; + }); + + return `linear-gradient(to right, ${samples.join(', ')})`; +} diff --git a/src/app/embed/embed-handoff.service.spec.ts b/src/app/embed/embed-handoff.service.spec.ts index fab4cc7bd..2bc805e5f 100644 --- a/src/app/embed/embed-handoff.service.spec.ts +++ b/src/app/embed/embed-handoff.service.spec.ts @@ -241,6 +241,140 @@ describe('EmbedHandoffService', () => { expect(result.message).toContain('missing_node_field'); }); + it('accepts continuous node and link color scales without changing embed v1', async () => { + seedValidHandoff({ + launch: { + globalSettings: { + nodeColorBy: 'score', + linkColorBy: 'distance', + nodeColorScale: { + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [ + { value: 0, color: '#440154' }, + { value: 4, color: '#21918c' }, + { value: 10, color: '#fde725' }, + ], + missingColor: '#eae553', + }, + linkColorScale: { + mode: 'auto', + missingColor: '#abcdef', + }, + }, + }, + files: [ + { + name: 'nodes.csv', + kind: 'node', + mimeType: 'text/csv', + contents: 'id,score\nA,1\nB,9\n', + }, + { + name: 'links.csv', + kind: 'link', + mimeType: 'text/csv', + contents: 'source,target,distance\nA,B,1\n', + }, + ], + }); + + const result = await service.consumePendingHandoffFromUrl(); + + expect(result.status).toBe('success'); + if (result.status !== 'success') { + fail('Expected a successful handoff result.'); + return; + } + expect(result.handoff.version).toBe(EMBED_HANDOFF_VERSION); + expect(result.handoff.launch?.globalSettings?.nodeColorScale).toEqual({ + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [ + { value: 0, color: '#440154' }, + { value: 4, color: '#21918c' }, + { value: 10, color: '#fde725' }, + ], + missingColor: '#eae553', + }); + expect(result.handoff.launch?.globalSettings?.linkColorScale).toEqual({ + mode: 'auto', + missingColor: '#abcdef', + }); + }); + + [ + { + label: 'a scale without its field', + globalSettings: { nodeColorScale: { mode: 'continuous' } }, + message: 'requires "nodeColorBy"', + }, + { + label: 'an unsupported mode', + globalSettings: { nodeColorBy: 'score', nodeColorScale: { mode: 'quantile' } }, + message: 'unsupported value', + }, + { + label: 'a malformed domain', + globalSettings: { nodeColorBy: 'score', nodeColorScale: { mode: 'continuous', domain: { min: 5, max: 5 } } }, + message: 'min less than max', + }, + { + label: 'stops without a custom domain', + globalSettings: { + nodeColorBy: 'score', + nodeColorScale: { + mode: 'continuous', + stops: [{ value: 0, color: '#000000' }, { value: 1, color: '#ffffff' }], + }, + }, + message: 'requires a custom domain', + }, + { + label: 'unordered stops', + globalSettings: { + nodeColorBy: 'score', + nodeColorScale: { + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [{ value: 0, color: '#000000' }, { value: 8, color: '#ffffff' }, { value: 7, color: '#123456' }], + }, + }, + message: 'increase strictly', + }, + { + label: 'invalid stop colors', + globalSettings: { + nodeColorBy: 'score', + nodeColorScale: { + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [{ value: 0, color: '#000000' }, { value: 10, color: 'white' }], + }, + }, + message: '6-digit hex color', + }, + ].forEach(({ label, globalSettings, message }) => { + it(`rejects ${label}`, async () => { + seedValidHandoff({ + launch: { globalSettings }, + files: [{ + name: 'nodes.csv', + kind: 'node', + mimeType: 'text/csv', + contents: 'id,score\nA,1\n', + }], + }); + + const result = await service.consumePendingHandoffFromUrl(); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.message).toContain(message); + } + }); + }); + it('rejects forbidden keys in handoff launch options', async () => { const createdAt = Date.now(); seedRawHandoff(`{ diff --git a/src/app/embed/embed-handoff.service.ts b/src/app/embed/embed-handoff.service.ts index 462d8c4e8..1ccda66e2 100644 --- a/src/app/embed/embed-handoff.service.ts +++ b/src/app/embed/embed-handoff.service.ts @@ -22,6 +22,7 @@ import { EmbedFileOptionsV1, EmbedFileV1, EmbedLaunchOptionsV1, + EmbedVariableColorScaleV1, EmbedPayloadMetadataV1, ImportedEmbedFile, StoredEmbedHandoffV1, @@ -401,6 +402,13 @@ export class EmbedHandoffService { normalized[fieldName] = value.trim(); }); + (['nodeColorScale', 'linkColorScale'] as const).forEach(fieldName => { + const value = globalSettings[fieldName]; + if (typeof value !== 'undefined') { + normalized[fieldName] = this.normalizeVariableColorScale(value, `globalSettings.${fieldName}`); + } + }); + if (typeof globalSettings.nodeShape !== 'undefined') { const nodeShape = this.requireAllowedLaunchString( globalSettings.nodeShape, @@ -426,6 +434,73 @@ export class EmbedHandoffService { return normalized; } + private normalizeVariableColorScale(value: unknown, fieldName: string): EmbedVariableColorScaleV1 { + if (!this.isPlainObject(value)) { + throw new Error(`Launch option "${fieldName}" must be an object.`); + } + + const allowedModes = new Set(['auto', 'categorical', 'continuous']); + if (typeof value.mode !== 'string' || !allowedModes.has(value.mode)) { + throw new Error(`Launch option "${fieldName}.mode" used an unsupported value.`); + } + + const normalized: EmbedVariableColorScaleV1 = { + mode: value.mode as EmbedVariableColorScaleV1['mode'], + }; + + if (typeof value.domain !== 'undefined') { + if (!this.isPlainObject(value.domain)) { + throw new Error(`Launch option "${fieldName}.domain" must contain finite min and max values.`); + } + const min = Number(value.domain.min); + const max = Number(value.domain.max); + if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) { + throw new Error(`Launch option "${fieldName}.domain" must have finite min less than max.`); + } + normalized.domain = { min, max }; + } + + if (typeof value.stops !== 'undefined') { + if (!normalized.domain) { + throw new Error(`Launch option "${fieldName}.stops" requires a custom domain.`); + } + if (!Array.isArray(value.stops) || value.stops.length < 2) { + throw new Error(`Launch option "${fieldName}.stops" must contain at least two stops.`); + } + + normalized.stops = value.stops.map((stop, index) => { + if (!this.isPlainObject(stop)) { + throw new Error(`Launch option "${fieldName}.stops[${index}]" must be an object.`); + } + const stopValue = Number(stop.value); + if (!Number.isFinite(stopValue) || typeof stop.color !== 'string' || !HEX_COLOR_PATTERN.test(stop.color.trim())) { + throw new Error(`Launch option "${fieldName}.stops[${index}]" must have a finite value and 6-digit hex color.`); + } + return { value: stopValue, color: stop.color.trim() }; + }); + + const domain = normalized.domain; + const stops = normalized.stops; + const strictlyIncreasing = stops.every((stop, index) => index === 0 || stop.value > stops[index - 1].value); + if (!strictlyIncreasing || stops[0].value !== domain.min || stops[stops.length - 1].value !== domain.max) { + throw new Error(`Launch option "${fieldName}.stops" must increase strictly and span the custom domain.`); + } + } + + if (typeof value.missingColor !== 'undefined') { + if (typeof value.missingColor !== 'string' || !HEX_COLOR_PATTERN.test(value.missingColor.trim())) { + throw new Error(`Launch option "${fieldName}.missingColor" must be a 6-digit hex color.`); + } + normalized.missingColor = value.missingColor.trim(); + } + + if (normalized.mode === 'categorical' && (normalized.domain || normalized.stops)) { + throw new Error(`Launch option "${fieldName}" cannot use a domain or stops in categorical mode.`); + } + + return normalized; + } + private requireAllowedLaunchString(value: unknown, allowedValues: Set, fieldName: string): string { if (typeof value !== 'string' || !allowedValues.has(value.trim())) { throw new Error(`Launch option "${fieldName}" used an unsupported value.`); @@ -457,6 +532,13 @@ export class EmbedHandoffService { this.requireAvailableLaunchField(globalSettings.nodeColorBy, nodeFields, 'nodeColorBy'); this.requireAvailableLaunchField(globalSettings.nodeShapeBy, nodeFields, 'nodeShapeBy'); this.requireAvailableLaunchField(globalSettings.linkColorBy, linkFields, 'linkColorBy'); + + if (globalSettings.nodeColorScale && (!globalSettings.nodeColorBy || globalSettings.nodeColorBy === 'None')) { + throw new Error('Launch global setting "nodeColorScale" requires "nodeColorBy".'); + } + if (globalSettings.linkColorScale && (!globalSettings.linkColorBy || globalSettings.linkColorBy === 'None')) { + throw new Error('Launch global setting "linkColorScale" requires "linkColorBy".'); + } } private collectAvailableFields(files: ImportedEmbedFile[], format: EmbedFileKind, defaults: string[]): Set { diff --git a/src/app/embed/embed-handoff.types.ts b/src/app/embed/embed-handoff.types.ts index 148f60a4f..d175ce0ad 100644 --- a/src/app/embed/embed-handoff.types.ts +++ b/src/app/embed/embed-handoff.types.ts @@ -67,9 +67,26 @@ export interface EmbedPayloadMetadataV1 { sourceApp?: string; } +export interface EmbedContinuousColorStopV1 { + value: number; + color: string; +} + +export interface EmbedVariableColorScaleV1 { + mode: 'auto' | 'categorical' | 'continuous'; + domain?: { + min: number; + max: number; + }; + stops?: EmbedContinuousColorStopV1[]; + missingColor?: string; +} + export interface EmbedLaunchGlobalSettingsV1 { nodeColorBy?: string; linkColorBy?: string; + nodeColorScale?: EmbedVariableColorScaleV1; + linkColorScale?: EmbedVariableColorScaleV1; nodeShapeBy?: string; nodeColor?: string; linkColor?: string; diff --git a/src/app/filesComponent/files-plugin.component.ts b/src/app/filesComponent/files-plugin.component.ts index 9635cdb44..6212d2119 100644 --- a/src/app/filesComponent/files-plugin.component.ts +++ b/src/app/filesComponent/files-plugin.component.ts @@ -15,7 +15,7 @@ import { cloneDeep } from 'lodash'; import { Subject, Subscription, takeUntil } from 'rxjs'; import { CommonStoreService } from '@app/contactTraceCommonServices/common-store.services'; import { EmbedHandoffService } from '@app/embed/embed-handoff.service'; -import { EmbedLaunchOptionsV1, ImportedEmbedFile } from '@app/embed/embed-handoff.types'; +import { EmbedLaunchOptionsV1, EmbedVariableColorScaleV1, ImportedEmbedFile } from '@app/embed/embed-handoff.types'; import { WorkerComputeService } from '@app/contactTraceCommonServices/worker-compute.service'; import { GraphMLService } from '@app/contactTraceCommonServices/graphml.service'; @@ -971,6 +971,12 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { if (globalSettings.linkColorBy) { widgets['link-color-variable'] = globalSettings.linkColorBy; } + if (globalSettings.nodeColorScale && globalSettings.nodeColorBy) { + this.applyEmbedVariableColorScale('node', globalSettings.nodeColorBy, globalSettings.nodeColorScale); + } + if (globalSettings.linkColorScale && globalSettings.linkColorBy) { + this.applyEmbedVariableColorScale('link', globalSettings.linkColorBy, globalSettings.linkColorScale); + } if (globalSettings.nodeShapeBy) { widgets['node-symbol-variable'] = globalSettings.nodeShapeBy; } @@ -1023,6 +1029,22 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { } } + private applyEmbedVariableColorScale( + target: 'node' | 'link', + field: string, + scale: EmbedVariableColorScaleV1 + ): void { + const current = this.commonService.getVariableColorScaleConfig(target, field); + this.commonService.setVariableColorScaleConfig(target, field, { + mode: scale.mode, + domain: scale.domain + ? { kind: 'custom', min: scale.domain.min, max: scale.domain.max } + : { kind: 'auto' }, + stops: scale.stops?.map(stop => ({ ...stop })), + missingColor: scale.missingColor ?? current.missingColor, + }); + } + private applyPendingEmbedLaunchOptions(finalize = false): void { const pending = this.pendingEmbedLaunchOptions; diff --git a/src/app/microbe-trace-next-plugin.component.html b/src/app/microbe-trace-next-plugin.component.html index d6a096f39..37aea809f 100644 --- a/src/app/microbe-trace-next-plugin.component.html +++ b/src/app/microbe-trace-next-plugin.component.html @@ -123,9 +123,11 @@ >
- Link Color Table + {{ getColorKeyTitle('link') }} - + @if (!isContinuousColorScale('link')) { + + }
- + + +
+ } @else { + - + + }
@@ -172,9 +184,11 @@ >
- Node Color Table + {{ getColorKeyTitle('node') }} - + @if (!isContinuousColorScale('node')) { + + }
- + + +
+ } @else { + - + + }
@@ -598,6 +622,33 @@ + @if (SelectedColorNodesByVariable !== 'None') { +
+
+
+ + +
+
+ @if (isContinuousColorScale('node')) { + + + } + } + + @if (!isContinuousColorScale('node')) {
Apply Color Assignment File @@ -622,6 +673,7 @@ {{ nodeColorAssignmentStatus.message }}
} + } @if (SelectedColorNodesByVariable === 'None') {
@@ -632,7 +684,7 @@
} @else {
-
+
@@ -693,9 +745,34 @@
+ @if (SelectedColorLinksByVariable !== 'None') { +
+
+
+ + +
+
+ @if (isContinuousColorScale('link')) { + + + } + } @if (SelectedColorLinksByVariable !== 'None') { @if (widgets['epiCurve-stackColorBy'] != 'None') { + @if (isContinuousNodeColorStack()) { +
+
+ +
+
+ +
+
+
Bins use the global color scale domain and remain fixed as the timeline changes.
+ } @else {
@@ -152,6 +164,7 @@
+ } } } diff --git a/src/app/visualizationComponents/TimelineComponent/timeline-component.component.ts b/src/app/visualizationComponents/TimelineComponent/timeline-component.component.ts index 107b65997..83f2d5cbd 100644 --- a/src/app/visualizationComponents/TimelineComponent/timeline-component.component.ts +++ b/src/app/visualizationComponents/TimelineComponent/timeline-component.component.ts @@ -14,6 +14,16 @@ import { GoogleTagManagerService } from 'angular-google-tag-manager'; import { ExportService } from '@app/contactTraceCommonServices/export.service'; import { Subject, takeUntil } from 'rxjs'; import { CommonStoreService } from '@app/contactTraceCommonServices/common-store.services'; +import { parseContinuousNumber, ResolvedVariableColorScale } from '@app/contactTraceCommonServices/variable-color-scale'; + +interface ContinuousEpiColorBin { + key: string; + label: string; + low?: number; + high?: number; + color: string; + missing?: boolean; +} @Component({ selector: 'app-timeline-component', @@ -58,6 +68,8 @@ export class TimelineComponent extends BaseComponentDirective implements OnInit, CalculatedResolution: string; private localColorMap: any = (x) => undefined; + private continuousColorBins: ContinuousEpiColorBin[] = []; + private continuousNodeScale: ResolvedVariableColorScale | null = null; private svg; private margin = { top: 5, left: 45, right: 20, bottom: 50 }; @@ -201,6 +213,10 @@ export class TimelineComponent extends BaseComponentDirective implements OnInit, if (this.widgets['epiCurve-stackGroupTransparencies'] == undefined) { this.widgets['epiCurve-stackGroupTransparencies'] = {}; } + const continuousBinCount = Math.round(Number(this.widgets['epiCurve-continuousBinCount'] ?? 5)); + this.widgets['epiCurve-continuousBinCount'] = Number.isFinite(continuousBinCount) + ? Math.min(12, Math.max(2, continuousBinCount)) + : 5; // binSize if (this.widgets['epiCurve-binSize'] == undefined) { @@ -276,12 +292,23 @@ public refresh(): void { let colorVariable = this.commonService.session.style.widgets['node-color-variable']; let nodeColorKeys; if (colorVariable != 'None' && this.widgets['epiCurve-stackColorBy'] == 'Node Color') { - nodeColorKeys = this.commonService.session.style.nodeColorsTableKeys[colorVariable].map(value => value=='null' ? null: value); - this.localColorMap = this.commonService.temp.style.nodeColorMap; + this.continuousNodeScale = this.getActiveContinuousNodeScale(); + if (this.continuousNodeScale) { + this.continuousColorBins = this.buildContinuousColorBins(this.continuousNodeScale); + nodeColorKeys = this.continuousColorBins.map(bin => bin.key); + const colors = new Map(this.continuousColorBins.map(bin => [bin.key, bin.color])); + this.localColorMap = (key) => colors.get(key) || this.continuousNodeScale?.missingColor; + } else { + this.continuousColorBins = []; + nodeColorKeys = (this.commonService.session.style.nodeColorsTableKeys[colorVariable] || []).map(value => value=='null' ? null: value); + this.localColorMap = this.commonService.temp.style.nodeColorMap; + } } else if (this.widgets['epiCurve-stackColorBy'] != 'None') { + this.continuousNodeScale = null; + this.continuousColorBins = []; nodeColorKeys = this.updateLocalColorMap(); } - if (nodeColorKeys) { + if (nodeColorKeys && !this.continuousNodeScale) { nodeColorKeys = this.getOrderedStackKeys(nodeColorKeys, this.widgets['epiCurve-stackColorBy'] == 'Node Color' ? colorVariable : this.widgets['epiCurve-stackColorBy']); } @@ -506,7 +533,8 @@ private getDefaultOrderedStackKeys(keys, colorVariable, smallestAtBottom = false this.vnodes.forEach((node) => { if (!this.hasValidStackDate(node)) return; - const key = keys.find((value) => value == node[colorVariable]); + const nodeStackValue = this.getNodeStackValue(node, colorVariable); + const key = keys.find((value) => value == nodeStackValue); if (key === undefined) return; counts.set(key, (counts.get(key) || 0) + 1); @@ -652,6 +680,12 @@ private getCurrentStackKeys(colorVariable) { } if (this.widgets['epiCurve-stackColorBy'] == 'Node Color') { + const continuousScale = this.getActiveContinuousNodeScale(); + if (continuousScale) { + this.continuousNodeScale = continuousScale; + this.continuousColorBins = this.buildContinuousColorBins(continuousScale); + return this.continuousColorBins.map(bin => bin.key); + } const nodeColorKeys = this.commonService.session.style.nodeColorsTableKeys[colorVariable] || []; return nodeColorKeys.map(value => value=='null' ? null: value); } @@ -768,6 +802,10 @@ private getSegmentCount(bin, segmentIndex): number { } private getTooltipLabel(value): string { + const continuousBin = this.continuousColorBins.find(bin => bin.key === value); + if (continuousBin) { + return continuousBin.label; + } if (value == null) { return "(Empty)"; } @@ -911,7 +949,8 @@ generateLegend(epiCurve, colors, fieldNames, opacities = []) { } fieldNames.forEach((name, i) => { // this first section calculates the location for each item/name in the legend - let nLength = name==null ? 7: name.toString().length + const legendLabel = this.getTooltipLabel(name); + let nLength = legendLabel.length let baseX = 70 + legendItemGap * rowCount + prevLength * legendCharWidth; if (baseX + markerRadius * 2 + markerTextGap + nLength * legendCharWidth > this.width - 70) { rowCount = 0; @@ -921,7 +960,7 @@ generateLegend(epiCurve, colors, fieldNames, opacities = []) { } epiCurve.append("circle").attr("cx", baseX).attr("cy", y).attr("r", markerRadius).style("fill", colors[i]).style("opacity", opacities[i] ?? 1) - epiCurve.append("text").attr("x", baseX + markerRadius + markerTextGap).attr("y", y).text(this.commonService.capitalize(name==null? '(Empty)': name.toString())).style("font-size", legendFontSizePx).attr("alignment-baseline","middle") + epiCurve.append("text").attr("x", baseX + markerRadius + markerTextGap).attr("y", y).text(legendLabel).style("font-size", legendFontSizePx).attr("alignment-baseline","middle") prevLength += nLength; rowCount += 1; @@ -941,7 +980,7 @@ generateLegend(epiCurve, colors, fieldNames, opacities = []) { } fieldNames.forEach((name, i) => { epiCurve.append("circle").attr("cx", xOffset).attr("cy", legendRowHeight * (count + 1)).attr("r", markerRadius).style("fill", colors[i]).style("opacity", opacities[i] ?? 1) - epiCurve.append("text").attr("x", xOffset + markerRadius + markerTextGap).attr("y", legendRowHeight * (count + 1)).text(this.commonService.capitalize(name==null? '(Empty)': name.toString())).style("font-size", legendFontSizePx).attr("alignment-baseline","middle") + epiCurve.append("text").attr("x", xOffset + markerRadius + markerTextGap).attr("y", legendRowHeight * (count + 1)).text(this.getTooltipLabel(name)).style("font-size", legendFontSizePx).attr("alignment-baseline","middle") count += 1; }) } @@ -1052,7 +1091,7 @@ updateBins(bins, colorVariable='None', nodeColorKeys=undefined) { bin.segmentCounts = []; bin.cumulativeSegmentCounts = []; nodeColorKeys.forEach((value, ind) => { - let currentCount = bin.filter((obj)=> obj[colorVariable]==value).length + let currentCount = bin.filter((obj)=> this.getNodeStackValue(obj, colorVariable)==value).length bin.segmentCounts.push(currentCount); heights[ind] += currentCount; bin.cumulativeSegmentCounts.push(heights[ind]); @@ -1071,7 +1110,7 @@ updateBins(bins, colorVariable='None', nodeColorKeys=undefined) { bin.height = []; bin.segmentCounts = []; nodeColorKeys.forEach(value => { - let currentCount = bin.filter((obj)=> obj[colorVariable]==value).length + let currentCount = bin.filter((obj)=> this.getNodeStackValue(obj, colorVariable)==value).length bin.segmentCounts.push(currentCount); bin.length2.push(currentCount); bin.height.push(bin.length2.reduce((paritalSum, a)=> paritalSum+a,0)); @@ -1183,12 +1222,97 @@ onUseNodeColorChange() { } else { $('#epi-color-select').slideUp(); } - if (this.widgets['epiCurve-stackOrder'] == 'Custom') { + if (this.widgets['epiCurve-stackOrder'] == 'Custom' && !this.isContinuousNodeColorStack()) { this.initializeCustomStackOrder(true); } this.refresh(); } +onContinuousBinCountChange(): void { + const value = Math.round(Number(this.widgets['epiCurve-continuousBinCount'])); + this.widgets['epiCurve-continuousBinCount'] = Number.isFinite(value) + ? Math.min(12, Math.max(2, value)) + : 5; + this.refresh(); +} + +isContinuousNodeColorStack(): boolean { + return !!this.getActiveContinuousNodeScale(); +} + +private getActiveContinuousNodeScale(): ResolvedVariableColorScale | null { + if (this.widgets?.['epiCurve-stackColorBy'] !== 'Node Color') { + return null; + } + const field = this.commonService.session?.style?.widgets?.['node-color-variable']; + if (!field || field === 'None') { + return null; + } + const cached = this.commonService.temp?.style?.nodeColorScale as ResolvedVariableColorScale; + const resolved = cached?.field === field ? cached : this.commonService.resolveVariableColorScale('node', field); + return resolved?.mode === 'continuous' ? resolved : null; +} + +private buildContinuousColorBins(scale: ResolvedVariableColorScale): ContinuousEpiColorBin[] { + const format = d3.format('.6~g'); + const { min, max } = scale.domain; + const requestedCount = Math.min(12, Math.max(2, Math.round(Number(this.widgets['epiCurve-continuousBinCount'] ?? 5)))); + const bins: ContinuousEpiColorBin[] = []; + + if (min === max) { + bins.push({ + key: '__mt_continuous_0', + label: format(min), + low: min, + high: max, + color: scale.colorMap(min), + }); + } else { + const width = (max - min) / requestedCount; + for (let index = 0; index < requestedCount; index += 1) { + const low = min + width * index; + const high = index === requestedCount - 1 ? max : min + width * (index + 1); + bins.push({ + key: `__mt_continuous_${index}`, + label: `[${format(low)}, ${format(high)}${index === requestedCount - 1 ? ']' : ')'}`, + low, + high, + color: scale.colorMap(low + (high - low) / 2), + }); + } + } + + if (scale.summary.missingCount + scale.summary.invalidCount > 0) { + bins.push({ + key: '__mt_continuous_missing', + label: 'Missing / invalid', + color: scale.missingColor, + missing: true, + }); + } + return bins; +} + +private getNodeStackValue(node, colorVariable) { + if (!this.continuousNodeScale || this.widgets['epiCurve-stackColorBy'] !== 'Node Color') { + return node?.[colorVariable]; + } + const parsed = parseContinuousNumber(node?.[colorVariable]); + if (parsed.kind !== 'number') { + return '__mt_continuous_missing'; + } + const numericBins = this.continuousColorBins.filter(bin => !bin.missing); + if (numericBins.length <= 1 || this.continuousNodeScale.domain[0] === this.continuousNodeScale.domain[1]) { + return numericBins[0]?.key; + } + const { min, max } = this.continuousNodeScale.domain; + const clamped = Math.min(max, Math.max(min, parsed.value)); + const index = clamped === max + ? numericBins.length - 1 + : Math.floor(((clamped - min) / (max - min)) * numericBins.length); + return numericBins[Math.min(numericBins.length - 1, Math.max(0, index))]?.key; +} + onStackOrderChange() { if (this.widgets['epiCurve-stackOrder'] == 'Custom') { this.initializeCustomStackOrder(true); diff --git a/src/assets/embed/receiver.js b/src/assets/embed/receiver.js index 45965fa41..5884751e7 100644 --- a/src/assets/embed/receiver.js +++ b/src/assets/embed/receiver.js @@ -484,6 +484,69 @@ return numericValue; } + function normalizeVariableColorScale(value, fieldName) { + if (!isPlainObject(value)) { + throw new Error('Launch option "' + fieldName + '" must be an object.'); + } + + var allowedModes = new Set(['auto', 'categorical', 'continuous']); + if (typeof value.mode !== 'string' || !allowedModes.has(value.mode)) { + throw new Error('Launch option "' + fieldName + '.mode" used an unsupported value.'); + } + + var normalized = { mode: value.mode }; + if (typeof value.domain !== 'undefined') { + if (!isPlainObject(value.domain)) { + throw new Error('Launch option "' + fieldName + '.domain" must contain finite min and max values.'); + } + var min = Number(value.domain.min); + var max = Number(value.domain.max); + if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) { + throw new Error('Launch option "' + fieldName + '.domain" must have finite min less than max.'); + } + normalized.domain = { min: min, max: max }; + } + + if (typeof value.stops !== 'undefined') { + if (!normalized.domain) { + throw new Error('Launch option "' + fieldName + '.stops" requires a custom domain.'); + } + if (!Array.isArray(value.stops) || value.stops.length < 2) { + throw new Error('Launch option "' + fieldName + '.stops" must contain at least two stops.'); + } + normalized.stops = value.stops.map(function (stop, index) { + if (!isPlainObject(stop)) { + throw new Error('Launch option "' + fieldName + '.stops[' + index + ']" must be an object.'); + } + var stopValue = Number(stop.value); + if (!Number.isFinite(stopValue) || typeof stop.color !== 'string' || !HEX_COLOR_PATTERN.test(stop.color.trim())) { + throw new Error('Launch option "' + fieldName + '.stops[' + index + ']" must have a finite value and 6-digit hex color.'); + } + return { value: stopValue, color: stop.color.trim() }; + }); + var strictlyIncreasing = normalized.stops.every(function (stop, index) { + return index === 0 || stop.value > normalized.stops[index - 1].value; + }); + if (!strictlyIncreasing + || normalized.stops[0].value !== normalized.domain.min + || normalized.stops[normalized.stops.length - 1].value !== normalized.domain.max) { + throw new Error('Launch option "' + fieldName + '.stops" must increase strictly and span the custom domain.'); + } + } + + if (typeof value.missingColor !== 'undefined') { + if (typeof value.missingColor !== 'string' || !HEX_COLOR_PATTERN.test(value.missingColor.trim())) { + throw new Error('Launch option "' + fieldName + '.missingColor" must be a 6-digit hex color.'); + } + normalized.missingColor = value.missingColor.trim(); + } + + if (normalized.mode === 'categorical' && (normalized.domain || normalized.stops)) { + throw new Error('Launch option "' + fieldName + '" cannot use a domain or stops in categorical mode.'); + } + return normalized; + } + function normalizeLaunchGlobalSettings(globalSettings) { if (!isPlainObject(globalSettings)) { throw new Error('Launch option "globalSettings" must be an object.'); @@ -514,6 +577,19 @@ normalized[fieldName] = value.trim(); }); + ['nodeColorScale', 'linkColorScale'].forEach(function (fieldName) { + if (typeof globalSettings[fieldName] !== 'undefined') { + normalized[fieldName] = normalizeVariableColorScale(globalSettings[fieldName], 'globalSettings.' + fieldName); + } + }); + + if (normalized.nodeColorScale && (!normalized.nodeColorBy || normalized.nodeColorBy === 'None')) { + throw new Error('Launch global setting "nodeColorScale" requires "nodeColorBy".'); + } + if (normalized.linkColorScale && (!normalized.linkColorBy || normalized.linkColorBy === 'None')) { + throw new Error('Launch global setting "linkColorScale" requires "linkColorBy".'); + } + if (typeof globalSettings.nodeShape !== 'undefined') { normalized.nodeShape = requireAllowedLaunchString(globalSettings.nodeShape, ALLOWED_NODE_SHAPES, 'globalSettings.nodeShape'); } From 7a837ca7938273cc26876226c681bf3e0045b80e Mon Sep 17 00:00:00 2001 From: "Guppy L. Stott" Date: Fri, 28 Aug 2026 13:38:40 -0400 Subject: [PATCH 2/8] Initial continuous color ramp draft feature --- .../view-state/continuous-color-ramps.cy.ts | 26 ++++++++ examples/continuous-color-ramps/README.md | 59 ++++++++++++++++++ .../embed-launch.example.json | 61 +++++++++++++++++++ .../riverbend-lineage-colors.csv | 4 ++ .../riverbend-outbreak-links.csv | 37 +++++++++++ .../riverbend-outbreak-nodes.csv | 25 ++++++++ .../globalSettingsDialogRequest.ts | 10 ++- .../microbe-trace-next-plugin.component.html | 28 +++++++++ .../microbe-trace-next-plugin.component.ts | 41 ++++++++++++- .../key-tables.component.html | 22 +++++++ .../key-tables.component.ts | 15 ++++- 11 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 examples/continuous-color-ramps/README.md create mode 100644 examples/continuous-color-ramps/embed-launch.example.json create mode 100644 examples/continuous-color-ramps/riverbend-lineage-colors.csv create mode 100644 examples/continuous-color-ramps/riverbend-outbreak-links.csv create mode 100644 examples/continuous-color-ramps/riverbend-outbreak-nodes.csv diff --git a/cypress/e2e/view-state/continuous-color-ramps.cy.ts b/cypress/e2e/view-state/continuous-color-ramps.cy.ts index 908af5d82..a39ebf55e 100644 --- a/cypress/e2e/view-state/continuous-color-ramps.cy.ts +++ b/cypress/e2e/view-state/continuous-color-ramps.cy.ts @@ -53,6 +53,15 @@ describe('Continuous numeric color ramps', () => { cy.get('#key-tables-node-legend').should('contain.text', 'Missing / invalid'); cy.get('#key-tables-node-table').should('not.exist'); + cy.get('[data-testid="key-tables-node-color-ramp-edit"]') + .should('have.attr', 'aria-label', 'Edit node color ramp') + .click(); + cy.contains('.p-dialog:visible .p-dialog-title', 'Global Settings').should('be.visible'); + cy.get('#node-continuous-color-editor').scrollIntoView().should('be.visible'); + cy.focused().should('have.id', 'node-continuous-color-editor-domain-kind'); + cy.get('#key-tables-node-legend').should('be.visible'); + cy.closeGlobalSettings(); + cy.window().then((win: any) => { const app = win.commonService.visuals.microbeTrace; const common = win.commonService; @@ -110,6 +119,23 @@ describe('Continuous numeric color ramps', () => { .and('contain', '0 #000000') .and('contain', '4 #ff0000') .and('contain', '20 #ffffff'); + + cy.openGlobalSettings(); + cy.get('#link-color-table-row', { timeout: 15000 }) + .scrollIntoView() + .should('be.visible') + .contains('.p-togglebutton-label', 'Show') + .click({ force: true }); + cy.closeGlobalSettings(); + + cy.get('#global-settings-link-color-table', { timeout: 15000 }).should('be.visible'); + cy.get('[data-testid="floating-link-color-ramp-edit"]') + .should('have.attr', 'aria-label', 'Edit link color ramp') + .click(); + cy.contains('.p-dialog:visible .p-dialog-title', 'Global Settings').should('be.visible'); + cy.get('#link-continuous-color-editor').scrollIntoView().should('be.visible'); + cy.focused().should('have.id', 'link-continuous-color-editor-domain-kind'); + cy.get('#global-settings-link-color-table').should('be.visible'); }); it('uses fixed equal-width continuous Node Color bins in the Epi Curve', () => { diff --git a/examples/continuous-color-ramps/README.md b/examples/continuous-color-ramps/README.md new file mode 100644 index 000000000..3a87ac4bc --- /dev/null +++ b/examples/continuous-color-ramps/README.md @@ -0,0 +1,59 @@ +# Riverbend Continuous Color Ramp Test Dataset + +This is a fully synthetic respiratory-outbreak investigation. It contains no real people, facilities, or protected health information. The shape is intentionally realistic enough for manual demonstrations while retaining deliberate edge cases for every continuous-color feature. + +## Files + +- `riverbend-outbreak-nodes.csv`: 24 case records with aligned 120-base sequences, dates, coordinates, clinical metadata, categorical fields, and several numeric field shapes. +- `riverbend-outbreak-links.csv`: 36 investigated contacts with categorical context and numeric duration, proximity, genetic-distance, probability, and confidence fields. Its conventional `distance` column duplicates `GeneticDistanceSNP` so network threshold controls work immediately. +- `riverbend-lineage-colors.csv`: optional categorical node color-assignment file. Applying it selects `Lineage` and verifies that imported categorical colors remain independent of continuous ramps. +- `embed-launch.example.json`: a valid `launch` object for an embed v1 payload. It demonstrates custom node and link domains, arbitrary ordered stops, missing-value colors, and endpoint clamping. + +## Load the dataset + +1. In MicrobeTrace, choose **File > Add Data**. +2. Select both `riverbend-outbreak-nodes.csv` and `riverbend-outbreak-links.csv`. +3. Launch the data as a node list plus link list. The IDs in `source` and `target` match the node `ID` field. +4. For Map, set the latitude and longitude fields to `Latitude` and `Longitude`. +5. For Epi Curve, use `Date of symptom onset Date` as the date field. + +The `seq` values are aligned and form three related synthetic lineages, so the same node data can be opened in Phylogenetic Tree as well as 2D Network, Map, Bubble, Table, and Epi Curve. + +## Feature coverage + +| Scenario | Node field | Link field | Expected behavior | +| --- | --- | --- | --- | +| Auto numeric detection | `RiskScore`, `ViralLoadCt`, `AgeYears` | `ContactMinutes`, `distance`, `GeneticDistanceSNP`, `EstimatedTransmissionProbability` | Auto resolves to Continuous because CSV numeric strings and missing values are valid numeric-or-missing data. | +| Full-data automatic domain | `RiskScore` | `ContactMinutes` | The domain remains based on all loaded rows through filtering, timeline playback, and visibility changes. | +| Missing values | `ViralLoadCt` on case 016; `IncubationEstimateDays` on cases 008 and 024 | `ContactMinutes` on one contact | Missing values use the configured missing/invalid color. | +| NaN-like missing value | `IncubationEstimateDays` on case 008 | — | `NaN` is treated as missing rather than as mixed text. | +| Mixed numeric/text override | `OxygenSaturationPct` (`not measured`, `pending`) | `ProximityMeters` (`unknown`) | Auto remains Categorical. Forcing Continuous colors finite values and sends the text values to the fallback color. | +| Constant domain | `AssayBatchTemperatureC` | `InvestigationConfidence` | Auto resolves to Continuous; the ramp uses its midpoint color and the legend shows one value. Missing rows still use the fallback color. | +| Custom bounds and clamping | `RiskScore`, custom domain 10–90 | `ContactMinutes`, custom domain 15–180 | Risk scores 5 and 96 clamp to the node endpoints. Contact durations 5 and 480 clamp to the link endpoints. | +| Arbitrary stops and reverse | `RiskScore` | `ContactMinutes` | Use the values in `embed-launch.example.json`, or recreate them in Global Styling. Add Stop, Remove, and Reverse should update both the visualization and legend. | +| Categorical override/history | `Lineage`, `Facility`, `Outcome` | `ContactType`, `Setting` | Force numeric fields to Categorical, edit categories, switch modes, and confirm category color history is restored. | +| Assignment import forces categorical | `Lineage` | — | Apply `riverbend-lineage-colors.csv`; Lineage becomes categorical and receives the supplied colors. | +| Epi Curve continuous bins | `RiskScore` or `ViralLoadCt` | — | Set **Color By: Node Color**, then test 2–12 equal-width bins, missing segments, cumulative mode, and stable boundaries during filtering. | +| Shared node consumers | `RiskScore` | — | The same resolved colors should appear in 2D Network, Map, Phylogenetic Tree, and Epi Curve. | +| Shared link consumers | — | `ContactMinutes` | The same resolved link ramp should appear in 2D Network and Map. | +| Legend shortcuts | Any continuous field | Any continuous field | In Dock and Show modes, the palette button should open Global Styling at the matching ramp editor while leaving a floating legend open. | +| Legend and export behavior | Any continuous field | Any continuous field | Continuous mode shows a read-only gradient legend with stop labels and a missing swatch; export choices should say Color Legend. | + +## Suggested walkthrough + +1. Set **Color Nodes By** to `RiskScore`. Confirm Auto reports Continuous and the automatic domain is 5–96. +2. Change to a custom 10–90 domain. Confirm cases 024 and 003 clamp to the low and high endpoint colors. +3. Edit stops, add an interior stop, reverse colors, and change the missing color. Try both docked and floating legends and use their palette shortcuts. +4. Filter to one county and play the symptom-onset timeline. The ramp domain and colors should not change. +5. Open Map and Phylogenetic Tree to compare node colors with 2D Network. +6. Open Epi Curve, select the onset date, choose **Node Color**, and test bin counts plus cumulative mode. +7. Set **Color Links By** to `ContactMinutes`, use a 15–180 custom domain, and compare 2D Network with Map. +8. Force `OxygenSaturationPct` and `ProximityMeters` to Continuous to verify fallback coloring for their text values. +9. Select the constant node and link fields to verify single-value legends. +10. Switch a numeric field to Categorical, edit category colors, switch back and forth, and confirm the categorical appearance is restored. +11. Apply `riverbend-lineage-colors.csv` to confirm assignment import selects categorical Lineage coloring. +12. Save and reload a style and session, then load a fresh copy of the node/link files to check persistence and new-dataset reset behavior. + +## Embed example + +`embed-launch.example.json` is the value for the embed payload's `launch` property, not a standalone handoff. Merge it into a normal version-1 partner handoff whose file entries contain the two CSV files. Its custom stop values are finite, strictly increasing, span their domains, and use six-digit hexadecimal colors. diff --git a/examples/continuous-color-ramps/embed-launch.example.json b/examples/continuous-color-ramps/embed-launch.example.json new file mode 100644 index 000000000..240abfe00 --- /dev/null +++ b/examples/continuous-color-ramps/embed-launch.example.json @@ -0,0 +1,61 @@ +{ + "datasetName": "Riverbend Regional Respiratory Outbreak", + "defaultView": "2D Network", + "linkThreshold": 12, + "globalSettings": { + "nodeColorBy": "RiskScore", + "nodeColorScale": { + "mode": "continuous", + "domain": { + "min": 10, + "max": 90 + }, + "stops": [ + { + "value": 10, + "color": "#440154" + }, + { + "value": 35, + "color": "#31688e" + }, + { + "value": 60, + "color": "#35b779" + }, + { + "value": 90, + "color": "#fde725" + } + ], + "missingColor": "#eae553" + }, + "linkColorBy": "ContactMinutes", + "linkColorScale": { + "mode": "continuous", + "domain": { + "min": 15, + "max": 180 + }, + "stops": [ + { + "value": 15, + "color": "#0d0887" + }, + { + "value": 60, + "color": "#9c179e" + }, + { + "value": 120, + "color": "#ed7953" + }, + { + "value": 180, + "color": "#f0f921" + } + ], + "missingColor": "#bdbdbd" + } + } +} diff --git a/examples/continuous-color-ramps/riverbend-lineage-colors.csv b/examples/continuous-color-ramps/riverbend-lineage-colors.csv new file mode 100644 index 000000000..958a68593 --- /dev/null +++ b/examples/continuous-color-ramps/riverbend-lineage-colors.csv @@ -0,0 +1,4 @@ +Lineage,color +RB-A.1,#3b4cc0 +RB-B.1,#2fb47c +RB-C.1,#f89540 diff --git a/examples/continuous-color-ramps/riverbend-outbreak-links.csv b/examples/continuous-color-ramps/riverbend-outbreak-links.csv new file mode 100644 index 000000000..417103f6e --- /dev/null +++ b/examples/continuous-color-ramps/riverbend-outbreak-links.csv @@ -0,0 +1,37 @@ +source,target,ContactType,Setting,ContactDate,ContactMinutes,ProximityMeters,GeneticDistanceSNP,distance,EstimatedTransmissionProbability,InvestigationConfidence,Notes +RB-2026-001,RB-2026-002,Clinical care,Hospital,2026-01-02,95,1.2,1,1,0.91,0.85,Shared treatment team +RB-2026-001,RB-2026-003,Clinical care,Hospital,2026-01-02,45,1.8,1,1,0.82,0.85,Patient transfer +RB-2026-002,RB-2026-004,Clinical care,Long-term care,2026-01-03,120,1.0,2,2,0.88,0.85,Bedside assessment +RB-2026-003,RB-2026-004,Roommate,Long-term care,2026-01-04,480,2.3,1,1,0.97,0.85,Shared room +RB-2026-003,RB-2026-005,Household,Household,2026-01-04,180,0.8,2,2,0.85,0.85,Weekend family visit +RB-2026-004,RB-2026-006,Household,Household,2026-01-05,210,1.1,2,2,0.79,0.85,Family contact +RB-2026-005,RB-2026-006,Workplace,Clinic,2026-01-06,75,2.5,1,1,0.74,0.85,Shared reception shift +RB-2026-005,RB-2026-007,Household,Household,2026-01-07,360,0.6,2,2,0.93,0.85,Parent-child contact +RB-2026-006,RB-2026-008,Household,Household,2026-01-08,300,0.7,2,2,0.90,0.85,Parent-child contact +RB-2026-007,RB-2026-008,School,Household,2026-01-09,240,1.4,1,1,0.81,0.85,Siblings +RB-2026-006,RB-2026-009,Community,Transit,2026-01-12,25,unknown,6,6,0.28,0.85,Reported bus exposure +RB-2026-009,RB-2026-010,Workplace,Distribution hub,2026-01-15,160,1.5,1,1,0.89,0.85,Shared loading bay +RB-2026-009,RB-2026-011,Workplace,Distribution hub,2026-01-16,135,2.0,1,1,0.84,0.85,Shared loading bay +RB-2026-010,RB-2026-012,Workplace,Distribution hub,2026-01-17,55,2.8,2,2,0.66,0.85,Break room contact +RB-2026-011,RB-2026-012,Household,Household,2026-01-18,420,0.5,1,1,0.95,0.85,Partners +RB-2026-011,RB-2026-013,School,Primary school,2026-01-19,35,3.2,2,2,0.58,0.85,School pickup +RB-2026-012,RB-2026-014,Household,Household,2026-01-20,300,0.7,2,2,0.92,0.85,Parent-child contact +RB-2026-013,RB-2026-014,School,Primary school,2026-01-21,180,1.1,1,1,0.86,0.85,Same classroom +RB-2026-013,RB-2026-015,School,Primary school,2026-01-22,90,2.1,2,2,0.72,0.85,Classroom instruction +RB-2026-014,RB-2026-016,School,Primary school,2026-01-23,70,2.4,2,2,0.69,0.85,Classroom instruction +RB-2026-015,RB-2026-016,Household,Household,2026-01-24,240,0.9,1,1,0.90,0.85,Partners +RB-2026-012,RB-2026-017,Community,Restaurant,2026-01-30,15,unknown,9,9,0.12,0.85,Unconfirmed shared venue +RB-2026-017,RB-2026-018,Clinical care,Hospital,2026-02-01,110,1.3,1,1,0.87,0.85,Emergency department +RB-2026-017,RB-2026-019,Clinical care,Hospital,2026-02-02,150,1.0,2,2,0.90,0.85,Inpatient care +RB-2026-018,RB-2026-020,Household,Household,2026-02-03,360,0.6,2,2,0.94,0.85,Family contact +RB-2026-019,RB-2026-021,Clinical care,Dialysis center,2026-02-04,130,1.7,2,2,0.83,0.85,Patient transport +RB-2026-020,RB-2026-022,Household,Household,2026-02-05,280,0.8,2,2,0.91,0.85,Family contact +RB-2026-021,RB-2026-022,Clinical care,Dialysis center,2026-02-06,200,1.4,1,1,0.89,0.85,Adjacent stations +RB-2026-021,RB-2026-023,Clinical care,Dialysis center,2026-02-07,165,1.9,2,2,0.80,0.85,Shared care team +RB-2026-022,RB-2026-024,Household,Household,2026-02-08,320,0.7,2,2,0.92,0.85,Family contact +RB-2026-023,RB-2026-024,Clinical care,Dialysis center,2026-02-10,85,2.2,1,1,0.71,0.85,Waiting room overlap +RB-2026-005,RB-2026-010,Community,Community event,2026-01-13,5,unknown,8,8,0.08,0.85,Brief outdoor encounter +RB-2026-008,RB-2026-013,Community,Youth program,2026-01-18,,4.5,5,5,0.31,0.85,Duration unavailable +RB-2026-016,RB-2026-018,Community,Restaurant,2026-01-29,40,3.8,7,7,0.19,0.85,Possible shared venue +RB-2026-020,RB-2026-023,Clinical care,Hospital,2026-02-06,60,2.6,3,3,0.55,0.85,Outpatient follow-up +RB-2026-004,RB-2026-009,Community,Transit,2026-01-11,20,5.5,11,11,0.05,,Low-confidence contact diff --git a/examples/continuous-color-ramps/riverbend-outbreak-nodes.csv b/examples/continuous-color-ramps/riverbend-outbreak-nodes.csv new file mode 100644 index 000000000..c6a883b75 --- /dev/null +++ b/examples/continuous-color-ramps/riverbend-outbreak-nodes.csv @@ -0,0 +1,25 @@ +ID,seq,CaseType,Lineage,Date of symptom onset Date,Collection Date,Recovery Date,County,State,Facility,Latitude,Longitude,AgeYears,ViralLoadCt,DaysFromFirstExposure,HouseholdSize,VaccinationDoses,RiskScore,ClinicalSeverityScore,IncubationEstimateDays,OxygenSaturationPct,AssayBatchTemperatureC,Outcome,Node type +RB-2026-001,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAC,Confirmed,RB-A.1,2026-01-03,2026-01-04,2026-01-12,Fulton,Georgia,Riverbend Medical Center,33.7531,-84.3862,72,18.4,3,2,4,88,7,3.2,91,22.5,Recovered,Person +RB-2026-002,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-A.1,2026-01-04,2026-01-05,2026-01-13,Fulton,Georgia,Riverbend Medical Center,33.7540,-84.3849,68,20.1,4,1,3,82,6,4.1,93,22.5,Recovered,Person +RB-2026-003,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAACTCTTTCACACGTGGTGTTTATTACCCTGAC,Confirmed,RB-A.1,2026-01-05,2026-01-06,2026-01-16,Fulton,Georgia,Willow Creek Skilled Nursing,33.7812,-84.3708,84,17.8,4,1,5,96,9,4.4,88,22.5,Hospitalized,Person +RB-2026-004,ATGTTTGTTTTCCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Probable,RB-A.1,2026-01-06,2026-01-08,2026-01-15,Fulton,Georgia,Willow Creek Skilled Nursing,33.7806,-84.3699,79,22.7,5,2,4,90,8,5.0,not measured,22.5,Recovered,Person +RB-2026-005,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAC,Confirmed,RB-A.1,2026-01-08,2026-01-09,2026-01-18,DeKalb,Georgia,Eastgate Family Clinic,33.7718,-84.2963,43,24.2,6,4,3,61,4,5.8,96,22.5,Recovered,Person +RB-2026-006,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAACTCTTTCACACGTGGTGTTTATTACCCTGAC,Confirmed,RB-A.1,2026-01-09,2026-01-10,2026-01-17,DeKalb,Georgia,Eastgate Family Clinic,33.7709,-84.2952,37,26.9,7,3,2,54,3,6.6,97,22.5,Recovered,Person +RB-2026-007,ATGTTTGTTTTCCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-A.1,2026-01-11,2026-01-12,2026-01-21,DeKalb,Georgia,Eastgate Family Clinic,33.7698,-84.2946,12,21.5,5,5,1,67,5,4.9,95,22.5,Recovered,Person +RB-2026-008,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-A.1,2026-01-13,2026-01-14,2026-01-23,DeKalb,Georgia,Eastgate Family Clinic,33.7687,-84.2938,9,23.8,4,5,0,63,4,NaN,96,22.5,Recovered,Person +RB-2026-009,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-B.1,2026-01-17,2026-01-18,2026-01-28,Cobb,Georgia,Northside Distribution Hub,33.8975,-84.4831,29,19.7,2,2,3,73,5,2.7,94,22.5,Recovered,Person +RB-2026-010,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-B.1,2026-01-18,2026-01-19,2026-01-27,Cobb,Georgia,Northside Distribution Hub,33.8981,-84.4819,34,21.2,3,3,2,69,4,3.4,96,22.5,Recovered,Person +RB-2026-011,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-B.1,2026-01-19,2026-01-20,2026-01-30,Cobb,Georgia,Northside Distribution Hub,33.8990,-84.4807,51,18.9,3,4,4,78,6,3.1,92,22.5,Recovered,Person +RB-2026-012,ATGTTTGTTTTTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-B.1,2026-01-20,2026-01-21,2026-02-01,Cobb,Georgia,Northside Distribution Hub,33.9002,-84.4798,46,25.6,4,2,3,57,3,4.0,98,22.5,Recovered,Person +RB-2026-013,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Probable,RB-B.1,2026-01-22,2026-01-24,2026-02-03,Cobb,Georgia,Oakview Primary School,33.9234,-84.5154,7,27.8,5,4,0,48,2,5.3,99,22.5,Recovered,Person +RB-2026-014,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-B.1,2026-01-23,2026-01-24,2026-02-02,Cobb,Georgia,Oakview Primary School,33.9241,-84.5143,8,24.9,4,5,0,52,3,4.7,98,22.5,Recovered,Person +RB-2026-015,ATGTTTGTTTTTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-B.1,2026-01-25,2026-01-26,2026-02-05,Cobb,Georgia,Oakview Primary School,33.9250,-84.5131,41,23.1,6,3,3,58,3,5.9,97,22.5,Recovered,Person +RB-2026-016,ATGTTTGTTTTTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-B.1,2026-01-27,2026-01-28,2026-02-06,Cobb,Georgia,Oakview Primary School,33.9257,-84.5120,39,,7,2,2,44,2,6.8,98,22.5,Recovered,Person +RB-2026-017,ATGTTTGTTTCTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-C.1,2026-02-02,2026-02-03,2026-02-14,Gwinnett,Georgia,Lakeshore Community Hospital,33.9588,-84.0554,56,16.8,2,1,4,91,8,2.4,89,22.5,Hospitalized,Person +RB-2026-018,ATGTTTGTTTCTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAG,Confirmed,RB-C.1,2026-02-03,2026-02-04,2026-02-15,Gwinnett,Georgia,Lakeshore Community Hospital,33.9596,-84.0542,63,19.3,3,2,3,85,7,3.0,91,22.5,Recovered,Person +RB-2026-019,ATGTTTGTTTCTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-C.1,2026-02-04,2026-02-05,2026-02-16,Gwinnett,Georgia,Lakeshore Community Hospital,33.9605,-84.0530,88,15.9,3,1,5,95,10,3.5,86,22.5,Deceased,Person +RB-2026-020,ATGTTTGTTTCTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAG,Confirmed,RB-C.1,2026-02-06,2026-02-07,2026-02-17,Gwinnett,Georgia,Lakeshore Community Hospital,33.9613,-84.0519,31,26.2,5,3,2,49,3,5.1,97,22.5,Recovered,Person +RB-2026-021,ATGTTTGTTTCTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-C.1,2026-02-08,2026-02-09,2026-02-18,Gwinnett,Georgia,Pinecrest Dialysis Center,33.9884,-84.0678,71,20.8,6,2,4,80,6,6.0,92,22.5,Recovered,Person +RB-2026-022,ATGTTTGTTTCTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAG,Confirmed,RB-C.1,2026-02-09,2026-02-10,2026-02-20,Gwinnett,Georgia,Pinecrest Dialysis Center,33.9892,-84.0667,65,22.4,6,1,4,76,5,5.7,94,22.5,Recovered,Person +RB-2026-023,ATGTTTGTTTCTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAG,Probable,RB-C.1,2026-02-11,2026-02-13,,Gwinnett,Georgia,Pinecrest Dialysis Center,33.9900,-84.0656,74,28.6,8,1,3,38,2,7.4,pending,22.5,Ongoing,Person +RB-2026-024,ATGTTTGTTTCTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-C.1,2026-02-14,2026-02-15,,Gwinnett,Georgia,Pinecrest Dialysis Center,33.9911,-84.0645,27,31.7,9,2,1,5,1,,99,,Ongoing,Person diff --git a/src/app/helperClasses/globalSettingsDialogRequest.ts b/src/app/helperClasses/globalSettingsDialogRequest.ts index cbbc3e749..5fff65581 100644 --- a/src/app/helperClasses/globalSettingsDialogRequest.ts +++ b/src/app/helperClasses/globalSettingsDialogRequest.ts @@ -7,23 +7,29 @@ export type DialogRectSnapshot = { height: number; }; +export type GlobalSettingsStylingTarget = 'node-color-ramp' | 'link-color-ramp'; + export type GlobalSettingsDialogRequest = string | { activeTab?: string; sourceDialogRect?: DialogRectSnapshot; + stylingTarget?: GlobalSettingsStylingTarget; }; export type NormalizedGlobalSettingsDialogRequest = { activeTab: string; sourceDialogRect?: DialogRectSnapshot; + stylingTarget?: GlobalSettingsStylingTarget; }; export function createGlobalSettingsDialogRequest( activeTab: string = 'Styling', - event?: MouseEvent + event?: MouseEvent, + stylingTarget?: GlobalSettingsStylingTarget ): Exclude { return { activeTab, - sourceDialogRect: getSourceDialogRect(event) + sourceDialogRect: getSourceDialogRect(event), + stylingTarget }; } diff --git a/src/app/microbe-trace-next-plugin.component.html b/src/app/microbe-trace-next-plugin.component.html index 37aea809f..ce4928b6d 100644 --- a/src/app/microbe-trace-next-plugin.component.html +++ b/src/app/microbe-trace-next-plugin.component.html @@ -128,6 +128,18 @@ @if (!isContinuousColorScale('link')) { } + @if (isContinuousColorScale('link')) { + + } } + @if (nodeColorContinuous) { + + } + }