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