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 a2fcbe3c..af8a068d 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 a932d81c..31405537 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 f02b84e1..f561304a 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 aa459a92..2e409934 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 55a2aee6..e550a7cf 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 6f42fd99..c9148925 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 bd95f8ad..df20a22e 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 d6c43efc..1b822c56 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 91665b48..16cedf80 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 9d1d47d3..756d568d 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 00000000..1bf9555e --- /dev/null +++ b/cypress/e2e/view-state/continuous-color-ramps.cy.ts @@ -0,0 +1,361 @@ +/// + +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(); + }); +}; + +const closeSettingsPaneIfVisible = (title: string): void => { + cy.get('body').then(($body) => { + const visibleTitle = $body.find('.p-dialog-title:visible').filter((_index, element) => + element.textContent?.trim() === title + ); + if (visibleTitle.length > 0) { + cy.closeSettingsPane(title); + } + }); +}; + +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 .continuous-ramp__label') + .should('be.visible') + .and('have.text', 'Node Color: Degree'); + 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.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; + 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 .continuous-ramp__label') + .should('be.visible') + .and('have.text', 'Link Color: Distance'); + 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'); + + 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('exports labeled continuous legends beside the 2D network', () => { + const exportFileBase = `continuous_ramp_export_${Date.now()}`; + const exportPath = `${Cypress.config('downloadsFolder')}/${exportFileBase}.svg`; + + useNodeColorField('degree'); + useLinkColorField('distance'); + + cy.get('#tool-btn-container a[title="Export Screen"]').click({ force: true }); + cy.contains('.p-dialog-title', 'Export Network Image') + .should('be.visible') + .parents('.p-dialog') + .as('exportDialog'); + + cy.get('@exportDialog') + .find('#network-export-filename') + .invoke('val', exportFileBase) + .trigger('input') + .trigger('change'); + cy.get('@exportDialog').find('#network-export-filetype').click({ force: true }); + cy.contains('li[role="option"]', 'svg').click({ force: true }); + cy.window() + .its('commonService.visuals.twoD.SelectedNetworkExportFileTypeListVariable') + .should('equal', 'svg'); + cy.get('@exportDialog').find('#network-export').should('be.visible').click(); + cy.contains('.p-dialog-title', 'Export Network Image', { timeout: 15000 }).should('not.exist'); + + cy.readFile(exportPath, 'utf8', { timeout: 30000 }).then((svgText: string) => { + ['Node Color: Degree', 'Link Color: Distance'].forEach((label) => { + const positionedLegend = new RegExp( + ` { + const app = win.commonService.visuals.microbeTrace; + const style = win.commonService.session.style; + + expect(app.SelectedColorNodesByVariable).to.equal('degree'); + expect(style.variableColorScales.node.degree.mode).to.equal('continuous'); + expect(style.variableColorScales.node.degree.stops.map((stop: any) => stop.value)).to.deep.equal([1, 7, 13]); + expect(app.SelectedColorLinksByVariable).to.equal('distance'); + expect(style.variableColorScales.link.distance.mode).to.equal('continuous'); + expect(style.variableColorScales.link.distance.stops.map((stop: any) => stop.value)).to.deep.equal([0, 20, 80]); + }); + }); + + it('uses the same continuous node and link colors in every shared visual consumer', () => { + useNodeColorField('degree'); + useLinkColorField('distance'); + + const canonicalColor = (win: any, color: string): string => { + const context = win.document.createElement('canvas').getContext('2d'); + context.fillStyle = '#000000'; + context.fillStyle = color; + return context.fillStyle.toLowerCase(); + }; + + cy.window().should((win: any) => { + const common = win.commonService; + const twoD = common.visuals.twoD; + const dataNode = common.session.data.nodes.find((node: any) => Number.isFinite(Number(node.degree))); + const renderedNode = twoD.cy.getElementById(dataNode._id); + expect(renderedNode.empty(), '2D node exists').to.equal(false); + expect(canonicalColor(win, renderedNode.style('background-color')), '2D node color') + .to.equal(canonicalColor(win, common.getNodeFillStyle(dataNode).color)); + + const renderedLink = twoD.cy.edges().filter((edge: any) => Number.isFinite(Number(edge.data('distance')))).first(); + expect(renderedLink.empty(), '2D link exists').to.equal(false); + expect(canonicalColor(win, renderedLink.style('line-color')), '2D link color') + .to.equal(canonicalColor(win, common.temp.style.linkColorMap(renderedLink.data('distance')))); + }); + + cy.contains('button', 'View').click(); + cy.contains('button[mat-menu-item]', 'Map').click(); + cy.get('.mapStyle', { timeout: 15000 }).should('be.visible'); + cy.get('#tool-btn-container-map a[title="Settings"]').click({ force: true }); + cy.contains('.p-dialog-title', 'Geospatial Settings').should('be.visible'); + cy.get('#map-field-zipcode').click(); + cy.contains('li[role="option"]', 'Zipcode').click(); + cy.closeSettingsPane('Geospatial Settings'); + cy.window().should((win: any) => { + const common = win.commonService; + const map = common.visuals.gisMap; + const nodeLayers = [ + ...Object.values(map.layers.featureGroup?._layers || {}), + ...Object.values(map.layers.markerClusterGroup?._featureGroup?._layers || {}), + ]; + const nodeLayer = nodeLayers + .find((layer: any) => layer.data && Number.isFinite(Number(layer.data.degree))) as any; + const linkLayer = Object.values(map.layers.links._layers) + .find((layer: any) => layer.data && Number.isFinite(Number(layer.data.distance))) as any; + expect(nodeLayer, 'Map node layer').to.exist; + expect(linkLayer, 'Map link layer').to.exist; + expect(canonicalColor(win, nodeLayer.options.fillColor), 'Map node color') + .to.equal(canonicalColor(win, common.getNodeFillStyle(nodeLayer.data).color)); + expect(canonicalColor(win, linkLayer.options.color), 'Map link color') + .to.equal(canonicalColor(win, common.temp.style.linkColorMap(linkLayer.data.distance))); + }); + + cy.contains('button', 'View').click(); + cy.contains('button[mat-menu-item]', 'Bubble').click(); + cy.get('#cyBubble', { timeout: 15000 }).should('be.visible'); + closeSettingsPaneIfVisible('Bubble Settings'); + cy.window().should((win: any) => { + const common = win.commonService; + const bubble = common.visuals.bubble; + const dataNode = common.session.data.nodes.find((node: any) => Number.isFinite(Number(node.degree))); + const renderedNode = bubble.cy.getElementById(dataNode._id); + expect(renderedNode.empty(), 'Bubble node exists').to.equal(false); + expect(canonicalColor(win, renderedNode.style('background-color')), 'Bubble node color') + .to.equal(canonicalColor(win, common.getNodeFillStyle(dataNode).color)); + }); + + cy.contains('button', 'View').click(); + cy.contains('button[mat-menu-item]', 'Phylogenetic Tree').click(); + cy.get('#phylocanvas svg', { timeout: 15000 }).should('be.visible'); + closeSettingsPaneIfVisible('Phylogenetic Tree Settings'); + cy.get('#phylocanvas svg g.tidytree-node-leaf circle').should(($circles) => { + const win = $circles[0].ownerDocument.defaultView as any; + const common = win.commonService; + const expectedColors = new Set(common.session.data.nodes.map((node: any) => + canonicalColor(win, common.getNodeFillStyle(node).color) + )); + const renderedColors = new Set(Array.from($circles).map((circle: Element) => + canonicalColor(win, win.getComputedStyle(circle).fill) + )); + + expect(renderedColors.size, 'Phylogenetic ramp colors').to.be.greaterThan(1); + renderedColors.forEach((color) => expect(expectedColors.has(color), `Phylogenetic color ${color}`).to.equal(true)); + }); + }); + + 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 b3c27bd7..6a843127 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 ffb07135..4649ee7e 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 63d031fd..8cd5232d 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 f4ba17fc..3dba8277 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 34b0e85e..0f6e3c55 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/examples/continuous-color-ramps/README.md b/examples/continuous-color-ramps/README.md new file mode 100644 index 00000000..1633ba97 --- /dev/null +++ b/examples/continuous-color-ramps/README.md @@ -0,0 +1,67 @@ +# 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. +- `riverbend-risk-ramp.csv`: continuous node color-assignment file for `RiskScore`. Its `mode` column switches the selected field to a custom continuous ramp using the listed numeric stops. +- `riverbend-contact-ramp.csv`: continuous link color-assignment file for `ContactMinutes`, including endpoints outside the suggested custom walkthrough domain to demonstrate clamping. +- `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. | +| Categorical assignment import | `Lineage` | — | Apply `riverbend-lineage-colors.csv`; Lineage becomes categorical and receives the supplied colors. | +| Continuous assignment import | `RiskScore` | `ContactMinutes` | Apply `riverbend-risk-ramp.csv` and `riverbend-contact-ramp.csv`; the declared fields switch to Continuous with custom domains spanning the first and last numeric stops. | +| 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. Apply `riverbend-risk-ramp.csv` and `riverbend-contact-ramp.csv` to confirm assignment import selects the declared node/link fields and reconstructs their continuous ramps. +13. 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. + +## Color-assignment table format + +Simple CSV and TSV assignment files use the field name as the first column header and `color` as the color column. Add an optional `mode` column with `continuous` in at least one row to declare a continuous ramp. Every first-column value must then be a finite number, and the file must contain at least two distinct stops. Empty mode cells inherit the declared mode; conflicting mode values are rejected atomically. + +## 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 00000000..240abfe0 --- /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-contact-ramp.csv b/examples/continuous-color-ramps/riverbend-contact-ramp.csv new file mode 100644 index 00000000..fe72fa39 --- /dev/null +++ b/examples/continuous-color-ramps/riverbend-contact-ramp.csv @@ -0,0 +1,5 @@ +ContactMinutes,color,mode +5,#000004,continuous +45,#721f81, +180,#f1605d, +480,#fcfdbf, 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 00000000..958a6859 --- /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 00000000..417103f6 --- /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 00000000..c6a883b7 --- /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/examples/continuous-color-ramps/riverbend-risk-ramp.csv b/examples/continuous-color-ramps/riverbend-risk-ramp.csv new file mode 100644 index 00000000..7469ec98 --- /dev/null +++ b/examples/continuous-color-ramps/riverbend-risk-ramp.csv @@ -0,0 +1,5 @@ +RiskScore,color,mode +5,#440154,continuous +35,#31688e, +60,#35b779, +96,#fde725, diff --git a/scripts/embed-security.test.js b/scripts/embed-security.test.js index 11ba1850..8aca78e8 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 1b80ba7a..4e3793a5 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-assignment.service.spec.ts b/src/app/contactTraceCommonServices/color-assignment.service.spec.ts index ce1dd5dd..1d2ae363 100644 --- a/src/app/contactTraceCommonServices/color-assignment.service.spec.ts +++ b/src/app/contactTraceCommonServices/color-assignment.service.spec.ts @@ -103,6 +103,30 @@ GCWGS-6 #CC9999 8`; expect(fieldResult.assignments['8']).toBe('#cc9999'); }); + it('parses explicitly continuous table assignments as normalized numeric ramp stops', () => { + const result = service.parse( + 'RiskScore,color,mode\n5.0,#440154,continuous\n50,#21918c,\n9.6e1,#fde725,continuous', + 'RiskScore' + ); + + expect(result.mode).toBe('continuous'); + expect(result.uniqueAssignmentCount).toBe(3); + expect(result.assignments).toEqual(jasmine.objectContaining({ + '5': '#440154', + '50': '#21918c', + '96': '#fde725' + })); + }); + + it('rejects invalid or underspecified continuous ramp declarations', () => { + expect(() => service.parse('RiskScore,color,mode\nlow,#440154,continuous\n10,#fde725,', 'RiskScore')) + .toThrowError(NodeColorAssignmentParseError, /finite numeric color-stop value/); + expect(() => service.parse('RiskScore,color,mode\n10,#440154,continuous', 'RiskScore')) + .toThrowError(NodeColorAssignmentParseError, /at least two distinct numeric stops/); + expect(() => service.parse('RiskScore,color,mode\n0,#440154,continuous\n10,#fde725,categorical', 'RiskScore')) + .toThrowError(NodeColorAssignmentParseError, /same mode/); + }); + it('parses the supplied ID-first table layout for the selected internal _id field', () => { const result = service.parse( 'id\tMLST\tcolor\tnotes\n' + diff --git a/src/app/contactTraceCommonServices/color-assignment.service.ts b/src/app/contactTraceCommonServices/color-assignment.service.ts index 2d0cb95a..0d22dcd9 100644 --- a/src/app/contactTraceCommonServices/color-assignment.service.ts +++ b/src/app/contactTraceCommonServices/color-assignment.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import * as Papa from 'papaparse'; export type NodeColorAssignmentFormat = 'itol-colorstrip' | 'delimited-table'; +export type ColorAssignmentMode = 'categorical' | 'continuous'; export interface NodeColorAssignmentParseIssue { line: number; @@ -11,6 +12,7 @@ export interface NodeColorAssignmentParseIssue { export interface ParsedNodeColorAssignments { format: NodeColorAssignmentFormat; datasetLabel?: string; + mode?: ColorAssignmentMode; assignments: Record; rowCount: number; duplicateCount: number; @@ -246,6 +248,7 @@ export class ColorAssignmentService { return { format: 'itol-colorstrip', datasetLabel, + mode: 'categorical', assignments, rowCount, duplicateCount, @@ -322,6 +325,7 @@ export class ColorAssignmentService { const headers = (rows[0] || []).map(header => String(header ?? '').trim()); const normalizedHeaders = headers.map(header => header.toLocaleLowerCase()); const colorIndex = normalizedHeaders.indexOf('color'); + const modeIndex = normalizedHeaders.indexOf('mode'); const valueIndex = 0; if (colorIndex < 0) { @@ -339,6 +343,43 @@ export class ColorAssignmentService { throw new NodeColorAssignmentParseError(issues); } + const declaredModes = rows + .slice(1) + .map((row, rowIndex) => ({ + line: rowIndex + 2, + value: modeIndex < 0 ? '' : String(row[modeIndex] ?? '').trim().toLocaleLowerCase() + })) + .filter(entry => !!entry.value); + let mode: ColorAssignmentMode | undefined; + + if (modeIndex >= 0) { + if (!declaredModes.length) { + issues.push({ line: 1, message: 'The "mode" column must declare "categorical" or "continuous".' }); + } else { + declaredModes.forEach(entry => { + if (entry.value !== 'categorical' && entry.value !== 'continuous') { + issues.push({ + line: entry.line, + message: `"${entry.value}" is not a valid color-assignment mode; expected "categorical" or "continuous".` + }); + } + }); + + const validModes = declaredModes + .map(entry => entry.value) + .filter((value): value is ColorAssignmentMode => value === 'categorical' || value === 'continuous'); + if (new Set(validModes).size > 1) { + issues.push({ line: 1, message: 'All populated "mode" cells must declare the same mode.' }); + } else { + mode = validModes[0]; + } + } + } + + if (issues.length) { + throw new NodeColorAssignmentParseError(issues); + } + const assignments = this.createAssignmentRecord(); let rowCount = 0; let duplicateCount = 0; @@ -346,10 +387,10 @@ export class ColorAssignmentService { rows.slice(1).forEach((row, rowIndex) => { const line = rowIndex + 2; rowCount++; - const value = String(row[valueIndex] ?? '').trim(); + const rawValue = String(row[valueIndex] ?? '').trim(); const rawColor = String(row[colorIndex] ?? '').trim(); - if (!value || !rawColor) { + if (!rawValue || !rawColor) { issues.push({ line, message: 'Both the value and color fields are required.' }); return; } @@ -360,6 +401,16 @@ export class ColorAssignmentService { return; } + let value = rawValue; + if (mode === 'continuous') { + const numericValue = Number(rawValue); + if (!Number.isFinite(numericValue) || !/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(rawValue)) { + issues.push({ line, message: `"${rawValue}" is not a finite numeric color-stop value.` }); + return; + } + value = String(numericValue); + } + const duplicateResult = this.addAssignment(assignments, value, color, line, issues); if (duplicateResult) { duplicateCount++; @@ -370,8 +421,15 @@ export class ColorAssignmentService { throw new NodeColorAssignmentParseError(issues); } + if (mode === 'continuous' && Object.keys(assignments).length < 2) { + throw new NodeColorAssignmentParseError([ + { line: 1, message: 'A continuous color ramp requires at least two distinct numeric stops.' } + ]); + } + return { format: 'delimited-table', + mode, assignments, rowCount, duplicateCount, diff --git a/src/app/contactTraceCommonServices/color-mapping.service.spec.ts b/src/app/contactTraceCommonServices/color-mapping.service.spec.ts index 261b855a..2a92ffde 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,11 +74,32 @@ describe('ColorMappingService node assignments', () => { {}, history, {}, - false + false, + createDefaultVariableColorScaleConfig('categorical') ); expect(otherResult.colorMap('8')).toBe('#123456'); expect(history.Other['8']).toBe('#123456'); expect(history.MLST['8']).toBe('#cc9999'); }); + + it('prefers field-specific link assignments over stored colors', () => { + const result = service.createLinkColorMap( + [{ visible: true, setting: 'household' }], + 'setting', + ['#111111'], + [1], + { setting: ['#222222'] }, + { setting: ['household'] }, + { household: '#333333' }, + false, + createDefaultVariableColorScaleConfig('categorical'), + [{ visible: true, setting: 'household' }], + { household: '#444444' } + ); + + expect(result.colorMap('household')).toBe('#444444'); + expect(result.updatedLinkColorsTable.setting).toEqual(['#444444']); + expect(result.updatedLinkColorsTableHistory.household).toBe('#444444'); + }); }); diff --git a/src/app/contactTraceCommonServices/color-mapping.service.ts b/src/app/contactTraceCommonServices/color-mapping.service.ts index d0dd9a37..4754bec0 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,27 @@ export class ColorMappingService { linkColorsTable: any, linkColorsTableKeys: any, linkColorsTableHistory: any, - debugMode: boolean + debugMode: boolean, + variableColorScaleConfig?: VariableColorScaleConfig, + domainLinks: any[] = links, + linkColorAssignments: Record = {} ): { 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 +326,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 }; } @@ -287,6 +355,7 @@ export class ColorMappingService { let updatedLinkColorsTableHistory = linkColorsTableHistory || {}; let updatedLinkColors = [...linkColors]; let updatedLinkAlphas = [...linkAlphas]; + const explicitAssignments = linkColorAssignments || {}; const hasLinkColorsTableForVariable = Array.isArray(updatedLinkColorsTable[linkColorVariable]); @@ -426,14 +495,17 @@ export class ColorMappingService { const repairDuplicateColors = isOriginColorVariable || uniqueCandidatePalette.length < distinctValues.length; distinctValues.forEach((val) => { - const existingColor = colorsByKey.get(val); + const explicitColor = explicitAssignments[val]; + const hasExplicitColor = Object.prototype.hasOwnProperty.call(explicitAssignments, val) + && typeof explicitColor === 'string'; + const existingColor = hasExplicitColor ? explicitColor : colorsByKey.get(val); if (existingColor) { - if (resetExpandedOriginColors) { + if (!hasExplicitColor && resetExpandedOriginColors) { delete updatedLinkColorsTableHistory[val]; return; } - if (repairDuplicateColors && usedColors.has(existingColor)) { + if (!hasExplicitColor && repairDuplicateColors && usedColors.has(existingColor)) { delete updatedLinkColorsTableHistory[val]; return; } @@ -498,7 +570,8 @@ export class ColorMappingService { updatedLinkAlphas, updatedLinkColorsTable, updatedLinkColorsTableKeys, - updatedLinkColorsTableHistory + updatedLinkColorsTableHistory, + resolvedScale }; } diff --git a/src/app/contactTraceCommonServices/common.service.color-assignments.spec.ts b/src/app/contactTraceCommonServices/common.service.color-assignments.spec.ts index e24a5b17..bd4e89dd 100644 --- a/src/app/contactTraceCommonServices/common.service.color-assignments.spec.ts +++ b/src/app/contactTraceCommonServices/common.service.color-assignments.spec.ts @@ -3,8 +3,9 @@ import { CommonService } from './common.service'; describe('CommonService node color assignments', () => { function createService(style: any): CommonService { const service = Object.create(CommonService.prototype) as CommonService; - (service as any).session = { style }; + (service as any).session = { data: { nodes: [], links: [] }, style }; spyOn(service, 'createNodeColorMap'); + spyOn(service, 'createLinkColorMap'); return service; } @@ -38,4 +39,58 @@ describe('CommonService node color assignments', () => { expect((service as any).session.style.nodeColorAssignments.MLST.shared).toBe('#aabbcc'); expect((service as any).session.style.nodeColorAssignments.Other.shared).toBe('#112233'); }); + + it('applies imported node ramp stops with a custom domain', () => { + const service = createService({}); + (service as any).session.data.nodes = [{ score: '0' }, { score: 5 }, { score: '10' }]; + + const applied = service.applyVariableColorAssignments('node', 'score', { + '10': '#ffffff', + '0': '#000000', + '5': '#ff0000' + }, 'continuous'); + + expect(applied.mode).toBe('continuous'); + expect(applied.stops).toEqual([ + { value: 0, color: '#000000' }, + { value: 5, color: '#ff0000' }, + { value: 10, color: '#ffffff' } + ]); + expect((service as any).session.style.variableColorScales.node.score).toEqual(jasmine.objectContaining({ + mode: 'continuous', + domain: { kind: 'custom', min: 0, max: 10 }, + stops: applied.stops + })); + expect(service.createNodeColorMap).toHaveBeenCalled(); + }); + + it('supports continuous and categorical link assignment files independently', () => { + const service = createService({}); + (service as any).session.data.links = [{ distance: 0 }, { distance: 20 }]; + + service.applyVariableColorAssignments('link', 'distance', { + '0': '#000000', + '20': '#ffffff' + }, 'continuous'); + service.applyLinkColorAssignments('setting', { household: '#123456' }); + + expect((service as any).session.style.variableColorScales.link.distance).toEqual(jasmine.objectContaining({ + mode: 'continuous', + domain: { kind: 'custom', min: 0, max: 20 } + })); + expect((service as any).session.style.linkColorAssignments.setting.household).toBe('#123456'); + expect(service.createLinkColorMap).toHaveBeenCalledTimes(2); + }); + + it('rejects nonnumeric continuous assignments without changing scale state', () => { + const service = createService({}); + (service as any).session.data.nodes = [{ score: 0 }, { score: 10 }]; + + expect(() => service.applyVariableColorAssignments('node', 'score', { + low: '#000000', + high: '#ffffff' + }, 'continuous')).toThrowError(/not a finite number/); + expect((service as any).session.style.variableColorScales).toBeUndefined(); + expect(service.createNodeColorMap).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/contactTraceCommonServices/common.service.ts b/src/app/contactTraceCommonServices/common.service.ts index d2cba905..599f96dc 100644 --- a/src/app/contactTraceCommonServices/common.service.ts +++ b/src/app/contactTraceCommonServices/common.service.ts @@ -17,6 +17,21 @@ 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, + ContinuousColorStop, + createDefaultVariableColorScaleConfig, + createVariableColorScaleState, + inspectContinuousValues, + normalizeVariableColorScaleConfig, + normalizeVariableColorScaleState, + parseContinuousNumber, + ResolvedVariableColorScale, + VariableColorScaleConfig, + VariableColorScaleState, + VariableColorTarget +} from './variable-color-scale'; import { WorkerComputeService } from './worker-compute.service'; import { buildStoredDistanceEdgeCache, @@ -34,6 +49,12 @@ interface SequencePairwiseLinkGuardrails { hardLimit: number; } +export interface AppliedVariableColorAssignments { + mode: 'categorical' | 'continuous'; + assignments: Record; + stops?: ContinuousColorStop[]; +} + interface SequencePairwiseLinkGuardrailResult { warningThreshold: number; hardLimit: number; @@ -651,6 +672,7 @@ export class CommonService extends AppComponentBase implements OnInit { nodeAlphas: [1], nodeColors: this.thirtyColorPalette, nodeColorAssignments: {}, + linkColorAssignments: {}, nodeColorsTable: {}, nodeColorsTableHistory: {}, nodeColorsTableKeys: {}, @@ -678,6 +700,7 @@ export class CommonService extends AppComponentBase implements OnInit { polygonAlphas: [0.5], polygonColors: this.polygonPalette, polygonValueNames: {}, + variableColorScales: createVariableColorScaleState(), overwrite: {}, widgets: this.defaultWidgets() }, @@ -709,9 +732,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,22 +754,115 @@ export class CommonService extends AppComponentBase implements OnInit { return Math.min(1, Math.max(0, numericValue)); } - private ensureNodeColorAssignmentState(style: any = this.session?.style): Record> { + 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 ensureVariableColorAssignmentState( + target: VariableColorTarget, + style: any = this.session?.style + ): Record> { if (!style || typeof style !== 'object') { return {}; } - if (!style.nodeColorAssignments || typeof style.nodeColorAssignments !== 'object' || Array.isArray(style.nodeColorAssignments)) { - style.nodeColorAssignments = {}; + const stateKey = target === 'node' ? 'nodeColorAssignments' : 'linkColorAssignments'; + if (!style[stateKey] || typeof style[stateKey] !== 'object' || Array.isArray(style[stateKey])) { + style[stateKey] = {}; } - return style.nodeColorAssignments as Record>; + return style[stateKey] as Record>; } - public applyNodeColorAssignments(field: string, assignments: Record): Record { + public applyVariableColorAssignments( + target: VariableColorTarget, + field: string, + assignments: Record, + mode: 'categorical' | 'continuous' + ): AppliedVariableColorAssignments { const selectedField = String(field ?? '').trim(); if (!selectedField || selectedField === 'None') { - throw new Error('Select a node color variable before applying color assignments.'); + throw new Error(`Select a ${target} color variable before applying color assignments.`); } const incomingEntries = Object.entries(assignments || {}); @@ -755,7 +873,62 @@ export class CommonService extends AppComponentBase implements OnInit { throw new Error(`Invalid color assignment for value "${invalidEntry[0]}".`); } - const assignmentState = this.ensureNodeColorAssignmentState(); + if (mode === 'continuous') { + const items = target === 'node' + ? this.session?.data?.nodes || [] + : this.session?.data?.links || []; + const summary = inspectContinuousValues(items, selectedField); + if (!summary.canUseContinuous) { + throw new Error(`The ${target} field "${selectedField}" does not contain numeric values for a continuous color ramp.`); + } + + const colorsByStop = new Map(); + incomingEntries.forEach(([value, color]) => { + const parsedValue = parseContinuousNumber(value); + if (parsedValue.kind !== 'number') { + throw new Error(`Continuous color-stop value "${value}" is not a finite number.`); + } + + const normalizedColor = String(color).toLowerCase(); + const existingColor = colorsByStop.get(parsedValue.value); + if (existingColor && existingColor !== normalizedColor) { + throw new Error(`Continuous color-stop value "${value}" is assigned both ${existingColor} and ${normalizedColor}.`); + } + colorsByStop.set(parsedValue.value, normalizedColor); + }); + + const stops = Array.from(colorsByStop.entries()) + .map(([value, color]) => ({ value, color })) + .sort((left, right) => left.value - right.value); + if (stops.length < 2 || stops[0].value === stops[stops.length - 1].value) { + throw new Error('A continuous color ramp requires at least two distinct numeric stops.'); + } + + const currentConfig = this.getVariableColorScaleConfig(target, selectedField); + this.setVariableColorScaleConfig(target, selectedField, { + ...currentConfig, + mode: 'continuous', + domain: { + kind: 'custom', + min: stops[0].value, + max: stops[stops.length - 1].value + }, + stops + }); + if (target === 'node') { + this.createNodeColorMap(); + } else { + this.createLinkColorMap(); + } + + return { + mode, + assignments: Object.fromEntries(stops.map(stop => [String(stop.value), stop.color])), + stops + }; + } + + const assignmentState = this.ensureVariableColorAssignmentState(target); const mergedAssignments = Object.create(null) as Record; const existingAssignments = assignmentState[selectedField]; @@ -772,8 +945,29 @@ export class CommonService extends AppComponentBase implements OnInit { }); assignmentState[selectedField] = mergedAssignments; - this.createNodeColorMap(); - return mergedAssignments; + this.setVariableColorScaleMode(target, selectedField, 'categorical'); + if (target === 'node') { + this.createNodeColorMap(); + } else { + this.createLinkColorMap(); + } + return { mode, assignments: mergedAssignments }; + } + + public applyNodeColorAssignments( + field: string, + assignments: Record, + mode: 'categorical' | 'continuous' = 'categorical' + ): Record { + return this.applyVariableColorAssignments('node', field, assignments, mode).assignments; + } + + public applyLinkColorAssignments( + field: string, + assignments: Record, + mode: 'categorical' | 'continuous' = 'categorical' + ): Record { + return this.applyVariableColorAssignments('link', field, assignments, mode).assignments; } public getNodeFillStyle(node: any): { color: string; alpha: number } { @@ -790,10 +984,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. @@ -2745,7 +2940,9 @@ export class CommonService extends AppComponentBase implements OnInit { ); this.ensureNetworkSubsetFilterState(); this.session.style = oldSession.style; - this.ensureNodeColorAssignmentState(this.session.style); + this.ensureVariableColorAssignmentState('node', this.session.style); + this.ensureVariableColorAssignmentState('link', this.session.style); + this.ensureVariableColorScaleState(this.session.style, true); this.session.meta.startTime = Date.now(); @@ -2885,7 +3082,9 @@ export class CommonService extends AppComponentBase implements OnInit { if(this.debugMode) { console.log('---- applying style: ', style); } - this.ensureNodeColorAssignmentState(style); + this.ensureVariableColorAssignmentState('node', style); + this.ensureVariableColorAssignmentState('link', style); + this.ensureVariableColorScaleState(style, true); this.session.style = style; this.session.style.widgets = Object.assign({}, this.defaultWidgets(), @@ -4264,7 +4463,8 @@ align(params): Promise { const nodeColorsTable = this.session.style.nodeColorsTable; // e.g. { varName: [ ... ] } const nodeColorsTableKeys = this.session.style.nodeColorsTableKeys; const nodeColorsTableHistory = this.session.style.nodeColorsTableHistory; - const nodeColorAssignments = this.ensureNodeColorAssignmentState()[nodeColorVariable] || {}; + const nodeColorAssignments = this.ensureVariableColorAssignmentState('node')[nodeColorVariable] || {}; + const variableColorScaleConfig = this.getVariableColorScaleConfig('node', nodeColorVariable); // 2) Call your new colorMappingService const result = this.colorMappingService.createNodeColorMap( @@ -4276,12 +4476,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 +4514,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 = {}; @@ -4336,6 +4546,7 @@ align(params): Promise { const linkColorsTable = this.session.style.linkColorsTable; const linkColorsTableKeys = this.session.style.linkColorsTableKeys; const linkColorsTableHistory = this.session.style.linkColorsTableHistory; + const linkColorAssignments = this.ensureVariableColorAssignmentState('link')[linkColorVariable] || {}; // 2) Delegate to colorMappingService const result = this.colorMappingService.createLinkColorMap( @@ -4346,13 +4557,17 @@ align(params): Promise { linkColorsTable, linkColorsTableKeys, linkColorsTableHistory, - this.debugMode + this.debugMode, + variableColorScaleConfig, + this.session.data.links, + linkColorAssignments ); // 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.spec.ts b/src/app/contactTraceCommonServices/export.service.spec.ts new file mode 100644 index 00000000..1befd0fb --- /dev/null +++ b/src/app/contactTraceCommonServices/export.service.spec.ts @@ -0,0 +1,36 @@ +import { ExportService } from './export.service'; + +describe('ExportService continuous color legends', () => { + let service: ExportService; + + beforeEach(() => { + service = new ExportService(); + }); + + it('exports a visible, accessible label with the continuous ramp', () => { + const legend = document.createElement('div'); + legend.id = 'key-tables-node-legend'; + legend.innerHTML = ` +
Node Color: Risk & score
+
+
+
+ 0 + 10 +
+ + `; + + const output = service.exportColorLegendAsSVG(legend); + + expect(output.width).toBeGreaterThanOrEqual(320); + expect(output.height).toBe(134); + expect(output.svg).toContain('Node Color: Risk & score. Continuous color ramp from 0 to 10.'); + expect(output.svg).toContain('font-weight="bold" fill="black">Node Color: Risk & score'); + expect(output.svg).toContain('y="36"'); + expect(output.svg).toContain('y="104"'); + }); +}); diff --git a/src/app/contactTraceCommonServices/export.service.ts b/src/app/contactTraceCommonServices/export.service.ts index 32268aa2..b8ddb886 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,60 @@ 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 labelElement = legendElement.querySelector('.continuous-ramp__label'); + const ticks = Array.from(legendElement.querySelectorAll('.continuous-ramp__ticks span')); + const missingSwatch = legendElement.querySelector('.continuous-ramp__missing-swatch'); + const label = this.normalizeCellText(labelElement?.textContent || '') || 'Continuous Color Legend'; + 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), + this.measureTextWidth(label, 'bold') + 24 + ); + const height = 134; + 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 accessibleDescription = `${label}. ${description}`; + const svg = ` + ${this.escapeSVGText(accessibleDescription)} + + ${this.escapeSVGText(label)} + ${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 00000000..1a403c16 --- /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 00000000..483f91ff --- /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 fab4cc7b..2bc805e5 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 462d8c4e..1ccda66e 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 148f60a4..d175ce0a 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 9635cdb4..6212d211 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/helperClasses/globalSettingsDialogRequest.ts b/src/app/helperClasses/globalSettingsDialogRequest.ts index cbbc3e74..5fff6558 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 d6a096f3..cd48e1d8 100644 --- a/src/app/microbe-trace-next-plugin.component.html +++ b/src/app/microbe-trace-next-plugin.component.html @@ -123,9 +123,23 @@ >
- Link Color Table + {{ getColorKeyTitle('link') }} - + @if (!isContinuousColorScale('link')) { + + } + @if (isContinuousColorScale('link')) { + + }
- + + +
+ } @else { + - + + }
@@ -172,9 +197,23 @@ >
- Node Color Table + {{ getColorKeyTitle('node') }} - + @if (!isContinuousColorScale('node')) { + + } + @if (isContinuousColorScale('node')) { + + }
- + + +
+ } @else { + - + + }
@@ -598,7 +648,35 @@ -
+ @if (SelectedColorNodesByVariable !== 'None') { +
+
+
+ + +
+
+ @if (isContinuousColorScale('node')) { + + + } + } + +
Apply Color Assignment File
@@ -632,7 +710,7 @@
} @else {
-
+
@@ -693,9 +771,60 @@
+ @if (SelectedColorLinksByVariable !== 'None') { +
+
+
+ + +
+
+ @if (isContinuousColorScale('link')) { + + + } + } + + @if (linkColorAssignmentStatus) { + + } @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 107b6599..83f2d5cb 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 45965fa4..5884751e 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'); }