diff --git a/cypress/e2e/journeys/flows/mixed-node-coloring.cy.ts b/cypress/e2e/journeys/flows/mixed-node-coloring.cy.ts new file mode 100644 index 00000000..c9917e48 --- /dev/null +++ b/cypress/e2e/journeys/flows/mixed-node-coloring.cy.ts @@ -0,0 +1,142 @@ +/// + +import type { DatasetProfile } from '../datasets/profile'; +import { + ensureBubbleView, + ensureMapView, + goToPhyloTreeView, + launchProfileToTwoD, + openGlobalStylingTab, +} from '../../../support/journey-helpers'; + +type WinWithMicrobeTrace = Window & { + commonService: any; + cytoscapeInstance?: any; +}; + +const profile: DatasetProfile = { + id: 'mixed-genotype-node-coloring', + title: 'Mixed genotype node coloring', + tags: ['color-by', 'mixed-node-colors', 'genotype', 'load-to-twod'], + files: [ + { + name: 'Cypress_MixedGenotype_Nodes.csv', + datatype: 'node', + field1: 'ID', + field2: 'seq', + }, + { + name: 'Cypress_MixedGenotype_Links.csv', + datatype: 'link', + field1: 'source', + field2: 'target', + }, + ], + preLaunch: { + metric: 'snps', + threshold: 16, + defaultView: '2D Network', + }, + expectations: { + afterLaunch: { + nodes: 4, + visibleLinks: 3, + }, + }, +}; + +const selectPrimeOption = (selector: string, label: string): void => { + cy.get(selector).click({ force: true }); + cy.contains('li[role="option"]', label, { timeout: 15000 }).click({ force: true }); +}; + +const assertMixedStyleSegments = (expectedFirstColor?: string): void => { + cy.window().then((win: unknown) => { + const { commonService } = win as WinWithMicrobeTrace; + const mixedNode = commonService.session.data.nodes.find((node: any) => node._id === 'sample-4'); + const singleNode = commonService.session.data.nodes.find((node: any) => node._id === 'sample-2'); + const mixedStyle = commonService.getNodeFillStyle(mixedNode); + const singleStyle = commonService.getNodeFillStyle(singleNode); + const color2a = String(commonService.temp.style.nodeColorMap('2a')); + const color3a = String(commonService.temp.style.nodeColorMap('3a')); + + expect(mixedStyle.segments?.map((segment: any) => segment.value)).to.deep.equal(['2a', '3a']); + expect(mixedStyle.segments?.map((segment: any) => segment.color)).to.deep.equal([color2a, color3a]); + expect(singleStyle.segments).to.equal(undefined); + + if (expectedFirstColor) { + expect(color2a.toLowerCase()).to.equal(expectedFirstColor); + expect(mixedStyle.segments?.[0].color.toLowerCase()).to.equal(expectedFirstColor); + } + }); +}; + +describe('Journey Flow - mixed node coloring', () => { + it('renders mixed genotype nodes with component color segments across node views', () => { + launchProfileToTwoD(profile); + + openGlobalStylingTab(); + cy.get('#node-mixed-colors-row').should('not.exist'); + cy.get('#node-mixed-colors-enabled').should('not.exist'); + selectPrimeOption('#node-color-variable', 'Genotype'); + cy.get('#node-mixed-colors-enabled') + .should('be.enabled') + .check({ force: true }); + cy.window().its('commonService.session.style.widgets.node-mixed-colors-enabled').should('equal', true); + cy.closeGlobalSettings(); + + assertMixedStyleSegments(); + + cy.window().then((win: unknown) => { + const typedWindow = win as WinWithMicrobeTrace; + const cyInstance = typedWindow.cytoscapeInstance; + const mixedNode = cyInstance.getElementById('sample-4'); + const singleNode = cyInstance.getElementById('sample-2'); + + expect(String(mixedNode.data('mixedColorImage') || '')).to.contain('data:image/svg+xml'); + expect(singleNode.data('mixedColorImage')).to.equal(undefined); + }); + + cy.get('#key-tables-node-table td[data-value="2a"]', { timeout: 15000 }) + .parents('tr') + .find('input[type="color"]') + .invoke('val', '#00aa00') + .trigger('input') + .trigger('change'); + + assertMixedStyleSegments('#00aa00'); + + ensureBubbleView(); + cy.window().then((win: unknown) => { + const bubble = (win as WinWithMicrobeTrace).commonService.visuals.bubble; + const mixedBubbleNode = bubble.cy.getElementById('sample-4'); + expect(String(mixedBubbleNode.data('mixedColorImage') || '')).to.contain('data:image/svg+xml'); + }); + + ensureMapView(); + cy.window().then((win: unknown) => { + const map = (win as WinWithMicrobeTrace).commonService.visuals.gisMap; + map.SelectedLatitude = 'lat'; + map.SelectedLongitude = 'long'; + map.onDataChange(null); + }); + cy.window().should((win: unknown) => { + const map = (win as WinWithMicrobeTrace).commonService.visuals.gisMap; + const marker = map.mapNodeMarkersById['sample-4']; + const iconUrl = String(marker?.options?.icon?.options?.iconUrl || ''); + expect(iconUrl).to.contain('data:image/svg+xml'); + expect(decodeURIComponent(iconUrl)).to.contain('#00aa00'); + }); + + goToPhyloTreeView(); + cy.get('#phylocanvas g.tidytree-node-leaf circle[title="sample-4"]', { timeout: 30000 }) + .should(($circle) => { + expect($circle.css('fill')).to.match(/url\(.+mt-tree-mixed-fill-/); + }); + cy.get('#phylocanvas defs.mt-tree-mixed-fill-defs linearGradient stop') + .should(($stops) => { + const stopColors = [...$stops].map((stop) => String(stop.getAttribute('stop-color')).toLowerCase()); + expect(stopColors).to.include('#00aa00'); + }); + }); +}); diff --git a/cypress/e2e/view-state/bubble-view.cy.ts b/cypress/e2e/view-state/bubble-view.cy.ts index 46ed5dcf..13917c7a 100644 --- a/cypress/e2e/view-state/bubble-view.cy.ts +++ b/cypress/e2e/view-state/bubble-view.cy.ts @@ -3,6 +3,13 @@ import { visitAppAndAcceptEula } from '../../support/journey-helpers'; let takeScreenshots = false; const getCy = () => cy.window().then(win => win.commonService.visuals.bubble.cy) +const showFloatingNodeColorTable = (): void => { + cy.get('#node-color-table-row') + .contains('.p-togglebutton-label', 'Show') + .click({ force: true }); + cy.window().its('commonService.visuals.microbeTrace.SelectedNodeColorTableTypesVariable').should('equal', 'Show'); +}; + describe('Bubble View', () => { const selectors = { container: '#cyBubble', @@ -204,6 +211,7 @@ describe('Bubble View', () => { cy.openGlobalSettings(); cy.get('#node-color-variable').click() cy.get('li[role="option"]').contains('Lineage').click() + showFloatingNodeColorTable(); cy.get('#node-color-table td input', { timeout: 10000 }).should('exist'); cy.get('#node-color-table tr').eq(1).find('.transparency-symbol').click({ force: true }); cy.get('#color-transparency').invoke('val', alpha).trigger('change'); @@ -225,6 +233,7 @@ describe('Bubble View', () => { cy.openGlobalSettings(); cy.get('#node-color-variable').click() cy.get('li[role="option"]').contains('Lineage').click() + showFloatingNodeColorTable(); cy.get('#node-color-table td input', { timeout: 10000 }).should('exist'); cy.get('#node-color-table tr').eq(1).find('.transparency-symbol').click({ force: true }); cy.get('#color-transparency').invoke('val', alpha).trigger('change'); @@ -565,11 +574,18 @@ describe('Bubble View', () => { }) it('changes color of node and link during timeline and then ensures color is kept after timeline ends', () => { + cy.openGlobalSettings(); + cy.contains('#global-settings-modal .nav-link', 'Styling').click({ force: true }); + cy.get('#node-color-variable').click(); + cy.get('li[role="option"]').contains('State').click(); + showFloatingNodeColorTable(); + cy.closeGlobalSettings(); + cy.get('#timeline-play-button').should('contain', 'Play').click(); cy.wait(7500) cy.get('#timeline-play-button').should('contain', 'Pause').click(); - cy.get('#key-tables-node-table').contains('td', 'Pennsylvania').parent('tr').find('input[type="color"]').first().invoke('val', '#777777').trigger('input').trigger('change'); + cy.get('#node-color-table').contains('td', 'Pennsylvania').parent('tr').find('input[type="color"]').first().invoke('val', '#777777').trigger('input').trigger('change'); cy.window().its('commonService.visuals.bubble').then(bubble => { let penNode = bubble.cy.nodes('[id = "MZ415508"]')[0] diff --git a/cypress/e2e/view-state/map-view.cy.ts b/cypress/e2e/view-state/map-view.cy.ts index a41f823b..29087849 100644 --- a/cypress/e2e/view-state/map-view.cy.ts +++ b/cypress/e2e/view-state/map-view.cy.ts @@ -125,6 +125,25 @@ const searchForFieldValue = (field: string, value: string): void => { const searchForNode = (nodeId: string): void => searchForFieldValue('_id', nodeId); +const showFloatingNodeColorTable = (): void => { + cy.get('#node-color-table-row') + .contains('.p-togglebutton-label', 'Show') + .click({ force: true }); + cy.window().its('commonService.visuals.microbeTrace.SelectedNodeColorTableTypesVariable').should('equal', 'Show'); +}; + +const closeFloatingLinkColorTableIfPresent = (): void => { + cy.get('body').then($body => { + const linkColorHeader = $body.find('.p-dialog-header:contains("Link Color Table")'); + if (linkColorHeader.length) { + cy.wrap(linkColorHeader) + .parents('.p-dialog') + .find('button.p-dialog-close-button') + .click({ force: true }); + } + }); +}; + /** * Tests for the Map visualization component. */ @@ -676,10 +695,7 @@ describe('Map View', () => { cy.wait(200) cy.closeSettingsPane('Geospatial Settings'); - cy.contains('.p-dialog-header', 'Link Color Table') - .parents('.p-dialog') - .find('button.p-dialog-close-button') - .click(); + closeFloatingLinkColorTableIfPresent(); let NC_node: any; cy.window().then((win: any) => { @@ -719,10 +735,7 @@ describe('Map View', () => { cy.wait(200) cy.closeSettingsPane('Geospatial Settings'); - cy.contains('.p-dialog-header', 'Link Color Table') - .parents('.p-dialog') - .find('button.p-dialog-close-button') - .click(); + closeFloatingLinkColorTableIfPresent(); let test_link: any; cy.window().then((win: any) => { @@ -758,10 +771,7 @@ describe('Map View', () => { it('should select a node by clicking on it', () => { cy.closeSettingsPane('Geospatial Settings'); - cy.contains('.p-dialog-header', 'Link Color Table') - .parents('.p-dialog') - .find('button.p-dialog-close-button') - .click(); + closeFloatingLinkColorTableIfPresent(); let NC_node: any; cy.window().then((win: any) => { @@ -933,6 +943,7 @@ describe('Map View', () => { cy.get('#node-color-variable').click() cy.get('li[role="option"]').contains('Lineage').click() + showFloatingNodeColorTable(); cy.get('#node-color-table td input', { timeout: 10000 }).should('exist'); cy.get('#node-color-table tr').eq(1).find('.transparency-symbol').click({ force: true }); cy.get('#color-transparency').invoke('val', tableAlpha).trigger('change'); diff --git a/cypress/e2e/view-state/phylogenetic-view.cy.ts b/cypress/e2e/view-state/phylogenetic-view.cy.ts index ffb07135..1698ceb0 100644 --- a/cypress/e2e/view-state/phylogenetic-view.cy.ts +++ b/cypress/e2e/view-state/phylogenetic-view.cy.ts @@ -49,6 +49,13 @@ describe('Phylogenetic Tree View', () => { }); }; + const showFloatingNodeColorTable = (): void => { + cy.get('#node-color-table-row') + .contains('.p-togglebutton-label', 'Show') + .click({ force: true }); + cy.window().its('commonService.visuals.microbeTrace.SelectedNodeColorTableTypesVariable').should('equal', 'Show'); + }; + /** * This block runs before each test. It loads the application, * continues with the sample dataset, and navigates to the view. @@ -99,6 +106,7 @@ describe('Phylogenetic Tree View', () => { cy.openGlobalSettings(); cy.get('#node-color-variable').click() cy.get('li[role="option"]').contains('Lineage').click() + showFloatingNodeColorTable(); cy.get('#node-color-table td input', { timeout: 10000 }).should('exist'); cy.get('#node-color-table tr').eq(1).find('.transparency-symbol').click({ force: true }); cy.get('#color-transparency').invoke('val', alpha).trigger('change'); diff --git a/cypress/fixtures/Cypress_MixedGenotype_Links.csv b/cypress/fixtures/Cypress_MixedGenotype_Links.csv new file mode 100644 index 00000000..525f5a80 --- /dev/null +++ b/cypress/fixtures/Cypress_MixedGenotype_Links.csv @@ -0,0 +1,4 @@ +source,target,distance +sample-1,sample-2,1 +sample-2,sample-3,1 +sample-3,sample-4,1 diff --git a/cypress/fixtures/Cypress_MixedGenotype_Nodes.csv b/cypress/fixtures/Cypress_MixedGenotype_Nodes.csv new file mode 100644 index 00000000..836c16cf --- /dev/null +++ b/cypress/fixtures/Cypress_MixedGenotype_Nodes.csv @@ -0,0 +1,10 @@ +ID,Genotype,lat,long,seq +sample-1,1a,38.1,-86.1,AAAAAAAAAA +sample-2,2a,38.12,-86.12,AAAAAAAATA +sample-3,3a,38.14,-86.14,AAAAAAATAA +sample-4,2a/3a,38.16,-86.16,AAAAAATAAA +sample-5,6/7a,38,86,AAAAAAAAAC +sample-6,,37,85,CAAAAAAAAA +sample-7,null,37,85.2,CAAAAAAAAA +sample-8,N/A,36,86.14,AAAAAACCCC +sample-9,n/a,36.1,86.15,AAAAAACTCC diff --git a/src/app/contactTraceCommonServices/color-mapping.service.spec.ts b/src/app/contactTraceCommonServices/color-mapping.service.spec.ts new file mode 100644 index 00000000..9817b977 --- /dev/null +++ b/src/app/contactTraceCommonServices/color-mapping.service.spec.ts @@ -0,0 +1,96 @@ +import { ColorMappingService, getMixedNodeColorSegments, parseMixedNodeColorValue } from './color-mapping.service'; + +describe('mixed node color helpers', () => { + it('splits strings on supported delimiters', () => { + expect(parseMixedNodeColorValue('1a/2a, 3a;4a+5a|6a and 7a')).toEqual([ + '1a', + '2a', + '3a', + '4a', + '5a', + '6a', + '7a' + ]); + }); + + it('accepts arrays, trims values, removes duplicates, and ignores null-like values', () => { + expect(parseMixedNodeColorValue([' 2a ', '2a/3a', 'N/A', 'n/a', '2a/N/A', '', null, undefined, NaN, 'nan', 'NULL', 'undefined'])).toEqual([ + '2a', + '3a' + ]); + }); + + it('maps mixed segments through the existing color and alpha maps', () => { + const colors = { '2a': '#00aa00', '3a': '#ffff00' }; + const alphas = { '2a': 0.4, '3a': 0.8 }; + + expect(getMixedNodeColorSegments( + '2a/3a', + value => colors[value], + value => alphas[value], + '#000000', + 1 + )).toEqual([ + { value: '2a', color: '#00aa00', alpha: 0.4, weight: 1 }, + { value: '3a', color: '#ffff00', alpha: 0.8, weight: 1 } + ]); + }); + + it('splits mixed values into component color table rows instead of adding combination rows', () => { + const service = new ColorMappingService(); + const result = service.createNodeColorMap( + [ + { visible: true, Genotype: '2a' }, + { visible: true, Genotype: '2a/3a' }, + { visible: true, Genotype: '3a' } + ], + 'Genotype', + ['#00aa00', '#ffff00'], + [1, 1], + {}, + {}, + {}, + false, + true + ); + + expect(result.aggregates['2a']).toBeCloseTo(1.5); + expect(result.aggregates['3a']).toBeCloseTo(1.5); + expect(result.updatedColorsTableKeys.Genotype).toEqual(['2a', '3a']); + expect(result.updatedColorsTableKeys.Genotype).not.toContain('2a/3a'); + }); + + it('keeps components that only occur inside mixed values in the color table domain', () => { + const service = new ColorMappingService(); + const result = service.createNodeColorMap( + [ + { visible: true, Genotype: '1a' }, + { visible: true, Genotype: '2a' }, + { visible: true, Genotype: '3a' }, + { visible: true, Genotype: '2a/3a' }, + { visible: true, Genotype: '6/7a' }, + { visible: true, Genotype: 'N/A' }, + { visible: true, Genotype: null } + ], + 'Genotype', + ['#111111', '#222222', '#333333', '#444444', '#555555', '#666666'], + [1, 1, 1, 1, 1, 1], + {}, + {}, + {}, + false, + true + ); + + expect(result.aggregates['6']).toBeCloseTo(0.5); + expect(result.aggregates['7a']).toBeCloseTo(0.5); + expect(result.aggregates['null']).toBeCloseTo(2); + expect(result.updatedColorsTableKeys.Genotype).toContain('6'); + expect(result.updatedColorsTableKeys.Genotype).toContain('7a'); + expect(result.updatedColorsTableKeys.Genotype).toContain('null'); + expect(result.updatedColorsTableKeys.Genotype).not.toContain('6/7a'); + expect(result.updatedColorsTableKeys.Genotype).not.toContain('N'); + expect(result.updatedColorsTableKeys.Genotype).not.toContain('A'); + expect(result.updatedColorsTableKeys.Genotype).not.toContain('N/A'); + }); +}); diff --git a/src/app/contactTraceCommonServices/color-mapping.service.ts b/src/app/contactTraceCommonServices/color-mapping.service.ts index 96df4c63..5980346c 100644 --- a/src/app/contactTraceCommonServices/color-mapping.service.ts +++ b/src/app/contactTraceCommonServices/color-mapping.service.ts @@ -1,6 +1,100 @@ import { Injectable } from '@angular/core'; import * as d3 from 'd3'; +export interface NodeColorSegment { + value: string; + color: string; + alpha: number; + weight: number; +} + +export interface NodeFillStyle { + color: string; + alpha: number; + segments?: NodeColorSegment[]; +} + +function isNullLikeNodeColorValue(value: any): boolean { + if (value === undefined || value === null) { + return true; + } + + if (typeof value === 'number' && Number.isNaN(value)) { + return true; + } + + const textValue = String(value).trim(); + const normalizedValue = textValue.toLowerCase(); + return !textValue + || normalizedValue === 'null' + || normalizedValue === 'undefined' + || normalizedValue === 'nan' + || normalizedValue === 'n/a'; +} + +function splitMixedNodeColorText(value: string): string[] { + return value + .replace(/\bn\/a\b/ig, '') + .split(/\s+(?:and)\s+|[/,;+|]/i) + .map(token => token.trim()) + .filter(token => !isNullLikeNodeColorValue(token)); +} + +export function parseMixedNodeColorValue(value: any): string[] { + const values = Array.isArray(value) ? value : [value]; + const seen = new Set(); + const tokens: string[] = []; + + values.forEach(item => { + if (isNullLikeNodeColorValue(item)) { + return; + } + + splitMixedNodeColorText(String(item)).forEach(token => { + if (seen.has(token)) { + return; + } + + seen.add(token); + tokens.push(token); + }); + }); + + return tokens; +} + +export function getMixedNodeColorSegments( + value: any, + colorMap: ((value: any) => string) | null | undefined, + alphaMap: ((value: any) => number) | null | undefined, + fallbackColor: string, + fallbackAlpha: number = 1 +): NodeColorSegment[] { + return parseMixedNodeColorValue(value).map(token => { + let color = fallbackColor; + let alpha = fallbackAlpha; + + try { + color = colorMap?.(token) || fallbackColor; + } catch { + color = fallbackColor; + } + + try { + alpha = alphaMap?.(token) ?? fallbackAlpha; + } catch { + alpha = fallbackAlpha; + } + + return { + value: token, + color, + alpha, + weight: 1 + }; + }); +} + /** * A dedicated service for node, link, polygon color mapping. * It is "pure" in that it does NOT own or mutate your session object. @@ -14,6 +108,56 @@ export class ColorMappingService { constructor() {} + public normalizeStyleCategoryValue(value: any): string { + if (isNullLikeNodeColorValue(value)) { + return 'null'; + } + + if (typeof value === 'string') { + const trimmedValue = value.trim(); + return trimmedValue; + } + + return String(value); + } + + private parseScalarMixedColorValue(value: any): string[] { + if (isNullLikeNodeColorValue(value)) { + return []; + } + + const textValue = String(value).trim(); + return splitMixedNodeColorText(textValue); + } + + public parseMixedColorValue(value: any): string[] { + const rawValues = Array.isArray(value) + ? value.flatMap(item => this.parseScalarMixedColorValue(item)) + : this.parseScalarMixedColorValue(value); + const seenValues = new Set(); + + return rawValues.filter(valuePart => { + const key = valuePart.toLowerCase(); + if (seenValues.has(key)) { + return false; + } + + seenValues.add(key); + return true; + }); + } + + public getNodeColorCategoriesForValue(value: any, mixedColorsEnabled: boolean): string[] { + if (mixedColorsEnabled) { + const mixedValues = this.parseMixedColorValue(value); + if (mixedValues.length > 0) { + return mixedValues; + } + } + + return [this.normalizeStyleCategoryValue(value)]; + } + /** * Creates a node color-mapping scale based on a specified "nodeColorVariable" * and a set of node items. Rather than referencing session or temp directly, @@ -47,7 +191,8 @@ export class ColorMappingService { nodeColorsTable: any, nodeColorsTableKeys: any, nodeColorsTableHistory: any, - debugMode: boolean + debugMode: boolean, + splitMixedValues: boolean = false ): { aggregates: Record; colorMap: d3.ScaleOrdinal; @@ -134,12 +279,17 @@ export class ColorMappingService { // Compute aggregates by scanning all node values const aggregates: Record = {}; nodes.forEach(d => { - const val = d[nodeColorVariable]; if (!d.visible) { // If node is not visible, you can decide to skip or do aggregates[val] = 0; return; } - aggregates[val] = (aggregates[val] || 0) + 1; + + const categories = this.getNodeColorCategoriesForValue(d[nodeColorVariable], splitMixedValues); + const categoryWeight = splitMixedValues && categories.length > 1 ? 1 / categories.length : 1; + + categories.forEach(category => { + aggregates[category] = (aggregates[category] || 0) + categoryWeight; + }); }); const distinctValues = Object.keys(aggregates); diff --git a/src/app/contactTraceCommonServices/common.service.ts b/src/app/contactTraceCommonServices/common.service.ts index 5caa8983..cd1020a4 100644 --- a/src/app/contactTraceCommonServices/common.service.ts +++ b/src/app/contactTraceCommonServices/common.service.ts @@ -16,7 +16,7 @@ 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 } from './color-mapping.service'; +import { ColorMappingService, NodeFillStyle, getMixedNodeColorSegments } from './color-mapping.service'; import { WorkerComputeService } from './worker-compute.service'; import { buildStoredDistanceEdgeCache, @@ -163,9 +163,11 @@ export class CommonService extends AppComponentBase implements OnInit { GlobalSettingsModel: any = { SelectedColorNodesByVariable: 'None', + SelectedNodeMixedColorsEnabledVariable: false, SelectedColorLinksByVariable: 'origin', SelectedNodeSymbolVariable: 'None', SelectedNodeColorVariable: 'None', + SelectedNodeMixedColorsEnabled: false, SelectedLinkColorVariable: '#a6cee3', SelectedPruneWithTypesVariable: 'None', SelectedStatisticsTypesVariable: 'Hide', @@ -474,6 +476,7 @@ export class CommonService extends AppComponentBase implements OnInit { 'node-charge': 200, 'node-border-width' : 2.0, 'node-color': '#1f77b4', + 'node-mixed-colors-enabled': false, 'node-color-table-counts': true, 'node-color-table-frequencies': false, 'node-color-variable': 'None', @@ -684,7 +687,7 @@ export class CommonService extends AppComponentBase implements OnInit { return Math.min(1, Math.max(0, numericValue)); } - public getNodeFillStyle(node: any): { color: string; alpha: number } { + public getNodeFillStyle(node: any): NodeFillStyle { const widgets = this.session.style.widgets; const variable = widgets['node-color-variable']; const fallbackColor = widgets['node-color'] || '#1f77b4'; @@ -697,6 +700,35 @@ export class CommonService extends AppComponentBase implements OnInit { } const value = node[variable]; + const mixedColorsEnabled = widgets['node-mixed-colors-enabled'] === true; + if (mixedColorsEnabled) { + const segments = getMixedNodeColorSegments( + value, + this.temp.style.nodeColorMap, + this.temp.style.nodeAlphaMap, + fallbackColor, + 1 + ).map(segment => ({ + ...segment, + alpha: this.clampStyleAlpha(segment.alpha, 1) + })); + + if (segments.length > 1) { + return { + color: segments[0].color, + alpha: segments[0].alpha, + segments + }; + } + + if (segments.length === 1) { + return { + color: segments[0].color, + alpha: segments[0].alpha + }; + } + } + let color = fallbackColor; let alpha = 1; @@ -3837,6 +3869,7 @@ 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 mixedColorsEnabled = !!this.session.style.widgets['node-mixed-colors-enabled']; // 2) Call your new colorMappingService const result = this.colorMappingService.createNodeColorMap( @@ -3847,7 +3880,8 @@ align(params): Promise { nodeColorsTable, nodeColorsTableKeys, nodeColorsTableHistory, - this.debugMode + this.debugMode, + mixedColorsEnabled ); // 3) Store the results back into session & temp diff --git a/src/app/contactTraceCommonServices/node-shapes.spec.ts b/src/app/contactTraceCommonServices/node-shapes.spec.ts new file mode 100644 index 00000000..4919f584 --- /dev/null +++ b/src/app/contactTraceCommonServices/node-shapes.spec.ts @@ -0,0 +1,94 @@ +import { getMixedNodeShapeDataUri } from './node-shapes'; + +function decodeSvgDataUri(dataUri: string): string { + return decodeURIComponent(dataUri.split(',')[1]); +} + +describe('mixed node shape SVG helpers', () => { + const segments = [ + { value: '2a', color: '#00aa00', alpha: 0.4, weight: 1 }, + { value: '3a', color: '#ffff00', alpha: 0.8, weight: 1 } + ]; + + it('emits hard-stop band fills with the component colors', () => { + const svg = decodeSvgDataUri(getMixedNodeShapeDataUri('triangle', '#ffffff', '#000000', 4, 1, segments)); + + expect(svg).toContain(' { + const svg = decodeSvgDataUri(getMixedNodeShapeDataUri( + 'triangle', + '#ffffff', + '#000000', + 4, + 1, + [segments[0]] + )); + + expect(svg).not.toContain(' { + const svg = decodeSvgDataUri(getMixedNodeShapeDataUri( + 'ellipse', + '#ffffff', + '#000000', + 48, + 1, + segments, + null, + { fillCanvas: true, includeStroke: false } + )); + + expect(svg).toContain(' { + const svg = decodeSvgDataUri(getMixedNodeShapeDataUri( + 'triangle', + '#ffffff', + '#000000', + 16, + 1, + segments, + null, + { basicShapeViewBoxPadding: 20 } + )); + + expect(svg).toContain('viewBox="-20 -20 340 340"'); + expect(svg).toContain('stroke-width="16"'); + }); + + it('clips custom icon shapes to the selected path instead of using pie arcs', () => { + const svg = decodeSvgDataUri(getMixedNodeShapeDataUri('virus', '#ffffff', '#000000', 8, 1, segments)); + + expect(svg).toContain(' { + const svg = decodeSvgDataUri(getMixedNodeShapeDataUri( + 'virus', + '#ffffff', + '#000000', + 8, + 1, + segments, + null, + { includeStroke: false, customShapePadding: 0, customShapeViewBoxPadding: 0 } + )); + + expect(svg).toContain('fill="url(#mixed-node-fill)"'); + expect(svg).not.toContain('stroke-width="8"'); + }); +}); diff --git a/src/app/contactTraceCommonServices/node-shapes.ts b/src/app/contactTraceCommonServices/node-shapes.ts index 51a506e2..b593f447 100644 --- a/src/app/contactTraceCommonServices/node-shapes.ts +++ b/src/app/contactTraceCommonServices/node-shapes.ts @@ -39,10 +39,25 @@ interface CustomNodeShapeDefinition extends NodeShapeOption { export interface CustomNodeShapeVectorData { width: number; height: number; + viewBox: string; path: string; fillPath: string; } +export interface MixedNodeShapeSegment { + color: string; + alpha?: number; + weight?: number; +} + +export interface MixedNodeShapeDataUriOptions { + basicShapeViewBoxPadding?: number; + customShapePadding?: number; + customShapeViewBoxPadding?: number; + fillCanvas?: boolean; + includeStroke?: boolean; +} + export const BASIC_NODE_SYMBOL_OPTIONS: NodeShapeOption[] = [ { key: 'ellipse', value: '\u2b24', name: ' (Circle) ', groupKey: 'basic' }, { key: 'triangle', value: '\u25b2', name: ' (Triangle)', groupKey: 'basic' }, @@ -903,6 +918,163 @@ function buildBasicNodeShapeDataUri( return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`; } +interface WeightedMixedNodeShapeSegment { + segment: MixedNodeShapeSegment; + startFraction: number; + endFraction: number; +} + +function formatSvgFraction(value: number): string { + return Number(value.toFixed(6)).toString(); +} + +function formatSvgPercent(value: number): string { + return `${formatSvgFraction(Math.min(1, Math.max(0, value)) * 100)}%`; +} + +function getWeightedMixedNodeShapeSegments(segments: MixedNodeShapeSegment[]): WeightedMixedNodeShapeSegment[] { + const validSegments = segments.filter(segment => Number(segment.weight ?? 1) > 0); + const totalWeight = validSegments.reduce((sum, segment) => sum + Number(segment.weight ?? 1), 0); + if (validSegments.length < 2 || totalWeight <= 0) { + return []; + } + + let startFraction = 0; + return validSegments.map((segment, index) => { + const segmentWeight = Number(segment.weight ?? 1); + const endFraction = index === validSegments.length - 1 + ? 1 + : startFraction + (segmentWeight / totalWeight); + const weightedSegment = { segment, startFraction, endFraction }; + startFraction = endFraction; + + return weightedSegment; + }); +} + +function buildMixedFillGradientDefinition( + gradientId: string, + segments: MixedNodeShapeSegment[], + fallbackOpacity: number +): string { + const weightedSegments = getWeightedMixedNodeShapeSegments(segments); + if (!weightedSegments.length) { + return ''; + } + + const stops = weightedSegments.flatMap(({ segment, startFraction, endFraction }) => { + const color = sanitizeSvgColor(segment.color); + const opacity = sanitizeSvgOpacity(segment.alpha ?? fallbackOpacity); + + return [ + ``, + `` + ]; + }).join(''); + + return `${stops}`; +} + +function buildMixedBasicNodeShapeContent( + normalizedShapeKey: string, + fillColor: string, + strokeColor: string, + strokeWidth: number, + fillOpacity: number, + segments: MixedNodeShapeSegment[], + selectedStrokeColor?: string | null, + options: MixedNodeShapeDataUriOptions = {} +): string { + const safeFill = sanitizeSvgColor(fillColor); + const safeFillOpacity = sanitizeSvgOpacity(fillOpacity); + const gradient = buildMixedFillGradientDefinition('mixed-node-fill', segments, safeFillOpacity); + const fillPaint = gradient ? 'url(#mixed-node-fill)' : safeFill; + const fillOpacityValue = gradient ? 1 : safeFillOpacity; + const includeStroke = options.includeStroke !== false; + const outlineStroke = sanitizeSvgColor(selectedStrokeColor ?? strokeColor); + const strokeAttributes = includeStroke + ? `stroke="${outlineStroke}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"` + : 'stroke="none"'; + + if (options.fillCanvas) { + return `${gradient}`; + } + + if (normalizedShapeKey === 'ellipse') { + return `${gradient}`; + } + + const path = normalizedShapeKey === 'barrel' + ? 'M 90 45 C 60 45 45 82 45 150 C 45 218 60 255 90 255 L 210 255 C 240 255 255 218 255 150 C 255 82 240 45 210 45 Z' + : buildBasicNodeShapePath(normalizedShapeKey); + if (!path) { + return `${gradient}`; + } + + return `${gradient}`; +} + +function buildMixedCustomNodeShapeContent( + definition: CustomNodeShapeDefinition, + fillColor: string, + strokeColor: string, + strokeWidth: number, + fillOpacity: number, + segments: MixedNodeShapeSegment[], + selectedStrokeColor?: string | null, + options: MixedNodeShapeDataUriOptions = {} +): string { + const safeFill = sanitizeSvgColor(fillColor); + const safeFillOpacity = sanitizeSvgOpacity(fillOpacity); + const gradient = buildMixedFillGradientDefinition('mixed-node-fill', segments, safeFillOpacity); + const fillPaint = gradient ? 'url(#mixed-node-fill)' : safeFill; + const fillOpacityValue = gradient ? 1 : safeFillOpacity; + const includeStroke = options.includeStroke !== false; + const outlineStroke = sanitizeSvgColor(selectedStrokeColor ?? strokeColor); + const customShapePadding = Math.min(100, Math.max(0, Number(options.customShapePadding ?? 40))); + const customShapeSize = Math.max(1, 300 - (customShapePadding * 2)); + const viewBoxPadding = Math.max(0, Number(options.customShapeViewBoxPadding ?? strokeWidth)); + const outlinePath = includeStroke + ? `` + : ''; + + return [ + ``, + gradient, + ``, + ``, + outlinePath, + '', + '' + ].join(''); +} + +export function getMixedNodeShapeDataUri( + shapeKey: string, + fillColor: string, + strokeColor: string, + strokeWidth: number, + fillOpacity: number = 1, + segments: MixedNodeShapeSegment[] = [], + selectedStrokeColor?: string | null, + options: MixedNodeShapeDataUriOptions = {} +): string { + const normalizedShapeKey = resolveNodeShapeKey(shapeKey); + const safeStroke = sanitizeSvgColor(strokeColor); + const safeStrokeWidth = Math.max(1, Number(strokeWidth) || 1); + const isCustomShape = isCustomNodeShape(normalizedShapeKey); + const basicShapeViewBoxPadding = !isCustomShape && !options.fillCanvas + ? Math.max(0, Number(options.basicShapeViewBoxPadding ?? 0)) + : 0; + const shapeContent = isCustomShape + ? buildMixedCustomNodeShapeContent(CUSTOM_NODE_SHAPE_DEFINITIONS[normalizedShapeKey], fillColor, safeStroke, safeStrokeWidth, fillOpacity, segments, selectedStrokeColor, options) + : buildMixedBasicNodeShapeContent(normalizedShapeKey, fillColor, safeStroke, safeStrokeWidth, fillOpacity, segments, selectedStrokeColor, options); + const viewBox = buildPaddedViewBox('0 0 300 300', basicShapeViewBoxPadding); + const svg = ``; + + return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`; +} + export function getMapNodeShapeDataUri(shapeKey: string, fillColor: string, strokeColor: string, strokeWidth: number, fillOpacity: number = 1): string { const normalizedShapeKey = resolveNodeShapeKey(shapeKey); if (isCustomNodeShape(normalizedShapeKey)) { @@ -974,6 +1146,7 @@ export function getCustomNodeShapeVectorData(shapeKey: string): CustomNodeShapeV return { width: definition.width, height: definition.height, + viewBox: definition.viewBox, path: definition.path, fillPath: definition.fillPath ?? definition.path }; diff --git a/src/app/filesComponent/files-plugin.component.ts b/src/app/filesComponent/files-plugin.component.ts index 336878e3..d706f279 100644 --- a/src/app/filesComponent/files-plugin.component.ts +++ b/src/app/filesComponent/files-plugin.component.ts @@ -225,6 +225,7 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { SelectedColorLinksByVariable: widgets['link-color-variable'] ?? 'origin', SelectedNodeSymbolVariable: widgets['node-symbol-variable'] ?? 'None', SelectedNodeColorVariable: widgets['node-color'] ?? '#1f77b4', + SelectedNodeMixedColorsEnabled: widgets['node-mixed-colors-enabled'] ?? false, SelectedLinkColorVariable: widgets['link-color'] ?? '#a6cee3', SelectedPruneWithTypesVariable: 'None', SelectedStatisticsTypesVariable: 'Hide', @@ -293,6 +294,7 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { microbeTrace.SelectedColorLinksByVariable = widgets['link-color-variable'] ?? 'origin'; microbeTrace.SelectedNodeSymbolVariable = widgets['node-symbol-variable'] ?? 'None'; microbeTrace.SelectedNodeColorVariable = widgets['node-color'] ?? '#1f77b4'; + microbeTrace.NodeMixedColorsEnabled = widgets['node-mixed-colors-enabled'] ?? false; microbeTrace.SelectedLinkColorVariable = widgets['link-color'] ?? '#a6cee3'; microbeTrace.SelectedBackgroundColorVariable = widgets['background-color'] ?? '#ffffff'; microbeTrace.SelectedDistanceMetricVariable = widgets['default-distance-metric'] ?? 'snps'; diff --git a/src/app/microbe-trace-next-plugin.component.html b/src/app/microbe-trace-next-plugin.component.html index 861baf93..721453f4 100644 --- a/src/app/microbe-trace-next-plugin.component.html +++ b/src/app/microbe-trace-next-plugin.component.html @@ -514,6 +514,16 @@ + @if (SelectedColorNodesByVariable !== 'None') { +
+
+
+ + +
+
+ } + @if (SelectedColorNodesByVariable === 'None') {
@@ -521,7 +531,8 @@
- } @else { + } + @if (hasNodeColorVariableSelected()) {
diff --git a/src/app/microbe-trace-next-plugin.component.ts b/src/app/microbe-trace-next-plugin.component.ts index 7498da2a..cf54abbc 100644 --- a/src/app/microbe-trace-next-plugin.component.ts +++ b/src/app/microbe-trace-next-plugin.component.ts @@ -259,6 +259,7 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A SelectedStatisticsTypesVariable: string = 'Show'; SelectedColorNodesByVariable: string = 'None'; + SelectedNodeMixedColorsEnabledVariable: boolean = false; SelectedNodeSymbolVariable: string = 'None'; SelectedNodeColorVariable: string = '#1f77b4'; SelectedLinkColorVariable: string = '#1f77b4'; @@ -560,6 +561,7 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A this.SelectedStatisticsTypesVariable = this.commonService.GlobalSettingsModel.SelectedStatisticsTypesVariable; this.SelectedColorNodesByVariable = this.commonService.GlobalSettingsModel.SelectedColorNodesByVariable; + this.SelectedNodeMixedColorsEnabledVariable = this.commonService.session.style.widgets['node-mixed-colors-enabled'] === true; this.SelectedNodeSymbolVariable = this.commonService.GlobalSettingsModel.SelectedNodeSymbolVariable ?? this.commonService.session.style.widgets['node-symbol-variable']; this.SelectedNodeColorVariable = this.commonService.session.style.widgets['node-color']; this.SelectedColorLinksByVariable = this.commonService.GlobalSettingsModel.SelectedColorLinksByVariable; @@ -1489,7 +1491,6 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A this.onSearch(); } - /** * Convert Files list to normal array list * @param files (Files List) @@ -1792,6 +1793,7 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A this.getGlobalSettingsData(); this.onColorNodesByChanged(); } + this.SelectedNodeMixedColorsEnabledVariable = this.widgets['node-mixed-colors-enabled'] === true; if (this.SelectedColorLinksByVariable != this.widgets['link-color-variable']){ this.SelectedColorLinksByVariable = this.widgets['link-color-variable']; @@ -1972,6 +1974,7 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A } + /** * calls updateNodeColors() for each view */ @@ -2536,12 +2539,15 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A this.SelectedColorNodesByVariable = 'None'; this.SelectedColorLinksByVariable = 'origin'; this.SelectedNodeSymbolVariable = 'None'; + this.SelectedNodeMixedColorsEnabledVariable = false; this.commonService.GlobalSettingsModel.SelectedColorNodesByVariable = 'None'; this.commonService.GlobalSettingsModel.SelectedColorLinksByVariable = 'origin'; this.commonService.GlobalSettingsModel.SelectedNodeSymbolVariable = 'None'; + this.commonService.GlobalSettingsModel.SelectedNodeMixedColorsEnabledVariable = false; widgets['node-color-variable'] = 'None'; + widgets['node-mixed-colors-enabled'] = false; widgets['link-color-variable'] = 'origin'; widgets['node-symbol-variable'] = 'None'; @@ -3183,6 +3189,25 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A } } + private normalizeSelectValue(value: any, fallback: string = 'None'): string { + if (value && typeof value === 'object' && 'value' in value) { + return String(value.value ?? fallback); + } + + if (value === undefined || value === null || value === '') { + return fallback; + } + + return String(value); + } + + public hasNodeColorVariableSelected(): boolean { + const selected = this.normalizeSelectValue( + this.SelectedColorNodesByVariable ?? this.commonService.session?.style?.widgets?.['node-color-variable'] + ); + return selected !== 'None'; + } + showLinkColorTable() { console.log('onLinkColorTableChanged - show'); if (this.isKeyTableDocked('link-color')) { @@ -3221,7 +3246,10 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A */ onColorNodesByChanged(silent: boolean = false) { + this.SelectedColorNodesByVariable = this.normalizeSelectValue(this.SelectedColorNodesByVariable); this.commonService.GlobalSettingsModel.SelectedColorNodesByVariable = this.SelectedColorNodesByVariable; + this.commonService.GlobalSettingsModel.SelectedNodeMixedColorsEnabledVariable = this.SelectedNodeMixedColorsEnabledVariable; + this.commonService.session.style.widgets['node-mixed-colors-enabled'] = this.SelectedNodeMixedColorsEnabledVariable === true; if (this.SelectedColorNodesByVariable !== 'None' && this.getKeyTableDisplayMode('node-color') === 'Dock') { this.keyTablesController.setDocked('node-color', true); this.ensureKeyTablesViewOpen(false); @@ -3284,6 +3312,17 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A } } + onNodeMixedColorsEnabledChanged(silent: boolean = false) { + this.commonService.GlobalSettingsModel.SelectedNodeMixedColorsEnabledVariable = this.SelectedNodeMixedColorsEnabledVariable === true; + this.commonService.session.style.widgets['node-mixed-colors-enabled'] = this.SelectedNodeMixedColorsEnabledVariable === true; + + if (this.SelectedColorNodesByVariable === 'None') { + return; + } + + this.onColorNodesByChanged(silent); + } + generateNodeColorTable(tableId: string, isEditable: boolean = true) { const valueColumnName = this.getKeyTableColumnDisplayName('node-color', 'value', 'Node ' + this.commonService.titleize(this.SelectedColorNodesByVariable)); const countColumnName = this.getKeyTableColumnDisplayName('node-color', 'count', 'Count'); @@ -3312,7 +3351,8 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A const disabled = isEditable ? '' : 'disabled'; aggregateValues.forEach((value, i) => { - if (aggregates[value] < 1) return; + const aggregateCount = Number(aggregates[value] ?? 0); + if (aggregateCount <= 0) return; const color = this.commonService.temp.style.nodeColorMap(value); @@ -3407,8 +3447,8 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A "" + this.getNodeValueDisplayName(value) + "" + - `` + aggregates[value] + "" + - `` + (aggregates[value] / vnodes.length).toLocaleString() + "" + + `` + aggregateCount + "" + + `` + (aggregateCount / vnodes.length).toLocaleString() + "" + "" ).append(isEditable ? cell : nonEditCell); @@ -3709,6 +3749,7 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A this.commonService.session.style.widgets['link-color'] = this.SelectedLinkColorVariable; this.commonService.session.style.widgets['node-color-variable'] = this.SelectedColorNodesByVariable; + this.commonService.session.style.widgets['node-mixed-colors-enabled'] = this.SelectedNodeMixedColorsEnabledVariable === true; this.commonService.session.style.widgets['node-symbol-variable'] = this.SelectedNodeSymbolVariable; this.commonService.session.style.widgets['node-symbol-table-visible'] = this.SelectedNodeShapeTableTypesVariable; this.commonService.session.style.widgets['link-threshold-variable'] = this.SelectedDistanceMetricVariable; @@ -3722,6 +3763,7 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A this.commonService.GlobalSettingsModel.SelectedLinkThresholdVariable = this.SelectedLinkThresholdVariable; this.commonService.GlobalSettingsModel.SelectedDistanceMetricVariable = this.SelectedDistanceMetricVariable; this.commonService.GlobalSettingsModel.SelectedNodeSymbolVariable = this.SelectedNodeSymbolVariable; + this.commonService.GlobalSettingsModel.SelectedNodeMixedColorsEnabledVariable = this.SelectedNodeMixedColorsEnabledVariable === true; this.commonService.GlobalSettingsModel.SelectedNodeShapeTableTypesVariable = this.SelectedNodeShapeTableTypesVariable; this.commonService.session.style.widgets['selected-color'] = this.SelectedColorVariable; this.commonService.session.style.widgets['selected-node-stroke-color'] = this.SelectedColorVariable; @@ -5111,6 +5153,7 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A //Styling|Color Nodes By this.SelectedColorNodesByVariable = this.commonService.session.style.widgets["node-color-variable"]; + this.SelectedNodeMixedColorsEnabledVariable = this.commonService.session.style.widgets['node-mixed-colors-enabled'] === true; this.onColorNodesByChanged(false); //Styling|Nodes diff --git a/src/app/visualizationComponents/BubbleComponent/bubble.component.ts b/src/app/visualizationComponents/BubbleComponent/bubble.component.ts index b835fda8..3bf4a790 100644 --- a/src/app/visualizationComponents/BubbleComponent/bubble.component.ts +++ b/src/app/visualizationComponents/BubbleComponent/bubble.component.ts @@ -13,8 +13,9 @@ import svg from 'cytoscape-svg'; import { ExportService, ExportOptions } from '@app/contactTraceCommonServices/export.service'; import { Subject, Subscription, takeUntil } from 'rxjs'; import { CommonStoreService } from '@app/contactTraceCommonServices/common-store.services'; +import { getMixedNodeShapeDataUri } from '@app/contactTraceCommonServices/node-shapes'; -type DataRecord = { index: number, id: string, x: number; y: number, color: string, opacity: number, Xgroup: number, Ygroup: number, strokeColor: string, totalCount?: number, counts ?: any }//selected: boolean } +type DataRecord = { index: number, id: string, x: number; y: number, color: string, opacity: number, Xgroup: number, Ygroup: number, strokeColor: string, totalCount?: number, counts ?: any, mixedColorImage?: string }//selected: boolean } type BubblePieExportSlice = { label: string; @@ -35,6 +36,14 @@ interface BubblePieSvgExportReplacement { slices: BubblePieExportSlice[]; } +interface BubbleMixedSvgExportReplacement { + exportHeight: number; + exportWidth: number; + exportX: number; + exportY: number; + segments: Array<{ color: string; alpha?: number; weight?: number }>; +} + @Component({ selector: 'bubble-component', templateUrl: './bubble.component.html', @@ -323,17 +332,21 @@ export class BubbleComponent extends BaseComponentDirective implements OnInit, M mapDataToCytoscapElements(data: DataRecord[]): cytoscape.ElementsDefinition { const nodes = data.map((node) => { let size = this.SelectedNodeCollapsingTypeVariable ? this.nodeSize * Math.sqrt(node.totalCount): this.nodeSize; + const nodeData: any = { + id: node.id, + nodeSize: size, + nodeColor: node.color, + nodeOpacity: node.opacity, + label: node.id, + counts: node.counts, + totalCount: node.totalCount, + }; + if (node.mixedColorImage) { + nodeData.mixedColorImage = node.mixedColorImage; + } // probably do something here for node size return { - data: { - id: node.id, - nodeSize: size, - nodeColor: node.color, - nodeOpacity: node.opacity, - label: node.id, - counts: node.counts, - totalCount: node.totalCount, - }, + data: nodeData, position: { x: node.x*this.scaleFactor, y: node.y*this.scaleFactor @@ -432,6 +445,18 @@ export class BubbleComponent extends BaseComponentDirective implements OnInit, M 'background-opacity': 'data(nodeOpacity)' } }, + { + selector: 'node[mixedColorImage]', + css: { + 'background-color': 'transparent', + 'background-opacity': 0, + 'background-image': 'data(mixedColorImage)', + 'background-fit': 'cover', + 'background-clip': 'node', + 'background-repeat': 'no-repeat', + 'background-image-opacity': 1, + } + }, { selector: '.X_axis', css: { @@ -920,6 +945,24 @@ export class BubbleComponent extends BaseComponentDirective implements OnInit, M return this.commonService.getNodeFillStyle(syntheticNode); } + private getMixedColorNodeImage(nodeData: any, fillColor: string, fillOpacity: number): string | undefined { + const segments = this.commonService.getNodeFillStyle(nodeData).segments; + if (!Array.isArray(segments) || segments.length < 2) { + return undefined; + } + + return getMixedNodeShapeDataUri( + 'ellipse', + fillColor, + '#000000', + 1, + fillOpacity, + segments, + null, + { fillCanvas: true, includeStroke: false } + ); + } + /** * @returns a string representing the SVG def of the patterns needed to generate the pie chart */ @@ -973,6 +1016,7 @@ export class BubbleComponent extends BaseComponentDirective implements OnInit, M const nodeStyle = this.commonService.getNodeFillStyle(currentFullNode); node.color = nodeStyle.color; node.opacity = nodeStyle.alpha; + node.mixedColorImage = this.getMixedColorNodeImage(currentFullNode, nodeStyle.color, nodeStyle.alpha); }) if (this.cy && this.cy.nodes().length > 0) { @@ -982,6 +1026,11 @@ export class BubbleComponent extends BaseComponentDirective implements OnInit, M if (!currentNode) return; node.data('nodeColor', currentNode.color); node.data('nodeOpacity', currentNode.opacity); + if (currentNode.mixedColorImage) { + node.data('mixedColorImage', currentNode.mixedColorImage); + } else { + node.removeData('mixedColorImage'); + } }); this.cy.style().update(); // Refresh Cytoscape styles to apply changes } @@ -1234,6 +1283,7 @@ export class BubbleComponent extends BaseComponentDirective implements OnInit, M const nodeStyle = this.commonService.getNodeFillStyle(currentFullNode); node.color = nodeStyle.color; node.opacity = nodeStyle.alpha; + node.mixedColorImage = this.getMixedColorNodeImage(currentFullNode, nodeStyle.color, nodeStyle.alpha); }) } else { this.updateColors(); @@ -1411,6 +1461,231 @@ export class BubbleComponent extends BaseComponentDirective implements OnInit, M return svgWidth / graphWidth; } + private getMixedNodeSvgExportReplacementList(): BubbleMixedSvgExportReplacement[] { + const replacements: BubbleMixedSvgExportReplacement[] = []; + if (!this.cy || this.SelectedNodeCollapsingTypeVariable) { + return replacements; + } + + const graphBounds = this.cy.elements().boundingBox(); + const fullNodes = this.commonService.session.data.nodeFilteredValues; + this.visibleData.forEach((dataNode) => { + if (!dataNode.mixedColorImage) { + return; + } + + const fullNode = fullNodes.find(node => node.index === dataNode.index); + if (!fullNode) { + return; + } + + const segments = this.commonService.getNodeFillStyle(fullNode).segments; + if (!Array.isArray(segments) || segments.length < 2) { + return; + } + + const cyNode = this.cy.getElementById(String(dataNode.id)); + if (cyNode.empty()) { + return; + } + + const position = cyNode.position(); + const nodeWidth = Number(cyNode.width()) || Number(cyNode.data('nodeSize')) || 0; + const nodeHeight = Number(cyNode.height()) || Number(cyNode.data('nodeSize')) || nodeWidth; + if ( + !Number.isFinite(position.x) + || !Number.isFinite(position.y) + || !Number.isFinite(nodeWidth) + || !Number.isFinite(nodeHeight) + || nodeWidth <= 0 + || nodeHeight <= 0 + ) { + return; + } + + replacements.push({ + exportHeight: nodeHeight, + exportWidth: nodeWidth, + exportX: position.x - graphBounds.x1 - (nodeWidth / 2), + exportY: position.y - graphBounds.y1 - (nodeHeight / 2), + segments + }); + }); + + return replacements; + } + + private findMatchingMixedNodeSvgExportReplacement( + image: Element, + replacements: BubbleMixedSvgExportReplacement[], + usedReplacements: Set + ): BubbleMixedSvgExportReplacement | null { + const imageWidth = this.getSvgLengthAttribute(image, 'width'); + const imageHeight = this.getSvgLengthAttribute(image, 'height'); + const imageTranslate = this.getSvgTranslateTransform(image) || { x: 0, y: 0 }; + const imageX = this.getSvgLengthAttribute(image, 'x') || 0; + const imageY = this.getSvgLengthAttribute(image, 'y') || 0; + if (imageWidth === null || imageHeight === null) { + return null; + } + + let bestMatch: BubbleMixedSvgExportReplacement | null = null; + let bestScore = Number.POSITIVE_INFINITY; + for (const replacement of replacements) { + if (usedReplacements.has(replacement)) { + continue; + } + + const score = + Math.abs(replacement.exportX - (imageTranslate.x + imageX)) + + Math.abs(replacement.exportY - (imageTranslate.y + imageY)) + + Math.abs(replacement.exportWidth - imageWidth) + + Math.abs(replacement.exportHeight - imageHeight); + if (score < bestScore) { + bestScore = score; + bestMatch = replacement; + } + } + + return bestMatch; + } + + private formatSvgPercent(value: number): string { + return `${this.formatSvgNumber(Math.min(1, Math.max(0, value)) * 100)}%`; + } + + private getWeightedMixedExportSegments( + segments: Array<{ color: string; alpha?: number; weight?: number }> + ): Array<{ color: string; alpha: number; startFraction: number; endFraction: number }> { + const validSegments = segments.filter(segment => Number(segment.weight ?? 1) > 0); + const totalWeight = validSegments.reduce((sum, segment) => sum + Number(segment.weight ?? 1), 0); + if (validSegments.length < 2 || totalWeight <= 0) { + return []; + } + + let startFraction = 0; + return validSegments.map((segment, index) => { + const endFraction = index === validSegments.length - 1 + ? 1 + : startFraction + (Number(segment.weight ?? 1) / totalWeight); + const weightedSegment = { + color: segment.color, + alpha: this.commonService.clampStyleAlpha(segment.alpha ?? 1, 1), + startFraction, + endFraction + }; + startFraction = endFraction; + + return weightedSegment; + }); + } + + private appendMixedFillGradient( + doc: XMLDocument, + parent: SVGElement, + gradientId: string, + segments: Array<{ color: string; alpha?: number; weight?: number }> + ): void { + const svgNamespace = 'http://www.w3.org/2000/svg'; + const defs = doc.createElementNS(svgNamespace, 'defs'); + const gradient = doc.createElementNS(svgNamespace, 'linearGradient'); + gradient.setAttribute('id', gradientId); + gradient.setAttribute('x1', '0%'); + gradient.setAttribute('y1', '0%'); + gradient.setAttribute('x2', '100%'); + gradient.setAttribute('y2', '0%'); + gradient.setAttribute('gradientUnits', 'objectBoundingBox'); + + this.getWeightedMixedExportSegments(segments).forEach(segment => { + [ + this.formatSvgPercent(segment.startFraction), + this.formatSvgPercent(segment.endFraction) + ].forEach(offset => { + const stop = doc.createElementNS(svgNamespace, 'stop'); + stop.setAttribute('offset', offset); + stop.setAttribute('stop-color', segment.color); + stop.setAttribute('stop-opacity', this.formatSvgNumber(segment.alpha)); + gradient.appendChild(stop); + }); + }); + + defs.appendChild(gradient); + parent.appendChild(defs); + } + + private createMixedNodeVectorExportElement( + doc: XMLDocument, + sourceImage: SVGImageElement, + replacement: BubbleMixedSvgExportReplacement, + replacementIndex: number + ): SVGGElement { + const svgNamespace = 'http://www.w3.org/2000/svg'; + const vectorGroup = doc.createElementNS(svgNamespace, 'g'); + vectorGroup.setAttribute('class', 'bubble-export-mixed-node-fill'); + vectorGroup.setAttribute('data-mt-export', 'bubble-mixed-node-fill'); + vectorGroup.setAttribute('aria-hidden', 'true'); + + ['clip-path', 'opacity', 'style'].forEach((attributeName) => { + const attributeValue = sourceImage.getAttribute(attributeName); + if (attributeValue) { + vectorGroup.setAttribute(attributeName, attributeValue); + } + }); + + const imageTransform = sourceImage.getAttribute('transform'); + if (imageTransform) { + vectorGroup.setAttribute('transform', imageTransform); + } + + const imageWidth = this.getSvgLengthAttribute(sourceImage, 'width') ?? replacement.exportWidth; + const imageHeight = this.getSvgLengthAttribute(sourceImage, 'height') ?? replacement.exportHeight; + const imageX = this.getSvgLengthAttribute(sourceImage, 'x') ?? 0; + const imageY = this.getSvgLengthAttribute(sourceImage, 'y') ?? 0; + const gradientId = `mt-bubble-mixed-export-${replacementIndex}`; + this.appendMixedFillGradient(doc, vectorGroup, gradientId, replacement.segments); + + const fillRect = doc.createElementNS(svgNamespace, 'rect'); + fillRect.setAttribute('x', this.formatSvgNumber(imageX)); + fillRect.setAttribute('y', this.formatSvgNumber(imageY)); + fillRect.setAttribute('width', this.formatSvgNumber(imageWidth)); + fillRect.setAttribute('height', this.formatSvgNumber(imageHeight)); + fillRect.setAttribute('fill', `url(#${gradientId})`); + fillRect.setAttribute('stroke', 'none'); + vectorGroup.appendChild(fillRect); + + return vectorGroup; + } + + private replaceExportedMixedNodeImagesWithVectorFills(doc: XMLDocument): void { + const replacementList = this.getMixedNodeSvgExportReplacementList(); + if (replacementList.length === 0) { + return; + } + + const images = Array.from(doc.getElementsByTagName('image')) + .filter((image) => { + const href = this.getSvgImageHref(image); + return !!href && (href.startsWith('data:image/png;base64,') || href.startsWith('data:image/svg+xml')); + }); + const usedReplacements = new Set(); + + images.forEach((image) => { + const replacement = this.findMatchingMixedNodeSvgExportReplacement(image, replacementList, usedReplacements); + if (!replacement || !image.parentNode) { + return; + } + + usedReplacements.add(replacement); + const vectorElement = this.createMixedNodeVectorExportElement( + doc, + image as SVGImageElement, + replacement, + usedReplacements.size + ); + image.parentNode.replaceChild(vectorElement, image); + }); + } + private getCollapsedPieSvgExportReplacementList(exportScale: number): BubblePieSvgExportReplacement[] { const replacements: BubblePieSvgExportReplacement[] = []; if (!this.cy || !this.SelectedNodeCollapsingTypeVariable) { @@ -1652,6 +1927,21 @@ export class BubbleComponent extends BaseComponentDirective implements OnInit, M return new XMLSerializer().serializeToString(doc.documentElement); } + private vectorizeMixedNodeSvgExport(content: string): string { + if (this.SelectedNodeCollapsingTypeVariable) { + return content; + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(content, 'image/svg+xml'); + if (doc.getElementsByTagName('parsererror').length > 0) { + return content; + } + + this.replaceExportedMixedNodeImagesWithVectorFills(doc); + return new XMLSerializer().serializeToString(doc.documentElement); + } + exportVisualization() { const exportOptions: ExportOptions = { filename: this.BubbleExportFileName, @@ -1666,6 +1956,7 @@ export class BubbleComponent extends BaseComponentDirective implements OnInit, M if (this.BubbleExportFileType == 'svg') { let options = { scale: 1, full: true, bg: '#ffffff'}; let content = (this.cy as any).svg(options); + content = this.vectorizeMixedNodeSvgExport(content); content = this.vectorizeCollapsedPieSvgExport(content); this.exportService.requestSVGExport([], content, true, false); diff --git a/src/app/visualizationComponents/MapComponent/map-plugin.component.ts b/src/app/visualizationComponents/MapComponent/map-plugin.component.ts index 3a495708..de927d7a 100644 --- a/src/app/visualizationComponents/MapComponent/map-plugin.component.ts +++ b/src/app/visualizationComponents/MapComponent/map-plugin.component.ts @@ -24,7 +24,7 @@ import { ComponentContainer } from 'golden-layout'; import { GoogleTagManagerService } from 'angular-google-tag-manager'; import { CommonStoreService } from '@app/contactTraceCommonServices/common-store.services'; import { ExportService, ExportOptions } from '@app/contactTraceCommonServices/export.service'; -import { getMapNodeShapeDataUri, isCustomNodeShape as isCustomNodeIconShape, resolveNodeShapeForNode, resolveNodeShapeKey } from '@app/contactTraceCommonServices/node-shapes'; +import { getMapNodeShapeDataUri, getMixedNodeShapeDataUri, isCustomNodeShape as isCustomNodeIconShape, resolveNodeShapeForNode, resolveNodeShapeKey } from '@app/contactTraceCommonServices/node-shapes'; declare var google: any; @@ -516,17 +516,25 @@ export class MapComponent extends BaseComponentDirective implements OnInit, Mico } } - private getMapNodeIcon(shapeKey: string, fillColor: string, strokeColor: string, selected: boolean, fillOpacity: number): L.Icon { + private getMapNodeIcon(shapeKey: string, fillColor: string, strokeColor: string, selected: boolean, fillOpacity: number, segments: any[] = []): L.Icon { const normalizedShapeKey = resolveNodeShapeKey(shapeKey); const safeFill = fillColor || '#000000'; const safeStroke = strokeColor || '#000000'; const safeFillOpacity = this.commonService.clampStyleAlpha(fillOpacity, 1); const strokeWidth = this.getStrokeWidth(normalizedShapeKey, selected); const shapeStrokeColor = isCustomNodeIconShape(normalizedShapeKey) && !selected ? (normalizedShapeKey == 'lettuce' ? '#ffffff' : '#000000') : safeStroke; // default for unselected shapes is black except for lettuce - const cacheKey = `${normalizedShapeKey}|${safeFill}|${shapeStrokeColor}|${strokeWidth}|${safeFillOpacity}`; + const segmentKey = segments.length > 1 + ? segments.map(segment => `${segment.value}:${segment.color}:${segment.alpha}:${segment.weight}`).join(',') + : ''; + const cacheKey = `${normalizedShapeKey}|${safeFill}|${shapeStrokeColor}|${strokeWidth}|${safeFillOpacity}|${segmentKey}|${selected ? safeStroke : ''}`; + const mixedShapeOptions = isCustomNodeIconShape(normalizedShapeKey) + ? { customShapePadding: 0, customShapeViewBoxPadding: Math.max(20, strokeWidth) } + : { basicShapeViewBoxPadding: Math.max(20, strokeWidth) }; if (!this.mapNodeIconCache[cacheKey]) { - this.mapNodeIconCache[cacheKey] = getMapNodeShapeDataUri(normalizedShapeKey, safeFill, shapeStrokeColor, strokeWidth, safeFillOpacity); + this.mapNodeIconCache[cacheKey] = segments.length > 1 + ? getMixedNodeShapeDataUri(normalizedShapeKey, safeFill, shapeStrokeColor, strokeWidth, safeFillOpacity, segments, selected ? safeStroke : null, mixedShapeOptions) + : getMapNodeShapeDataUri(normalizedShapeKey, safeFill, shapeStrokeColor, strokeWidth, safeFillOpacity); } return icon({ @@ -1744,9 +1752,10 @@ export class MapComponent extends BaseComponentDirective implements OnInit, Mico const strokeColor = d.selected ? selectedColor : '#000000'; const strokeWidth = d.selected ? 36 : 16; const shapeKey = this.getNodeShapeKey(d); + const mixedSegments = nodeStyle.segments ?? []; let nodeMarker: MarkerWithData = L.marker(L.latLng(d._jlat, d._jlon), { - icon: this.getMapNodeIcon(shapeKey, nodeStyle.color, strokeColor, d.selected, nodeFillOpacity), + icon: this.getMapNodeIcon(shapeKey, nodeStyle.color, strokeColor, d.selected, nodeFillOpacity, nodeStyle.segments ?? []), opacity: 1, fillOpacity: nodeFillOpacity, fillColor: nodeStyle.color, diff --git a/src/app/visualizationComponents/PhylogeneticComponent/phylogenetic-plugin.component.ts b/src/app/visualizationComponents/PhylogeneticComponent/phylogenetic-plugin.component.ts index e79eabd6..0a0795ea 100644 --- a/src/app/visualizationComponents/PhylogeneticComponent/phylogenetic-plugin.component.ts +++ b/src/app/visualizationComponents/PhylogeneticComponent/phylogenetic-plugin.component.ts @@ -23,7 +23,7 @@ import { MicobeTraceNextPluginEvents } from '../../helperClasses/interfaces'; import { throws } from 'assert'; import { Subject, takeUntil } from 'rxjs'; import { CommonStoreService } from '@app/contactTraceCommonServices/common-store.services'; -import { getTreeNodeShapeDataUri, getTreeNodeShapeScale, isCustomNodeShape as isCustomNodeIconShape, resolveNodeShapeForNode } from '@app/contactTraceCommonServices/node-shapes'; +import { getMixedNodeShapeDataUri, getTreeNodeShapeDataUri, getTreeNodeShapeScale, isCustomNodeShape as isCustomNodeIconShape, resolveNodeShapeForNode } from '@app/contactTraceCommonServices/node-shapes'; /** * @title PhylogeneticComponent @@ -158,6 +158,7 @@ export class PhylogeneticComponent extends BaseComponentDirective implements OnI originalTreeData: any = null; hasTreeBeenModifiedFromOriginal = false; private treeLeafShapeUriCache = new Map(); + private mixedLeafGradientIdCounter = 0; private visuals: MicrobeTraceNextVisuals; private destroy$ = new Subject(); @@ -384,6 +385,7 @@ export class PhylogeneticComponent extends BaseComponentDirective implements OnI const fillStyle = this.getLeafNodeFillStyle(nodeData); const fillColor = fillStyle.color; const fillOpacity = fillStyle.alpha; + const mixedSegments = Array.isArray(fillStyle.segments) ? fillStyle.segments : []; const nodeSelection = d3.select(node); nodeSelection @@ -410,16 +412,19 @@ export class PhylogeneticComponent extends BaseComponentDirective implements OnI if (shapeKey === 'ellipse') { let strokeWidth = isSelected? (leafSize > 9 ? '5px': '3px') : (leafSize > 9 ? '2px' : '1px') + const mixedFill = mixedSegments.length > 1 + ? this.renderLeafCircleMixedFill(node, mixedSegments, fillOpacity) + : null; this.removeLeafNodeShapeOverlay(node); nodeSelection - .style('fill', fillColor) - .style('fill-opacity', fillOpacity) + .style('fill', mixedFill ?? fillColor) + .style('fill-opacity', mixedFill ? 1 : fillOpacity) .style('stroke', strokeColor) .style('stroke-width', strokeWidth); return; } - this.renderLeafNodeShapeOverlay(node, shapeKey, leafSize, fillColor, strokeColor, isSelected, fillOpacity); + this.renderLeafNodeShapeOverlay(node, shapeKey, leafSize, fillColor, strokeColor, isSelected, fillOpacity, mixedSegments); nodeSelection .style('fill', fillColor) .style('fill-opacity', 0) @@ -430,9 +435,12 @@ export class PhylogeneticComponent extends BaseComponentDirective implements OnI this.removeLeafNodeShapeOverlay(node); let strokeWidth = isSelected? (leafSize > 9 ? '5px': '3px') : (leafSize > 9 ? '2px' : '1px') + const mixedFill = mixedSegments.length > 1 + ? this.renderLeafCircleMixedFill(node, mixedSegments, fillOpacity) + : null; nodeSelection - .style('fill', fillColor) - .style('fill-opacity', fillOpacity) + .style('fill', mixedFill ?? fillColor) + .style('fill-opacity', mixedFill ? 1 : fillOpacity) .style('stroke', isSelected ? selectedColor : '#000000') .style('stroke-width', strokeWidth); } @@ -443,20 +451,116 @@ export class PhylogeneticComponent extends BaseComponentDirective implements OnI ); } - private getLeafNodeFillStyle(nodeData: any): { color: string; alpha: number } { + private getLeafNodeFillStyle(nodeData: any): { color: string; alpha: number; segments?: any[] } { return this.visuals.phylogenetic.commonService.getNodeFillStyle(nodeData); } + private formatSvgFraction(value: number): string { + return Number(value.toFixed(6)).toString(); + } + + private formatSvgPercent(value: number): string { + return `${this.formatSvgFraction(Math.min(1, Math.max(0, value)) * 100)}%`; + } + + private getWeightedMixedSegments(segments: any[]): Array<{ segment: any; startFraction: number; endFraction: number }> { + const validSegments = segments.filter(segment => Number(segment.weight ?? 1) > 0); + const totalWeight = validSegments.reduce((sum, segment) => sum + Number(segment.weight ?? 1), 0); + if (validSegments.length < 2 || totalWeight <= 0) { + return []; + } + + let startFraction = 0; + return validSegments.map((segment, index) => { + const endFraction = index === validSegments.length - 1 + ? 1 + : startFraction + (Number(segment.weight ?? 1) / totalWeight); + const weightedSegment = { segment, startFraction, endFraction }; + startFraction = endFraction; + + return weightedSegment; + }); + } + + private getLeafMixedFillGradientId(node: SVGElement): string { + const nodeWithGradient = node as SVGElement & { __mixedFillGradientId?: string }; + if (!nodeWithGradient.__mixedFillGradientId) { + this.mixedLeafGradientIdCounter += 1; + nodeWithGradient.__mixedFillGradientId = `mt-tree-mixed-fill-${this.mixedLeafGradientIdCounter}`; + } + + return nodeWithGradient.__mixedFillGradientId; + } + + private renderLeafCircleMixedFill(node: SVGElement, segments: any[], fallbackOpacity: number): string | null { + const weightedSegments = this.getWeightedMixedSegments(segments); + const svgElement = node.ownerSVGElement; + if (!svgElement || weightedSegments.length < 2) { + return null; + } + + const svgSelection = d3.select(svgElement); + let defsSelection = svgSelection.select('defs.mt-tree-mixed-fill-defs'); + if (defsSelection.empty()) { + defsSelection = svgSelection.insert('defs', ':first-child') + .attr('class', 'mt-tree-mixed-fill-defs'); + } + + const gradientId = this.getLeafMixedFillGradientId(node); + const gradientSelection = defsSelection + .selectAll(`linearGradient#${gradientId}`) + .data([gradientId]); + + const mergedGradient = gradientSelection + .join( + enter => enter + .append('linearGradient') + .attr('id', gradientId) + .attr('x1', '0%') + .attr('y1', '0%') + .attr('x2', '100%') + .attr('y2', '0%'), + update => update + ) + .attr('gradientUnits', 'objectBoundingBox'); + + const stops = weightedSegments.flatMap(({ segment, startFraction, endFraction }) => { + const color = String(segment.color || '#000000'); + const opacity = Number.isFinite(Number(segment.alpha)) + ? Math.min(1, Math.max(0, Number(segment.alpha))) + : Math.min(1, Math.max(0, Number(fallbackOpacity))); + + return [ + { offset: this.formatSvgPercent(startFraction), color, opacity }, + { offset: this.formatSvgPercent(endFraction), color, opacity } + ]; + }); + + mergedGradient + .selectAll('stop') + .data(stops) + .join('stop') + .attr('offset', stop => stop.offset) + .attr('stop-color', stop => stop.color) + .attr('stop-opacity', stop => stop.opacity); + + return `url(#${gradientId})`; + } + private removeLeafNodeShapeOverlay(node: SVGElement): void { const parentNode = node.parentNode as SVGGElement | null; if (!parentNode) { return; } - d3.select(parentNode).selectAll('image.tidytree-node-shape-overlay').remove(); + d3.select(parentNode).selectAll('image.tidytree-node-shape-overlay, svg.tidytree-node-shape-overlay').remove(); } - private getLeafShapeStrokeWidth(shapeKey: string, isSelected: boolean): number { + private getLeafShapeStrokeWidth(shapeKey: string, isSelected: boolean, hasMixedSegments: boolean = false): number { + if (hasMixedSegments && isCustomNodeIconShape(shapeKey)) { + return isSelected ? 48 : 32; + } + if (shapeKey == 'lettuce') { return isSelected ? 10 : 3; } else if (shapeKey == 'ship' || shapeKey == 'tick' || shapeKey == 'swab') { @@ -468,14 +572,26 @@ export class PhylogeneticComponent extends BaseComponentDirective implements OnI } } - private getLeafShapeDataUri(shapeKey: string, fillColor: string, strokeColor: string, strokeWidth: number, fillOpacity: number): string { - const cacheKey = `${shapeKey}|${fillColor}|${strokeColor}|${strokeWidth}|${fillOpacity}`; + private getLeafShapeDataUri( + shapeKey: string, + fillColor: string, + strokeColor: string, + strokeWidth: number, + fillOpacity: number, + segments: any[] = [] + ): string { + const segmentKey = segments.length > 1 + ? segments.map(segment => `${segment.value ?? ''}:${segment.color}:${segment.alpha ?? fillOpacity}:${segment.weight ?? 1}`).join(',') + : ''; + const cacheKey = `${shapeKey}|${fillColor}|${strokeColor}|${strokeWidth}|${fillOpacity}|${segmentKey}`; const cachedUri = this.treeLeafShapeUriCache.get(cacheKey); if (cachedUri) { return cachedUri; } - const dataUri = getTreeNodeShapeDataUri(shapeKey, fillColor, strokeColor, strokeWidth, fillOpacity); + const dataUri = segments.length > 1 + ? getMixedNodeShapeDataUri(shapeKey, fillColor, strokeColor, strokeWidth, fillOpacity, segments, null, { customShapePadding: 0, customShapeViewBoxPadding: 0 }) + : getTreeNodeShapeDataUri(shapeKey, fillColor, strokeColor, strokeWidth, fillOpacity); this.treeLeafShapeUriCache.set(cacheKey, dataUri); return dataUri; } @@ -487,18 +603,22 @@ export class PhylogeneticComponent extends BaseComponentDirective implements OnI fillColor: string, strokeColor: string, isSelected: boolean, - fillOpacity: number + fillOpacity: number, + segments: any[] = [] ): void { const parentNode = node.parentNode as SVGGElement | null; if (!parentNode) { return; } + d3.select(parentNode).selectAll('svg.tidytree-node-shape-overlay').remove(); + const diameter = leafSize * 2; const strokeWidth = this.getLeafShapeStrokeWidth(shapeKey, isSelected); - const shapeUri = this.getLeafShapeDataUri(shapeKey, fillColor, strokeColor, strokeWidth, fillOpacity); + const shapeUri = this.getLeafShapeDataUri(shapeKey, fillColor, strokeColor, strokeWidth, fillOpacity, segments); const overlayDiameter = diameter * getTreeNodeShapeScale(shapeKey); - const overlayOffset = overlayDiameter / 2; + const overlayImageDiameter = overlayDiameter + 4; + const overlayOffset = overlayImageDiameter / 2; const overlaySelection = d3.select(parentNode) .selectAll('image.tidytree-node-shape-overlay') .data([0]); @@ -513,8 +633,8 @@ export class PhylogeneticComponent extends BaseComponentDirective implements OnI ) .attr('x', -overlayOffset) .attr('y', -overlayOffset) - .attr('width', overlayDiameter+4) - .attr('height', overlayDiameter+4) + .attr('width', overlayImageDiameter) + .attr('height', overlayImageDiameter) .attr('preserveAspectRatio', 'xMidYMid meet') .attr('href', shapeUri) .attr('xlink:href', shapeUri); diff --git a/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts b/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts index ae89e174..44e84099 100644 --- a/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts +++ b/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts @@ -14,7 +14,7 @@ import { saveSvgAsPng } from 'save-svg-as-png'; import { ComponentContainer } from 'golden-layout'; import { GoogleTagManagerService } from 'angular-google-tag-manager'; import { GraphData } from './data'; -import { getCustomNodeShapeData, getCustomNodeShapeVectorData, isCustomNodeShape as isCustomNodeIconShape, resolveNodeShapeCytoscapeShape as resolveCustomNodeIconCytoscapeShape, resolveNodeShapeForNode, resolveNodeShapeKey } from '@app/contactTraceCommonServices/node-shapes'; +import { getCustomNodeShapeData, getCustomNodeShapeVectorData, getMixedNodeShapeDataUri, isCustomNodeShape as isCustomNodeIconShape, resolveNodeShapeCytoscapeShape as resolveCustomNodeIconCytoscapeShape, resolveNodeShapeForNode, resolveNodeShapeKey } from '@app/contactTraceCommonServices/node-shapes'; import cytoscape, { Core, Style } from 'cytoscape'; import svg from 'cytoscape-svg'; import { Subject, Subscription, takeUntil } from 'rxjs'; @@ -38,6 +38,19 @@ interface CustomNodeSvgExportReplacement { height: number; } +interface MixedNodeSvgExportReplacement { + exportHeight: number; + exportWidth: number; + exportX: number; + exportY: number; + segments: Array<{ color: string; alpha?: number; weight?: number }>; + vectorData: { + fillPath: string; + width: number; + height: number; + } | null; +} + type PolygonColorTableDisplayMode = 'Show' | 'Dock' | 'Hide'; @Component({ @@ -236,6 +249,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic nodeSize: node.nodeSize, nodeColor: node.nodeColor, bgOpacity: node.bgOpacity, + mixedColorImage: node.mixedColorImage, borderWidth: node.borderWidth, selectedBorderColor: this.widgets['selected-color'], fontSize: this.getNodeFontSize(node), @@ -245,6 +259,47 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic }; } + private getMixedColorNodeImage(node: any, shapeKey: string, fillColor: string, fillOpacity: number): string | undefined { + const segments = this.commonService.getNodeFillStyle(node).segments; + if (!segments || segments.length < 2) { + return undefined; + } + + const normalizedShapeKey = resolveNodeShapeKey(shapeKey); + const isCustomShape = isCustomNodeIconShape(normalizedShapeKey); + + return getMixedNodeShapeDataUri( + normalizedShapeKey, + fillColor, + '#000000', + 1, + fillOpacity, + segments, + null, + { + fillCanvas: !isCustomShape, + includeStroke: false, + customShapePadding: 0, + customShapeViewBoxPadding: 0 + } + ); + } + + private setMixedColorNodeImageData( + node: cytoscape.NodeSingular, + fullNode: any, + shapeKey: string, + fillColor: string, + fillOpacity: number + ): void { + const mixedColorImage = this.getMixedColorNodeImage(fullNode, shapeKey, fillColor, fillOpacity); + if (mixedColorImage) { + node.data('mixedColorImage', mixedColorImage); + } else { + node.removeData('mixedColorImage'); + } + } + private yieldToBrowser(): Promise { return new Promise(resolve => setTimeout(resolve, 0)); } @@ -704,6 +759,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic [node.nodeColor, node.bgOpacity] = this.getNodeColor(node); node.borderWidth = this.getNodeBorderWidth(node); const shapeKey = this.getNodeShape(node); + node.mixedColorImage = this.getMixedColorNodeImage(node, shapeKey, node.nodeColor, node.bgOpacity); const parent = (node.group && this.widgets['polygons-show']) || undefined; return { data: this.buildCytoscapeNodeData(node, shapeKey, parent), @@ -718,6 +774,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic [node.nodeColor, node.bgOpacity] = this.getNodeColor(node); // <-- Added for dynamic node color node.borderWidth = this.getNodeBorderWidth(node); const shapeKey = this.getNodeShape(node); + node.mixedColorImage = this.getMixedColorNodeImage(node, shapeKey, node.nodeColor, node.bgOpacity); const parent = (node.group && this.widgets['polygons-show']) || undefined; return { data: this.buildCytoscapeNodeData(node, shapeKey, parent), @@ -823,6 +880,29 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic 'background-opacity': 0, 'border-width': 0 } + }, + { + selector: 'node[!isParent][mixedColorImage]', + css: { + // @ts-ignore + 'background-image': 'data(mixedColorImage)', + 'background-image-containment': 'inside', + 'background-fit': 'contain', + 'background-clip': 'node', + 'background-position-x': '50%', + 'background-position-y': '50%', + 'background-repeat': 'no-repeat', + // @ts-ignore + 'background-image-opacity': 1, + 'background-opacity': 0, + 'border-width': 'data(borderWidth)' + } + }, + { + selector: 'node[!isParent][mixedColorImage][customIconKey]', + css: { + 'border-width': 0 + } }, { selector: '.hidden', @@ -931,6 +1011,21 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic 'border-width': 3 } }, + { + selector: 'node:selected[!isParent][mixedColorImage]', + css: { + // @ts-ignore + 'background-image': 'data(mixedColorImage)', + 'background-image-containment': 'inside', + 'background-fit': 'contain', + 'background-clip': 'node', + // @ts-ignore + 'background-image-opacity': 1, + 'background-opacity': 0, + 'border-color': 'data(selectedBorderColor)', + 'border-width': 3 + } + }, { selector: 'edge:selected', css: { @@ -1635,6 +1730,9 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic if (!isCustomNodeIconShape(shapeKey)) { return; } + if (node.data('mixedColorImage')) { + return; + } const vectorData = getCustomNodeShapeVectorData(shapeKey); if (!vectorData) { @@ -1668,6 +1766,53 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic return replacements; } + private getMixedNodeSvgExportReplacementList(): MixedNodeSvgExportReplacement[] { + const replacements: MixedNodeSvgExportReplacement[] = []; + if (!this.cy) { + return replacements; + } + + const graphBounds = this.cy.elements().boundingBox(); + this.cy.nodes().forEach(node => { + if (!node.data('mixedColorImage')) { + return; + } + + const fillStyle = this.commonService.getNodeFillStyle(node.data()); + const segments = Array.isArray(fillStyle.segments) ? fillStyle.segments : []; + if (segments.length < 2) { + return; + } + + const position = node.position(); + const padding = Number(node.numericStyle('padding')) || 0; + const paddingX2 = padding * 2; + const nodeTotalWidth = node.width() + paddingX2; + const nodeTotalHeight = node.height() + paddingX2; + const shapeKey = node.data('shapeKey'); + const customVectorData = isCustomNodeIconShape(shapeKey) + ? getCustomNodeShapeVectorData(shapeKey) + : null; + + replacements.push({ + exportHeight: nodeTotalHeight, + exportWidth: nodeTotalWidth, + exportX: position.x - graphBounds.x1 - (nodeTotalWidth / 2), + exportY: position.y - graphBounds.y1 - (nodeTotalHeight / 2), + segments, + vectorData: customVectorData + ? { + fillPath: customVectorData.fillPath, + width: customVectorData.width, + height: customVectorData.height + } + : null + }); + }); + + return replacements; + } + private getSvgLengthAttribute(element: Element, attributeName: string): number | null { const attributeValue = element.getAttribute(attributeName); if (!attributeValue) { @@ -1754,6 +1899,112 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic return bestMatch; } + private findMatchingMixedNodeSvgExportReplacement( + image: Element, + replacements: MixedNodeSvgExportReplacement[], + usedReplacements: Set + ): MixedNodeSvgExportReplacement | null { + const imageWidth = this.getSvgLengthAttribute(image, 'width'); + const imageHeight = this.getSvgLengthAttribute(image, 'height'); + const imageTranslate = this.getSvgTranslateTransform(image) || { x: 0, y: 0 }; + const imageX = this.getSvgLengthAttribute(image, 'x') || 0; + const imageY = this.getSvgLengthAttribute(image, 'y') || 0; + if (imageWidth === null || imageHeight === null) { + return null; + } + + let bestMatch: MixedNodeSvgExportReplacement | null = null; + let bestScore = Number.POSITIVE_INFINITY; + for (const replacement of replacements) { + if (usedReplacements.has(replacement)) { + continue; + } + + const score = + Math.abs(replacement.exportX - (imageTranslate.x + imageX)) + + Math.abs(replacement.exportY - (imageTranslate.y + imageY)) + + Math.abs(replacement.exportWidth - imageWidth) + + Math.abs(replacement.exportHeight - imageHeight); + if (score < bestScore) { + bestScore = score; + bestMatch = replacement; + } + } + + return bestMatch; + } + + private formatSvgExportNumber(value: number): string { + if (!Number.isFinite(value)) { + return '0'; + } + + return Number(value.toFixed(6)).toString(); + } + + private formatSvgExportPercent(value: number): string { + return `${this.formatSvgExportNumber(Math.min(1, Math.max(0, value)) * 100)}%`; + } + + private getWeightedMixedExportSegments( + segments: Array<{ color: string; alpha?: number; weight?: number }> + ): Array<{ color: string; alpha: number; startFraction: number; endFraction: number }> { + const validSegments = segments.filter(segment => Number(segment.weight ?? 1) > 0); + const totalWeight = validSegments.reduce((sum, segment) => sum + Number(segment.weight ?? 1), 0); + if (validSegments.length < 2 || totalWeight <= 0) { + return []; + } + + let startFraction = 0; + return validSegments.map((segment, index) => { + const endFraction = index === validSegments.length - 1 + ? 1 + : startFraction + (Number(segment.weight ?? 1) / totalWeight); + const weightedSegment = { + color: segment.color, + alpha: this.commonService.clampStyleAlpha(segment.alpha ?? 1, 1), + startFraction, + endFraction + }; + startFraction = endFraction; + + return weightedSegment; + }); + } + + private appendMixedFillGradient( + doc: XMLDocument, + parent: SVGElement, + gradientId: string, + segments: Array<{ color: string; alpha?: number; weight?: number }> + ): void { + const svgNamespace = 'http://www.w3.org/2000/svg'; + const defs = doc.createElementNS(svgNamespace, 'defs'); + const gradient = doc.createElementNS(svgNamespace, 'linearGradient'); + gradient.setAttribute('id', gradientId); + gradient.setAttribute('x1', '0%'); + gradient.setAttribute('y1', '0%'); + gradient.setAttribute('x2', '100%'); + gradient.setAttribute('y2', '0%'); + gradient.setAttribute('gradientUnits', 'objectBoundingBox'); + + this.getWeightedMixedExportSegments(segments).forEach(segment => { + [ + this.formatSvgExportPercent(segment.startFraction), + this.formatSvgExportPercent(segment.endFraction) + ].forEach(offset => { + const stop = doc.createElementNS(svgNamespace, 'stop'); + stop.setAttribute('offset', offset); + stop.setAttribute('stop-color', segment.color); + stop.setAttribute('stop-opacity', this.formatSvgExportNumber(segment.alpha)); + gradient.appendChild(stop); + }); + }); + + defs.appendChild(gradient); + parent.appendChild(defs); + } + private createCustomNodeVectorExportElement( doc: XMLDocument, sourceImage: SVGImageElement, @@ -1813,6 +2064,107 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic return vectorGroup; } + private createMixedNodeVectorExportElement( + doc: XMLDocument, + sourceImage: SVGImageElement, + replacement: MixedNodeSvgExportReplacement, + replacementIndex: number + ): SVGGElement { + const svgNamespace = 'http://www.w3.org/2000/svg'; + const vectorGroup = doc.createElementNS(svgNamespace, 'g'); + vectorGroup.setAttribute('class', 'mixed-node-export-fill'); + vectorGroup.setAttribute('data-mt-export', 'mixed-node-fill'); + vectorGroup.setAttribute('aria-hidden', 'true'); + + const attributesToCopy = ['opacity', 'style', 'clip-path']; + for (const attributeName of attributesToCopy) { + const attributeValue = sourceImage.getAttribute(attributeName); + if (attributeValue) { + vectorGroup.setAttribute(attributeName, attributeValue); + } + } + + const imageWidth = this.getSvgLengthAttribute(sourceImage, 'width') ?? replacement.exportWidth; + const imageHeight = this.getSvgLengthAttribute(sourceImage, 'height') ?? replacement.exportHeight; + const imageX = this.getSvgLengthAttribute(sourceImage, 'x') ?? 0; + const imageY = this.getSvgLengthAttribute(sourceImage, 'y') ?? 0; + const imageTransform = sourceImage.getAttribute('transform') || ''; + const transforms: string[] = []; + if (imageX !== 0 || imageY !== 0) { + transforms.push(`translate(${imageX}, ${imageY})`); + } + if (imageTransform) { + transforms.push(imageTransform); + } + if (transforms.length) { + vectorGroup.setAttribute('transform', transforms.join(' ')); + } + + const gradientId = `mt-mixed-node-export-${replacementIndex}`; + this.appendMixedFillGradient(doc, vectorGroup, gradientId, replacement.segments); + + if (replacement.vectorData) { + const scaleGroup = doc.createElementNS(svgNamespace, 'g'); + scaleGroup.setAttribute('transform', `scale(${imageWidth / replacement.vectorData.width}, ${imageHeight / replacement.vectorData.height})`); + + const shapeGroup = doc.createElementNS(svgNamespace, 'g'); + shapeGroup.setAttribute('transform', `translate(0, ${replacement.vectorData.height}) scale(1,-1)`); + + const fillPath = doc.createElementNS(svgNamespace, 'path'); + fillPath.setAttribute('d', replacement.vectorData.fillPath); + fillPath.setAttribute('fill', `url(#${gradientId})`); + fillPath.setAttribute('stroke', 'none'); + shapeGroup.appendChild(fillPath); + + scaleGroup.appendChild(shapeGroup); + vectorGroup.appendChild(scaleGroup); + return vectorGroup; + } + + const fillRect = doc.createElementNS(svgNamespace, 'rect'); + fillRect.setAttribute('x', '0'); + fillRect.setAttribute('y', '0'); + fillRect.setAttribute('width', `${imageWidth}`); + fillRect.setAttribute('height', `${imageHeight}`); + fillRect.setAttribute('fill', `url(#${gradientId})`); + fillRect.setAttribute('stroke', 'none'); + vectorGroup.appendChild(fillRect); + + return vectorGroup; + } + + private replaceExportedMixedNodeImagesWithVectorFills(doc: XMLDocument): void { + const replacementList = this.getMixedNodeSvgExportReplacementList(); + if (replacementList.length === 0) { + return; + } + + const images = Array.from(doc.getElementsByTagName('image')) + .filter(image => { + const href = this.getSvgImageHref(image); + return !!href + && (href.startsWith('data:image/png;base64,') || href.startsWith('data:image/svg+xml')) + && this.hasClipPathAncestor(image); + }); + const usedReplacements = new Set(); + + images.forEach(image => { + const replacement = this.findMatchingMixedNodeSvgExportReplacement(image, replacementList, usedReplacements); + if (!replacement || !image.parentNode) { + return; + } + + usedReplacements.add(replacement); + const vectorElement = this.createMixedNodeVectorExportElement( + doc, + image as SVGImageElement, + replacement, + usedReplacements.size + ); + image.parentNode.replaceChild(vectorElement, image); + }); + } + private replaceExportedCustomNodeImagesWithVectorShapes(doc: XMLDocument): void { const replacementList = this.getCustomNodeSvgExportReplacementList(); if (replacementList.length === 0) { @@ -1866,6 +2218,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic // Add 10px of padding around network const parser = new DOMParser(); const doc = parser.parseFromString(content, 'image/svg+xml'); + this.replaceExportedMixedNodeImagesWithVectorFills(doc); this.replaceExportedCustomNodeImagesWithVectorShapes(doc); const svg1 = doc.documentElement; svg1.setAttribute('height', (parseFloat(svg1.getAttribute('height'))+20).toString()); @@ -4906,11 +5259,12 @@ private updateArrowStyles(): void { this.cy.nodes().forEach(node => { const fullNode = this.getFullNodeDataForCyNode(node); const [newColor, opacity] = this.getNodeColor(fullNode); - node.data('nodeColor', newColor); + node.data('nodeColor', newColor); node.data('bgOpacity', opacity); node.data('borderColor', newColor); - const shapeKey = node.data('shapeKey'); + const shapeKey = node.data('shapeKey') || this.getNodeShape(fullNode); + this.setMixedColorNodeImageData(node, fullNode, shapeKey, newColor, opacity); if (isCustomNodeIconShape(shapeKey)) { const customShapeData = getCustomNodeShapeData(shapeKey, newColor); node.data('iconBackgroundImage', customShapeData.iconBackgroundImage); @@ -5568,7 +5922,8 @@ scaleLinkWidth() { if (!this.cy) return; this.cy.nodes().forEach(node => { if (this.isGroupNode(node)) return; - const newBorderWidth = this.getNodeBorderWidth(this.getFullNodeDataForCyNode(node)); + const fullNode = this.getFullNodeDataForCyNode(node); + const newBorderWidth = this.getNodeBorderWidth(fullNode); node.data('borderWidth', newBorderWidth); }); this.cy.style().update(); // Refresh Cytoscape styles to apply changes @@ -5638,11 +5993,12 @@ scaleLinkWidth() { } const fullNode = this.getFullNodeDataForCyNode(node); const shapeKey = this.getNodeShape(fullNode); - node.data('shapeKey', shapeKey); + node.data('shapeKey', shapeKey); node.data('shape', resolveCustomNodeIconCytoscapeShape(shapeKey)); + const [nodeColor, nodeOpacity] = this.getNodeColor(fullNode); + this.setMixedColorNodeImageData(node, fullNode, shapeKey, nodeColor, nodeOpacity); if (isCustomNodeIconShape(shapeKey)) { - const nodeColor = node.data('nodeColor') || this.getNodeColor(fullNode)[0]; const customShapeData = getCustomNodeShapeData(shapeKey, nodeColor); node.data('iconBackgroundImage', customShapeData.iconBackgroundImage); node.data('customIconKey', customShapeData.customIconKey);