define([
"jquery",
"underscore",
"backbone",
"views/CitationView",
"views/ProvEntitySelectView",
"views/ProvStatementView",
], function (
$,
_,
Backbone,
CitationView,
ProvEntitySelectView,
ProvStatement,
) {
"use strict";
const dataRecords = (records) =>
(records || []).filter(({ type }) =>
["data", "image", "PDF"].includes(type),
);
const editableRecords = (records) =>
(records || []).filter(({ editable }) => editable !== false);
const CLASS_NAMES = {
alertError: "alert-error",
filename: "filename",
helpText: "help-text subtle alert",
learnMoreIcon: "icon icon-on-right icon-long-arrow-right",
view: "prov-chart",
};
const MESSAGES = {
chooseFiles: "Choose from files in this dataset: ",
derivedDataHelp:
" by selecting which data objects were created from transforming, changing, or updating it. ",
inputDataHelp:
" by selecting which data object was input or consumed by it. ",
learnMore: "Learn more about data provenance",
originAndProcessing: "Describe the origin and processing history of ",
programGeneratedHelp:
" by selecting which program, script, or code created it. ",
programUsedHelp:
" by selecting a program, script, or code that used it to create another data object. ",
programOutputHelp:
" by selecting which data objects were created by or output by it. ",
provenanceRejected: "The provenance change was rejected.",
relationshipCannotBeEdited:
"Only aggregated programs with no run or one unambiguous run can be edited here.",
remove: "Remove",
sourceDataHelp:
" by selecting which data object was used as a source to create it. ",
processingHistory: "Describe the processing history of ",
};
const RELATIONSHIP_MODES = {
derivedData: "derivedData",
generatingProgram: "generatingProgram",
programInput: "programInput",
programOutput: "programOutput",
sourceData: "sourceData",
usingProgram: "usingProgram",
};
const createHelpText = (lead, fileName, tail) =>
$(document.createElement("p"))
.addClass(CLASS_NAMES.helpText)
.text(lead)
.append(
$(document.createElement("code"))
.addClass(CLASS_NAMES.filename)
.text(fileName),
)
.append(tail);
const classifyRelationshipMode = (
chartType,
selectedEntityType,
contextType,
) => {
if (chartType === "sources") {
if (selectedEntityType === "program") {
return RELATIONSHIP_MODES.generatingProgram;
}
if (contextType === "program") return RELATIONSHIP_MODES.programInput;
return RELATIONSHIP_MODES.sourceData;
}
if (selectedEntityType === "program") {
return RELATIONSHIP_MODES.usingProgram;
}
if (contextType === "program") return RELATIONSHIP_MODES.programOutput;
return RELATIONSHIP_MODES.derivedData;
};
const getPickerCopy = (mode, fileName) => {
if (mode === RELATIONSHIP_MODES.generatingProgram) {
return {
title: `Add the program that generated ${fileName}`,
lead: MESSAGES.originAndProcessing,
tail: MESSAGES.programGeneratedHelp,
};
}
if (mode === RELATIONSHIP_MODES.programInput) {
return {
title: `Add source data to ${fileName}`,
lead: MESSAGES.originAndProcessing,
tail: MESSAGES.inputDataHelp,
};
}
if (mode === RELATIONSHIP_MODES.sourceData) {
return {
title: `Add source data to ${fileName}`,
lead: MESSAGES.originAndProcessing,
tail: MESSAGES.sourceDataHelp,
};
}
if (mode === RELATIONSHIP_MODES.usingProgram) {
return {
title: `Add the program that used ${fileName}`,
lead: MESSAGES.processingHistory,
tail: MESSAGES.programUsedHelp,
};
}
if (mode === RELATIONSHIP_MODES.programOutput) {
return {
title: `Add derived data for ${fileName}`,
lead: MESSAGES.processingHistory,
tail: MESSAGES.programOutputHelp,
};
}
return {
title: `Add derived data for ${fileName}`,
lead: MESSAGES.processingHistory,
tail: MESSAGES.derivedDataHelp,
};
};
/**
* @class ProvChartView
* @classdesc Displays and edits provenance relationships for one package
* member
* @classcategory Views
* @augments Backbone.View
* @screenshot views/ProvChartView.png
*/
const ProvChartView = Backbone.View.extend({
/**
* Initialize a source or derivation chart.
* @param {object} options View options
* @param {Backbone.View} [options.parentView] Parent metadata view
* @param {object[]} [options.sources] Source chart records
* @param {object[]} [options.derivations] Derivation chart records
* @param {object} options.context Package member represented by the chart
* @param {HTMLElement|JQuery} [options.contextEl] Metadata detail element
* @param {DataPackage} [options.dataPackage] Editable data package
* @param {ProvenanceChartAdapter} [options.projection] Chart projection
* @param {number} [options.nodeHeight] Node height in pixels
* @param {number} [options.offsetTop] Chart top offset in pixels
* @param {string} [options.title] Chart title
* @param {boolean} [options.editModeOn] Whether editing is enabled
* @returns {void}
*/
initialize(options = {}) {
this.parentView = options.parentView || null;
this.sources = options.sources || null;
this.derivations = options.derivations || null;
this.context = options.context || null; // The package member
this.contextEl = options.contextEl || $("body"); // The parent view DOM element for the package member
this.dataPackage = options.dataPackage || null;
this.projection = options.projection || null;
this.citationModels = new Map();
this.memberPidSet = new Set(
(this.projection?.records || []).map((record) => record.pid),
);
this.nodeHeight = options.nodeHeight || 67; //Pixel height of the node including padding and margins
this.offsetTop = options.offsetTop || this.nodeHeight; //The top margin of the chart, in pixels
this.title = options.title || "";
this.editModeOn = options.editModeOn || false;
this.canEditContext = this.editModeOn && this.context.editable !== false;
this.subviews = [];
this.selectProvEntityView = null;
this.type = null;
this.serviceUrl =
MetacatUI.appModel.get("objectServiceUrl") ||
MetacatUI.appModel.get("resolveServiceUrl");
// For source charts
if (!this.derivations && this.sources) {
this.type = "sources";
this.provEntities = this.sources;
// Find the number of sources and programs
const sources = [];
const programs = [];
this.sources.forEach((model) => {
if (model.type === "program") programs.push(model);
else sources.push(model);
});
this.sources = sources;
this.programs = programs;
this.numSources = this.sources.length;
this.numPrograms = this.programs.length;
this.numProvEntities = this.numSources;
this.numDerivations = 0;
}
// For derivation charts
if (!this.sources && this.derivations) {
this.type = "derivations";
this.provEntities = this.derivations;
// Find the number of derivations and programs
const derivations = [];
const programs = [];
this.derivations.forEach((model) => {
if (model.type === "program") programs.push(model);
else derivations.push(model);
});
this.derivations = derivations;
this.programs = programs;
this.numDerivations = this.derivations.length;
this.numPrograms = this.programs.length;
this.numProvEntities = this.numDerivations;
this.numSources = 0;
}
// Add the chart type to the class list
this.className += ` ${this.type}`;
// Create a title
if (
this.type === "sources" &&
this.numSources === 0 &&
this.numPrograms > 0
) {
this.title = "";
} else if (
this.context.type === "program" &&
this.type === "derivations"
) {
this.title = `${this.numProvEntities} outputs`;
} else if (this.context.type === "program" && this.type === "sources") {
this.title = `${this.numProvEntities} inputs`;
} else {
this.title = `${this.numProvEntities} ${this.type}`;
}
//The default height of the chart when all nodes are visible/expanded
this.height =
Math.max(this.numProvEntities, this.numPrograms) * this.nodeHeight;
// Add height for one more node if edit mode is on. The node height affects portion of
// the left or right border that is visible, which is what is used to display the vertical
// portion of the connectors between data nodes and programs or the package member
// (that owns the metadata detail section).
if (this.canEditContext) this.height += this.nodeHeight;
},
tagName: "aside",
className: CLASS_NAMES.view,
events: {
"click .expand-control": "expandNodes",
"click .collapse-control": "collapseNodes",
"click .preview": "previewData",
"click .editor": "selectProvEntities",
"click #selectDone": "getSelectedProvEntities",
},
/**
* Render the provenance nodes and their controls.
* @returns {ProvChartView|boolean} This view, or false when the chart is
* empty and cannot be edited
*/
render: function () {
if (!this.numProvEntities && !this.numPrograms && !this.canEditContext)
return false;
const view = this;
//Are there any programs? If no programs are present in this package member and edit mode is on,
// then we need to draw an edit icon in the program position, unless this member is a program (programs
// aren't connected directly to programs).
if (
this.programs.length ||
(this.canEditContext && this.context.type !== "program")
) {
this.$el.append(
$(document.createElement("div")).addClass(
`${this.type}-programs programs`,
),
);
}
let position = 0;
let programPosition = 0;
this.provEntities.forEach((entity) => {
let metadataID = entity.isDocumentedBy;
let metadata = null;
if (Array.isArray(metadataID)) [metadataID] = metadataID;
if (metadataID) metadata = view.projection?.getRecord(metadataID);
// Programs will be positioned at a different point in the graph
if (entity.type === "program") {
// Find the program position
view
.$(".programs")
.append(view.createNode(entity, programPosition, metadata));
} else {
view.$el.append(view.createNode(entity, position, metadata));
// Sources and Derivation charts have a pointer for each node
view.$el.append(view.createConnecter(position));
}
// Bump the position for non-programs only
if (entity.type === "program") programPosition += 1;
else position += 1;
});
// If edit mode is on, then draw an editor node.
if (this.canEditContext) {
// If a program prov icon has already been
// displayed, then don't display a program edit icon, as currently only one program is
// supported per ProvCharView. Also, don't display a program icon if the package members
// the package member being annotated is a program. Programs as inputs
// or outputs of other programs are not currently supported.
if (this.context.type !== "program" && this.numPrograms === 0) {
const programNode = this.createEditorNode(
"program",
this.context.pid,
programPosition,
);
this.$(".programs").append(programNode, this.createConnecter());
this.createEditTooltip(programNode);
this.numPrograms += 1;
}
// Draw a data node editor
const dataNode = this.createEditorNode(
"data",
this.context.pid,
position,
);
this.$el.append(dataNode);
this.createEditTooltip(dataNode);
position += 1;
if (this.type === "sources") this.numSources += 1;
if (this.type === "derivations") this.numDerivations += 1;
// Add a connector for this edit icon.
this.$el.append(this.createConnecter(position - 1));
}
//Move the last-viewed prov node to the top of the chart so it is always displayed first
if (this.$(".node.previous").length > 0)
this.switchNodes(
this.$(".node.previous").first(),
this.$(".node").first(),
);
//Add classes
this.$el.addClass(this.className);
if (this.numPrograms > 0) this.$el.addClass("has-programs");
if (this.numDerivations == 1 && !this.numPrograms)
this.$el.addClass("one-derivation");
//Specify classes for the context element (e.g. entity details container)
var contextClasses =
this.type == "sources" ? "hasProvLeft" : "hasProvRight";
if (this.numPrograms > 0 && this.type == "sources") {
contextClasses += " hasProgramsLeft";
} else if (this.numPrograms > 0 && this.type == "derivations") {
contextClasses += " hasProgramsRight";
}
$(this.contextEl).addClass(contextClasses);
//If it's a derivation chart, add a connector line
if (this.type == "derivations" && !this.numPrograms)
this.$el.append(this.createPointer());
//If it's a sources chart, add a pointer arrow
if (this.type == "sources" && !this.numPrograms)
this.$el.append(this.createPointer());
//Charts with programs need an extra connecter
if (this.programs.length && (this.numSources || this.numDerivations))
this.$(".programs").append(this.createConnecter());
if (this.$(".collapsed").length) {
var expandIcon = $(document.createElement("i")).addClass(
"icon icon-expand-alt",
),
collapseIcon = $(document.createElement("i")).addClass(
"icon icon-collapse-alt",
);
this.$el
.addClass("expand-collapse")
.append(
$(document.createElement("a"))
.addClass("expand-control")
.text("view more ")
.append(expandIcon),
)
.append(
$(document.createElement("a"))
.addClass("collapse-control")
.text("view less ")
.append(collapseIcon),
);
this.collapseNodes();
} else this.$el.css("height", this.height - this.offsetTop);
//Lastly, add the title
if (this.title) {
this.$el.prepend(
$(document.createElement("h3")).addClass("title").text(this.title),
);
}
if (this.canEditContext)
this.$(".program.editor").click(function (e) {
view.selectProvEntities(e);
});
// Render the non-editor prov nodes so each has a unique style.
var nodeMin = 1;
var nodeMax = 23; // Max number of 'uniqueNoden' css classes defined (in metacatui-common.css)
var i = view.getRandomInt(nodeMin, nodeMin + 5);
_.each(view.$(".node:not(.editor)"), function (thisNode) {
//Don't use the unique class on images since they will look a lot different anyway by their image
if (!$(thisNode).first().hasClass("image")) {
var className = "uniqueNode" + i;
//Add the unique class and up the iterator
if ($(thisNode).prop("tagName") != "polygon")
$(thisNode).addClass(className);
else
$(thisNode).attr(
"class",
$(thisNode).attr("class") + " " + className,
);
// Increment the node counter, but not past the max value, which is the number of
// unique css classes that are defined.
i == nodeMax ? (i = nodeMin) : i++;
}
});
return this;
},
/**
* Adapt a projected chart record for CitationView
* @param {object} record Projected chart record
* @returns {Backbone.Model|null} Cached citation source model
* @since 2.39.0
*/
getCitationModel(record) {
if (!record) return null;
if (this.citationModels.has(record.pid)) {
return this.citationModels.get(record.pid);
}
const model = new Backbone.Model({
id: record.pid,
fileName: record.fileName,
title: record.title,
origin: record.origin,
dateUploaded: record.dateUploaded,
seriesId: record.seriesId,
datasource: record.datasource,
});
model.type = "DataONEObject";
model.createViewURL = () =>
`${MetacatUI.root}/view/${encodeURIComponent(record.pid)}`;
this.citationModels.set(record.pid, model);
return model;
},
/**
* Create a rendered provenance node and its detail popover.
* @param {object} provEntity Projected provenance record
* @param {number} position Vertical chart position
* @param {object|null} metadata Metadata record for the citation
* @returns {JQuery|SVGElement} Rendered data or program node
*/
createNode(provEntity, position, metadata) {
// What kind of icon will visually represent this object type?
let icon = "";
let { type } = provEntity;
const view = this;
if (type === "data") icon = "icon-table";
else if (type === "metadata") icon = "icon-file-text";
else if (type === "image") icon = "icon-picture";
else if (type === "PDF") icon = "icon-file pdf";
if (!type) {
type = "data";
icon = "icon-table";
}
// Get the name of this object
const name = provEntity.fileName || provEntity.pid || type;
// Get the node position and whether it overflows its context element.
let top;
let isCollapsed;
if (provEntity.type === "program") {
const distanceFromMiddle =
position * this.nodeHeight - this.nodeHeight / 2;
const operator = distanceFromMiddle > 0 ? "+" : "-";
top = `calc(50% ${operator} ${Math.abs(distanceFromMiddle)}px)`;
isCollapsed = "expanded";
} else {
top = position * this.nodeHeight - this.nodeHeight / 2;
isCollapsed =
top + this.nodeHeight + this.offsetTop >
$(this.contextEl).outerHeight()
? "collapsed"
: "expanded";
}
let nodeEl;
let svg;
let programIconGroup;
if (provEntity.type !== "program") {
// Create a DOM element to represent the node
nodeEl = $(document.createElement("div")).css("top", top);
// Add a delete icon to the node if editing is on
if (this.editModeOn && provEntity.editable !== false) {
// Create a delete icon
const deleteIcon = $(document.createElement("i"))
.addClass("data icon icon-remove-sign remove")
.attr("title", MESSAGES.remove)
.hide();
// Add the delete icon to the node
nodeEl.append(deleteIcon);
deleteIcon.on("click", (evt) => {
// Stop propagation of the click event so parent elements do not receive it.
// This will prevent the node popover from displaying for this node when the delete icon is clicked.
evt.stopPropagation();
// Remove the provenance icon and the associated relationships from the DataPackage.
try {
view.removeProv(provEntity.pid, "data");
} catch (error) {
view.showProvenanceError(error);
}
});
// When the node is hovered over, show the delete icon
nodeEl.hover(
// mouseenter action
() => {
deleteIcon.show();
},
// mouseleave action
// If the mouse passes over the delete icon as it is exiting the prov icon, then the event target
// becomes the delete icon itself, and not the div containing the prov icon. To be safe, just
// turn off all delete icons in this prov chart, which works every time and doesn't require us
// to test the event target that was fired.
() => {
view.$(".remove").hide();
},
);
}
} else {
type = "program";
// Create an SVG drawing for the program arrow shape
svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
nodeEl = $(
document.createElementNS("http://www.w3.org/2000/svg", "polygon"),
).attr("points", "2,20 2,48 17,48 17,67 67,33.5 17,2 17,20");
//Set a viewBox, height, width, and top position
svg.setAttribute(
"viewBox",
`0 0 ${this.nodeHeight} ${this.nodeHeight}`,
);
svg.setAttribute("class", "popover-this");
$(svg)
.attr("width", `${this.nodeHeight}px`)
.attr("height", `${this.nodeHeight}px`)
.css("top", top);
//Create the code icon
const iconEl = $(
document.createElementNS("http://www.w3.org/2000/svg", "text"),
)
.text("\uF121")
.attr("class", "icon icon-foo program-icon pointer");
//Create a group element to contain the icon
programIconGroup = $(
document.createElementNS("http://www.w3.org/2000/svg", "g"),
)
.attr("transform", "translate(18,43)")
.attr("class", "popover-this program-icon pointer");
//Glue it all together
programIconGroup.append(iconEl);
$(svg).append(nodeEl, programIconGroup);
if (this.editModeOn && provEntity.editable !== false) {
// Add a delete icon to the node if editing is on
const deleteGroup = $(
document.createElementNS("http://www.w3.org/2000/svg", "g"),
)
.attr("transform", "translate(35,25)")
.attr("class", "program pointer");
const deleteIcon = $(
document.createElementNS("http://www.w3.org/2000/svg", "text"),
)
.text("\uF057")
.attr("class", "icon icon-foo remove pointer")
.attr("title", MESSAGES.remove)
.hide();
deleteGroup.append(deleteIcon);
$(svg).append(deleteGroup);
deleteIcon.on("click", (evt) => {
// Stop propagation of the click event so parent elements do not receive it.
// This will prevent the node popover from displaying for this node when the delete icon is clicked.
evt.stopPropagation();
try {
view.removeProv(provEntity.pid, "program");
} catch (error) {
view.showProvenanceError(error);
}
});
$(svg).hover(
// mouseenter action
() => {
deleteIcon.show();
},
// mouseleave action
() => {
// Hide all remove icons for program nodes. See comments for
// hiding data icons above (mouseleave for data icons).
view.$("[class*='remove']").hide();
},
);
}
}
// Add classes via .attr() so it works for SVG, too
const currentClasses = $(nodeEl).attr("class") || "";
$(nodeEl)
.attr(
"class",
`${currentClasses} ${type} node pointer popover-this ${isCollapsed}`,
)
.attr("tabindex", 0)
// Reference the id of the data object
.attr("data-id", provEntity.pid);
// Read-only provenance looks normal outside edit mode. The message only
// explains why an expected edit control is unavailable.
if (this.editModeOn && provEntity.editable === false) {
$(nodeEl).attr("title", MESSAGES.relationshipCannotBeEdited);
}
// Display images in the prov chart node
if (provEntity.type === "image") {
$(nodeEl).css(
"background-image",
`url('${view.serviceUrl}${encodeURIComponent(provEntity.pid)}')`,
);
}
// Create an icon inside the node for other format types
else {
const iconEl = $(document.createElement("i")).addClass(`${icon} icon`);
// Put the icon in the node
$(nodeEl).append(iconEl);
}
// The placement and title depend on the chart type.
const isDerivation = this.type === "derivations";
const placement = isDerivation ? "left" : "right";
const title = `${isDerivation ? "Derived" : "Source"} ${type}`;
const citationModel = this.getCitationModel(metadata || provEntity);
// The citation
let createLink = true;
if (
provEntity.pid === MetacatUI.appModel.get("pid") ||
citationModel.get("id") === MetacatUI.appModel.get("pid")
) {
createLink = false;
}
const citationHeader = $(document.createElement("h6"))
.addClass("subtle")
.text("Citation");
const citationView = new CitationView({
model: citationModel,
createLink,
}).render();
const citationEl = citationView.el;
this.subviews.push(citationView);
// The title
const titleEl = $(document.createElement("span")).append(
$(document.createElement("i")).addClass(`${icon} icon-on-left`),
title,
);
// The name
const nameEl = name
? $(document.createElement("h5")).addClass("name").text(name)
: null;
// The View link
const arrowIcon = $(document.createElement("i")).addClass(
"icon-double-angle-right icon-on-right",
);
const linkEl = $(document.createElement("a"))
.attr(
"href",
`${MetacatUI.root}/view/${encodeURIComponent(provEntity.pid)}`,
)
.addClass("btn")
.text("View")
.append(arrowIcon);
if (this.memberPidSet.has(provEntity.pid)) {
linkEl.addClass("preview").attr("data-id", provEntity.pid);
}
// The provenance statements
const provStatementView = new ProvStatement({
model: provEntity,
relationships: this.projection?.getStatements(provEntity.pid),
currentlyViewing: this.context,
});
const provStatementEl = provStatementView.render().el;
this.subviews.push(provStatementView);
// Glue all the parts together
const headerContainer = $(document.createElement("div"))
.addClass("well header")
.append(citationHeader, citationEl, linkEl);
const popoverContent = $(document.createElement("div"))
.append(headerContainer, provStatementEl)
.attr("data-id", provEntity.pid);
// Add the name of the data object to the popover
if (name) $(headerContainer).prepend(nameEl);
// Display images in the prov chart node popover
if (provEntity.type === "image") {
const img = $(document.createElement("img"))
.attr(
"src",
`${view.serviceUrl}${encodeURIComponent(provEntity.pid)}`,
)
.addClass("thumbnail");
$(citationEl).after(img);
}
// Mark the node that was last viewed, if any
if (MetacatUI.appModel.get("previousPid") === provEntity.pid) {
$(nodeEl).addClass("previous");
$(citationEl).before(
$(document.createElement("h7")).text("Last viewed"),
);
}
// Get the id->class name map for unique node colors
const classMap = this.parentView.classMap || null;
//Add a popover to the node that will show the citation for this dataset and a provenance statement
$(nodeEl)
.popover({
html: true,
placement: placement,
trigger: "click",
container: this.el,
title: titleEl,
content: function () {
//Find the unique class name associated with this ID
if (classMap) {
var allProvLinks = $(popoverContent).find(
".provenance-statement .node-link[data-id]",
);
_.each(allProvLinks, function (provlink, i, allProvLinks) {
var id = $(provlink).attr("data-id"),
mapItem = _.findWhere(classMap, { id: id });
if (typeof mapItem !== "undefined") {
var className = mapItem.className,
matchingProvLinks = $(allProvLinks).filter(
"[data-id='" + id + "']",
);
if (matchingProvLinks.length > 0)
$(matchingProvLinks).addClass(className);
}
});
}
return popoverContent;
},
})
.on("show.bs.popover", function () {
//Close the last open node popover
$(".popover-this.active").popover("hide");
//Toggle the active class
if ($(this).parent("svg").length)
$(this).attr("class", $(this).attr("class") + " active");
else $(this).toggleClass("active");
})
.on("hide.bs.popover", function () {
//Toggle the active class
if ($(this).parent("svg").length)
$(this).attr(
"class",
$(this).attr("class").replace(" active", " "),
);
else $(this).toggleClass("active");
});
/*
* Set a separate event listener on the program icon since it is overlapped with the program arrow
*/
if (provEntity.type === "program") {
programIconGroup.on("click", function () {
var programNode = $(this).prev("polygon"),
isOpen = $(programNode).attr("class").indexOf("active") > -1;
if (isOpen) $(programNode).popover("hide");
else $(programNode).popover("show");
});
}
// If the prov statement views in the popover content have an expand collapse list view, then we want to delegate events
// again when the popover is done displaying. This is because the ExpandCollapseList view hides/shows DOM elements, and each time
// the DOM elements are hidden, their events are detached.
if (provStatementView.subviews.length > 0) {
//Get the ExpandCollapseList views
var expandCollapseLists = _.where(provStatementView.subviews, {
name: "ExpandCollapseList",
});
if (expandCollapseLists.length > 0) {
//When the popover is *done* displaying
$(nodeEl).on("shown.bs.popover", function () {
//Delegate the events of each of the ExpandCollapseList views
_.each(expandCollapseLists, function (subview) {
subview.delegateEvents(subview.events);
});
});
}
}
//If this node is rendered as an SVG, return that. Otherwise return the node element created.
return typeof svg != "undefined" ? svg : nodeEl;
},
/**
* Create a control for adding a provenance relationship.
* @param {string} type Either data or program
* @param {string} id Context object identifier
* @param {number} position Vertical chart position
* @returns {JQuery|SVGElement} Rendered edit control
*/
createEditorNode: function (type, id, position) {
//Get the top CSS style of this node based on its position in the chart and determine if it vertically overflows past its context element
if (type == "program") {
var distanceFromMiddle =
position * this.nodeHeight - this.nodeHeight / 2,
operator = distanceFromMiddle > 0 ? "+" : "-",
top =
"calc(50% " +
operator +
" " +
Math.abs(distanceFromMiddle).toString() +
"px)",
isCollapsed = "expanded";
} else {
var top = position * this.nodeHeight - this.nodeHeight / 2,
isCollapsed =
top + this.nodeHeight + this.offsetTop >
$(this.contextEl).outerHeight()
? "collapsed"
: "expanded";
}
var nodeEl = null;
var svg = null;
// Only two types of editor nodes, "data" and "program"
if (type != "program") {
//Create a DOM element to represent the node
nodeEl = $(document.createElement("div")).css("top", top);
//Add classes via .attr() so it works for SVG, too
var currentClasses = nodeEl.attr("class") || "";
nodeEl.attr(
"class",
currentClasses + " " + type + " node pointer editor " + isCollapsed,
);
nodeEl.attr("tabindex", 0);
//Reference the id of the data object
nodeEl.attr("data-id", id);
//Create the plus icon
var iconEl = $(document.createElement("i")).addClass("icon icon-plus");
//Put the icon in the node
nodeEl.append(iconEl, "Add");
} else {
//Create an SVG drawing for the program arrow shape
svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
nodeEl = $(
document.createElementNS("http://www.w3.org/2000/svg", "polygon"),
);
//Create the SVG shape by adding x,y coordinates for lines to connect to
nodeEl.attr("points", "2,20 2,48 17,48 17,67 67,33.5 17,2 17,20");
//Set a viewBox, height, width, and top position
svg.setAttribute(
"viewBox",
"0 0 " + this.nodeHeight + " " + this.nodeHeight,
);
//svg.setAttribute("class", "editor");
$(svg)
.attr("width", this.nodeHeight + "px")
.attr("height", this.nodeHeight + "px")
.css("top", top);
//Create the plus icon
var iconEl = $(
document.createElementNS("http://www.w3.org/2000/svg", "text"),
)
.text("\uF067")
//.attr("class", "icon icon-foo program-icon pointer");
.attr("class", "icon icon-foo pointer");
//Create a group element to contain the icon
var g = document.createElementNS("http://www.w3.org/2000/svg", "g");
$(g).attr("transform", "translate(25,30)");
$(g).attr("class", " program editor pointer ");
//Add classes via .attr() so it works for SVG, too
var currentClasses = nodeEl.attr("class") || "";
nodeEl.attr(
"class",
currentClasses + " " + type + " node editor pointer " + isCollapsed,
);
nodeEl.attr("tabindex", 0);
nodeEl.attr("data-id", id);
//Create a "group" element
var gAdd = $(
document.createElementNS("http://www.w3.org/2000/svg", "g"),
);
//Position the group element and add the text "Add"
gAdd
.attr("transform", "translate(18,45)")
.attr("class", " program node editor pointer ")
.append(
$(
document.createElementNS("http://www.w3.org/2000/svg", "text"),
).text("Add"),
);
//Glue it all together
$(g).append(iconEl);
$(svg).append(nodeEl, g, gAdd);
}
if (svg != null) {
return svg;
} else {
return nodeEl;
}
},
/**
* Create a connector at a chart position.
* @param {number} [position] Vertical chart position
* @returns {JQuery} Rendered connector
*/
createConnecter: function (position) {
if (typeof position == "undefined") {
var top = "50%",
isCollapsed = "";
} else {
var top = this.nodeHeight * position,
isCollapsed =
top + this.nodeHeight / 2 + this.offsetTop >
$(this.contextEl).outerHeight()
? "collapsed"
: "expanded";
}
return $(document.createElement("div"))
.addClass("connecter " + isCollapsed)
.css("top", top);
},
/**
* Create the arrow that points from the chart to its context.
* @returns {JQuery} Rendered pointer
*/
createPointer() {
return $(document.createElement("img"))
.attr("src", MetacatUI.root + "/img/arrow.gif")
.addClass("prov-pointer");
},
/**
* Add editing guidance to a provenance control.
* @param {HTMLElement|JQuery} nodeEl Provenance control
* @returns {void}
*/
createEditTooltip: function (nodeEl) {
//Start a tooltip title and get the object's file name
var toolTipTitle = "",
fileName = this.context.fileName || " this data file.",
nodeType = $(nodeEl).is("svg") ? "program" : "data";
//Create the tooltip title
if (this.type == "sources" && nodeType == "data") {
toolTipTitle = "Add source " + nodeType + " to " + fileName;
} else if (this.type == "sources" && nodeType == "program") {
toolTipTitle = "Add a program that output " + fileName;
} else if (this.type == "derivations" && nodeType == "data") {
toolTipTitle = "Add derived data to " + fileName;
} else if (this.type == "derivations" && nodeType == "program") {
toolTipTitle = "Add a program that used " + fileName;
}
//Create the tooltip settings for programs and data nodes
var tooltipOptions = {
placement: "top",
title: toolTipTitle,
delay: 600,
};
//Programs need tooltips to be handled a bit differently since they are SVG elements
if (nodeType == "program") {
//Add the trigger
tooltipOptions.trigger = "manual";
tooltipOptions.container = this.el;
//Create the Bootstrap tooltip and manually show and hide it
//based on mouseover and mouseout events
$(nodeEl)
.tooltip(tooltipOptions)
.mouseenter(function () {
setTimeout(function () {
$(nodeEl).tooltip("show");
}, 500);
})
.mouseleave(function () {
setTimeout(function () {
$(nodeEl).tooltip("hide");
}, 500);
});
} else {
tooltipOptions.trigger = "hover";
//Create the Bootstrap tooltip
$(nodeEl).tooltip(tooltipOptions);
}
},
/**
* Display every node in a collapsed chart.
* @returns {void}
*/
expandNodes: function () {
//Change the context element (accompanying metadata section) and the chart itself to the full expanded height
$(this.contextEl).height(this.height + this.offsetTop);
this.$el.height(this.height - this.offsetTop);
//Hide the expand control and show the hidden nodes
this.$(".expand-control").fadeOut();
this.$(".collapse-control").fadeIn();
this.$(".collapsed").fadeIn();
},
/**
* Hide nodes that extend beyond the chart context.
* @returns {void}
*/
collapseNodes() {
//Fit the context element to its contents
$(this.contextEl).height("auto");
//For source charts
// Use the last expanded/visible connector element to determine the chart height
const lastConnecter = _.last(this.$(".connecter.expanded"));
if (lastConnecter) {
this.$el.height(parseInt(lastConnecter.style.top, 10));
} else {
this.$el.height(this.height);
}
// Find the pointer and move to the halfway point of the chart height
this.$(".prov-pointer").css("top", "50%");
//Hide the expand control and show the hidden nodes
this.$(".expand-control").fadeIn();
this.$(".collapse-control").css("display", "none");
this.$(".collapsed").fadeOut();
},
/**
* Exchange the chart positions of two nodes.
* @param {HTMLElement|JQuery} nodeA Node to move to the first position
* @param {HTMLElement|JQuery} nodeB Node to move to the previous position
* @returns {void}
*/
switchNodes: function (nodeA, nodeB) {
if (nodeA == nodeB) return;
var oldPosition = $(nodeA).css("top");
var isCollapsed = $(nodeA).hasClass("collapsed");
$(nodeA)
.css("top", (this.nodeHeight / 2) * -1)
.removeClass("collapsed");
$(nodeB).first().css("top", oldPosition);
if (isCollapsed) $(nodeB).first().addClass("collapsed");
},
/**
* Preview or navigate to the selected provenance record.
* @param {Event} e Preview click event
* @returns {void}
*/
previewData: function (e) {
//Don't go anywhere yet...
e.stopPropagation();
e.preventDefault();
//If this prov chart has a parent view with a previewData function, then execute that
if (
this.parentView &&
this.parentView.previewData &&
this.parentView.previewData(e)
) {
//Trigger a click on the active node to deactivate it
this.$(".node.active").click();
//Exit
return;
}
//Get the target of the click
var button = $(e.target);
if (!$(button).hasClass("preview"))
button = $(button).parents("a.preview");
if (button.length < 1) button = $(button).parents("[href]");
//Trigger a click on the active node to deactivate it
this.$(".node.active").click();
//navigate to the link href
window.location = $(button).attr("href");
},
/**
* Open the package member picker for a provenance relationship.
* @param {Event} e Edit control click event
* @returns {void}
*/
selectProvEntities(e) {
const provInfoURL = MetacatUI.appModel.get("provenanceInfoURL");
const helpLink = provInfoURL
? $(document.createElement("a"))
.attr("href", provInfoURL)
.attr("target", "_blank")
.text(MESSAGES.learnMore)
.append(
$(document.createElement("i")).addClass(
CLASS_NAMES.learnMoreIcon,
),
)
: null;
const isProgram = e.currentTarget.classList.contains("program");
const selectEntityType = isProgram ? "program" : "data";
const mode = classifyRelationshipMode(
this.type,
selectEntityType,
this.context.type,
);
const { title, lead, tail } = getPickerCopy(mode, this.context.fileName);
const helpText = createHelpText(lead, this.context.fileName, tail);
let relatedRecords = this.programs;
if (!isProgram) {
relatedRecords =
this.type === "sources" ? this.sources : this.derivations;
}
const excludedPids = new Set(
(relatedRecords || []).map(({ pid }) => pid),
);
// Add a link to a help/more info page to the help text
if (helpLink) {
helpText.append(helpLink);
}
this.closeProvEntitySelect();
this.selectProvEntityView = new ProvEntitySelectView({
title,
selectLabel: MESSAGES.chooseFiles,
additionalElements: helpText,
selectEntityType, // Can be either "data" or "program"
projection: this.projection,
context: this.context,
excludedPids,
// Number of rows in the select list
displayRows: Math.min(10, this.projection?.records.length || 0),
});
this.$el.append(this.selectProvEntityView.render().el);
this.subviews.push(this.selectProvEntityView);
// Display the modal and wait for completion.
this.$("#selectModal").modal("show");
},
/**
* Close and forget the active provenance picker
* @returns {void}
* @since 2.39.0
*/
closeProvEntitySelect() {
if (!this.selectProvEntityView) return;
const selectView = this.selectProvEntityView;
selectView.onClose();
this.subviews = _.without(this.subviews, selectView);
this.selectProvEntityView = null;
},
/**
* Apply the relationships selected in the provenance picker.
* @returns {boolean} Whether at least one relationship was applied
*/
getSelectedProvEntities() {
const selectView = this.selectProvEntityView;
// Read values from the selection list modal dialog
const selectedValues = selectView.readSelected();
this.$("#selectModal").modal("hide");
// Return if no values were selected.
if (!selectedValues.length) {
this.closeProvEntitySelect();
return false;
}
// Get the entity type ("program" or "data") from the selection view. This
// is the entity type of the prov icon that was clicked in order to add
// this type to the prov of the current package member. The entityType
// is either "program" or "data".
const entityType = selectView.selectEntityType;
this.closeProvEntitySelect();
// Add the selected values to this prov graph.
try {
this.addProv(selectedValues, entityType);
return true;
} catch (error) {
this.showProvenanceError(error);
return false;
}
},
/**
* Add the relationships represented by this chart through DataPackage.
* @param {string[]} values Related object identifiers
* @param {string} entityType Either data or program
* @returns {void}
* @throws {Error} When DataPackage rejects a relationship
*/
addProv: function (values, entityType) {
values.forEach((pid) => {
this.changeProv("add", pid, entityType);
});
},
/**
* Show a rejected provenance change
* @param {Error} error Provenance error
* @returns {void}
* @since 2.39.0
*/
showProvenanceError(error) {
const message = error?.message || MESSAGES.provenanceRejected;
if (this.parentView?.showMessage) {
this.parentView.showMessage(message, { type: "error" });
} else {
MetacatUI.appView?.showAlert?.(
message,
CLASS_NAMES.alertError,
"body",
5000,
);
}
},
/**
* Close the picker, remove subviews, and remove the chart.
* @returns {void}
*/
onClose() {
const modal = this.$("#selectModal");
if (modal.length && typeof modal.modal === "function") {
modal.modal("hide");
}
this.closeProvEntitySelect();
this.subviews.forEach((view) => {
if (view.onClose) view.onClose();
else view.remove?.();
});
this.subviews = [];
this.remove();
this.unbind();
},
/**
* Choose an integer from an inclusive minimum and exclusive maximum.
* @param {number} min Minimum value
* @param {number} max Exclusive maximum value
* @returns {number} Random integer
*/
getRandomInt: function (min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min)) + min; //The maximum is exclusive and the minimum is inclusive
},
/**
* Remove the relationship represented by a chart node.
* @param {string} pidToRemove Related object identifier
* @param {string} entityType Either data or program
* @returns {void}
* @throws {Error} When DataPackage rejects the change
*/
removeProv(pidToRemove, entityType) {
this.changeProv("remove", pidToRemove, entityType);
},
/**
* Apply one chart relationship change through DataPackage
* @param {string} action Either add or remove
* @param {string} pid Related object identifier
* @param {string} entityType Either data or program
* @returns {void}
* @throws {Error} When DataPackage rejects the change
* @since 2.39.0
*/
changeProv(action, pid, entityType) {
const generatedByProgram =
action === "add" ? "addGeneratedByProgram" : "removeGeneratedByProgram";
const usedByProgram =
action === "add" ? "addUsedByProgram" : "removeUsedByProgram";
const wasDerivedFrom =
action === "add" ? "addWasDerivedFrom" : "removeWasDerivedFrom";
const mode = classifyRelationshipMode(
this.type,
entityType,
this.context.type,
);
if (mode === RELATIONSHIP_MODES.generatingProgram) {
this.dataPackage[generatedByProgram](this.context.pid, pid);
dataRecords(this.sources).forEach((source) => {
this.dataPackage[usedByProgram](source.pid, pid);
});
} else if (mode === RELATIONSHIP_MODES.programInput) {
this.dataPackage[usedByProgram](pid, this.context.pid);
dataRecords(this.projection?.getDerivations(this.context.pid)).forEach(
(derivation) => {
this.dataPackage[wasDerivedFrom](derivation.pid, pid);
},
);
} else if (mode === RELATIONSHIP_MODES.sourceData) {
this.dataPackage[wasDerivedFrom](this.context.pid, pid);
editableRecords(this.programs).forEach((program) => {
this.dataPackage[usedByProgram](pid, program.pid);
});
} else if (mode === RELATIONSHIP_MODES.usingProgram) {
this.dataPackage[usedByProgram](this.context.pid, pid);
dataRecords(this.derivations).forEach((derivation) => {
this.dataPackage[generatedByProgram](derivation.pid, pid);
});
} else if (mode === RELATIONSHIP_MODES.programOutput) {
this.dataPackage[generatedByProgram](pid, this.context.pid);
dataRecords(this.projection?.getSources(this.context.pid)).forEach(
(source) => {
this.dataPackage[wasDerivedFrom](pid, source.pid);
},
);
} else {
this.dataPackage[wasDerivedFrom](pid, this.context.pid);
editableRecords(this.programs).forEach((program) => {
this.dataPackage[generatedByProgram](pid, program.pid);
});
}
},
});
return ProvChartView;
});