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 2148dc643..0338de172 100644 --- a/cypress/e2e/journeys/flows/bubble-cluster-recompute-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/bubble-cluster-recompute-uploaded.cy.ts @@ -185,6 +185,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); @@ -261,6 +262,7 @@ describe('Journey Flow - Bubble uploaded cluster recompute', () => { openGlobalStylingTab(); selectPrimeOption('#node-color-variable', 'Cluster'); + selectPrimeOption('#node-color-scale-mode', 'Categorical'); cy.window().its('commonService.session.style.widgets.node-color-variable').should('equal', 'cluster'); cy.closeGlobalSettings(); diff --git a/cypress/e2e/journeys/flows/color-by-uploaded.cy.ts b/cypress/e2e/journeys/flows/color-by-uploaded.cy.ts index a932d81c8..314055375 100644 --- a/cypress/e2e/journeys/flows/color-by-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/color-by-uploaded.cy.ts @@ -175,7 +175,7 @@ describe('Journey Flow - Uploaded color-by controls', () => { selectPrimeOption('#node-color-variable', 'Profession'); cy.window().its('commonService.session.style.widgets.node-color-variable').should('equal', 'Profession'); - cy.get('#node-color-table-row').should('be.visible'); + cy.get('#node-color-table-row').scrollIntoView().should('be.visible'); cy.get('#key-tables-node-table', { timeout: 15000 }).should('be.visible'); cy.get('#key-tables-node-table tr').should(($rows) => { expect($rows.length, 'node color table rows').to.be.greaterThan(2); @@ -185,7 +185,7 @@ describe('Journey Flow - Uploaded color-by controls', () => { selectPrimeOption('#link-tooltip-variable', 'Contact type'); cy.window().its('commonService.session.style.widgets.link-color-variable').should('equal', 'Contact type'); - cy.get('#link-color-table-row').should('be.visible'); + cy.get('#link-color-table-row').scrollIntoView().should('be.visible'); cy.get('#key-tables-link-table', { timeout: 15000 }).should('be.visible'); cy.get('#key-tables-link-table tr').should(($rows) => { expect($rows.length, 'link color table rows').to.be.greaterThan(2); diff --git a/cypress/e2e/journeys/flows/dashboard-global-styling-uploaded.cy.ts b/cypress/e2e/journeys/flows/dashboard-global-styling-uploaded.cy.ts index f02b84e18..f561304ae 100644 --- a/cypress/e2e/journeys/flows/dashboard-global-styling-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/dashboard-global-styling-uploaded.cy.ts @@ -64,21 +64,29 @@ const matchesExpectedColor = (actual: string, expectedHex: string): boolean => { normalizedActual === normalizeColor(hexToRgbString(expectedHex)); }; +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const getBubbleDataNodes = (bubble: any) => bubble.cy.nodes().filter((node: any) => !node.hasClass('X_axis') && !node.hasClass('Y_axis')); const selectPrimeOption = (selector: string, label: string): void => { - cy.get(selector).click({ force: true }).wait(100); - cy.get('body').then(($body) => { - const overlay = $body.find('.p-select-overlay:visible').last(); - - expect(overlay.length, `visible PrimeNG overlay for ${selector}`).to.be.greaterThan(0); - - cy.wrap(overlay) - .contains('li[role="option"]', label, { timeout: 15000 }) + cy.get(selector, { timeout: 15000 }) + .scrollIntoView() + .should('be.visible') + .click({ force: true }); + cy.get('.p-select-overlay:visible', { timeout: 15000 }) + .last() + .should('be.visible') + .within(() => { + cy.contains( + 'li[role="option"]', + new RegExp(`^\\s*${escapeRegExp(label)}\\s*$`), + { timeout: 15000 }, + ) .scrollIntoView() .click({ force: true }); - }); + }); + cy.get('body').find('.p-select-overlay:visible').should('have.length', 0); }; const changeColorTableEntry = (tableSelector: string, value: string, nextColor: string): void => { @@ -640,6 +648,7 @@ describe('Journey Flow - Dashboard global styling propagation', () => { openGlobalStylingTab(); selectPrimeOption('#link-tooltip-variable', 'Cluster'); cy.window().its('commonService.session.style.widgets.link-color-variable').should('equal', 'cluster'); + selectPrimeOption('#link-color-scale-mode', 'Categorical'); cy.get('#key-tables-link-table', { timeout: 15000 }).should('be.visible'); getCommonLinkClusters().then(([targetCluster, controlCluster]) => { diff --git a/cypress/e2e/journeys/flows/epi-curve-styling-uploaded.cy.ts b/cypress/e2e/journeys/flows/epi-curve-styling-uploaded.cy.ts index aa459a928..2e409934c 100644 --- a/cypress/e2e/journeys/flows/epi-curve-styling-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/epi-curve-styling-uploaded.cy.ts @@ -322,6 +322,7 @@ describe('Journey Flow - Epi Curve styling on uploaded data', () => { cy.openGlobalSettings(); switchGlobalSettingsTab('Styling'); selectVisiblePrimeOption('#node-color-variable', 'Cluster'); + selectVisiblePrimeOption('#node-color-scale-mode', 'Categorical'); cy.window() .its('commonService.session.style.widgets.node-color-variable') diff --git a/cypress/e2e/journeys/flows/map-cluster-recompute-uploaded.cy.ts b/cypress/e2e/journeys/flows/map-cluster-recompute-uploaded.cy.ts index 55a2aee69..e550a7cfb 100644 --- a/cypress/e2e/journeys/flows/map-cluster-recompute-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/map-cluster-recompute-uploaded.cy.ts @@ -159,6 +159,8 @@ describe('Journey Flow - Map uploaded cluster-color recompute', () => { openGlobalStylingTab(); selectPrimeOption('#node-color-variable', 'Cluster'); selectPrimeOption('#link-tooltip-variable', 'Cluster'); + selectPrimeOption('#node-color-scale-mode', 'Categorical'); + selectPrimeOption('#link-color-scale-mode', 'Categorical'); cy.window().its('commonService.session.style.widgets.node-color-variable').should('equal', 'cluster'); cy.window().its('commonService.session.style.widgets.link-color-variable').should('equal', 'cluster'); diff --git a/cypress/e2e/journeys/flows/node-color-history-scoping.cy.ts b/cypress/e2e/journeys/flows/node-color-history-scoping.cy.ts index 6f42fd999..c91489253 100644 --- a/cypress/e2e/journeys/flows/node-color-history-scoping.cy.ts +++ b/cypress/e2e/journeys/flows/node-color-history-scoping.cy.ts @@ -46,6 +46,7 @@ describe('Journey Flow - Node color history is scoped by field', () => { openGlobalStylingTab(); selectPrimeOption('#node-color-variable', 'Seedgroups'); + selectPrimeOption('#node-color-scale-mode', 'Categorical'); cy.window().should((win: any) => { const style = win.commonService.session.style as NodeColorStyle; @@ -59,6 +60,7 @@ describe('Journey Flow - Node color history is scoped by field', () => { }); selectPrimeOption('#node-color-variable', 'Raremutations'); + selectPrimeOption('#node-color-scale-mode', 'Categorical'); cy.window().should((win: any) => { const style = win.commonService.session.style as NodeColorStyle; diff --git a/cypress/e2e/journeys/flows/phylogenetic-metadata-uploaded.cy.ts b/cypress/e2e/journeys/flows/phylogenetic-metadata-uploaded.cy.ts index d790a7f96..7f52e7438 100644 --- a/cypress/e2e/journeys/flows/phylogenetic-metadata-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/phylogenetic-metadata-uploaded.cy.ts @@ -618,6 +618,7 @@ describe('Journey Flow - Phylogenetic Tree metadata-backed controls', () => { const expectedSelectedStroke = normalizeColor(hexToRgbString(selectedColor)); openGlobalStylingTab(); + cy.get('#selected-color').scrollIntoView().should('be.visible'); setColorInputValue('#selected-color', selectedColor); cy.window().its('commonService.session.style.widgets.selected-color').should('equal', selectedColor); @@ -646,6 +647,7 @@ describe('Journey Flow - Phylogenetic Tree metadata-backed controls', () => { openGlobalStylingTab(); selectPrimeOption('#node-color-variable', 'Cluster'); + selectPrimeOption('#node-color-scale-mode', 'Categorical'); cy.window() .its('commonService.session.style.widgets.node-color-variable') diff --git a/cypress/e2e/journeys/flows/timeline-key-tables-refresh-uploaded.cy.ts b/cypress/e2e/journeys/flows/timeline-key-tables-refresh-uploaded.cy.ts index d6c43efc8..1b822c56a 100644 --- a/cypress/e2e/journeys/flows/timeline-key-tables-refresh-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/timeline-key-tables-refresh-uploaded.cy.ts @@ -21,18 +21,26 @@ type WinWithMT = Window & { type CountMap = Record; -const selectPrimeOption = (selector: string, label: string): void => { - cy.get(selector).click({ force: true }); - cy.get('body').then(($body) => { - const overlay = $body.find('.p-select-overlay:visible').last(); - - expect(overlay.length, `visible PrimeNG overlay for ${selector}`).to.be.greaterThan(0); +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - cy.wrap(overlay) - .contains('li[role="option"]', label, { timeout: 15000 }) +const selectPrimeOption = (selector: string, label: string): void => { + cy.get(selector, { timeout: 15000 }) + .scrollIntoView() + .should('be.visible') + .click({ force: true }); + cy.get('.p-select-overlay:visible', { timeout: 15000 }) + .last() + .should('be.visible') + .within(() => { + cy.contains( + 'li[role="option"]', + new RegExp(`^\\s*${escapeRegExp(label)}\\s*$`), + { timeout: 15000 }, + ) .scrollIntoView() .click({ force: true }); - }).wait(50); + }); + cy.get('body').find('.p-select-overlay:visible').should('have.length', 0); }; const readColorTableCounts = ($table: JQuery): CountMap => { @@ -135,6 +143,7 @@ describe('Journey Flow - Timeline key table refresh', () => { selectPrimeOption('#link-tooltip-variable', 'Cluster'); cy.window().its('commonService.session.style.widgets.node-color-variable').should('equal', nodeColorField); cy.window().its('commonService.session.style.widgets.link-color-variable').should('equal', linkColorField); + selectPrimeOption('#link-color-scale-mode', 'Categorical'); cy.get('#key-tables-node-table', { timeout: 15000 }).should('be.visible'); cy.get('#key-tables-link-table', { timeout: 15000 }).should('be.visible'); assertNodeColorTableMatchesVisibleNodes(nodeColorField, 'timeline max'); diff --git a/cypress/e2e/journeys/flows/twod-timeline-controls-uploaded.cy.ts b/cypress/e2e/journeys/flows/twod-timeline-controls-uploaded.cy.ts index 9ffdd6a76..9936ad62f 100644 --- a/cypress/e2e/journeys/flows/twod-timeline-controls-uploaded.cy.ts +++ b/cypress/e2e/journeys/flows/twod-timeline-controls-uploaded.cy.ts @@ -47,12 +47,22 @@ const normalizeLogicalLinkId = (value: string): string => String(value || '').re const clickVisiblePrimeOption = (label: string): void => { cy.get('.p-select-overlay:visible', { timeout: 15000 }) .last() - .contains('.p-select-option', new RegExp(`^${escapeRegExp(label)}$`)) - .click({ force: true }); + .should('be.visible') + .within(() => { + cy.contains( + 'li[role="option"], .p-select-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); }; @@ -310,6 +320,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; @@ -406,6 +417,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 d3ce94833..8a6131584 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(); +}; + const showFloatingNodeColorTable = (): void => { cy.get('#node-color-table-row') .contains('.p-togglebutton-label', 'Show') @@ -263,6 +268,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}'); @@ -319,6 +325,7 @@ describe('Bubble View', () => { cy.openGlobalSettings(); cy.get('#node-color-variable').click() cy.get('li[role="option"]').contains('Cluster').click() + selectCategoricalColorScale('node') cy.wait(500) let initialDef; let initialBGImage; diff --git a/cypress/e2e/view-state/continuous-color-ramps.cy.ts b/cypress/e2e/view-state/continuous-color-ramps.cy.ts new file mode 100644 index 000000000..0f68f7637 --- /dev/null +++ b/cypress/e2e/view-state/continuous-color-ramps.cy.ts @@ -0,0 +1,568 @@ +/// + +import { + installSaveAsCaptureHook, + visitAppAndAcceptEula, + writeCapturedDownloadToDisk, +} from '../../support/journey-helpers'; +import { selectEpiCurveDropdown } from '../../support/epi-curve-helpers'; + +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(); +}; + +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); + } + }); +}; + +const centerGlobalSettingsControl = ( + selector: string +): Cypress.Chainable> => { + cy.get(selector, { timeout: 15000 }).then(($control) => { + const control = $control.get(0); + const scrollContainer = control.closest('.p-dialog-content') as HTMLElement | null; + + expect(scrollContainer, `scroll container for ${selector}`).to.exist; + if (scrollContainer) { + const controlRect = control.getBoundingClientRect(); + const containerRect = scrollContainer.getBoundingClientRect(); + scrollContainer.scrollTop += controlRect.top + - containerRect.top + - ((scrollContainer.clientHeight - controlRect.height) / 2); + scrollContainer.dispatchEvent(new Event('scroll')); + } + }); + + return cy.get(selector, { timeout: 15000 }); +}; + +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.get('[data-testid="key-tables-link-color-ramp-edit"]') + .should('be.visible') + .click(); + cy.contains('.p-dialog:visible .p-dialog-title', 'Global Settings').should('be.visible'); + cy.contains('#global-settings-modal .p-accordionheader', 'Link Color') + .should('have.attr', 'aria-expanded', 'true'); + cy.get('#link-color-table-row') + .should('exist') + .contains('.p-togglebutton-label', 'Show') + .click({ force: true }); + cy.closeGlobalSettings(); + cy.wait(300); + + 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') + .and('be.visible') + .click({ force: true }); + cy.window() + .its('commonService.visuals.microbeTrace.GlobalSettingsDialogSettings.isVisible') + .should('equal', true); + cy.contains('.p-dialog:visible .p-dialog-title', 'Global Settings').should('be.visible'); + centerGlobalSettingsControl('#link-continuous-color-editor').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.get('body').type('{esc}', { force: true }); + cy.get('.p-select-overlay:visible').should('not.exist'); + cy.window() + .its('commonService.visuals.twoD.SelectedNetworkExportFileTypeListVariable') + .should('equal', 'svg'); + cy.get('@exportDialog').find('#network-export').should('be.visible').click({ force: true }); + 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('round-trips ramps through style files while keeping legacy styles categorical', () => { + const styleFileBase = `continuous_ramp_style_${Date.now()}`; + const styleFilePath = `${Cypress.config('downloadsFolder')}/${styleFileBase}.style`; + const legacyStylePath = `${Cypress.config('downloadsFolder')}/${styleFileBase}-legacy.style`; + + useNodeColorField('degree'); + useLinkColorField('distance'); + + cy.window().then((win: any) => { + const app = win.commonService.visuals.microbeTrace; + app.onVariableColorConfigChanged('node', { + mode: 'continuous', + domain: { kind: 'custom', min: 1, max: 13 }, + stops: [ + { value: 1, color: '#000000' }, + { value: 7, color: '#ff0000' }, + { value: 13, color: '#ffffff' }, + ], + missingColor: '#123456', + }); + app.onVariableColorConfigChanged('link', { + mode: 'continuous', + domain: { kind: 'custom', min: 0, max: 80 }, + stops: [ + { value: 0, color: '#000004' }, + { value: 20, color: '#b5367a' }, + { value: 80, color: '#fcfdbf' }, + ], + missingColor: '#654321', + }); + }); + + installSaveAsCaptureHook(); + cy.window().then((win: any) => { + const app = win.commonService.visuals.microbeTrace; + app.selectedSaveFileType = 'style'; + app.saveFileName = styleFileBase; + app.DisplayStashDialog('Save'); + }); + writeCapturedDownloadToDisk(`${styleFileBase}.style`, styleFilePath); + + cy.readFile(styleFilePath, 'utf8').then((savedStyle) => { + const style = JSON.parse(String(savedStyle)); + expect(style.variableColorScales.version).to.equal(1); + expect(style.variableColorScales.node.degree).to.deep.include({ + mode: 'continuous', + domain: { kind: 'custom', min: 1, max: 13 }, + missingColor: '#123456', + }); + expect(style.variableColorScales.node.degree.stops).to.deep.equal([ + { value: 1, color: '#000000' }, + { value: 7, color: '#ff0000' }, + { value: 13, color: '#ffffff' }, + ]); + expect(style.variableColorScales.link.distance.mode).to.equal('continuous'); + }); + + cy.window().then((win: any) => { + const app = win.commonService.visuals.microbeTrace; + app.onVariableColorModeChanged('node', 'categorical'); + app.onVariableColorModeChanged('link', 'categorical'); + }); + cy.openGlobalSettings(); + cy.contains('#global-settings-modal .nav-link', 'Styling').click(); + cy.get('#apply-style').selectFile(styleFilePath, { force: true }); + + cy.window().should((win: any) => { + const common = win.commonService; + const nodeScale = common.temp.style.nodeColorScale; + const linkScale = common.temp.style.linkColorScale; + expect(nodeScale.mode).to.equal('continuous'); + expect(nodeScale.domain).to.deep.equal({ min: 1, max: 13 }); + expect(nodeScale.colorMap('invalid')).to.equal('#123456'); + expect(linkScale.mode).to.equal('continuous'); + expect(linkScale.domain).to.deep.equal({ min: 0, max: 80 }); + + const dataNode = common.session.data.nodes.find((node: any) => Number.isFinite(Number(node.degree))); + const renderedNode = common.visuals.twoD.cy.getElementById(dataNode._id); + expect(canonicalColor(win, renderedNode.style('background-color'))) + .to.equal(canonicalColor(win, nodeScale.colorMap(dataNode.degree))); + }); + cy.get('#key-tables-node-legend', { timeout: 15000 }).should('be.visible'); + cy.get('#key-tables-link-legend', { timeout: 15000 }).should('be.visible'); + + cy.readFile(styleFilePath, 'utf8').then((savedStyle) => { + const legacyStyle = JSON.parse(String(savedStyle)); + delete legacyStyle.variableColorScales; + cy.writeFile(legacyStylePath, legacyStyle); + }); + cy.get('#apply-style').selectFile(legacyStylePath, { force: true }); + + cy.window().should((win: any) => { + const common = win.commonService; + expect(common.session.style.variableColorScales.node.degree.mode).to.equal('categorical'); + expect(common.session.style.variableColorScales.link.distance.mode).to.equal('categorical'); + expect(common.temp.style.nodeColorScale.mode).to.equal('categorical'); + expect(common.temp.style.linkColorScale.mode).to.equal('categorical'); + + const dataNode = common.session.data.nodes.find((node: any) => Number.isFinite(Number(node.degree))); + const renderedNode = common.visuals.twoD.cy.getElementById(dataNode._id); + expect(canonicalColor(win, renderedNode.style('background-color'))) + .to.equal(canonicalColor(win, common.getNodeFillStyle(dataNode).color)); + }); + cy.get('#key-tables-node-table', { timeout: 15000 }).should('be.visible'); + cy.get('#key-tables-link-table', { timeout: 15000 }).should('be.visible'); + }); + + it('reports style-file schema errors and explains normalized ramp settings', () => { + const malformedStylePath = `${Cypress.config('downloadsFolder')}/malformed-ramp-${Date.now()}.style`; + const invalidStylePath = `${Cypress.config('downloadsFolder')}/invalid-style-${Date.now()}.style`; + + cy.window().then((win: any) => { + const style = JSON.parse(win.commonService.serializeStyleFile()); + style.widgets['node-color-variable'] = 'degree'; + style.variableColorScales = { + node: { + degree: { + mode: 'rainbow', + domain: { kind: 'custom', min: 13, max: 1 }, + stops: [ + { value: 1, color: 'black' }, + { value: 1, color: '#ffffff' }, + ], + missingColor: 'yellow', + }, + }, + }; + cy.writeFile(malformedStylePath, style); + }); + + cy.openGlobalSettings(); + cy.contains('#global-settings-modal .nav-link', 'Styling').click(); + cy.get('#apply-style').selectFile(malformedStylePath, { force: true }); + cy.get('[data-testid="style-file-status"]', { timeout: 15000 }) + .should('have.attr', 'role', 'status') + .and('contain.text', 'Applied') + .and('contain.text', 'normalization warnings') + .and('contain.text', 'version 1 was assumed') + .and('contain.text', 'Auto mode was used') + .and('contain.text', 'automatic data domain was used') + .and('contain.text', 'default missing-value color was used') + .and('contain.text', 'invalid entry removed') + .and('contain.text', 'default ramp was used'); + + cy.window().should((win: any) => { + const config = win.commonService.session.style.variableColorScales.node.degree; + expect(config.mode).to.equal('auto'); + expect(config.domain).to.deep.equal({ kind: 'auto' }); + expect(config.stops).to.equal(undefined); + expect(config.missingColor.toLowerCase()).to.equal('#eae553'); + }); + + cy.writeFile(invalidStylePath, { widgets: [], nodeColors: '#123456' }); + cy.get('#apply-style').selectFile(invalidStylePath, { force: true }); + cy.get('[data-testid="style-file-status"]', { timeout: 15000 }) + .should('have.attr', 'role', 'alert') + .and('contain.text', 'not a valid MicrobeTrace style file') + .and('contain.text', 'widgets must be object') + .and('contain.text', 'nodeColors must be array'); + + cy.window().should((win: any) => { + expect(win.commonService.session.style.widgets['node-color-variable']).to.equal('degree'); + }); + }); + + it('uses the same continuous node and link colors in every shared visual consumer', () => { + useNodeColorField('degree'); + useLinkColorField('distance'); + + 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(common.getLinkColorValue(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(common.getLinkColorValue(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 503437bad..e349c0c1e 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); @@ -448,9 +453,11 @@ describe('Map View', () => { cy.wait(10); if (takeScreenshots) cy.screenshot('map/no-links', { overwrite: true}); - cy.window().its('commonService.visuals.gisMap.lmap._layers').should(layers => { - expect(Object.values(layers).length).to.equal(249); - }) + cy.window().should((win: any) => { + const mapView = win.commonService.visuals.gisMap; + expect(win.commonService.session.style.widgets['map-link-show']).to.equal(false); + expect(mapView.lmap.hasLayer(mapView.layers.links)).to.equal(false); + }); }) // jitter and re-jitter @@ -506,7 +513,7 @@ describe('Map View', () => { cy.window().its('commonService.session.style.widgets.map-counties-show').should('equal', false); cy.contains('.p-dialog-title', 'Geospatial Settings').parents('.p-dialog').contains('.p-accordionheader', 'Offline').click(); - cy.get('#map-counties-show-hide').contains('Show').click(); + cy.get('#map-counties-show-hide').contains('Borders Only').click(); cy.window().its('commonService.session.style.widgets.map-counties-show').should('equal', true); cy.closeSettingsPane('Geospatial Settings'); cy.wait(1000) @@ -531,7 +538,7 @@ describe('Map View', () => { cy.get(selectors.settingsBtn).click(); cy.contains('.p-dialog-title', 'Geospatial Settings').should('be.visible'); - cy.get('#map-states-show-hide').contains('Show').click(); + cy.get('#map-states-show-hide').contains('Borders Only').click(); cy.window().its('commonService.session.style.widgets.map-states-show').should('equal', true); cy.closeSettingsPane('Geospatial Settings'); cy.wait(200) @@ -583,7 +590,10 @@ describe('Map View', () => { // make a test for dragging around the map and tests coordinates (lmap._lastCenter is current coordinates) it('tests panning and centering the map', () => { cy.closeSettingsPane('Geospatial Settings'); - cy.get('#centerMapButton').click({ force: true }); + cy.window().then((win: any) => { + win.commonService.visuals.gisMap.centerMap(); + }); + cy.wait(1000); let initialCenter : {lat: number, lng: number}; let newCenter : {lat: number, lng: number}; @@ -592,37 +602,19 @@ describe('Map View', () => { const lmap = win.commonService.visuals.gisMap.lmap; const c = lmap.getCenter(); initialCenter = { lat: c.lat, lng: c.lng }; - const container = lmap.getContainer() as HTMLElement; - const start = { clientX: 100, clientY: 400 }; - const end = { clientX: -100, clientY: 600 }; - - const md1 = new MouseEvent('mousedown', Object.assign({ - bubbles: true, cancelable: true, composed: true, - pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0 - }, start)) - container.dispatchEvent(md1); - - const mm1 = new MouseEvent('mousemove', Object.assign({ - bubbles: true, cancelable: true, composed: true, - pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0 - }, end)) - container.dispatchEvent(mm1); - - const me1 = new MouseEvent('mouseend', Object.assign({ - bubbles: true, cancelable: true, composed: true, - pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0 - }, end)) - container.dispatchEvent(me1); + lmap.panBy([200, 200], { animate: false }); newCenter = lmap.getCenter(); const latDiff = Math.abs(newCenter.lat - initialCenter.lat); const lngDiff = Math.abs(newCenter.lng - initialCenter.lng); - expect(latDiff > 1 && lngDiff > 1).to.equal(true); + expect(latDiff > 0.05 && lngDiff > 0.05).to.equal(true); }); cy.wait(500); - cy.get('#centerMapButton').click({ force: true }); + cy.window().then((win: any) => { + win.commonService.visuals.gisMap.centerMap(); + }); cy.wait(500); cy.window().then((win: any) => { @@ -631,58 +623,49 @@ describe('Map View', () => { newCenter = { lat: c.lat, lng: c.lng }; const latDiff = Math.abs(newCenter.lat - initialCenter.lat); const lngDiff = Math.abs(newCenter.lng - initialCenter.lng); - expect(latDiff < 0.05 && lngDiff < 0.05).to.equal(true); + expect( + latDiff < 0.1 && lngDiff < 0.1, + `center restored from ${JSON.stringify(initialCenter)} to ${JSON.stringify(newCenter)}`, + ).to.equal(true); }) }); it('tests zoom changes from zoom in, zoom out, and center map buttons', () => { + let centeredZoom: number; + cy.closeSettingsPane('Geospatial Settings'); cy.get('#centerMapButton').click({ force: true }); cy.wait(1000) cy.window().then((win: any) => { const lmap = win.commonService.visuals.gisMap.lmap; - let zoomLevel = lmap.getZoom(); - expect(zoomLevel).to.equal(5); + centeredZoom = lmap.getZoom(); + expect(centeredZoom).to.be.a('number'); }) - let zoomInButton = cy.get('.leaflet-control-zoom-in span'); - zoomInButton.click() - cy.wait(250) - zoomInButton.click() - cy.wait(250); - zoomInButton.click() - cy.wait(500) - - cy.window().then((win: any) => { - const lmap = win.commonService.visuals.gisMap.lmap; - let zoomLevel = lmap.getZoom(); - expect(zoomLevel).to.equal(8); - }) - - let zoomOutButton = cy.get('.leaflet-control-zoom-out span'); - zoomOutButton.click() - cy.wait(250) - zoomOutButton.click() - cy.wait(250); - zoomOutButton.click() - cy.wait(250); - zoomOutButton.click() - cy.wait(250); - zoomOutButton.click() - cy.wait(500) + for (let offset = 1; offset <= 3; offset++) { + cy.get('.leaflet-control-zoom-in').click(); + cy.window().should((win: any) => { + const lmap = win.commonService.visuals.gisMap.lmap; + expect(lmap.getZoom()).to.equal(centeredZoom + offset); + expect(lmap._animatingZoom).not.to.equal(true); + }); + } - cy.window().then((win: any) => { - const lmap = win.commonService.visuals.gisMap.lmap; - let zoomLevel = lmap.getZoom(); - expect(zoomLevel).to.equal(3); - }) + for (let offset = 1; offset <= 5; offset++) { + cy.get('.leaflet-control-zoom-out').click(); + cy.window().should((win: any) => { + const lmap = win.commonService.visuals.gisMap.lmap; + expect(lmap.getZoom()).to.equal(centeredZoom + 3 - offset); + expect(lmap._animatingZoom).not.to.equal(true); + }); + } cy.get('#centerMapButton').click({ force: true }); cy.wait(1000) cy.window().then((win: any) => { const lmap = win.commonService.visuals.gisMap.lmap; let zoomLevel = lmap.getZoom(); - expect(zoomLevel).to.equal(5); + expect(zoomLevel).to.equal(centeredZoom); }) }); @@ -860,16 +843,10 @@ describe('Map View', () => { cy.window().should((win: any) => { const mapView = win.commonService.visuals.gisMap; const selectedNode = win.commonService.session.data.nodes.find((node: any) => node._id === targetNodeId); - const spiderfiedCluster = mapView.layers.markerClusterGroup._spiderfied; expect(selectedNode?.selected, `${targetNodeId} remains selected after enabling auto-expand`).to.equal(true); expect(mapView.SelectedManualPositionNodeId, 'manual position target follows search').to.equal(targetNodeId); - expect(spiderfiedCluster, 'spiderfied metanode').to.exist; - - const spiderfiedNodeIds = spiderfiedCluster - .getAllChildMarkers() - .map((marker: any) => marker.data?._id); - expect(spiderfiedNodeIds, 'spiderfied metanode node ids').to.include(targetNodeId); + expectMapNodeExpanded(mapView, targetNodeId); }); cy.closeSettingsPane('Geospatial Settings'); @@ -1057,6 +1034,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'); @@ -1100,6 +1078,8 @@ describe('Map View', () => { }) it('should set node color variable and link color varialbe to cluster, then update link threshold to update node color', () => { + let highThresholdNodeColor = ''; + cy.get(selectors.settingsBtn).click(); cy.contains('.p-dialog-title', 'Geospatial Settings').should('be.visible'); @@ -1109,9 +1089,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++) { @@ -1123,13 +1105,16 @@ describe('Map View', () => { let links = win.commonService.visuals.gisMap.layers.links._layers; Object.values(links).filter((l: any) => l.data && l.data.source == 'MZ787305').forEach((l: any) => { - expect(l.options.color).to.be.eq('#1f78b4') + expect(l.options.color).to.eq(win.commonService.temp.style.linkColorMap(l.data.cluster)); }) let nodes = win.commonService.visuals.gisMap.layers.featureGroup._layers; Object.values(nodes).filter((node: any) => node.data && (node.data._id == 'MZ787305' || node.data._id == 'MZ740979')).forEach((node: any) => { - expect(node.options.fillColor).to.be.eq('#f22020') + expect(node.options.fillColor).to.eq(win.commonService.getNodeFillStyle(node.data).color); }) + const trackedNode = Object.values(nodes).find((node: any) => node.data?._id === 'MZ787305') as any; + expect(trackedNode, 'tracked high-threshold node').to.exist; + highThresholdNodeColor = trackedNode.options.fillColor; }) for (let i = 0; i < 8; i++) { @@ -1140,14 +1125,19 @@ describe('Map View', () => { expect(win.commonService.session.style.widgets["link-threshold"]).to.eq(14) let links = win.commonService.visuals.gisMap.layers.links._layers; - expect((Object.values(links).filter((l: any) => l.data && l.data.source == 'MZ787305')[0] as any).options.color).to.be.eq('#b2df8a') + Object.values(links).filter((l: any) => l.data && l.data.source == 'MZ787305').forEach((link: any) => { + expect(link.options.color).to.eq(win.commonService.temp.style.linkColorMap(link.data.cluster)); + }); let nodes = win.commonService.visuals.gisMap.layers.featureGroup._layers; Object.values(nodes).filter((node: any) => node.data && (node.data._id == 'MZ787305' || node.data._id == 'MZ740979')).forEach((node: any) => { - expect(node.options.fillColor).to.be.eq('#f47a22'); + expect(node.options.fillColor).to.eq(win.commonService.getNodeFillStyle(node.data).color); }) Object.values(nodes).filter((node: any) => node.data && node.data._id == 'MZ744285').forEach((node: any) => { - expect(node.options.fillColor).to.be.eq('#b732cc') + expect(node.options.fillColor).to.eq(win.commonService.getNodeFillStyle(node.data).color); }) + const trackedNode = Object.values(nodes).find((node: any) => node.data?._id === 'MZ787305') as any; + expect(trackedNode, 'tracked low-threshold node').to.exist; + expect(trackedNode.options.fillColor).to.not.equal(highThresholdNodeColor); }) }) @@ -1173,9 +1163,20 @@ describe('Map View', () => { cy.closeGlobalSettings(); - cy.window().its('commonService.visuals.gisMap').then(mapView => { - let nodeLayers = mapView.layers.featureGroup._layers; + cy.get(selectors.settingsBtn).click(); + 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() + .its('commonService.visuals.gisMap.layers.featureGroup._layers', { timeout: 10000 }) + .should(nodeLayers => { expect(Object.keys(nodeLayers)).to.have.length(30); + }); + + cy.window().its('commonService.visuals.gisMap').should(mapView => { + let nodeLayers = mapView.layers.featureGroup._layers; Object.values(nodeLayers).forEach((node: any) => { if (node.data && node.data.Profession === 'Education') { expect(node.options.fillColor).to.equal('#f22020'); diff --git a/cypress/e2e/view-state/phylogenetic-view.cy.ts b/cypress/e2e/view-state/phylogenetic-view.cy.ts index 88ededdc7..92fbcd427 100644 --- a/cypress/e2e/view-state/phylogenetic-view.cy.ts +++ b/cypress/e2e/view-state/phylogenetic-view.cy.ts @@ -126,6 +126,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 6bffec49a..d180025f6 100644 --- a/cypress/e2e/view-state/timeline-epicurve-view.cy.ts +++ b/cypress/e2e/view-state/timeline-epicurve-view.cy.ts @@ -1,3 +1,5 @@ +import { visitAppAndAcceptEula } from '../../support/journey-helpers'; + describe('Epi Curve / Timeline View', () => { const selectors = { container: '#epiCurve', @@ -6,11 +8,7 @@ describe('Epi Curve / Timeline View', () => { }; beforeEach(() => { - cy.visit('/'); - cy.wait(6000); - - cy.contains('button', 'Continue with Sample Dataset', { timeout: 10000 }).click({ force: true }); - cy.get('#overlay').should('not.be.visible', { timeout: 10000 }); + visitAppAndAcceptEula({ skipDemoSession: false, dismissWelcomeOverlay: true }); cy.contains('button', 'View').click(); cy.contains('button[mat-menu-item]', 'Epi Curve').click(); @@ -194,23 +192,32 @@ 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() .its('commonService.GlobalSettingsModel.SelectedNodeColorTableTypesVariable') .should('equal', 'Show'); - cy.get('#key-tables-node-table').should('be.visible'); + cy.get('#global-settings-node-color-table').should('be.visible'); cy.contains('#global-settings-modal .nav-link', 'Filtering').click(); - let expectedClusterCount = 4 - for (let i = 0; i < 2; i++) { - cy.get('#link-threshold').type('{downarrow}'); - } - - cy.wait(2000); + const expectedClusterCount = 4; + let initialThreshold = 0; cy.window().then((win: any) => { - expect(win.commonService.session.style.widgets['link-threshold']).to.eq(14); + initialThreshold = Number(win.commonService.session.style.widgets['link-threshold']); + expect(initialThreshold).to.be.greaterThan(2); + }); + cy.get('#link-threshold').then(($input) => { + cy.wrap($input) + .invoke('val', String(initialThreshold - 2)) + .trigger('input') + .trigger('change'); + }); + + cy.window().should((win: any) => { + expect(win.commonService.session.style.widgets['link-threshold']).to.eq(initialThreshold - 2); expect(win.commonService.session.data.clusters.length).to.eq(expectedClusterCount) }); @@ -231,7 +238,11 @@ describe('Epi Curve / Timeline View', () => { expect(rectFills.some((fill) => fill === '#b732cc' || fill === 'rgb(183,50,204)' || fill === 'rgb(183, 50, 204)')).to.equal(true); }); - cy.get('#key-tables-node-table td input').first().invoke('val', '#777777').trigger('input').trigger('change'); + cy.get('#global-settings-node-color-table td input') + .first() + .invoke('val', '#777777') + .trigger('input') + .trigger('change'); cy.get('#epiCurveSVG .epiCurve-epi-curve rect') .then(($rects) => { const rectFills = [...$rects] @@ -389,7 +400,7 @@ describe('Epi Curve / Timeline View', () => { .contains(value) .click(); - const normalize = (s: string) => s.replace(/_/g, '').toLowerCase(); + const normalize = (s: string) => s.replace(/[\s_]+/g, '').toLowerCase(); let widgetLocation: string; if (field == 'Graph Type') { widgetLocation = 'commonService.session.style.widgets.epiCurve-graphType' diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 57fd3f51f..83b86ed62 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -20,6 +20,27 @@ function closeVisibleSelectOverlays(): void { }); } +function expandVisibleGlobalStylingAccordions(): void { + cy.get('body').then(($body) => { + const collapsedHeaders = $body.find( + '.p-dialog:visible #style-config p-accordion-header[aria-expanded="false"]' + ); + + if (!collapsedHeaders.length) return; + cy.wrap(collapsedHeaders).each(($header) => { + const contentId = $header.attr('aria-controls'); + cy.wrap($header) + .click({ force: true }) + .should('have.attr', 'aria-expanded', 'true'); + if (contentId) { + cy.get(`#${contentId} p-motion`) + .should('not.have.class', 'p-collapsible-enter-active') + .and('not.have.class', 'p-collapsible-enter-to'); + } + }); + }); +} + declare global { namespace Cypress { interface Chainable { @@ -190,6 +211,7 @@ Cypress.Commands.add('openGlobalSettings', () => { .within(() => { cy.contains('.nav-link', 'Timeline', { timeout: 15000 }).should('be.visible'); }); + expandVisibleGlobalStylingAccordions(); }); Cypress.Commands.add('closeGlobalSettings', () => { @@ -205,14 +227,15 @@ 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('.nav-link:visible', 'Timeline').click({ force: true }); cy.get('#timeline-config').should('exist'); closeVisibleSelectOverlays(); - cy.get('#node-timeline-variable').click({ force: true }); + cy.get('.p-dialog:visible #node-timeline-variable').click({ force: true }); cy.get(visibleSelectOverlay, { timeout: 15000 }) .last() .then(($overlay) => { diff --git a/docs/integrations/partner-embed-integration.md b/docs/integrations/partner-embed-integration.md index 34b0e85ec..0f6e3c551 100644 --- a/docs/integrations/partner-embed-integration.md +++ b/docs/integrations/partner-embed-integration.md @@ -79,6 +79,16 @@ Wire it up: globalSettings: { nodeColorBy: 'group', linkColorBy: 'distance', + linkColorScale: { + mode: 'continuous', + domain: { min: 0, max: 0.05 }, + stops: [ + { value: 0, color: '#440154' }, + { value: 0.025, color: '#21918c' }, + { value: 0.05, color: '#fde725' } + ], + missingColor: '#eae553' + }, nodeColor: '#1f77b4', linkColor: '#a6cee3', nodeShape: 'ellipse', @@ -158,6 +168,7 @@ Optional fields: globalSettings: { nodeColorBy: 'cluster', linkColorBy: 'distance', + linkColorScale: { mode: 'auto' }, nodeShapeBy: 'None', nodeColor: '#1f77b4', linkColor: '#a6cee3', @@ -182,6 +193,8 @@ Supported launch settings: | `launch.ambiguityThreshold` | non-negative number | Sets the ambiguity threshold used by applicable TN93 ambiguity strategies. | | `launch.globalSettings.nodeColorBy` | `None`, generated node field such as `cluster`, or imported node field | Colors nodes by a field. | | `launch.globalSettings.linkColorBy` | `None`, generated link field such as `origin` or `distance`, or imported link field | Colors links by a field. | +| `launch.globalSettings.nodeColorScale` | `{ mode, domain?, stops?, missingColor? }` | Configures `nodeColorBy` as `auto`, `categorical`, or `continuous`. Requires `nodeColorBy`. | +| `launch.globalSettings.linkColorScale` | `{ mode, domain?, stops?, missingColor? }` | Configures `linkColorBy` as `auto`, `categorical`, or `continuous`. Requires `linkColorBy`. | | `launch.globalSettings.nodeShapeBy` | `None`, generated node field such as `cluster`, or imported node field | Assigns node shapes by a field. | | `launch.globalSettings.nodeColor` | 6-digit hex color, for example `#1f77b4` | Sets the fallback node color when `nodeColorBy` is `None`. | | `launch.globalSettings.linkColor` | 6-digit hex color, for example `#a6cee3` | Sets the fallback link color when `linkColorBy` is `None`. | @@ -191,7 +204,9 @@ Supported launch settings: | `launch.globalSettings.backgroundColor` | 6-digit hex color, for example `#ffffff` | Sets the initial visualization background. | | `launch.globalSettings.tn93DistanceDisplayFormat` | `decimal` or `percentage` | Controls TN93 distance display formatting. | -If `distanceMetric` is supplied without `linkThreshold`, MicrobeTrace applies the metric's normal default threshold (`snps`: `16`, `tn93`: `0.015`). Field-based settings are validated after MicrobeTrace normalizes the imported files. If a requested node or link field is missing, the handoff fails instead of silently launching with a different setting. These launch defaults affect only this handoff. They are not persisted as the user's future MicrobeTrace defaults. +If `distanceMetric` is supplied without `linkThreshold`, MicrobeTrace applies the metric's normal default threshold (`snps`: `16`, `tn93`: `0.015`). Field-based settings are validated after MicrobeTrace normalizes the imported files. If a requested node or link field is missing, the handoff fails instead of silently launching with a different setting. + +Color scale domains require finite `min < max` values. Custom stops require a custom domain, at least two strictly increasing finite values that begin and end at the domain bounds, and six-digit hex colors. `missingColor` also uses six-digit hex notation. These launch defaults affect only this handoff. They are not persisted as the user's future MicrobeTrace defaults. - `onSuccess(result)` - `onError(error)` diff --git a/examples/continuous-color-ramps/README.md b/examples/continuous-color-ramps/README.md new file mode 100644 index 000000000..3fba93bca --- /dev/null +++ b/examples/continuous-color-ramps/README.md @@ -0,0 +1,99 @@ +# 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. + +## Style-file format + +Saved `.style` files persist color-ramp choices in `variableColorScales`. Settings are stored by target and field so switching among fields does not discard their categorical or continuous configuration. A custom node ramp has this shape: + +```json +{ + "widgets": { + "node-color-variable": "RiskScore" + }, + "variableColorScales": { + "version": 1, + "node": { + "RiskScore": { + "mode": "continuous", + "domain": { "kind": "custom", "min": 10, "max": 90 }, + "stops": [ + { "value": 10, "color": "#313695" }, + { "value": 50, "color": "#ffffbf" }, + { "value": 90, "color": "#a50026" } + ], + "missingColor": "#eae553" + } + }, + "link": {} + } +} +``` + +Use `"domain": { "kind": "auto" }` when bounds should follow the complete loaded dataset. `mode` may be `auto`, `categorical`, or `continuous`; stop values must be finite and strictly increasing, and colors must be six-digit hexadecimal values. Files saved before `variableColorScales` existed remain categorical for their selected node and link fields, preserving their original appearance. The application adds normalized version-1 scale state when those files are saved again. + +Style files are checked against the application's Draft-07 JSON Schema before they are applied. Invalid file envelopes, such as a missing `widgets` object or a known legacy collection with the wrong container type, are rejected without changing the current style. Recoverable `variableColorScales` problems are normalized for backward and forward compatibility, and the Global Styling panel lists each repair—for example, an invalid mode falling back to Auto, invalid custom bounds returning to the automatic domain, or unusable stops returning to the default ramp. + +## Embed example + +`embed-launch.example.json` is the value for the embed payload's `launch` property, not a standalone handoff. Merge it into a normal version-1 partner handoff whose file entries contain the two CSV files. Its custom stop values are finite, strictly increasing, span their domains, and use six-digit hexadecimal colors. diff --git a/examples/continuous-color-ramps/embed-launch.example.json b/examples/continuous-color-ramps/embed-launch.example.json new file mode 100644 index 000000000..240abfe00 --- /dev/null +++ b/examples/continuous-color-ramps/embed-launch.example.json @@ -0,0 +1,61 @@ +{ + "datasetName": "Riverbend Regional Respiratory Outbreak", + "defaultView": "2D Network", + "linkThreshold": 12, + "globalSettings": { + "nodeColorBy": "RiskScore", + "nodeColorScale": { + "mode": "continuous", + "domain": { + "min": 10, + "max": 90 + }, + "stops": [ + { + "value": 10, + "color": "#440154" + }, + { + "value": 35, + "color": "#31688e" + }, + { + "value": 60, + "color": "#35b779" + }, + { + "value": 90, + "color": "#fde725" + } + ], + "missingColor": "#eae553" + }, + "linkColorBy": "ContactMinutes", + "linkColorScale": { + "mode": "continuous", + "domain": { + "min": 15, + "max": 180 + }, + "stops": [ + { + "value": 15, + "color": "#0d0887" + }, + { + "value": 60, + "color": "#9c179e" + }, + { + "value": 120, + "color": "#ed7953" + }, + { + "value": 180, + "color": "#f0f921" + } + ], + "missingColor": "#bdbdbd" + } + } +} diff --git a/examples/continuous-color-ramps/riverbend-contact-ramp.csv b/examples/continuous-color-ramps/riverbend-contact-ramp.csv new file mode 100644 index 000000000..fe72fa391 --- /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 000000000..958a68593 --- /dev/null +++ b/examples/continuous-color-ramps/riverbend-lineage-colors.csv @@ -0,0 +1,4 @@ +Lineage,color +RB-A.1,#3b4cc0 +RB-B.1,#2fb47c +RB-C.1,#f89540 diff --git a/examples/continuous-color-ramps/riverbend-outbreak-links.csv b/examples/continuous-color-ramps/riverbend-outbreak-links.csv new file mode 100644 index 000000000..417103f6e --- /dev/null +++ b/examples/continuous-color-ramps/riverbend-outbreak-links.csv @@ -0,0 +1,37 @@ +source,target,ContactType,Setting,ContactDate,ContactMinutes,ProximityMeters,GeneticDistanceSNP,distance,EstimatedTransmissionProbability,InvestigationConfidence,Notes +RB-2026-001,RB-2026-002,Clinical care,Hospital,2026-01-02,95,1.2,1,1,0.91,0.85,Shared treatment team +RB-2026-001,RB-2026-003,Clinical care,Hospital,2026-01-02,45,1.8,1,1,0.82,0.85,Patient transfer +RB-2026-002,RB-2026-004,Clinical care,Long-term care,2026-01-03,120,1.0,2,2,0.88,0.85,Bedside assessment +RB-2026-003,RB-2026-004,Roommate,Long-term care,2026-01-04,480,2.3,1,1,0.97,0.85,Shared room +RB-2026-003,RB-2026-005,Household,Household,2026-01-04,180,0.8,2,2,0.85,0.85,Weekend family visit +RB-2026-004,RB-2026-006,Household,Household,2026-01-05,210,1.1,2,2,0.79,0.85,Family contact +RB-2026-005,RB-2026-006,Workplace,Clinic,2026-01-06,75,2.5,1,1,0.74,0.85,Shared reception shift +RB-2026-005,RB-2026-007,Household,Household,2026-01-07,360,0.6,2,2,0.93,0.85,Parent-child contact +RB-2026-006,RB-2026-008,Household,Household,2026-01-08,300,0.7,2,2,0.90,0.85,Parent-child contact +RB-2026-007,RB-2026-008,School,Household,2026-01-09,240,1.4,1,1,0.81,0.85,Siblings +RB-2026-006,RB-2026-009,Community,Transit,2026-01-12,25,unknown,6,6,0.28,0.85,Reported bus exposure +RB-2026-009,RB-2026-010,Workplace,Distribution hub,2026-01-15,160,1.5,1,1,0.89,0.85,Shared loading bay +RB-2026-009,RB-2026-011,Workplace,Distribution hub,2026-01-16,135,2.0,1,1,0.84,0.85,Shared loading bay +RB-2026-010,RB-2026-012,Workplace,Distribution hub,2026-01-17,55,2.8,2,2,0.66,0.85,Break room contact +RB-2026-011,RB-2026-012,Household,Household,2026-01-18,420,0.5,1,1,0.95,0.85,Partners +RB-2026-011,RB-2026-013,School,Primary school,2026-01-19,35,3.2,2,2,0.58,0.85,School pickup +RB-2026-012,RB-2026-014,Household,Household,2026-01-20,300,0.7,2,2,0.92,0.85,Parent-child contact +RB-2026-013,RB-2026-014,School,Primary school,2026-01-21,180,1.1,1,1,0.86,0.85,Same classroom +RB-2026-013,RB-2026-015,School,Primary school,2026-01-22,90,2.1,2,2,0.72,0.85,Classroom instruction +RB-2026-014,RB-2026-016,School,Primary school,2026-01-23,70,2.4,2,2,0.69,0.85,Classroom instruction +RB-2026-015,RB-2026-016,Household,Household,2026-01-24,240,0.9,1,1,0.90,0.85,Partners +RB-2026-012,RB-2026-017,Community,Restaurant,2026-01-30,15,unknown,9,9,0.12,0.85,Unconfirmed shared venue +RB-2026-017,RB-2026-018,Clinical care,Hospital,2026-02-01,110,1.3,1,1,0.87,0.85,Emergency department +RB-2026-017,RB-2026-019,Clinical care,Hospital,2026-02-02,150,1.0,2,2,0.90,0.85,Inpatient care +RB-2026-018,RB-2026-020,Household,Household,2026-02-03,360,0.6,2,2,0.94,0.85,Family contact +RB-2026-019,RB-2026-021,Clinical care,Dialysis center,2026-02-04,130,1.7,2,2,0.83,0.85,Patient transport +RB-2026-020,RB-2026-022,Household,Household,2026-02-05,280,0.8,2,2,0.91,0.85,Family contact +RB-2026-021,RB-2026-022,Clinical care,Dialysis center,2026-02-06,200,1.4,1,1,0.89,0.85,Adjacent stations +RB-2026-021,RB-2026-023,Clinical care,Dialysis center,2026-02-07,165,1.9,2,2,0.80,0.85,Shared care team +RB-2026-022,RB-2026-024,Household,Household,2026-02-08,320,0.7,2,2,0.92,0.85,Family contact +RB-2026-023,RB-2026-024,Clinical care,Dialysis center,2026-02-10,85,2.2,1,1,0.71,0.85,Waiting room overlap +RB-2026-005,RB-2026-010,Community,Community event,2026-01-13,5,unknown,8,8,0.08,0.85,Brief outdoor encounter +RB-2026-008,RB-2026-013,Community,Youth program,2026-01-18,,4.5,5,5,0.31,0.85,Duration unavailable +RB-2026-016,RB-2026-018,Community,Restaurant,2026-01-29,40,3.8,7,7,0.19,0.85,Possible shared venue +RB-2026-020,RB-2026-023,Clinical care,Hospital,2026-02-06,60,2.6,3,3,0.55,0.85,Outpatient follow-up +RB-2026-004,RB-2026-009,Community,Transit,2026-01-11,20,5.5,11,11,0.05,,Low-confidence contact diff --git a/examples/continuous-color-ramps/riverbend-outbreak-nodes.csv b/examples/continuous-color-ramps/riverbend-outbreak-nodes.csv new file mode 100644 index 000000000..c6a883b75 --- /dev/null +++ b/examples/continuous-color-ramps/riverbend-outbreak-nodes.csv @@ -0,0 +1,25 @@ +ID,seq,CaseType,Lineage,Date of symptom onset Date,Collection Date,Recovery Date,County,State,Facility,Latitude,Longitude,AgeYears,ViralLoadCt,DaysFromFirstExposure,HouseholdSize,VaccinationDoses,RiskScore,ClinicalSeverityScore,IncubationEstimateDays,OxygenSaturationPct,AssayBatchTemperatureC,Outcome,Node type +RB-2026-001,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAC,Confirmed,RB-A.1,2026-01-03,2026-01-04,2026-01-12,Fulton,Georgia,Riverbend Medical Center,33.7531,-84.3862,72,18.4,3,2,4,88,7,3.2,91,22.5,Recovered,Person +RB-2026-002,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-A.1,2026-01-04,2026-01-05,2026-01-13,Fulton,Georgia,Riverbend Medical Center,33.7540,-84.3849,68,20.1,4,1,3,82,6,4.1,93,22.5,Recovered,Person +RB-2026-003,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAACTCTTTCACACGTGGTGTTTATTACCCTGAC,Confirmed,RB-A.1,2026-01-05,2026-01-06,2026-01-16,Fulton,Georgia,Willow Creek Skilled Nursing,33.7812,-84.3708,84,17.8,4,1,5,96,9,4.4,88,22.5,Hospitalized,Person +RB-2026-004,ATGTTTGTTTTCCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Probable,RB-A.1,2026-01-06,2026-01-08,2026-01-15,Fulton,Georgia,Willow Creek Skilled Nursing,33.7806,-84.3699,79,22.7,5,2,4,90,8,5.0,not measured,22.5,Recovered,Person +RB-2026-005,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAC,Confirmed,RB-A.1,2026-01-08,2026-01-09,2026-01-18,DeKalb,Georgia,Eastgate Family Clinic,33.7718,-84.2963,43,24.2,6,4,3,61,4,5.8,96,22.5,Recovered,Person +RB-2026-006,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAACTCTTTCACACGTGGTGTTTATTACCCTGAC,Confirmed,RB-A.1,2026-01-09,2026-01-10,2026-01-17,DeKalb,Georgia,Eastgate Family Clinic,33.7709,-84.2952,37,26.9,7,3,2,54,3,6.6,97,22.5,Recovered,Person +RB-2026-007,ATGTTTGTTTTCCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-A.1,2026-01-11,2026-01-12,2026-01-21,DeKalb,Georgia,Eastgate Family Clinic,33.7698,-84.2946,12,21.5,5,5,1,67,5,4.9,95,22.5,Recovered,Person +RB-2026-008,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTTAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-A.1,2026-01-13,2026-01-14,2026-01-23,DeKalb,Georgia,Eastgate Family Clinic,33.7687,-84.2938,9,23.8,4,5,0,63,4,NaN,96,22.5,Recovered,Person +RB-2026-009,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-B.1,2026-01-17,2026-01-18,2026-01-28,Cobb,Georgia,Northside Distribution Hub,33.8975,-84.4831,29,19.7,2,2,3,73,5,2.7,94,22.5,Recovered,Person +RB-2026-010,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-B.1,2026-01-18,2026-01-19,2026-01-27,Cobb,Georgia,Northside Distribution Hub,33.8981,-84.4819,34,21.2,3,3,2,69,4,3.4,96,22.5,Recovered,Person +RB-2026-011,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-B.1,2026-01-19,2026-01-20,2026-01-30,Cobb,Georgia,Northside Distribution Hub,33.8990,-84.4807,51,18.9,3,4,4,78,6,3.1,92,22.5,Recovered,Person +RB-2026-012,ATGTTTGTTTTTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-B.1,2026-01-20,2026-01-21,2026-02-01,Cobb,Georgia,Northside Distribution Hub,33.9002,-84.4798,46,25.6,4,2,3,57,3,4.0,98,22.5,Recovered,Person +RB-2026-013,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Probable,RB-B.1,2026-01-22,2026-01-24,2026-02-03,Cobb,Georgia,Oakview Primary School,33.9234,-84.5154,7,27.8,5,4,0,48,2,5.3,99,22.5,Recovered,Person +RB-2026-014,ATGTTTGTTTTTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-B.1,2026-01-23,2026-01-24,2026-02-02,Cobb,Georgia,Oakview Primary School,33.9241,-84.5143,8,24.9,4,5,0,52,3,4.7,98,22.5,Recovered,Person +RB-2026-015,ATGTTTGTTTTTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAT,Confirmed,RB-B.1,2026-01-25,2026-01-26,2026-02-05,Cobb,Georgia,Oakview Primary School,33.9250,-84.5131,41,23.1,6,3,3,58,3,5.9,97,22.5,Recovered,Person +RB-2026-016,ATGTTTGTTTTTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-B.1,2026-01-27,2026-01-28,2026-02-06,Cobb,Georgia,Oakview Primary School,33.9257,-84.5120,39,,7,2,2,44,2,6.8,98,22.5,Recovered,Person +RB-2026-017,ATGTTTGTTTCTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-C.1,2026-02-02,2026-02-03,2026-02-14,Gwinnett,Georgia,Lakeshore Community Hospital,33.9588,-84.0554,56,16.8,2,1,4,91,8,2.4,89,22.5,Hospitalized,Person +RB-2026-018,ATGTTTGTTTCTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAG,Confirmed,RB-C.1,2026-02-03,2026-02-04,2026-02-15,Gwinnett,Georgia,Lakeshore Community Hospital,33.9596,-84.0542,63,19.3,3,2,3,85,7,3.0,91,22.5,Recovered,Person +RB-2026-019,ATGTTTGTTTCTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-C.1,2026-02-04,2026-02-05,2026-02-16,Gwinnett,Georgia,Lakeshore Community Hospital,33.9605,-84.0530,88,15.9,3,1,5,95,10,3.5,86,22.5,Deceased,Person +RB-2026-020,ATGTTTGTTTCTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAG,Confirmed,RB-C.1,2026-02-06,2026-02-07,2026-02-17,Gwinnett,Georgia,Lakeshore Community Hospital,33.9613,-84.0519,31,26.2,5,3,2,49,3,5.1,97,22.5,Recovered,Person +RB-2026-021,ATGTTTGTTTCTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-C.1,2026-02-08,2026-02-09,2026-02-18,Gwinnett,Georgia,Pinecrest Dialysis Center,33.9884,-84.0678,71,20.8,6,2,4,80,6,6.0,92,22.5,Recovered,Person +RB-2026-022,ATGTTTGTTTCTCTTGTTTTATTGCCACTAGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAG,Confirmed,RB-C.1,2026-02-09,2026-02-10,2026-02-20,Gwinnett,Georgia,Pinecrest Dialysis Center,33.9892,-84.0667,65,22.4,6,1,4,76,5,5.7,94,22.5,Recovered,Person +RB-2026-023,ATGTTTGTTTCTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCGATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAG,Probable,RB-C.1,2026-02-11,2026-02-13,,Gwinnett,Georgia,Pinecrest Dialysis Center,33.9900,-84.0656,74,28.6,8,1,3,38,2,7.4,pending,22.5,Ongoing,Person +RB-2026-024,ATGTTTGTTTCTCTTGTTTTATTGCCACTGGTCTCTAGTCAGTGTGTCAATCTTACAACCAGAACTCAATTACCCCCTGCATACACTAATTCTTTCACACGTGGTGTTTATTACCCTGAA,Confirmed,RB-C.1,2026-02-14,2026-02-15,,Gwinnett,Georgia,Pinecrest Dialysis Center,33.9911,-84.0645,27,31.7,9,2,1,5,1,,99,,Ongoing,Person diff --git a/examples/continuous-color-ramps/riverbend-risk-ramp.csv b/examples/continuous-color-ramps/riverbend-risk-ramp.csv new file mode 100644 index 000000000..7469ec987 --- /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/examples/example_files/selected_states.geojson b/examples/example_files/selected_states.geojson index eeab830ac..62e06995b 100644 --- a/examples/example_files/selected_states.geojson +++ b/examples/example_files/selected_states.geojson @@ -1,6 +1,6 @@ { "type": "FeatureCollection", - "features": [ + "features": [ { "type": "Feature", "geometry": { @@ -24819,4 +24819,4 @@ } } ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index d856f2523..e100a61d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@types/leaflet.markercluster": "^1.5.1", "angular-cli-ghpages": "^3.1.0", "angular-plotly.js": "^6.0.0", + "ajv": "8.18.0", "assert": "^2.1.0", "chart.js": "^2.9.4", "core-js": "^3.30.2", diff --git a/package.json b/package.json index f486b9c05..3fa80a080 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "@types/leaflet.markercluster": "^1.5.1", "angular-cli-ghpages": "^3.1.0", "angular-plotly.js": "^6.0.0", + "ajv": "8.18.0", "assert": "^2.1.0", "chart.js": "^2.9.4", "core-js": "^3.30.2", diff --git a/scripts/embed-security.test.js b/scripts/embed-security.test.js index 11ba18502..8aca78e8b 100644 --- a/scripts/embed-security.test.js +++ b/scripts/embed-security.test.js @@ -177,6 +177,19 @@ test('receiver stores normalized launch options and includes them in the receipt globalSettings: { nodeColorBy: 'id', linkColorBy: 'distance', + nodeColorScale: { + mode: 'auto', + missingColor: '#eae553' + }, + linkColorScale: { + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [ + { value: 0, color: '#440154' }, + { value: 5, color: '#21918c' }, + { value: 10, color: '#fde725' } + ] + }, nodeShapeBy: 'seq', nodeColor: '#123456', linkColor: '#654321', @@ -202,6 +215,19 @@ test('receiver stores normalized launch options and includes them in the receipt globalSettings: { nodeColorBy: 'id', linkColorBy: 'distance', + nodeColorScale: { + mode: 'auto', + missingColor: '#eae553', + }, + linkColorScale: { + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [ + { value: 0, color: '#440154' }, + { value: 5, color: '#21918c' }, + { value: 10, color: '#fde725' }, + ], + }, nodeShapeBy: 'seq', nodeColor: '#123456', linkColor: '#654321', @@ -233,6 +259,33 @@ test('receiver rejects invalid launch options before storage', async () => { assert.match(errorMessage.message.message, /defaultView/); }); +test('receiver rejects malformed continuous color scales before storage', async () => { + const context = createReceiverContext(); + + await waitForReceiverReady(context); + await sendReceiverTransfer(context, ` + launch: { + globalSettings: { + nodeColorBy: 'id', + nodeColorScale: { + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [ + { value: 0, color: '#440154' }, + { value: 8, color: '#21918c' }, + { value: 7, color: '#fde725' } + ] + } + } + }, + `); + await settle(); + + const errorMessage = context.__openerMessages.find((message) => message.message.type === 'MT_HANDOFF_ERROR'); + assert.equal(context.__store.has('handoff:handoff-test-id'), false); + assert.match(errorMessage.message.message, /increase strictly/); +}); + test('receiver continues to reject full session payloads', async () => { const context = createReceiverContext(); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 8b0135e51..2275335b6 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -84,6 +84,7 @@ import { WaterfallComponent } from './visualizationComponents/WaterfallComponent 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'; @@ -132,6 +133,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 ce1dd5dd7..1d2ae3635 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 2d0cb95aa..0d22dcd9a 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 b382e52e5..323f79f37 100644 --- a/src/app/contactTraceCommonServices/color-mapping.service.spec.ts +++ b/src/app/contactTraceCommonServices/color-mapping.service.spec.ts @@ -1,4 +1,15 @@ -import { buildCanonicalNodeColorCounts, canonicalizeMixedNodeColorComponents, ColorMappingService, formatMixedNodeColorDisplayName, getMixedNodeColorLegendEntries, getMixedNodeColorSegments, normalizeNodeStyleCategoryValue, parseMixedNodeColorValue, parseWeightedMixedNodeColorValue } from './color-mapping.service'; +import { + buildCanonicalNodeColorCounts, + canonicalizeMixedNodeColorComponents, + ColorMappingService, + formatMixedNodeColorDisplayName, + getMixedNodeColorLegendEntries, + getMixedNodeColorSegments, + normalizeNodeStyleCategoryValue, + parseMixedNodeColorValue, + parseWeightedMixedNodeColorValue +} from './color-mapping.service'; +import { createDefaultVariableColorScaleConfig } from './variable-color-scale'; describe('mixed node color helpers', () => { it('normalizes null-like aliases to the shared empty category', () => { @@ -316,7 +327,8 @@ describe('ColorMappingService node assignments', () => { { MLST: ['8'] }, history, { '8': '#444444' }, - false + false, + createDefaultVariableColorScaleConfig('categorical') ); expect(result.colorMap('8')).toBe('#444444'); @@ -337,7 +349,8 @@ describe('ColorMappingService node assignments', () => { { MLST: ['8', '84'] }, {}, { '8': '#cccccc' }, - false + false, + createDefaultVariableColorScaleConfig('categorical') ); expect(result.colorMap('8')).toBe('#cccccc'); @@ -355,7 +368,8 @@ describe('ColorMappingService node assignments', () => { {}, history, { '8': '#cc9999' }, - false + false, + createDefaultVariableColorScaleConfig('categorical') ); expect(history.MLST['8']).toBe('#cc9999'); @@ -368,11 +382,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 b1255eb65..062e6d8c0 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'; export interface NodeColorSegment { value: string; @@ -470,6 +477,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); + } + public normalizeStyleCategoryValue(value: any): string { return normalizeNodeStyleCategoryValue(value); } @@ -535,19 +554,33 @@ export class ColorMappingService { nodeColorsTableHistory: any, nodeColorAssignments: Record, debugMode: boolean, + variableColorScaleConfigOrSplitMixedValues?: VariableColorScaleConfig | boolean, splitMixedValues: boolean = false ): { 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; invalidMixedWeightCount: number; } { + const variableColorScaleConfig = typeof variableColorScaleConfigOrSplitMixedValues === 'boolean' + ? undefined + : variableColorScaleConfigOrSplitMixedValues; + const shouldSplitMixedValues = typeof variableColorScaleConfigOrSplitMixedValues === 'boolean' + ? variableColorScaleConfigOrSplitMixedValues + : splitMixedValues; + 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'; @@ -560,6 +593,22 @@ export class ColorMappingService { updatedColorsTable: nodeColorsTable, updatedColorsTableKeys: nodeColorsTableKeys, updatedColorsTableHistory: nodeColorsTableHistory, + resolvedScale, + invalidMixedWeightCount: 0 + }; + } + + if (resolvedScale.mode === 'continuous') { + return { + aggregates: {}, + colorMap: resolvedScale.colorMap, + alphaMap: () => 1, + updatedNodeColors: nodeColors, + updatedNodeAlphas: nodeAlphas, + updatedColorsTable: nodeColorsTable || {}, + updatedColorsTableKeys: nodeColorsTableKeys || {}, + updatedColorsTableHistory: nodeColorsTableHistory || {}, + resolvedScale, invalidMixedWeightCount: 0 }; } @@ -631,13 +680,13 @@ export class ColorMappingService { return; } - const parsedMixedValue = splitMixedValues + const parsedMixedValue = shouldSplitMixedValues ? canonicalizeWeightedMixedNodeColorComponents(d[nodeColorVariable], storedKeysForVariable) : null; const categories = parsedMixedValue?.components.length ? parsedMixedValue.components.map(component => component.value) : [this.normalizeStyleCategoryValue(d[nodeColorVariable])]; - const isMixedValue = splitMixedValues && categories.length > 1; + const isMixedValue = shouldSplitMixedValues && categories.length > 1; if (parsedMixedValue?.invalidWeights) { invalidMixedWeightCount += 1; } @@ -656,7 +705,7 @@ export class ColorMappingService { }); }); - const distinctValues = splitMixedValues + const distinctValues = shouldSplitMixedValues ? sortNodeColorCategoryValues(Object.keys(aggregates), storedKeysForVariable) : sortAtomicNodeColorCategoryValues(Object.keys(aggregates), storedKeysForVariable); @@ -752,6 +801,7 @@ export class ColorMappingService { updatedColorsTable, updatedColorsTableKeys, updatedColorsTableHistory, + resolvedScale, invalidMixedWeightCount }; } @@ -767,17 +817,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 @@ -791,7 +851,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 }; } @@ -805,6 +880,7 @@ export class ColorMappingService { let updatedLinkColorsTableHistory = linkColorsTableHistory || {}; let updatedLinkColors = [...linkColors]; let updatedLinkAlphas = [...linkAlphas]; + const explicitAssignments = linkColorAssignments || {}; const hasLinkColorsTableForVariable = Array.isArray(updatedLinkColorsTable[linkColorVariable]); @@ -944,14 +1020,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; } @@ -1016,7 +1095,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 e24a5b178..bd4e89dd0 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.style-files.spec.ts b/src/app/contactTraceCommonServices/common.service.style-files.spec.ts new file mode 100644 index 000000000..f0f02b75f --- /dev/null +++ b/src/app/contactTraceCommonServices/common.service.style-files.spec.ts @@ -0,0 +1,223 @@ +import { CommonService } from './common.service'; + +describe('CommonService style-file color scales', () => { + function createService(style: any): CommonService { + const service = Object.create(CommonService.prototype) as CommonService; + (service as any).session = { + data: { nodes: [], links: [] }, + style + }; + (service as any).temp = { + style: { + nodeColorMap: () => '#000000', + nodeAlphaMap: () => 1, + nodeColorScale: null, + linkColorMap: () => '#000000', + linkAlphaMap: () => 1, + linkColorScale: null + } + }; + return service; + } + + function stubStyleApplication(service: CommonService): void { + // The lightweight service fixture bypasses the constructor, so class-field + // functions such as defaultWidgets are not installed on the instance. + (service as any).defaultWidgets = () => ({}); + spyOn(service, 'createNodeColorMap'); + spyOn(service, 'createLinkColorMap'); + spyOn(service, 'createPolygonColorMap'); + spyOn(service, 'onStyleFileApplied'); + } + + it('keeps selected fields categorical when loading a legacy style file', () => { + const legacyStyle: any = { + widgets: { + 'node-color-variable': 'score', + 'link-color-variable': 'distance' + } + }; + const service = createService({}); + stubStyleApplication(service); + + service.applyStyle(legacyStyle); + + expect(legacyStyle.variableColorScales).toEqual(jasmine.objectContaining({ version: 1 })); + expect(legacyStyle.variableColorScales.node.score.mode).toBe('categorical'); + expect(legacyStyle.variableColorScales.link.distance.mode).toBe('categorical'); + expect(service.createNodeColorMap).toHaveBeenCalled(); + expect(service.createLinkColorMap).toHaveBeenCalled(); + }); + + it('preserves explicit ramps even when a hand-authored file omits the scale-state version', () => { + const style: any = { + widgets: { + 'node-color-variable': 'score', + 'link-color-variable': 'distance' + }, + variableColorScales: { + node: { + score: { + mode: 'continuous', + domain: { kind: 'custom', min: 0, max: 10 }, + stops: [ + { value: 0, color: '#000000' }, + { value: 10, color: '#ffffff' } + ], + missingColor: '#123456' + } + }, + link: {} + } + }; + const service = createService({}); + stubStyleApplication(service); + + service.applyStyle(style); + + expect(style.variableColorScales.version).toBe(1); + expect(style.variableColorScales.node.score).toEqual({ + mode: 'continuous', + domain: { kind: 'custom', min: 0, max: 10 }, + stops: [ + { value: 0, color: '#000000' }, + { value: 10, color: '#ffffff' } + ], + missingColor: '#123456' + }); + expect(style.variableColorScales.link.distance.mode).toBe('categorical'); + }); + + it('serializes normalized ramp state without mutating the live style', () => { + const liveStyle: any = { + widgets: { + 'node-color-variable': 'score', + 'link-color-variable': 'distance' + }, + variableColorScales: { + version: 1, + node: { + score: { + mode: 'continuous', + domain: { kind: 'custom', min: 0, max: 10 }, + stops: [ + { value: 0, color: '#440154' }, + { value: 10, color: '#fde725' } + ], + missingColor: '#eae553' + } + }, + link: { + distance: { + mode: 'auto', + domain: { kind: 'auto' }, + missingColor: '#eae553' + } + } + } + }; + const service = createService(liveStyle); + + const payload = JSON.parse(service.serializeStyleFile()); + + expect(payload.variableColorScales.version).toBe(1); + expect(payload.variableColorScales.node.score.mode).toBe('continuous'); + expect(payload.variableColorScales.node.score.stops).toEqual([ + { value: 0, color: '#440154' }, + { value: 10, color: '#fde725' } + ]); + expect(payload.variableColorScales.link.distance.mode).toBe('auto'); + expect(payload.nodeColorAssignments).toEqual({}); + expect(payload.linkColorAssignments).toEqual({}); + expect(Object.prototype.hasOwnProperty.call(liveStyle, 'nodeColorAssignments')).toBeFalse(); + expect(liveStyle.variableColorScales.link.distance.mode).toBe('auto'); + }); + + it('upgrades a never-normalized legacy style when it is saved', () => { + const liveStyle: any = { + widgets: { + 'node-color-variable': 'score', + 'link-color-variable': 'distance' + } + }; + const service = createService(liveStyle); + + const payload = JSON.parse(service.serializeStyleFile()); + + expect(payload.variableColorScales.node.score.mode).toBe('categorical'); + expect(payload.variableColorScales.link.distance.mode).toBe('categorical'); + expect(Object.prototype.hasOwnProperty.call(liveStyle, 'variableColorScales')).toBeFalse(); + }); + + it('clears a cached continuous link scale when a style switches to a fixed link color', () => { + const service = createService({ + widgets: { + 'link-color-variable': 'None', + 'link-color': '#abcdef', + 'link-opacity': 0.25 + } + }); + (service as any).temp.style.linkColorScale = { mode: 'continuous', field: 'distance' }; + + service.createLinkColorMap(); + + expect((service as any).temp.style.linkColorScale).toBeNull(); + expect((service as any).temp.style.linkColorMap()).toBe('#abcdef'); + expect((service as any).temp.style.linkAlphaMap()).toBe(0.75); + }); + + it('clears a cached continuous node scale when a style switches to a fixed node color', () => { + const service = createService({ + widgets: { + 'node-color-variable': 'None', + 'node-color': '#abcdef' + } + }); + (service as any).temp.style.nodeColorScale = { mode: 'continuous', field: 'score' }; + + service.createNodeColorMap(); + + expect((service as any).temp.style.nodeColorScale).toBeNull(); + expect((service as any).temp.style.nodeColorMap()).toBe('#abcdef'); + expect((service as any).temp.style.nodeAlphaMap()).toBe(1); + expect((service as any).session.style.variableColorScales).toBeUndefined(); + }); + + it('uses the missing color value for epidemiological links without a visible genetic origin', () => { + const service = createService({}); + + expect(service.getLinkColorValue({ + distance: 0, + hasDistance: false, + origin: ['Contact Tracing'] + }, 'distance')).toBeNull(); + + expect(service.getLinkColorValue({ + distance: 8, + hasDistance: true, + distanceOrigin: 'Genetic Distance', + origin: ['Contact Tracing'] + }, 'distance')).toBeNull(); + }); + + it('keeps distance coloring when the genetic origin is visible', () => { + const service = createService({}); + + expect(service.getLinkColorValue({ + distance: 8, + hasDistance: true, + distanceOrigin: 'Genetic Distance', + origin: ['Contact Tracing', 'Genetic Distance'] + }, 'distance')).toBe(8); + }); + + it('preserves legacy distance links that do not have explicit origin metadata', () => { + const service = createService({}); + + expect(service.getLinkColorValue({ + distance: 5, + hasDistance: true, + origin: ['Legacy distance file'] + }, 'distance')).toBe(5); + }); +}); diff --git a/src/app/contactTraceCommonServices/common.service.ts b/src/app/contactTraceCommonServices/common.service.ts index 6898946d5..f22770fe6 100644 --- a/src/app/contactTraceCommonServices/common.service.ts +++ b/src/app/contactTraceCommonServices/common.service.ts @@ -16,7 +16,27 @@ import { GraphData } from '@app/visualizationComponents/TwoDComponent/data'; import { CommonStoreService } from './common-store.services'; import { LayoutConfig } from 'golden-layout'; import { REFERENCE, HBX2, WATERMARK } from '@app/constants/longStrings.constants'; -import { ColorMappingService, NodeFillStyle, getMixedNodeColorSegments, normalizeNodeStyleCategoryValue } from './color-mapping.service'; +import { + ColorMappingService, + getMixedNodeColorSegments, + NodeFillStyle, + normalizeNodeStyleCategoryValue +} 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 +54,12 @@ interface SequencePairwiseLinkGuardrails { hardLimit: number; } +export interface AppliedVariableColorAssignments { + mode: 'categorical' | 'continuous'; + assignments: Record; + stops?: ContinuousColorStop[]; +} + interface SequencePairwiseLinkGuardrailResult { warningThreshold: number; hardLimit: number; @@ -703,6 +729,7 @@ export class CommonService extends AppComponentBase implements OnInit { nodeAlphas: [1], nodeColors: this.thirtyColorPalette, nodeColorAssignments: {}, + linkColorAssignments: {}, nodeColorsTable: {}, nodeColorsTableHistory: {}, nodeColorsTableKeys: {}, @@ -730,6 +757,7 @@ export class CommonService extends AppComponentBase implements OnInit { polygonAlphas: [0.5], polygonColors: this.polygonPalette, polygonValueNames: {}, + variableColorScales: createVariableColorScaleState(), overwrite: {}, widgets: this.defaultWidgets() }, @@ -762,9 +790,11 @@ export class CommonService extends AppComponentBase implements OnInit { nodeMixedColorInvalidWeightCount: 0, 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: {} }; @@ -782,26 +812,136 @@ export class CommonService extends AppComponentBase implements OnInit { return Math.min(1, Math.max(0, numericValue)); } + public ensureVariableColorScaleState( + style: any = this.session?.style, + preserveLegacySelections = false + ): VariableColorScaleState { + if (!style || typeof style !== 'object') { + return createVariableColorScaleState(); + } + + const existingState = style.variableColorScales; + const hasVersionedState = existingState + && typeof existingState === 'object' + && !Array.isArray(existingState) + && existingState.version === 1; + const normalizedState = normalizeVariableColorScaleState(existingState); + + if (!hasVersionedState && preserveLegacySelections) { + const nodeField = style.widgets?.['node-color-variable']; + const linkField = style.widgets?.['link-color-variable']; + if (nodeField && nodeField !== 'None' && !normalizedState.node[nodeField]) { + normalizedState.node[nodeField] = createDefaultVariableColorScaleConfig('categorical'); + } + if (linkField && linkField !== 'None' && !normalizedState.link[linkField]) { + normalizedState.link[linkField] = createDefaultVariableColorScaleConfig('categorical'); + } + } + + style.variableColorScales = normalizedState; + return normalizedState; + } + + /** + * Returns a detached, normalized style-file payload. Legacy styles did not + * record a color-scale mode, so active legacy selections are pinned to + * categorical before export rather than changing behavior on the next load. + */ + public createStyleFilePayload(): any { + const style = _.cloneDeep(this.session?.style || {}); + this.ensureVariableColorAssignmentState('node', style); + this.ensureVariableColorAssignmentState('link', style); + this.ensureVariableColorScaleState(style, true); + return style; + } + + public serializeStyleFile(): string { + return JSON.stringify(this.createStyleFilePayload()); + } + + 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)); + } + public normalizeNodeStyleCategoryValue(value: any): string { return normalizeNodeStyleCategoryValue(value); } - private ensureNodeColorAssignmentState(style: any = this.session?.style): Record> { + 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 || {}); @@ -812,7 +952,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]; @@ -829,8 +1024,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): NodeFillStyle { @@ -849,8 +1065,9 @@ export class CommonService extends AppComponentBase implements OnInit { const normalizedValue = this.normalizeNodeStyleCategoryValue(value); const colorHistory = this.session.style.nodeColorsTableHistory?.[variable]; const historicalColor = colorHistory?.[normalizedValue]; + const useContinuousScale = this.temp.style.nodeColorScale?.mode === 'continuous'; const mixedColorsEnabled = widgets['node-mixed-colors-enabled'] === true; - if (mixedColorsEnabled) { + if (!useContinuousScale && mixedColorsEnabled) { const segments = getMixedNodeColorSegments( value, category => colorHistory?.[String(category)] || this.temp.style.nodeColorMap?.(category), @@ -881,7 +1098,7 @@ export class CommonService extends AppComponentBase implements OnInit { 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. @@ -2123,6 +2340,38 @@ export class CommonService extends AppComponentBase implements OnInit { return []; } + /** + * Returns the value that should drive link coloring in the current view. + * A stored genetic distance is not visually applicable when the link is + * currently shown only because of a non-distance (for example, + * epidemiological) origin. Importers also use distance 0 as a placeholder + * for links that have no genetic distance at all, so those links must use + * the missing-value color rather than the bottom of a continuous ramp. + */ + getLinkColorValue(link: any, field: string): any { + const value = link?.[field]; + if (String(field).toLowerCase() !== 'distance') { + return value; + } + + if (link?.hasDistance !== true) { + return null; + } + + const distanceOrigins = this.getLinkDistanceOrigins(link); + if (distanceOrigins.length === 0) { + // Preserve legacy distance links that predate explicit origin metadata. + return value; + } + + const visibleOrigins = this.normalizeLinkOrigins(link?.origin); + const hasVisibleDistanceOrigin = visibleOrigins.some(origin => + this.isDistanceBackedOrigin(origin, distanceOrigins) + ); + + return hasVisibleDistanceOrigin ? value : null; + } + syncLinkDistanceOrigins(link: any): void { const distanceOrigins = this.getLinkDistanceOrigins(link); @@ -2811,7 +3060,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(); @@ -2969,7 +3220,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(), @@ -4351,6 +4604,13 @@ align(params): Promise { // 1) Gather the parameters from session & temp const nodeColorVariable = this.session.style.widgets['node-color-variable']; const nodes = this.session.data.nodes; + + if (!nodeColorVariable || nodeColorVariable === 'None') { + this.temp.style.nodeColorMap = () => this.session.style.widgets['node-color']; + this.temp.style.nodeAlphaMap = () => 1; + this.temp.style.nodeColorScale = null; + return {}; + } // The arrays and tables you use to store color config //const nodeColors = this.session.style.nodeColors; // e.g. [ "#1f77b4", ... ] @@ -4360,8 +4620,9 @@ 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.ensureVariableColorAssignmentState('node')[nodeColorVariable] || {}; + const variableColorScaleConfig = this.getVariableColorScaleConfig('node', nodeColorVariable); const mixedColorsEnabled = !!this.session.style.widgets['node-mixed-colors-enabled']; - const nodeColorAssignments = this.ensureNodeColorAssignmentState()[nodeColorVariable] || {}; // 2) Call your new colorMappingService const result = this.colorMappingService.createNodeColorMap( @@ -4374,12 +4635,14 @@ align(params): Promise { nodeColorsTableHistory, nodeColorAssignments, this.debugMode, + variableColorScaleConfig, mixedColorsEnabled ); // 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; this.temp.style.nodeMixedColorInvalidWeightCount = result.invalidMixedWeightCount; // And also store the updated arrays/tables @@ -4407,13 +4670,24 @@ align(params): Promise { if (linkColorVariable == "None") { this.temp.style.linkColorMap = () => this.session.style.widgets["link-color"]; this.temp.style.linkAlphaMap = () => 1 - this.session.style.widgets["link-opacity"]; + this.temp.style.linkColorScale = null; return []; } const links = this.getVisibleLinksForCurrentTimeline(); + const colorLinks = this.getLinksWithEffectiveColorValue(links, linkColorVariable); + const colorDomainLinks = this.getLinksWithEffectiveColorValue(this.session.data.links, linkColorVariable); + const variableColorScaleConfig = this.getVariableColorScaleConfig('link', linkColorVariable); + const resolvedScale = this.colorMappingService.resolveVariableColorScale( + colorDomainLinks, + 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 = {}; @@ -4435,23 +4709,28 @@ 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( - links, + colorLinks, linkColorVariable, linkColors, linkAlphas, linkColorsTable, linkColorsTableKeys, linkColorsTableHistory, - this.debugMode + this.debugMode, + variableColorScaleConfig, + colorDomainLinks, + 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; @@ -4467,6 +4746,17 @@ align(params): Promise { return result.aggregates; } + + private getLinksWithEffectiveColorValue(links: any[], field: string): any[] { + if (String(field).toLowerCase() !== 'distance') { + return links; + } + + return links.map(link => ({ + ...link, + [field]: this.getLinkColorValue(link, field) + })); + } /** * updates the functions that set the color and transparency of the polygons [commonService.temp.style.polygonColorMap() and commonService.temp.style.polygonAlphaMap()] diff --git a/src/app/contactTraceCommonServices/export.service.spec.ts b/src/app/contactTraceCommonServices/export.service.spec.ts new file mode 100644 index 000000000..1befd0fb1 --- /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 32268aa25..b8ddb8867 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/style-file.schema.spec.ts b/src/app/contactTraceCommonServices/style-file.schema.spec.ts new file mode 100644 index 000000000..026d89fa9 --- /dev/null +++ b/src/app/contactTraceCommonServices/style-file.schema.spec.ts @@ -0,0 +1,137 @@ +import { validateStyleFileSchema } from './style-file.schema'; + +describe('MicrobeTrace style-file schema', () => { + it('accepts legacy style files without color-scale state', () => { + const result = validateStyleFileSchema({ + widgets: { + 'node-color-variable': 'degree', + 'link-color-variable': 'distance' + }, + nodeColors: ['#123456'], + linkColors: ['#abcdef'] + }); + + expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + }); + + it('accepts a valid versioned continuous scale', () => { + const result = validateStyleFileSchema({ + widgets: { 'node-color-variable': 'score' }, + variableColorScales: { + version: 1, + node: { + score: { + mode: 'continuous', + domain: { kind: 'custom', min: 0, max: 10 }, + stops: [ + { value: 0, color: '#000000' }, + { value: 10, color: '#ffffff' } + ], + missingColor: '#eae553' + } + }, + link: {} + } + }); + + expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + }); + + it('rejects invalid style envelopes and legacy property types', () => { + expect(validateStyleFileSchema([]).errors).toContain('style must be object.'); + expect(validateStyleFileSchema({}).errors).toContain('widgets is required.'); + const errors = validateStyleFileSchema({ widgets: [], nodeColors: '#123456' }).errors; + expect(errors).toContain('widgets must be object.'); + expect(errors).toContain('nodeColors must be array.'); + }); + + it('reports the repairs used for malformed ramp settings', () => { + const result = validateStyleFileSchema({ + widgets: { 'node-color-variable': 'score' }, + variableColorScales: { + node: { + score: { + mode: 'rainbow', + domain: { kind: 'custom', min: 10, max: 0 }, + stops: [ + { value: 0, color: 'black' }, + { value: 0, color: '#ffffff' } + ], + missingColor: 'yellow' + } + } + } + }); + + expect(result.valid).toBeTrue(); + expect(result.errors).toEqual([]); + expect(result.warnings).toContain('variableColorScales.version is missing; version 1 was assumed.'); + expect(result.warnings).toContain('variableColorScales.link is missing; an empty link scale collection was used.'); + expect(result.warnings).toContain('variableColorScales.node.score.mode is invalid or missing; Auto mode was used.'); + expect(result.warnings).toContain('variableColorScales.node.score.domain must have finite values with min less than max; the automatic data domain was used.'); + expect(result.warnings).toContain('variableColorScales.node.score.missingColor is invalid or missing; the default missing-value color was used.'); + expect(result.warnings).toContain('variableColorScales.node.score.stops had 1 invalid entry removed.'); + expect(result.warnings).toContain('variableColorScales.node.score.stops has fewer than two valid stops; the default ramp was used.'); + }); + + it('distinguishes sortable stops from duplicate stops', () => { + const baseStyle: any = { + widgets: {}, + variableColorScales: { + version: 1, + node: {}, + link: {} + } + }; + const makeConfig = (stops: Array<{ value: number; color: string }>) => ({ + mode: 'continuous', + domain: { kind: 'auto' }, + stops, + missingColor: '#eae553' + }); + const sortable = JSON.parse(JSON.stringify(baseStyle)); + sortable.variableColorScales.node.score = makeConfig([ + { value: 10, color: '#ffffff' }, + { value: 0, color: '#000000' } + ]); + const duplicate = JSON.parse(JSON.stringify(baseStyle)); + duplicate.variableColorScales.node.score = makeConfig([ + { value: 0, color: '#000000' }, + { value: 0, color: '#ffffff' } + ]); + + expect(validateStyleFileSchema(sortable).warnings).toContain( + 'variableColorScales.node.score.stops was out of order and was sorted by value.' + ); + expect(validateStyleFileSchema(duplicate).warnings).toContain( + 'variableColorScales.node.score.stops contains duplicate values; the default ramp was used.' + ); + }); + + it('reports numeric-string conversion without claiming the values were discarded', () => { + const result = validateStyleFileSchema({ + widgets: {}, + variableColorScales: { + version: 1, + node: { + score: { + mode: 'continuous', + domain: { kind: 'custom', min: '0', max: '10' }, + stops: [ + { value: '0', color: '#000000' }, + { value: '10', color: '#ffffff' } + ], + missingColor: '#eae553' + } + }, + link: {} + } + }); + + expect(result.valid).toBeTrue(); + expect(result.warnings).toContain('variableColorScales.node.score.domain had 2 bounds converted to numbers.'); + expect(result.warnings).toContain('variableColorScales.node.score.stops had 2 numeric values converted to numbers.'); + expect(result.warnings.some(warning => warning.includes('automatic data domain was used'))).toBeFalse(); + expect(result.warnings.some(warning => warning.includes('default ramp was used'))).toBeFalse(); + }); +}); diff --git a/src/app/contactTraceCommonServices/style-file.schema.ts b/src/app/contactTraceCommonServices/style-file.schema.ts new file mode 100644 index 000000000..47570fb93 --- /dev/null +++ b/src/app/contactTraceCommonServices/style-file.schema.ts @@ -0,0 +1,370 @@ +import Ajv, { ErrorObject } from 'ajv'; + +export const MICROBETRACE_STYLE_FILE_SCHEMA = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'https://microbetrace.cdc.gov/schemas/style-file-v1.json', + title: 'MicrobeTrace style file', + description: 'Validates the stable style-file envelope and versioned variable color-scale extension.', + type: 'object', + required: ['widgets'], + additionalProperties: true, + properties: { + widgets: { + type: 'object', + additionalProperties: true + }, + linkAlphas: { type: 'array' }, + linkColors: { type: 'array' }, + linkValueNames: { type: 'object' }, + keyTableColumnNames: { type: 'object' }, + nodeAlphas: { type: 'array' }, + nodeColors: { type: 'array' }, + nodeColorAssignments: { type: 'object' }, + linkColorAssignments: { type: 'object' }, + nodeColorsTable: { type: 'object' }, + nodeColorsTableHistory: { type: 'object' }, + nodeColorsTableKeys: { type: 'object' }, + linkColorsTable: { type: 'object' }, + linkColorsTableHistory: { type: 'object' }, + linkColorsTableKeys: { type: 'object' }, + nodeSymbols: { type: 'array' }, + nodeSymbolsTable: { type: 'object' }, + nodeSymbolsTableKeys: { type: 'object' }, + nodeValueNames: { type: 'object' }, + polygonAlphas: { type: 'array' }, + polygonColors: { type: 'array' }, + polygonValueNames: { type: 'object' }, + overwrite: { type: 'object' }, + variableColorScales: { $ref: '#/definitions/variableColorScaleState' } + }, + definitions: { + color: { + type: 'string', + pattern: '^#[0-9a-fA-F]{6}$' + }, + colorStop: { + type: 'object', + required: ['value', 'color'], + additionalProperties: false, + properties: { + value: { type: 'number' }, + color: { $ref: '#/definitions/color' } + } + }, + automaticDomain: { + type: 'object', + required: ['kind'], + additionalProperties: false, + properties: { + kind: { const: 'auto' } + } + }, + customDomain: { + type: 'object', + required: ['kind', 'min', 'max'], + additionalProperties: false, + properties: { + kind: { const: 'custom' }, + min: { type: 'number' }, + max: { type: 'number' } + } + }, + variableColorScaleConfig: { + type: 'object', + required: ['mode', 'domain', 'missingColor'], + additionalProperties: false, + properties: { + mode: { enum: ['auto', 'categorical', 'continuous'] }, + domain: { + oneOf: [ + { $ref: '#/definitions/automaticDomain' }, + { $ref: '#/definitions/customDomain' } + ] + }, + stops: { + type: 'array', + minItems: 2, + items: { $ref: '#/definitions/colorStop' } + }, + missingColor: { $ref: '#/definitions/color' } + } + }, + variableColorScaleTarget: { + type: 'object', + additionalProperties: { $ref: '#/definitions/variableColorScaleConfig' } + }, + variableColorScaleState: { + type: 'object', + required: ['version', 'node', 'link'], + additionalProperties: false, + properties: { + version: { const: 1 }, + node: { $ref: '#/definitions/variableColorScaleTarget' }, + link: { $ref: '#/definitions/variableColorScaleTarget' } + } + } + } +} as const; + +export interface StyleFileSchemaValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +const ajv = new Ajv({ allErrors: true, strict: false }); +const validateAgainstSchema = ajv.compile(MICROBETRACE_STYLE_FILE_SCHEMA as any); +const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i; + +function decodeJsonPointer(pointer: string): string[] { + if (!pointer) { + return []; + } + + return pointer + .split('/') + .slice(1) + .map(segment => segment.replace(/~1/g, '/').replace(/~0/g, '~')); +} + +function formatPath(pointer: string, suffix?: string): string { + const segments = decodeJsonPointer(pointer); + if (suffix) { + segments.push(suffix); + } + return segments.length ? segments.join('.') : 'style'; +} + +function formatFatalError(error: ErrorObject): string { + if (error.keyword === 'required') { + const missingProperty = String((error.params as any).missingProperty || 'property'); + return `${formatPath(error.instancePath, missingProperty)} is required.`; + } + + if (error.keyword === 'type') { + return `${formatPath(error.instancePath)} must be ${String((error.params as any).type)}.`; + } + + return `${formatPath(error.instancePath)} ${error.message || 'is invalid'}.`; +} + +function getScaleConfigPath(pointer: string): string { + const segments = decodeJsonPointer(pointer); + const targetIndex = segments.findIndex(segment => segment === 'node' || segment === 'link'); + if (targetIndex >= 0 && segments[targetIndex + 1]) { + return segments.slice(0, targetIndex + 2).join('.'); + } + return 'variableColorScales'; +} + +function formatScaleWarning(error: ErrorObject): string { + const path = formatPath(error.instancePath); + const configPath = getScaleConfigPath(error.instancePath); + const missingProperty = error.keyword === 'required' + ? String((error.params as any).missingProperty || '') + : ''; + const additionalProperty = error.keyword === 'additionalProperties' + ? String((error.params as any).additionalProperty || '') + : ''; + + if (additionalProperty) { + return `${formatPath(error.instancePath, additionalProperty)} is not part of the style schema and was ignored.`; + } + + if (path === 'variableColorScales' || error.instancePath === '/variableColorScales') { + if (missingProperty === 'version') { + return 'variableColorScales.version is missing; version 1 was assumed.'; + } + if (missingProperty === 'node' || missingProperty === 'link') { + return `variableColorScales.${missingProperty} is missing; an empty ${missingProperty} scale collection was used.`; + } + if (error.keyword === 'type') { + return 'variableColorScales is not an object; legacy categorical behavior was used.'; + } + } + + if (error.instancePath === '/variableColorScales/version') { + return 'variableColorScales.version is unsupported; recognized settings were normalized to version 1.'; + } + + if (missingProperty === 'mode' || error.instancePath.endsWith('/mode')) { + return `${configPath}.mode is invalid or missing; Auto mode was used.`; + } + + if (missingProperty === 'domain') { + return `${configPath}.domain is invalid or missing; the automatic data domain was used.`; + } + + if (missingProperty === 'missingColor' || error.instancePath.endsWith('/missingColor')) { + return `${configPath}.missingColor is invalid or missing; the default missing-value color was used.`; + } + + if (error.instancePath === '/variableColorScales/node' || error.instancePath === '/variableColorScales/link') { + return `${path} is invalid; an empty scale collection was used.`; + } + + if (error.keyword === 'type' && configPath === path) { + return `${configPath} is not an object; default Auto scale settings were used.`; + } + + return `${path} does not match the style schema and was normalized.`; +} + +function getScaleConfigs(style: any): Array<{ path: string; config: any }> { + const configs: Array<{ path: string; config: any }> = []; + const state = style?.variableColorScales; + if (!state || typeof state !== 'object' || Array.isArray(state)) { + return configs; + } + + (['node', 'link'] as const).forEach(target => { + const targetState = state[target]; + if (!targetState || typeof targetState !== 'object' || Array.isArray(targetState)) { + return; + } + + Object.entries(targetState).forEach(([field, config]) => { + configs.push({ + path: `variableColorScales.${target}.${field}`, + config + }); + }); + }); + + return configs; +} + +function collectStopNormalizationWarnings(style: any): string[] { + const warnings: string[] = []; + getScaleConfigs(style).forEach(({ path, config }) => { + if (!config || typeof config !== 'object' || Array.isArray(config) || config.stops === undefined) { + return; + } + + if (!Array.isArray(config.stops)) { + warnings.push(`${path}.stops is not an array; the default ramp was used.`); + return; + } + + const normalizedStops = config.stops + .map((stop: any) => ({ + raw: stop, + value: Number(stop?.value), + color: typeof stop?.color === 'string' ? stop.color.trim() : '' + })); + const validStops = normalizedStops.filter(stop => + Number.isFinite(stop.value) && HEX_COLOR_PATTERN.test(stop.color) + ); + const invalidCount = normalizedStops.length - validStops.length; + const convertedCount = validStops.filter(stop => typeof stop.raw?.value !== 'number').length; + const extraPropertyCount = validStops.filter(stop => + stop.raw + && typeof stop.raw === 'object' + && !Array.isArray(stop.raw) + && Object.keys(stop.raw).some(key => key !== 'value' && key !== 'color') + ).length; + + if (convertedCount) { + warnings.push(`${path}.stops had ${convertedCount} numeric value${convertedCount === 1 ? '' : 's'} converted to numbers.`); + } + if (extraPropertyCount) { + warnings.push(`${path}.stops had unsupported properties removed from ${extraPropertyCount} stop${extraPropertyCount === 1 ? '' : 's'}.`); + } + if (invalidCount) { + warnings.push(`${path}.stops had ${invalidCount} invalid ${invalidCount === 1 ? 'entry' : 'entries'} removed.`); + } + + const values = validStops.map(stop => stop.value); + if (validStops.length < 2) { + warnings.push(`${path}.stops has fewer than two valid stops; the default ramp was used.`); + return; + } + if (new Set(values).size !== values.length) { + warnings.push(`${path}.stops contains duplicate values; the default ramp was used.`); + return; + } + if (values.some((value, index) => index > 0 && value < values[index - 1])) { + warnings.push(`${path}.stops was out of order and was sorted by value.`); + } + }); + + return warnings; +} + +function collectDomainNormalizationWarnings(style: any): string[] { + const warnings: string[] = []; + getScaleConfigs(style).forEach(({ path, config }) => { + if (!config || typeof config !== 'object' || Array.isArray(config) || config.domain === undefined) { + return; + } + + const domain = config.domain; + if (!domain || typeof domain !== 'object' || Array.isArray(domain)) { + warnings.push(`${path}.domain is invalid; the automatic data domain was used.`); + return; + } + + if (domain.kind === 'auto') { + const ignoredKeys = Object.keys(domain).filter(key => key !== 'kind'); + if (ignoredKeys.length) { + warnings.push(`${path}.domain ignored unsupported ${ignoredKeys.length === 1 ? 'property' : 'properties'}: ${ignoredKeys.join(', ')}.`); + } + return; + } + + if (domain.kind !== 'custom') { + warnings.push(`${path}.domain.kind is invalid or missing; the automatic data domain was used.`); + return; + } + + const min = Number(domain.min); + const max = Number(domain.max); + if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) { + warnings.push(`${path}.domain must have finite values with min less than max; the automatic data domain was used.`); + return; + } + + const convertedBounds = [domain.min, domain.max].filter(value => typeof value !== 'number').length; + if (convertedBounds) { + warnings.push(`${path}.domain had ${convertedBounds} ${convertedBounds === 1 ? 'bound' : 'bounds'} converted to numbers.`); + } + const ignoredKeys = Object.keys(domain).filter(key => key !== 'kind' && key !== 'min' && key !== 'max'); + if (ignoredKeys.length) { + warnings.push(`${path}.domain ignored unsupported ${ignoredKeys.length === 1 ? 'property' : 'properties'}: ${ignoredKeys.join(', ')}.`); + } + }); + + return warnings; +} + +export function validateStyleFileSchema(style: unknown): StyleFileSchemaValidationResult { + const schemaValid = validateAgainstSchema(style); + const errors = new Set(); + const warnings = new Set(); + + if (!schemaValid) { + (validateAgainstSchema.errors || []).forEach(error => { + if (error.instancePath === '/variableColorScales' || error.instancePath.startsWith('/variableColorScales/')) { + // Domain and stop errors are summarized from the raw values below so + // warnings describe the normalizer's actual repair rather than noisy + // oneOf branch failures from JSON Schema. + if (!error.instancePath.includes('/domain') && !error.instancePath.includes('/stops')) { + warnings.add(formatScaleWarning(error)); + } + } else { + errors.add(formatFatalError(error)); + } + }); + } + + if (style && typeof style === 'object' && !Array.isArray(style)) { + collectStopNormalizationWarnings(style).forEach(warning => warnings.add(warning)); + collectDomainNormalizationWarnings(style).forEach(warning => warnings.add(warning)); + } + + return { + valid: errors.size === 0, + errors: Array.from(errors), + warnings: Array.from(warnings) + }; +} diff --git a/src/app/contactTraceCommonServices/variable-color-scale.spec.ts b/src/app/contactTraceCommonServices/variable-color-scale.spec.ts new file mode 100644 index 000000000..04e9a236d --- /dev/null +++ b/src/app/contactTraceCommonServices/variable-color-scale.spec.ts @@ -0,0 +1,149 @@ +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('inspects large numeric fields without spreading the values as function arguments', () => { + const itemCount = 150_000; + const summary = inspectContinuousValues( + Array.from({ length: itemCount }, (_, value) => ({ value })), + 'value', + ); + + expect(summary).toEqual(jasmine.objectContaining({ + numericCount: itemCount, + missingCount: 0, + invalidCount: 0, + uniqueCount: itemCount, + min: 0, + max: itemCount - 1, + canUseContinuous: true, + autoUsesContinuous: true, + })); + }); + + it('uses continuous Auto mode for numeric-or-missing fields', () => { + const resolved = resolveVariableColorScale([ + { value: '-2' }, + { value: null }, + { value: 8, visible: false }, + ], 'value', createDefaultVariableColorScaleConfig()); + + expect(resolved.mode).toBe('continuous'); + expect(resolved.domain).toEqual({ min: -2, max: 8 }); + expect(resolved.stops.map(stop => stop.value)).toEqual([-2, 3, 8]); + expect(resolved.colorMap(null)).toBe(DEFAULT_CONTINUOUS_MISSING_COLOR); + }); + + it('keeps mixed fields categorical in Auto and permits a continuous override', () => { + const values = [{ value: 0 }, { value: 'unknown' }, { value: 10 }]; + const automatic = resolveVariableColorScale(values, 'value', createDefaultVariableColorScaleConfig()); + const forced = resolveVariableColorScale(values, 'value', { + ...createDefaultVariableColorScaleConfig(), + mode: 'continuous', + missingColor: '#123456', + }); + + expect(automatic.mode).toBe('categorical'); + expect(forced.mode).toBe('continuous'); + expect(forced.colorMap('unknown')).toBe('#123456'); + }); + + it('honors categorical overrides without changing numeric detection', () => { + const resolved = resolveVariableColorScale([{ value: 1 }, { value: 2 }], 'value', { + ...createDefaultVariableColorScaleConfig(), + mode: 'categorical', + }); + + expect(resolved.mode).toBe('categorical'); + expect(resolved.summary.autoUsesContinuous).toBeTrue(); + }); + + it('uses custom bounds, arbitrary stops, CIELAB interpolation, and clamping', () => { + const resolved = resolveVariableColorScale([{ value: -100 }, { value: 100 }], 'value', { + mode: 'continuous', + domain: { kind: 'custom', min: 0, max: 20 }, + stops: [ + { value: 0, color: '#000000' }, + { value: 5, color: '#ff0000' }, + { value: 20, color: '#ffffff' }, + ], + missingColor: '#abcdef', + }); + + expect(resolved.domain).toEqual({ min: 0, max: 20 }); + expect(d3.color(resolved.colorMap(-5))?.hex()).toBe('#000000'); + expect(d3.color(resolved.colorMap(5))?.hex()).toBe('#ff0000'); + expect(d3.color(resolved.colorMap(25))?.hex()).toBe('#ffffff'); + expect(resolved.colorMap(undefined)).toBe('#abcdef'); + }); + + it('uses the middle ramp color and a single-value domain for constant fields', () => { + const resolved = resolveVariableColorScale([{ value: 7 }, { value: '7' }], 'value'); + + expect(resolved.constant).toBeTrue(); + expect(resolved.domain).toEqual({ min: 7, max: 7 }); + expect(resolved.colorMap(7).toLowerCase()).toBe('#21918c'); + expect(resolved.colorMap(999).toLowerCase()).toBe('#21918c'); + }); + + it('normalizes malformed persisted settings safely', () => { + const config = normalizeVariableColorScaleConfig({ + mode: 'rainbow', + domain: { kind: 'custom', min: 5, max: 1 }, + stops: [ + { value: 1, color: 'red' }, + { value: 1, color: '#123456' }, + ], + missingColor: 'yellow', + }); + const state = normalizeVariableColorScaleState({ + version: 99, + node: { score: config }, + link: [], + }); + + expect(config).toEqual(jasmine.objectContaining(createDefaultVariableColorScaleConfig())); + expect(config.stops).toBeUndefined(); + expect(state.version).toBe(1); + expect(state.node.score).toEqual(jasmine.objectContaining(createDefaultVariableColorScaleConfig())); + expect(state.node.score.stops).toBeUndefined(); + expect(state.link).toEqual({}); + }); +}); diff --git a/src/app/contactTraceCommonServices/variable-color-scale.ts b/src/app/contactTraceCommonServices/variable-color-scale.ts new file mode 100644 index 000000000..a4b19a072 --- /dev/null +++ b/src/app/contactTraceCommonServices/variable-color-scale.ts @@ -0,0 +1,334 @@ +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 uniqueNumericValues = new Set(); + let numericCount = 0; + let missingCount = 0; + let invalidCount = 0; + let min: number | null = null; + let max: number | null = null; + + (items || []).forEach(item => { + const parsed = parseContinuousNumber(item?.[field]); + if (parsed.kind === 'number') { + numericCount += 1; + uniqueNumericValues.add(parsed.value); + min = min === null ? parsed.value : Math.min(min, parsed.value); + max = max === null ? parsed.value : Math.max(max, parsed.value); + } else if (parsed.kind === 'missing') { + missingCount += 1; + } else { + invalidCount += 1; + } + }); + + return { + numericCount, + missingCount, + invalidCount, + uniqueCount: uniqueNumericValues.size, + min, + max, + canUseContinuous: numericCount > 0, + autoUsesContinuous: numericCount > 0 && invalidCount === 0 + }; +} + +export function normalizeVariableColorScaleConfig(value: unknown): VariableColorScaleConfig { + const candidate = value && typeof value === 'object' && !Array.isArray(value) + ? value as Partial + : {}; + const mode: ColorScaleMode = candidate.mode === 'categorical' || candidate.mode === 'continuous' + ? candidate.mode + : 'auto'; + + let domain: VariableColorScaleDomain = { kind: 'auto' }; + if (candidate.domain?.kind === 'custom') { + const min = Number(candidate.domain.min); + const max = Number(candidate.domain.max); + if (Number.isFinite(min) && Number.isFinite(max) && min < max) { + domain = { kind: 'custom', min, max }; + } + } + + const stops = Array.isArray(candidate.stops) + ? candidate.stops + .map(stop => ({ + value: Number(stop?.value), + color: normalizeHexColor(stop?.color, '') + })) + .filter(stop => Number.isFinite(stop.value) && !!stop.color) + .sort((left, right) => left.value - right.value) + : undefined; + + const hasStrictlyIncreasingStops = !!stops + && stops.length >= 2 + && stops.every((stop, index) => index === 0 || stop.value > stops[index - 1].value); + + return { + mode, + domain, + stops: hasStrictlyIncreasingStops ? stops : undefined, + missingColor: normalizeHexColor(candidate.missingColor, DEFAULT_CONTINUOUS_MISSING_COLOR) + }; +} + +export function normalizeVariableColorScaleState(value: unknown): VariableColorScaleState { + const candidate = value && typeof value === 'object' && !Array.isArray(value) + ? value as Partial + : {}; + const state = createVariableColorScaleState(); + + (['node', 'link'] as VariableColorTarget[]).forEach(target => { + const targetValue = candidate[target]; + if (!targetValue || typeof targetValue !== 'object' || Array.isArray(targetValue)) { + return; + } + + Object.entries(targetValue).forEach(([field, config]) => { + if (field) { + state[target][field] = normalizeVariableColorScaleConfig(config); + } + }); + }); + + return state; +} + +function buildDefaultStops(min: number, max: number): ContinuousColorStop[] { + if (min === max) { + return [ + { value: min, color: DEFAULT_CONTINUOUS_COLORS[0] }, + { value: min, color: DEFAULT_CONTINUOUS_COLORS[1] }, + { value: min, color: DEFAULT_CONTINUOUS_COLORS[2] } + ]; + } + + return [ + { value: min, color: DEFAULT_CONTINUOUS_COLORS[0] }, + { value: min + ((max - min) / 2), color: DEFAULT_CONTINUOUS_COLORS[1] }, + { value: max, color: DEFAULT_CONTINUOUS_COLORS[2] } + ]; +} + +function resolveStops(config: VariableColorScaleConfig, min: number, max: number): ContinuousColorStop[] { + const configuredStops = config.stops; + if (!configuredStops || configuredStops.length < 2 || min === max) { + return buildDefaultStops(min, max); + } + + const sortedStops = configuredStops + .filter(stop => Number.isFinite(stop.value) && HEX_COLOR_PATTERN.test(stop.color)) + .sort((left, right) => left.value - right.value); + if (sortedStops.length < 2) { + return buildDefaultStops(min, max); + } + + const interiorStops = sortedStops + .slice(1, -1) + .filter(stop => stop.value > min && stop.value < max); + + return [ + { value: min, color: sortedStops[0].color }, + ...interiorStops, + { value: max, color: sortedStops[sortedStops.length - 1].color } + ]; +} + +export function resolveVariableColorScale( + items: any[], + field: string, + rawConfig?: VariableColorScaleConfig +): ResolvedVariableColorScale { + const config = normalizeVariableColorScaleConfig(rawConfig); + const summary = inspectContinuousValues(items, field); + const requestedMode = config.mode; + const useContinuous = summary.canUseContinuous && ( + requestedMode === 'continuous' + || (requestedMode === 'auto' && summary.autoUsesContinuous) + ); + const missingColor = config.missingColor; + + if (!useContinuous || summary.min === null || summary.max === null) { + return { + field, + requestedMode, + mode: 'categorical', + summary, + domain: null, + stops: [], + missingColor, + constant: false, + colorMap: () => missingColor + }; + } + + const min = config.domain.kind === 'custom' ? config.domain.min : summary.min; + const max = config.domain.kind === 'custom' ? config.domain.max : summary.max; + const constant = min === max; + const stops = resolveStops(config, min, max); + + if (constant) { + const midpointColor = stops[Math.floor(stops.length / 2)]?.color || DEFAULT_CONTINUOUS_COLORS[1]; + return { + field, + requestedMode, + mode: 'continuous', + summary, + domain: { min, max }, + stops, + missingColor, + constant: true, + colorMap: value => parseContinuousNumber(value).kind === 'number' ? midpointColor : missingColor + }; + } + + const scale = d3.scaleLinear() + .domain(stops.map(stop => stop.value)) + .range(stops.map(stop => stop.color)) + .interpolate(d3.interpolateLab) + .clamp(true); + + return { + field, + requestedMode, + mode: 'continuous', + summary, + domain: { min, max }, + stops, + missingColor, + constant: false, + colorMap: value => { + const parsed = parseContinuousNumber(value); + return parsed.kind === 'number' ? scale(parsed.value) : missingColor; + } + }; +} + +export function buildContinuousGradientCss(resolved: ResolvedVariableColorScale, sampleCount = 32): string { + if (resolved.mode !== 'continuous' || !resolved.domain) { + return resolved.missingColor; + } + + if (resolved.constant) { + return resolved.colorMap(resolved.domain.min); + } + + const count = Math.max(2, sampleCount); + const span = resolved.domain.max - resolved.domain.min; + const samples = Array.from({ length: count }, (_, index) => { + const fraction = index / (count - 1); + const value = resolved.domain.min + span * fraction; + return `${resolved.colorMap(value)} ${(fraction * 100).toFixed(2)}%`; + }); + + return `linear-gradient(to right, ${samples.join(', ')})`; +} diff --git a/src/app/embed/embed-handoff.service.spec.ts b/src/app/embed/embed-handoff.service.spec.ts index fab4cc7bd..2bc805e5f 100644 --- a/src/app/embed/embed-handoff.service.spec.ts +++ b/src/app/embed/embed-handoff.service.spec.ts @@ -241,6 +241,140 @@ describe('EmbedHandoffService', () => { expect(result.message).toContain('missing_node_field'); }); + it('accepts continuous node and link color scales without changing embed v1', async () => { + seedValidHandoff({ + launch: { + globalSettings: { + nodeColorBy: 'score', + linkColorBy: 'distance', + nodeColorScale: { + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [ + { value: 0, color: '#440154' }, + { value: 4, color: '#21918c' }, + { value: 10, color: '#fde725' }, + ], + missingColor: '#eae553', + }, + linkColorScale: { + mode: 'auto', + missingColor: '#abcdef', + }, + }, + }, + files: [ + { + name: 'nodes.csv', + kind: 'node', + mimeType: 'text/csv', + contents: 'id,score\nA,1\nB,9\n', + }, + { + name: 'links.csv', + kind: 'link', + mimeType: 'text/csv', + contents: 'source,target,distance\nA,B,1\n', + }, + ], + }); + + const result = await service.consumePendingHandoffFromUrl(); + + expect(result.status).toBe('success'); + if (result.status !== 'success') { + fail('Expected a successful handoff result.'); + return; + } + expect(result.handoff.version).toBe(EMBED_HANDOFF_VERSION); + expect(result.handoff.launch?.globalSettings?.nodeColorScale).toEqual({ + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [ + { value: 0, color: '#440154' }, + { value: 4, color: '#21918c' }, + { value: 10, color: '#fde725' }, + ], + missingColor: '#eae553', + }); + expect(result.handoff.launch?.globalSettings?.linkColorScale).toEqual({ + mode: 'auto', + missingColor: '#abcdef', + }); + }); + + [ + { + label: 'a scale without its field', + globalSettings: { nodeColorScale: { mode: 'continuous' } }, + message: 'requires "nodeColorBy"', + }, + { + label: 'an unsupported mode', + globalSettings: { nodeColorBy: 'score', nodeColorScale: { mode: 'quantile' } }, + message: 'unsupported value', + }, + { + label: 'a malformed domain', + globalSettings: { nodeColorBy: 'score', nodeColorScale: { mode: 'continuous', domain: { min: 5, max: 5 } } }, + message: 'min less than max', + }, + { + label: 'stops without a custom domain', + globalSettings: { + nodeColorBy: 'score', + nodeColorScale: { + mode: 'continuous', + stops: [{ value: 0, color: '#000000' }, { value: 1, color: '#ffffff' }], + }, + }, + message: 'requires a custom domain', + }, + { + label: 'unordered stops', + globalSettings: { + nodeColorBy: 'score', + nodeColorScale: { + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [{ value: 0, color: '#000000' }, { value: 8, color: '#ffffff' }, { value: 7, color: '#123456' }], + }, + }, + message: 'increase strictly', + }, + { + label: 'invalid stop colors', + globalSettings: { + nodeColorBy: 'score', + nodeColorScale: { + mode: 'continuous', + domain: { min: 0, max: 10 }, + stops: [{ value: 0, color: '#000000' }, { value: 10, color: 'white' }], + }, + }, + message: '6-digit hex color', + }, + ].forEach(({ label, globalSettings, message }) => { + it(`rejects ${label}`, async () => { + seedValidHandoff({ + launch: { globalSettings }, + files: [{ + name: 'nodes.csv', + kind: 'node', + mimeType: 'text/csv', + contents: 'id,score\nA,1\n', + }], + }); + + const result = await service.consumePendingHandoffFromUrl(); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.message).toContain(message); + } + }); + }); + it('rejects forbidden keys in handoff launch options', async () => { const createdAt = Date.now(); seedRawHandoff(`{ diff --git a/src/app/embed/embed-handoff.service.ts b/src/app/embed/embed-handoff.service.ts index 462d8c4e8..1ccda66e2 100644 --- a/src/app/embed/embed-handoff.service.ts +++ b/src/app/embed/embed-handoff.service.ts @@ -22,6 +22,7 @@ import { EmbedFileOptionsV1, EmbedFileV1, EmbedLaunchOptionsV1, + EmbedVariableColorScaleV1, EmbedPayloadMetadataV1, ImportedEmbedFile, StoredEmbedHandoffV1, @@ -401,6 +402,13 @@ export class EmbedHandoffService { normalized[fieldName] = value.trim(); }); + (['nodeColorScale', 'linkColorScale'] as const).forEach(fieldName => { + const value = globalSettings[fieldName]; + if (typeof value !== 'undefined') { + normalized[fieldName] = this.normalizeVariableColorScale(value, `globalSettings.${fieldName}`); + } + }); + if (typeof globalSettings.nodeShape !== 'undefined') { const nodeShape = this.requireAllowedLaunchString( globalSettings.nodeShape, @@ -426,6 +434,73 @@ export class EmbedHandoffService { return normalized; } + private normalizeVariableColorScale(value: unknown, fieldName: string): EmbedVariableColorScaleV1 { + if (!this.isPlainObject(value)) { + throw new Error(`Launch option "${fieldName}" must be an object.`); + } + + const allowedModes = new Set(['auto', 'categorical', 'continuous']); + if (typeof value.mode !== 'string' || !allowedModes.has(value.mode)) { + throw new Error(`Launch option "${fieldName}.mode" used an unsupported value.`); + } + + const normalized: EmbedVariableColorScaleV1 = { + mode: value.mode as EmbedVariableColorScaleV1['mode'], + }; + + if (typeof value.domain !== 'undefined') { + if (!this.isPlainObject(value.domain)) { + throw new Error(`Launch option "${fieldName}.domain" must contain finite min and max values.`); + } + const min = Number(value.domain.min); + const max = Number(value.domain.max); + if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) { + throw new Error(`Launch option "${fieldName}.domain" must have finite min less than max.`); + } + normalized.domain = { min, max }; + } + + if (typeof value.stops !== 'undefined') { + if (!normalized.domain) { + throw new Error(`Launch option "${fieldName}.stops" requires a custom domain.`); + } + if (!Array.isArray(value.stops) || value.stops.length < 2) { + throw new Error(`Launch option "${fieldName}.stops" must contain at least two stops.`); + } + + normalized.stops = value.stops.map((stop, index) => { + if (!this.isPlainObject(stop)) { + throw new Error(`Launch option "${fieldName}.stops[${index}]" must be an object.`); + } + const stopValue = Number(stop.value); + if (!Number.isFinite(stopValue) || typeof stop.color !== 'string' || !HEX_COLOR_PATTERN.test(stop.color.trim())) { + throw new Error(`Launch option "${fieldName}.stops[${index}]" must have a finite value and 6-digit hex color.`); + } + return { value: stopValue, color: stop.color.trim() }; + }); + + const domain = normalized.domain; + const stops = normalized.stops; + const strictlyIncreasing = stops.every((stop, index) => index === 0 || stop.value > stops[index - 1].value); + if (!strictlyIncreasing || stops[0].value !== domain.min || stops[stops.length - 1].value !== domain.max) { + throw new Error(`Launch option "${fieldName}.stops" must increase strictly and span the custom domain.`); + } + } + + if (typeof value.missingColor !== 'undefined') { + if (typeof value.missingColor !== 'string' || !HEX_COLOR_PATTERN.test(value.missingColor.trim())) { + throw new Error(`Launch option "${fieldName}.missingColor" must be a 6-digit hex color.`); + } + normalized.missingColor = value.missingColor.trim(); + } + + if (normalized.mode === 'categorical' && (normalized.domain || normalized.stops)) { + throw new Error(`Launch option "${fieldName}" cannot use a domain or stops in categorical mode.`); + } + + return normalized; + } + private requireAllowedLaunchString(value: unknown, allowedValues: Set, fieldName: string): string { if (typeof value !== 'string' || !allowedValues.has(value.trim())) { throw new Error(`Launch option "${fieldName}" used an unsupported value.`); @@ -457,6 +532,13 @@ export class EmbedHandoffService { this.requireAvailableLaunchField(globalSettings.nodeColorBy, nodeFields, 'nodeColorBy'); this.requireAvailableLaunchField(globalSettings.nodeShapeBy, nodeFields, 'nodeShapeBy'); this.requireAvailableLaunchField(globalSettings.linkColorBy, linkFields, 'linkColorBy'); + + if (globalSettings.nodeColorScale && (!globalSettings.nodeColorBy || globalSettings.nodeColorBy === 'None')) { + throw new Error('Launch global setting "nodeColorScale" requires "nodeColorBy".'); + } + if (globalSettings.linkColorScale && (!globalSettings.linkColorBy || globalSettings.linkColorBy === 'None')) { + throw new Error('Launch global setting "linkColorScale" requires "linkColorBy".'); + } } private collectAvailableFields(files: ImportedEmbedFile[], format: EmbedFileKind, defaults: string[]): Set { diff --git a/src/app/embed/embed-handoff.types.ts b/src/app/embed/embed-handoff.types.ts index 148f60a4f..d175ce0ad 100644 --- a/src/app/embed/embed-handoff.types.ts +++ b/src/app/embed/embed-handoff.types.ts @@ -67,9 +67,26 @@ export interface EmbedPayloadMetadataV1 { sourceApp?: string; } +export interface EmbedContinuousColorStopV1 { + value: number; + color: string; +} + +export interface EmbedVariableColorScaleV1 { + mode: 'auto' | 'categorical' | 'continuous'; + domain?: { + min: number; + max: number; + }; + stops?: EmbedContinuousColorStopV1[]; + missingColor?: string; +} + export interface EmbedLaunchGlobalSettingsV1 { nodeColorBy?: string; linkColorBy?: string; + nodeColorScale?: EmbedVariableColorScaleV1; + linkColorScale?: EmbedVariableColorScaleV1; nodeShapeBy?: string; nodeColor?: string; linkColor?: string; diff --git a/src/app/filesComponent/files-plugin.component.ts b/src/app/filesComponent/files-plugin.component.ts index b6322e6fe..48934214a 100644 --- a/src/app/filesComponent/files-plugin.component.ts +++ b/src/app/filesComponent/files-plugin.component.ts @@ -16,7 +16,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 { extractGeoJSONFeatureLocations, GEOJSON_FEATURE_ID_FIELD, @@ -1102,6 +1102,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; } @@ -1154,6 +1160,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/golden-layout-host.component.ts b/src/app/golden-layout-host.component.ts index b0ee2564b..8981931c0 100644 --- a/src/app/golden-layout-host.component.ts +++ b/src/app/golden-layout-host.component.ts @@ -355,4 +355,3 @@ export class GoldenLayoutHostComponent implements OnDestroy { } } - diff --git a/src/app/helperClasses/globalSettingsDialogRequest.ts b/src/app/helperClasses/globalSettingsDialogRequest.ts index cbbc3e749..5fff65581 100644 --- a/src/app/helperClasses/globalSettingsDialogRequest.ts +++ b/src/app/helperClasses/globalSettingsDialogRequest.ts @@ -7,23 +7,29 @@ export type DialogRectSnapshot = { height: number; }; +export type GlobalSettingsStylingTarget = 'node-color-ramp' | 'link-color-ramp'; + export type GlobalSettingsDialogRequest = string | { activeTab?: string; sourceDialogRect?: DialogRectSnapshot; + stylingTarget?: GlobalSettingsStylingTarget; }; export type NormalizedGlobalSettingsDialogRequest = { activeTab: string; sourceDialogRect?: DialogRectSnapshot; + stylingTarget?: GlobalSettingsStylingTarget; }; export function createGlobalSettingsDialogRequest( activeTab: string = 'Styling', - event?: MouseEvent + event?: MouseEvent, + stylingTarget?: GlobalSettingsStylingTarget ): Exclude { return { activeTab, - sourceDialogRect: getSourceDialogRect(event) + sourceDialogRect: getSourceDialogRect(event), + stylingTarget }; } diff --git a/src/app/microbe-trace-next-plugin.component.html b/src/app/microbe-trace-next-plugin.component.html index ab19550a2..65a9c6f19 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 { + + }
@@ -842,6 +892,13 @@
+ + + Node Color +
@@ -850,6 +907,31 @@
@if (SelectedColorNodesByVariable !== 'None') { +
+
+
+ + +
+
+ @if (isContinuousColorScale('node')) { + + + }
@@ -864,7 +946,7 @@ } } -
+
Apply Color Assignment File
@@ -899,12 +981,18 @@ } @if (hasNodeColorVariableSelected()) {
-
+
} + + + + + Node Shape +
@@ -953,15 +1041,72 @@
} +
+
+ + + Link Color +
+ @if (SelectedColorLinksByVariable !== 'None') { +
+
+
+ + +
+
+ @if (isContinuousColorScale('link')) { + + + } + } + + @if (linkColorAssignmentStatus) { + + } @if (SelectedColorLinksByVariable !== 'None') {
} + + +