();
private loadViewSubscription?: Subscription;
@@ -127,7 +129,8 @@ export class FilesComponent extends BaseComponentDirective implements OnInit {
private store: CommonStoreService,
private embedHandoffService: EmbedHandoffService,
private ngZone: NgZone,
- private workerComputeService: WorkerComputeService
+ private workerComputeService: WorkerComputeService,
+ private graphMLService: GraphMLService
) {
super(elRef.nativeElement);
@@ -1000,6 +1003,43 @@ export class FilesComponent extends BaseComponentDirective implements OnInit {
this.store.setLoadingMessageUpdated(msg);
}
+
+ showNetworkImportWarnings(fileName: string, warnings: string[]) {
+ if (!warnings.length) {
+ return;
+ }
+
+ this.pendingNetworkImportWarnings.push(...warnings.map(warning => `${fileName}: ${warning}`));
+ }
+
+ showGraphMLWarnings(fileName: string, warnings: string[]) {
+ this.showNetworkImportWarnings(fileName, warnings);
+ }
+
+ flushNetworkImportWarnings() {
+ if (!this.pendingNetworkImportWarnings.length) {
+ return;
+ }
+
+ const warnings = [...this.pendingNetworkImportWarnings];
+ this.pendingNetworkImportWarnings = [];
+
+ const microbeTrace = this.commonService.visuals.microbeTrace;
+ if (microbeTrace?.openNetworkImportWarnings) {
+ microbeTrace.openNetworkImportWarnings(warnings);
+ return;
+ }
+ if (microbeTrace?.openGraphMLImportWarnings) {
+ microbeTrace.openGraphMLImportWarnings(warnings);
+ return;
+ }
+
+ warnings.forEach(warning => this.showMessage(warning));
+ }
+
+ flushGraphMLWarnings() {
+ this.flushNetworkImportWarnings();
+ }
/**
@@ -1072,6 +1112,7 @@ export class FilesComponent extends BaseComponentDirective implements OnInit {
this.commonService.session.messages = [];
this.messages = [];
+ this.pendingNetworkImportWarnings = [];
if (this.commonService.debugMode) {
console.log('session files', this.commonService.session.files);
@@ -1113,7 +1154,7 @@ export class FilesComponent extends BaseComponentDirective implements OnInit {
const check = nFiles > 0;
// sorts files based on hierarchy
- const hierarchy = ['auspice', 'newick', 'matrix', 'link', 'node', 'fasta'];
+ const hierarchy = ['auspice', 'newick', 'matrix', 'network', 'graphml', 'link', 'node', 'fasta'];
this.commonService.session.files.sort((a, b) => hierarchy.indexOf(a.format) - hierarchy.indexOf(b.format));
@@ -1250,6 +1291,54 @@ export class FilesComponent extends BaseComponentDirective implements OnInit {
if (fileNum === nFiles) this.processData(loadGeneration);
});
+ } else if (file.format === 'network' || file.format === 'graphml') {
+
+ this.showMessage(`Parsing ${file.name} as Network...`);
+ try {
+ const networkDocument = this.graphMLService.importNetworkDocument(file.contents, { sourceName: file.name });
+ let newNodes = 0;
+ let newLinks = 0;
+
+ networkDocument.nodeFields.forEach(field => {
+ if (!this.commonService.includes(this.commonService.session.data.nodeFields, field)) {
+ this.commonService.session.data.nodeFields.push(field);
+ }
+ });
+ networkDocument.linkFields.forEach(field => {
+ if (!this.commonService.includes(this.commonService.session.data.linkFields, field)) {
+ this.commonService.session.data.linkFields.push(field);
+ }
+ });
+
+ networkDocument.nodes.forEach(node => {
+ newNodes += this.commonService.addNode(node, check);
+ });
+ networkDocument.links.forEach(link => {
+ newLinks += this.commonService.addLink(link, check);
+ });
+
+ this.showNetworkImportWarnings(file.name, networkDocument.warnings);
+
+ console.log('Network Parse time:', (Date.now() - start).toLocaleString(), 'ms');
+ this.commonService.recordPerformanceTiming('ingestion', networkDocument.format === 'graphml' ? 'parseAndMergeGraphML' : 'parseAndMergeNetworkDocument', start, {
+ file: file.name,
+ format: networkDocument.format,
+ graphs: networkDocument.graphIds.length,
+ newNodes,
+ totalNodes: networkDocument.nodes.length,
+ newLinks,
+ totalLinks: networkDocument.links.length,
+ warnings: networkDocument.warnings.length
+ });
+ this.showMessage(` - Parsed ${newNodes} New, ${networkDocument.nodes.length} Total Nodes from ${networkDocument.formatLabel}.`);
+ this.showMessage(` - Parsed ${newLinks} New, ${networkDocument.links.length} Total Links from ${networkDocument.graphIds.length} ${networkDocument.formatLabel} Graph${networkDocument.graphIds.length === 1 ? '' : 's'}.`);
+ if (fileNum === nFiles) this.processData(loadGeneration);
+ } catch (error: any) {
+ console.error('Network parse error:', error);
+ this.showMessage(` - Error processing Network file: ${error?.message || error}`);
+ this.commonService.session.network.isFullyLoaded = false;
+ }
+
} else if (file.format === 'link') {
this.showMessage(`Parsing ${file.name} as Link List...`);
@@ -1872,6 +1961,8 @@ export class FilesComponent extends BaseComponentDirective implements OnInit {
return;
}
+ this.flushNetworkImportWarnings();
+
let nodes = this.commonService.session.data.nodes;
if(this.commonService.debugMode) {
console.log(nodes);
@@ -2147,9 +2238,20 @@ export class FilesComponent extends BaseComponentDirective implements OnInit {
reader.onerror = () => reject(reader.error);
reader.onloadend = (out) => {
try {
- const output = JSON.parse(out.target['result'] as string);
+ const rawContents = out.target['result'] as string;
+ const output = JSON.parse(rawContents);
console.log(output);
- if (output.meta && output.tree) {
+ if (this.graphMLService.looksLikeCX2(output)) {
+ const cxFile = {
+ contents: rawContents,
+ name: fileName,
+ extension: extension,
+ type: rawfile.type || 'application/json',
+ format: 'network'
+ };
+ this.commonService.session.files.push(cxFile);
+ this.addToTable(cxFile);
+ } else if (output.meta && output.tree) {
const auspiceFile = { contents: output, name: fileName, extension: extension, format: 'auspice', datatype: 'auspice'};
this.commonService.session.files.push(auspiceFile);
this.addToTable(auspiceFile);
@@ -2200,9 +2302,12 @@ export class FilesComponent extends BaseComponentDirective implements OnInit {
private inferTabularFileFormat(
file: any,
headers: any[] = [],
- hints: { isFasta?: boolean; isNewick?: boolean; isAuspice?: boolean } = {}
+ hints: { isFasta?: boolean; isNewick?: boolean; isAuspice?: boolean; isNetworkDocument?: boolean; isNetworkXml?: boolean } = {}
): string {
const explicitFormat = String(file?.format ?? '').toLowerCase();
+ if (explicitFormat === 'network' || explicitFormat === 'graphml') {
+ return 'network';
+ }
const knownFormats = ['link', 'node', 'matrix', 'fasta', 'newick', 'auspice'];
if (knownFormats.includes(explicitFormat)) {
return explicitFormat;
@@ -2211,6 +2316,9 @@ export class FilesComponent extends BaseComponentDirective implements OnInit {
if (hints.isAuspice) {
return 'auspice';
}
+ if (hints.isNetworkDocument || hints.isNetworkXml) {
+ return 'network';
+ }
if (hints.isFasta) {
return 'fasta';
}
@@ -2274,10 +2382,14 @@ export class FilesComponent extends BaseComponentDirective implements OnInit {
const extension = file.extension ? file.extension : this.commonService.filterXSS(file.name).split('.').pop().toLowerCase();
const isFasta = extension.indexOf('fas') > -1;
const isNewick = extension.indexOf('nwk') > -1 || extension.indexOf('newick') > -1;
+ // Some network formats are uploaded as generic XML/JSON/text, so detect by
+ // extension first and then by document shape.
+ const isNetworkDocument = ['graphml', 'gexf', 'xgmml', 'cx', 'cx2', 'dot', 'gv', 'gml'].includes(extension)
+ || this.graphMLService.looksLikeNetworkDocument(file.contents);
const isXL = (extension === 'xlsx' || extension === 'xls');
const isJSON = (extension === 'json');
const isAuspice = (extension === 'json' && file.contents.meta && file.contents.tree);
- const tableFormatHints = { isFasta, isNewick, isAuspice };
+ const tableFormatHints = { isFasta, isNewick, isAuspice, isNetworkDocument };
if (isXL) {
try {
const workbook = XLSX.read(file.contents, { type: 'array', cellDates: true, dateNF: 'mm/dd/yyyy' });
@@ -2295,6 +2407,33 @@ export class FilesComponent extends BaseComponentDirective implements OnInit {
addTableTile([file.field1, file.field2, file.field3], this);
return;
}
+ } else if (isNetworkDocument) {
+ try {
+ const networkDocument = this.graphMLService.importNetworkDocument(file.contents, { sourceName: file.name });
+ file.format = 'network';
+ const detectedFormat = addTableTile(['network_format', '_id', 'source', 'target', 'distance', 'origin'], this);
+ this.nodeIds = this.nodeIds.filter(x => x.fileName !== file.name);
+ this.edgeIds = this.edgeIds.filter(x => x.fileName !== file.name);
+ this.nodeIds.push({
+ fileName: file.name,
+ ids: networkDocument.nodes.map(node => '' + node._id)
+ });
+ this.edgeIds.push({
+ fileName: file.name,
+ ids: networkDocument.links.map(link => ({
+ source: '' + link.source,
+ target: '' + link.target
+ }))
+ });
+ networkDocument.warnings.forEach(warning => console.warn(`Network ${file.name}: ${warning}`));
+ this.nodeEdgeCheck();
+ if(this.commonService.debugMode) {
+ console.log('addToTable Network detected format: ', detectedFormat, networkDocument);
+ }
+ } catch (error) {
+ console.error('Unable to read Network file: ', file.name, error);
+ addTableTile(['network_format', '_id', 'source', 'target'], this);
+ }
} else if (isJSON) {
let data = [];
console.log('This is a JSON file');
@@ -2368,8 +2507,12 @@ export class FilesComponent extends BaseComponentDirective implements OnInit {
const showsColumnMapping = detectedFormat === 'node' || detectedFormat === 'link';
const root = $('').data('filename', file.name);
const fnamerow = $('');
- $('')
- .append($('').on('click', () => {
+ const fileNameCell = $('').addClass('file-name col');
+ const deleteLink = $('')
+ .attr('href', 'javascript:void(0);')
+ .addClass('far flaticon-delete-1 align-middle p-1')
+ .attr('title', 'Remove this file')
+ .on('click', () => {
const fileIndex = parentContext.commonService.session.files.findIndex(f => f.name === file.name);
if (fileIndex >= 0) {
parentContext.commonService.session.files.splice(fileIndex, 1);
@@ -2382,53 +2525,87 @@ export class FilesComponent extends BaseComponentDirective implements OnInit {
}
root.slideUp(() => root.remove());
parentContext.refreshTemplateState();
- }))
- .append($(``).on('click', () => {
+ });
+ const downloadLink = $('')
+ .attr('href', 'javascript:void(0);')
+ .addClass('far flaticon-download-1 align-middle p-1')
+ .attr('title', parentContext.isFileContentsEmpty(file) ? 'Unable to resave this file' : 'Resave this file')
+ .on('click', () => {
if (parentContext.isFileContentsEmpty(file)) {
alert('Unable to resave this file.');
} else {
saveAs(new Blob([file.contents], { type: file.type || 'text' }), file.name);
}
- }))
- .append('' + file.name + '')
- .append(`
-
-
-
-
-
-
-
-
`).appendTo(fnamerow);
+ });
+ if (parentContext.isFileContentsEmpty(file)) {
+ downloadLink.css('color', 'gray');
+ }
+ const fileNameText = $('')
+ .addClass('p-1')
+ .text(file.name);
+ const formatButtons = $('')
+ .addClass('btn-group btn-group-toggle btn-group-sm float-right')
+ .attr('data-toggle', 'buttons');
+ const formatOptions = [
+ { type: 'link', label: 'Link' },
+ { type: 'node', label: 'Node' },
+ { type: 'matrix', label: 'Matrix' },
+ { type: 'fasta', label: 'FASTA' },
+ { type: 'newick', label: 'Newick' },
+ { type: 'auspice', label: 'Auspice' },
+ { type: 'network', label: 'Network' },
+ ];
+ formatOptions.forEach(({ type, label }) => {
+ const formatLabel = $('')
+ .addClass(`btn btn-light${detectedFormat === type ? ' active' : ''}`);
+ const formatInput = $('')
+ .attr('type', 'radio')
+ .attr('name', `options-${file.name}`)
+ .attr('autocomplete', 'off')
+ .data('type', type)
+ .attr('data-type', type)
+ .prop('checked', detectedFormat === type);
+ formatLabel
+ .append(formatInput)
+ .append($('').text(label));
+ formatButtons.append(formatLabel);
+ });
+ fileNameCell
+ .append(deleteLink)
+ .append(downloadLink)
+ .append(fileNameText)
+ .append(formatButtons)
+ .appendTo(fnamerow);
fnamerow.appendTo(root);
const optionsrow = $('');
- const options = '' + headers.map(h => ``).join('\n');
- optionsrow.append(`
-
-
-
-
-
-
-
-
-
-
-
-
`);
+ const buildFieldSelect = (fieldNumber: number) => {
+ const select = $('')
+ .attr('id', `file-${file.name}-field-${fieldNumber}`)
+ .addClass('form-control form-control-sm');
+ select.append($('').val('None').text('None'));
+ headers.forEach(h => {
+ select.append($('').val(h).text(parentContext.commonService.titleize(h)));
+ });
+ return select;
+ };
+ const addMappingControl = (fieldNumber: number, labelText: string) => {
+ const container = $('')
+ .addClass('col-4')
+ .data('file', file.name)
+ .attr('data-file', file.name);
+ if (!showsColumnMapping) {
+ container.css('display', 'none');
+ }
+ const selectId = `file-${file.name}-field-${fieldNumber}`;
+ container
+ .append($('').attr('for', selectId).text(labelText))
+ .append(buildFieldSelect(fieldNumber));
+ optionsrow.append(container);
+ };
+ addMappingControl(1, isNode ? 'ID' : 'Source');
+ addMappingControl(2, isNode ? 'Sequence' : 'Target');
+ addMappingControl(3, 'Distance');
optionsrow.appendTo(root);
diff --git a/src/app/microbe-trace-next-plugin.component.html b/src/app/microbe-trace-next-plugin.component.html
index 861baf93..7938d209 100644
--- a/src/app/microbe-trace-next-plugin.component.html
+++ b/src/app/microbe-trace-next-plugin.component.html
@@ -19,6 +19,7 @@
+
diff --git a/src/app/microbe-trace-next-plugin.component.ts b/src/app/microbe-trace-next-plugin.component.ts
index 7498da2a..a41e48da 100644
--- a/src/app/microbe-trace-next-plugin.component.ts
+++ b/src/app/microbe-trace-next-plugin.component.ts
@@ -18,6 +18,7 @@ import JSZip from 'jszip';
import html2canvas from 'html2canvas';
import { CommonStoreService } from './contactTraceCommonServices/common-store.services';
import { ExportService, ExportOptions } from './contactTraceCommonServices/export.service';
+import { GraphMLService } from './contactTraceCommonServices/graphml.service';
import { sanitizeExportRows } from './contactTraceCommonServices/export-sanitization';
import * as XLSX from 'xlsx';
import { buildDate, commitHash } from "src/environments/version";
@@ -361,6 +362,7 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A
private el: ElementRef,
private store: CommonStoreService,
private exportService: ExportService,
+ private graphMLService: GraphMLService,
private embedHandoffService: EmbedHandoffService
) {
@@ -1846,6 +1848,30 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A
}, );
}
+ openNetworkImportWarnings(warnings: string[]) {
+ if (!warnings.length) {
+ return;
+ }
+
+ this.confirmationService.confirm({
+ header: 'Network Import Warnings',
+ message: `Some Network file features are not supported by MicrobeTrace and were ignored or imported as data fields.
+
+${warnings.join('\n')}`,
+ closable: false,
+ closeOnEscape: false,
+ icon: 'pi pi-exclamation-triangle',
+ rejectVisible: false,
+ acceptButtonProps: {
+ label: 'Confirm',
+ },
+ });
+ }
+
+ openGraphMLImportWarnings(warnings: string[]) {
+ this.openNetworkImportWarnings(warnings);
+ }
+
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) {
@@ -4147,6 +4173,12 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A
}
}
+ ExportGraphML() {
+ const graphMLExport = this.graphMLService.exportSession(this.commonService.session);
+ const blob = new Blob([graphMLExport.contents], { type: 'application/graphml+xml;charset=utf-8' });
+ this.saveGeneratedFile(blob, 'microbetrace.graphml');
+ }
+
updateExportResolution() {
const visualWrapper = this.visualWrapperRef.nativeElement;
console.log(visualWrapper)
diff --git a/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts b/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts
index ae89e174..bc46f028 100644
--- a/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts
+++ b/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts
@@ -664,6 +664,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic
source: link.source,
target: link.target,
lineSelectedColor: this.widgets['selected-color'],
+ mtRawLinkLabel: this.getRawLinkLabel(link),
label: this.getLinkLabel(link).text, // Existing link label
lineColor: this.getLinkColor({origin: originItem}).color, // Default to black if not specified
lineOpacity: this.getLinkColor({origin: originItem}).opacity, // Default to fully opaque if not specified
@@ -680,6 +681,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic
source: link.source,
target: link.target,
lineSelectedColor: this.widgets['selected-color'],
+ mtRawLinkLabel: this.getRawLinkLabel(link),
label: this.getLinkLabel(link).text, // Existing link label
lineColor: this.getLinkColor(link).color, // Default to black if not specified
lineOpacity: this.getLinkColor(link).opacity, // Default to fully opaque if not specified
@@ -3609,6 +3611,9 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic
} else if (labelVariable == 'target_index') { // currently doesn't work; previous link.source and link.target were object now they are just a string of the id
return { text: link['target'] }
//return link['target']['index']
+ } else if (labelVariable == 'label') {
+ const labelValue = this.getRawLinkLabel(link);
+ return { text: labelValue === undefined || labelValue === null ? '' : `${labelValue}` };
} else if (labelVariable != 'distance') {
return { text: `${link[labelVariable]}` || '' };
}
@@ -3639,6 +3644,10 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic
}
}
+ private getRawLinkLabel(link: any): any {
+ return link?.mtRawLinkLabel ?? link?.label;
+ }
+
/**
* Calls setNodeLabelSize to update label-size and redraw labels