Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -507,6 +507,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 @@ -1870,6 +1870,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 @@ -5605,6 +5625,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