Skip to content
Open
500 changes: 500 additions & 0 deletions cypress/e2e/view-state/twod-collapse.cy.ts

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions cypress/fixtures/CollapsedMeanDistanceLinks.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
source,target,distance
A,B,0.001
B,C,0.001
A,C,0.100
A,D,0.250
B,D,0.250
C,D,0.250
5 changes: 5 additions & 0 deletions cypress/fixtures/CollapsedMeanDistanceNodes.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
_id,category,label
A,Alpha,Node A
B,Beta,Node B
C,Gamma,Node C
D,Outside,Node D
2 changes: 2 additions & 0 deletions src/app/contactTraceCommonServices/common.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,8 @@ export class CommonService extends AppComponentBase implements OnInit {
'network-friction': 0.4,
'network-gravity': 0.05,
'network-link-strength': 0.124,
'network-node-collapse-enabled': false,
'network-node-collapse-threshold': 0,
'node-charge': 200,
'node-border-width' : 2.0,
'node-color': '#1f77b4',
Expand Down
117 changes: 117 additions & 0 deletions src/app/contactTraceCommonServices/pie-chart-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
export interface PieChartSlice {
label: string;
count: number;
color: string;
alpha?: number;
}

export interface PieChartPathSlice extends PieChartSlice {
path: string;
}

export function getPieChartTotalCount(slices: PieChartSlice[]): number {
return (slices || []).reduce((total, slice) => {
const count = Number(slice?.count);
return Number.isFinite(count) && count > 0 ? total + count : total;
}, 0);
}

export function getValidPieChartSlices(slices: PieChartSlice[]): PieChartSlice[] {
return (slices || []).filter(slice => {
const count = Number(slice?.count);
return Number.isFinite(count) && count > 0;
});
}

export function buildPieChartPathSlices(
slices: PieChartSlice[],
centerX: number,
centerY: number,
radius: number
): PieChartPathSlice[] {
const totalCount = getPieChartTotalCount(slices);
const validSlices = getValidPieChartSlices(slices);
const safeRadius = Math.max(0, Number(radius) || 0);

if (totalCount <= 0 || safeRadius <= 0) {
return [];
}

if (validSlices.length === 1) {
const topY = centerY - safeRadius;
const bottomY = centerY + safeRadius;
return [{
...validSlices[0],
path: `M ${centerX} ${topY} A ${safeRadius} ${safeRadius} 0 1 1 ${centerX} ${bottomY} A ${safeRadius} ${safeRadius} 0 1 1 ${centerX} ${topY} Z`
}];
}

let cumulative = 0;
let previousAngle = -Math.PI / 2;

return validSlices.map(slice => {
const proportion = Number(slice.count) / totalCount;
cumulative += proportion;
const endAngle = (-Math.PI / 2) + (2 * Math.PI * cumulative);
const startX = centerX + (safeRadius * Math.cos(previousAngle));
const startY = centerY + (safeRadius * Math.sin(previousAngle));
const endX = centerX + (safeRadius * Math.cos(endAngle));
const endY = centerY + (safeRadius * Math.sin(endAngle));
const largeArcFlag = proportion > 0.5 ? 1 : 0;
previousAngle = endAngle;

return {
...slice,
path: `M ${centerX} ${centerY} L ${startX} ${startY} A ${safeRadius} ${safeRadius} 0 ${largeArcFlag} 1 ${endX} ${endY} Z`
};
});
}

export function buildPieChartPatternDef(patternId: string, slices: PieChartSlice[]): string {
const totalCount = getPieChartTotalCount(slices);
const validSlices = getValidPieChartSlices(slices);

if (!patternId || totalCount <= 0 || validSlices.length < 2) {
return '';
}

const coordinates: Array<[number, number]> = [];
const proportions: number[] = [];
let cumulative = 0;

validSlices.forEach(slice => {
const proportion = Number(slice.count) / totalCount;
cumulative += proportion;
proportions.push(proportion);
coordinates.push([
Math.cos(2 * Math.PI * cumulative),
Math.sin(2 * Math.PI * cumulative)
]);
});

let patternString = `<pattern id='${patternId}' viewBox='-1 -1 2 2' style='transform: rotate(-.25turn)' width='100%' height='100%'>`;

for (let i = 0; i < coordinates.length; i++) {
const arcStart = i === 0 ? '1 0' : `${coordinates[i - 1][0]} ${coordinates[i - 1][1]}`;
const largeArcFlag = proportions[i] > 0.5 ? 1 : 0;
const arcEnd = i === coordinates.length - 1 ? '1 0' : `${coordinates[i][0]} ${coordinates[i][1]}`;
const alpha = Number(validSlices[i].alpha);
const fillOpacity = Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1;
patternString += `<path d='M 0 0 L ${arcStart} A 1 1 0 ${largeArcFlag} 1 ${arcEnd} L 0 0' fill='${validSlices[i].color}' fill-opacity='${fillOpacity}' />`;
}

patternString += '</pattern>';
return patternString;
}

export function buildPieChartSvgDataUri(patternId: string, size: number, slices: PieChartSlice[]): string {
const safeSize = Math.max(1, Number(size) || 1);
const patternDef = buildPieChartPatternDef(patternId, slices);

if (!patternDef) {
return '';
}

const svgPattern = `<svg width='${safeSize}' height='${safeSize}' xmlns='http://www.w3.org/2000/svg'><defs>${patternDef}</defs><circle fill="url(#${patternId})" cx='${safeSize / 2}' cy='${safeSize / 2}' r='${safeSize / 2}'/></svg>`;
return 'data:image/svg+xml;base64,' + btoa(svgPattern);
}
84 changes: 84 additions & 0 deletions src/app/contactTraceCommonServices/threshold-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ export interface VisibleClusterSummary {
clusterCount: number;
}

export interface ThresholdConnectedComponent {
nodeIds: string[];
nodeIndexes: number[];
}

export interface ThresholdConnectedComponentSummary {
components: ThresholdConnectedComponent[];
nodeComponentById: Record<string, number>;
}

class UnionFind {
private readonly parent: number[];
private readonly sizes: number[];
Expand Down Expand Up @@ -137,6 +147,80 @@ function getNumericMetricValue(link: ThresholdAnalysisLinkLike, metric: string):
return null;
}

function getLinkEndpointId(endpoint: any): string {
if (endpoint && typeof endpoint === 'object') {
return String(endpoint._id ?? endpoint.id ?? '');
}

return String(endpoint ?? '');
}

export function buildThresholdConnectedComponents(
nodes: ThresholdAnalysisNodeLike[],
links: ThresholdAnalysisLinkLike[],
metric: string,
threshold: number
): ThresholdConnectedComponentSummary {
const nodeIds = nodes.map((node) => getNodeId(node));
const nodeIndexById: Record<string, number> = Object.create(null);
const uf = new UnionFind(nodeIds.length);
const numericThreshold = Number(threshold);

nodeIds.forEach((nodeId, index) => {
nodeIndexById[nodeId] = index;
});

if (Number.isFinite(numericThreshold)) {
links.forEach((link) => {
const value = getNumericMetricValue(link, metric);

if (value === null || value > numericThreshold) {
return;
}

const sourceIndex = nodeIndexById[getLinkEndpointId(link.source)];
const targetIndex = nodeIndexById[getLinkEndpointId(link.target)];

if (
sourceIndex === undefined ||
targetIndex === undefined ||
sourceIndex === targetIndex
) {
return;
}

uf.union(sourceIndex, targetIndex);
});
}

const rootToComponentId = new Map<number, number>();
const components: ThresholdConnectedComponent[] = [];
const nodeComponentById: Record<string, number> = Object.create(null);

nodeIds.forEach((nodeId, nodeIndex) => {
const root = uf.find(nodeIndex);
let componentId = rootToComponentId.get(root);

if (componentId === undefined) {
componentId = components.length;
rootToComponentId.set(root, componentId);
components.push({
nodeIds: [],
nodeIndexes: []
});
}

components[componentId].nodeIds.push(nodeId);
components[componentId].nodeIndexes.push(nodeIndex);
nodeComponentById[nodeId] = componentId;
});

return {
components,
nodeComponentById
};
}

export function buildStoredDistanceEdgeCache(
nodes: ThresholdAnalysisNodeLike[],
links: ThresholdAnalysisLinkLike[],
Expand Down
21 changes: 21 additions & 0 deletions src/app/microbe-trace-next-plugin.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1828,6 +1828,26 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A
}, );
}

public openNodeCollapseShapeConfirmation(accept: () => void, reject?: () => void): void {
this.confirmationService.confirm({
message: `All collapsed nodes will be displayed as circles. Custom node shapes are not preserved while nodes are collapsed.
Are you sure that you want to proceed?`,
closable: false,
closeOnEscape: false,
icon: 'pi pi-exclamation-triangle',
rejectButtonProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true,
},
acceptButtonProps: {
label: 'Confirm',
},
accept,
reject,
});
}

onPruneWithTypesChanged(newValue: string) {
if (this.userConfirmedNN == false && this.SelectedPruneWithTypesVariable == "Nearest Neighbor") {
if (this.commonService.session.data.links.filter(l => l.origin.length > 1 && Array.isArray(l.origin)).length>0) {
Expand Down Expand Up @@ -5220,6 +5240,7 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A
this.commonService.updateThresholdHistogram(this.linkThresholdSparkline.nativeElement);
}

this.commonService.visuals.twoD?.refreshDistanceMetricSettings?.();
this.refreshThresholdStabilityPanel();
}

Expand Down
Loading
Loading