import $ from "jquery";
import { createTree } from "jquery.fancytree/dist/modules/jquery.fancytree";
import "jquery.fancytree/dist/modules/jquery.fancytree.dnd5";
import "jquery.fancytree/dist/skin-win8/ui.fancytree.min.css";
import "font-awesome/css/font-awesome.css";
import { Resources } from "../../resources/resources";
import { Events } from "./components";
import "../../../lib/jscolor/jscolor";
/* global JSColor:readonly */
function formatArea(area) {
var supscript = String.fromCharCode("2".charCodeAt(0) + 128);
var output;
if (area >= 100000) {
output = Math.round((area * 10) / 1000000) / 10 + " mm" + supscript;
} else {
output = Math.round(area * 10) / 10 + " μm" + supscript;
}
return output;
}
export /**
* Represents a UI component that allows the manipulation of AI analysis annotations.
* @memberof PMA.UI.Components
* @alias AIImageAnalysisResults
* @param {object} options - Configuration options
* @param {AnnotationManager} options.annotationManager - The annotation manager to use.
* @param {string|HTMLElement} options.element - Selector or HTML element where the UI will be rendered.
* @param {boolean} [options.zoomOnNavigation=false] - Whether or not zoom to or just highlight annotation on focus.
* @param {boolean} [options.focusOnSelection=false] - Whether or not focus on selected annotation.
*/
class AIImageAnalysisResults {
constructor(options) {
if (!options.annotationManager) {
console.error("No annotation manager provided!");
return;
}
this.annotationManager = options.annotationManager;
if (!options.element) {
console.error("No element provided!");
return;
}
if (options.statusElement instanceof HTMLElement) {
this.element = options.element;
} else if (typeof options.element == "string") {
let el = document.querySelector(options.element);
if (!el) {
console.error("Invalid selector for element");
return;
} else {
this.element = el;
}
} else {
console.error("Invalid element");
return;
}
this.zoomOnNavigation = options.zoomOnNavigation === true;
this.focusOnSelection = options.focusOnSelection === true;
this.id = Array.from(Array(20), () => Math.floor(Math.random() * 36).toString(36)).join("");
this.navigationCurrentAnnotation = null;
this.navigationAnnotations = [];
this.buttonClass = options.buttonClass ? options.buttonClass : "";
$(this.element).append(`<div class="pma-ui-ai-image-analysis image-analysis-${this.id}">
<h4 style="width: 100%; text-align: center;">AI Image Analysis Results</h4>
<hr style="width: 80%;margin: 0 auto; margin-bottom: 8px;">
<div class="ai-analysis-tree" style="flex: 1; overflow: auto;"></div>
</div>`);
// // Uncomment the following lines if you want to add navigation buttons
// $(this.element).append(`<div class="pma-ui-ai-image-analysis image-analysis-${this.id}">
// <h4 style="width: 100%; text-align: center;">AI Image Analysis Results</h4>
// <hr style="width: 80%;margin: 0 auto; margin-bottom: 8px;">
// <div class="ai-analysis-tree" style="flex: 1; overflow: auto;"></div>
// <div class="ai-analysis-navigation" style="display: flex; padding: 8px; border-top: 1px solid #0001; margin-top: 8px; align-items: center;">
// <button class="navigation-btn navigate-previous-btn ${this.buttonClass}" style="width: 25%; margin: 0 8px;">Previous</button>
// <div class="navigate-info" style="width: 50%; display: flex; justify-content: center; align-items: center; margin: 0 8px; text-align: center;">No annotation is currently focused</div>
// <button class="navigation-btn navigate-next-btn ${this.buttonClass}" style="width: 25%; margin: 0 8px;">Next</button>
// </div>
// </div>`);
const self = this;
const aiAnnotations = this.annotationManager.viewport
.getAnnotations()
.filter((x) => x.metaData.Context)
.map((x) => {
try {
const data = JSON.parse(x.metaData.Context);
return {
id: x.metaData.AnnotationID,
color: x.metaData.Color,
area: x.metaData.Area,
points: x.getGeometry().getType() === "MultiPoint" ? x.getGeometry().getPoints().length : 0,
data: data,
};
}
catch {
return null;
}
})
.filter(x => x);
const groupBy = (keys) => (array) =>
array.reduce((objectsByKeyValue, obj) => {
const value = keys.map((key) => obj.data[key]).join("_");
objectsByKeyValue[value] = (objectsByKeyValue[value] || []).concat(obj);
return objectsByKeyValue;
}, {});
const aiGroups = groupBy(["AIService", "AIAnalysisOn"])(aiAnnotations);
var sourceData = Object.entries(aiGroups).map((analysis) => {
const classifications = groupBy(["AIClassification"])(analysis[1]);
const f = analysis[1][0];
return {
title: `<strong>${f.data.AIVendor} on ${new Date(f.data.AIAnalysisOn).toLocaleString("en-GB", {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})}</strong>
<div class="pma-ui-ai-analysis-info">
${f.data.AIService ? "Analysis type: <i>" + f.data.AIAnalysisType + "</i><br />" : ""}
${f.data.AIService ? "AI service: <i>" + f.data.AIService + "</i><br />" : ""}
${f.data.AIScenarioType ? "AI scenario: <i>" + f.data.AIScenarioType + (f.data.AIScenarioVersion ? ` (v.${f.data.AIScenarioVersion})` : "") + "</i><br />" : ""}
${f.data.PositivityThreshold !== null && f.data.PositivityThreshold !== undefined ? "Positivity threshold: <i>" + f.data.PositivityThreshold + "</i><br />" : ""}
${
f.data.ProportionScoreFloat !== null && f.data.ProportionScoreFloat !== undefined
? "Proportion score (float): <i>" + f.data.ProportionScoreFloat + "</i><br />"
: ""
}
${f.data.ProportionScore !== null && f.data.ProportionScore !== undefined ? "Proportion score: <i>" + f.data.ProportionScore + "</i><br />" : ""}
${f.data.IntensityScore !== null && f.data.IntensityScore !== undefined ? "Intensity score: <i>" + f.data.IntensityScore + "</i><br />" : ""}
${f.data.Her2Score !== null && f.data.Her2Score !== undefined ? "HER2 score: <i>" + f.data.Her2Score + "</i><br />" : ""}
${
f.data.TumorProportionScoreFloat !== null && f.data.TumorProportionScoreFloat !== undefined
? "Tumor proportion score (float): <i>" + f.data.TumorProportionScoreFloat + "</i><br />"
: ""
}
${
f.data.CombinedPositiveScoreFloat !== null && f.data.CombinedPositiveScoreFloat !== undefined
? "Combined positive score (float): <i>" + f.data.CombinedPositiveScoreFloat + "</i><br />"
: ""
}
${f.data.ProliferationScore !== null && f.data.ProliferationScore !== undefined ? "Proliferation score: <i>" + f.data.ProliferationScore + "</i>" : ""}
</div>`,
key: f.data.AIModel + "_" + f.data.AIAnalysisOn,
id: Object.entries(classifications).flatMap((classification) => classification[1].map((x) => x.id)),
type: "analysis",
selected: true,
children: Object.entries(classifications).map((classification) => {
const classificationColor = classification[1][0].color;
const noOfPoints = classification[1]
.map((x) => (x.points ? x.points : 0))
.reduce((accumulator, currentValue) => {
return accumulator + currentValue;
}, 0);
const totalArea = classification[1]
.map((x) => (x.area ? x.area : 0))
.reduce((accumulator, currentValue) => {
return accumulator + currentValue;
}, 0);
return {
title: `<strong style="text-transform: capitalize;">${classification[0]}</strong> (${classification[1].length})
<span class="color-switcher-wrapper">
<button class="color-switcher" data-value="${classificationColor}"
style="height: 19px; padding: 0; margin: 0; border: none; border-radius: 25%; margin-left: 4px;">
</button>
</span>
<span class="color-reset-wrapper">
<button class="color-reset hidden" title="Reset color" data-value="${classificationColor}"
style="height: 19px; padding: 0; margin: 0; border: none; border-radius: 25%; margin-left: 4px; background: transparent;">
<i class="fa ga-lg fa-undo" aria-hidden="true"></i>
</button>
</span>
${noOfPoints > 1 ? "<div>No of points: <i>" + noOfPoints + "</i></div>" : ""}
${totalArea > 0 ? "<div>Area: <i>" + formatArea(totalArea) + "</i></div>" : ""}`,
key: f.data.AIModel + "_" + f.data.AIAnalysisOn + "_" + classification[0],
id: classification[1].map((x) => x.id),
type: "classification",
selected: true,
// children: classification[1].map((annot, idx) => {
// return {
// title: `<strong style="text-transform: capitalize;">${classification[0]} ${idx + 1}</strong>
// ${annot.points > 1 ? "<div>No of points: <i>" + annot.points + "</i></div>" : ""}
// ${annot.data.AIIntensity ? "<div>Intensity: <i>" + annot.data.AIIntensity + "</i></div>" : ""}
// ${area && area > 0 ? "<div>Area: <i>" + formatArea(annot.area) + "</i></div>" : ""}`,
// key: annot.id,
// id: [annot.id],
// type: "annotation",
// selected: true,
// };
// }),
};
}),
};
});
this.fancytree = createTree(`.image-analysis-${this.id} .ai-analysis-tree`, {
checkbox: true,
selectMode: 3,
toggleEffect: false,
source: sourceData,
select: function (event, data) {
data.node.data.id.forEach((id) => {
self.annotationManager.viewport.showAnnotation(id, data.node.isSelected());
});
},
icon: function (event, data) {
if (data.node.type === "analysis" || data.node.type === "classification" || data.node.type === "annotation") {
return "fa fa-lg fa-crosshairs highlighter";
} else {
return "";
}
},
createNode: function (event, data) {
const ht = $(data.node.li).find(".highlighter");
if (ht) {
ht.off("mouseenter");
ht.off("mouseleave");
ht.on("mouseenter", (e) => {
data.node.data.id.forEach((id) => {
var f = self.annotationManager.viewport.annotationsLayer.getSource().getFeatureById(id);
if (f) {
var feats = self.annotationManager.hoverInteraction.getFeatures();
feats.push(f);
}
});
});
ht.on("mouseleave", (e) => {
self.annotationManager.hoverInteraction.getFeatures().clear();
});
if (data.node.type === "annotation" && data.node.data.id.length === 1) {
ht.prop("title", "Click to focus");
ht.off("click");
ht.on("click", (e) => {
var c = self.fancytree.findAll((n) => {
return n.extraClasses === "current-node";
});
c.forEach((cn) => {
cn.extraClasses = "";
cn.render();
});
data.node.extraClasses = "current-node";
self.navigationCurrentAnnotation = self.annotationManager.viewport.getAnnotations().find((x) => x.getId() === data.node.data.id[0]);
self.navigationAnnotations = data.node.parent.data.id;
var curIdx = data.node.parent.data.id.findIndex((x) => self.navigationCurrentAnnotation.getId() === x);
$(`.image-analysis-${self.id} .navigate-info`).text(self.navigationCurrentAnnotation.metaData.Classification + " " + (curIdx + 1));
self.annotationManager.viewport.focusToAnnotation(data.node.data.id[0], self.zoomOnNavigation, [50, 50, 50, 50]);
});
}
}
const swr = $(data.node.li).find(".color-switcher");
const cr = $(data.node.li).find(".color-reset");
if (swr && swr.length > 0) {
var myPicker = new JSColor(swr[0], {
format: "hexa",
value: $(swr).data("value"),
closable: true,
previewSize: 19,
closeText: Resources.translate("Close"),
onChange: () => {
if (cr && cr.length > 0) {
cr.removeClass("hidden");
}
const annots = self.annotationManager.viewport.getAnnotations().filter((x) => data.node.data.id.toString().includes(x.getId().toString()));
annots.forEach((ann) => {
const md = ann.metaData;
md.Color = swr[0].jscolor.targetElement.dataset.currentColor;
self.annotationManager.setMetadata(ann, md);
});
self.annotationManager.fireEvent(Events.AnnotationModified, null);
},
});
if (cr && cr.length > 0) {
cr.off("click");
cr.on("click", (e) => {
const oldColor = $(e.currentTarget).data("value");
myPicker.fromString(oldColor);
$(e.currentTarget).addClass("hidden");
const annots = self.annotationManager.viewport.getAnnotations().filter((x) => data.node.data.id.toString().includes(x.getId().toString()));
annots.forEach((ann) => {
const md = ann.metaData;
md.Color = oldColor;
self.annotationManager.setMetadata(ann, md);
});
self.annotationManager.fireEvent(Events.AnnotationModified, null);
});
}
}
},
});
function navigatePrevious() {
if (!self.navigationCurrentAnnotation || !self.navigationCurrentAnnotation) {
return;
}
var curIdx = self.navigationAnnotations.findIndex((x) => x === self.navigationCurrentAnnotation.getId());
var previousIdx = curIdx - 1;
if (curIdx === 0) {
previousIdx = self.navigationAnnotations.length - 1;
}
self.navigationCurrentAnnotation = self.annotationManager.viewport.getAnnotations().find((x) => x.getId() === self.navigationAnnotations[previousIdx]);
while (
self.annotationManager.viewport.hiddenAnnotations &&
self.annotationManager.viewport.hiddenAnnotations.findIndex((x) => x.getId() === self.navigationCurrentAnnotation.getId()) !== -1
) {
if (curIdx === previousIdx) {
$(`.image-analysis-${self.id} .navigate-info`).text("No annotation is currently focused");
return;
}
previousIdx--;
if (previousIdx === -1) {
previousIdx = self.navigationAnnotations.length - 1;
}
self.navigationCurrentAnnotation = self.annotationManager.viewport.getAnnotations().find((x) => x.getId() === self.navigationAnnotations[previousIdx]);
}
var c = self.fancytree.findAll((n) => {
return n.extraClasses === "current-node";
});
c.forEach((cn) => {
cn.extraClasses = "";
cn.render();
});
var n = self.fancytree.findFirst((n) => {
return n.key.toString() === self.navigationCurrentAnnotation.getId().toString();
});
if (!n) {
return;
}
n.extraClasses = "current-node";
n.render();
n.li.scrollIntoView({ block: "center", inline: "nearest" });
$(`.image-analysis-${self.id} .navigate-info`).text(self.navigationCurrentAnnotation.metaData.Classification + " " + (previousIdx + 1));
self.annotationManager.viewport.focusToAnnotation(self.navigationCurrentAnnotation.getId(), self.zoomOnNavigation, [50, 50, 50, 50]);
self.annotationManager.highlightAnnotation(self.navigationCurrentAnnotation.getId());
}
function navigateNext() {
if (!self.navigationCurrentAnnotation || !self.navigationCurrentAnnotation) {
return;
}
var curIdx = self.navigationAnnotations.findIndex((x) => x === self.navigationCurrentAnnotation.getId());
var nextIdx = curIdx + 1;
if (curIdx === self.navigationAnnotations.length - 1) {
nextIdx = 0;
}
self.navigationCurrentAnnotation = self.annotationManager.viewport.getAnnotations().find((x) => x.getId() === self.navigationAnnotations[nextIdx]);
while (
self.annotationManager.viewport.hiddenAnnotations &&
self.annotationManager.viewport.hiddenAnnotations.findIndex((x) => x.getId() === self.navigationCurrentAnnotation.getId()) !== -1
) {
if (curIdx === nextIdx) {
$(`.image-analysis-${self.id} .navigate-info`).text("No annotation is currently focused");
return;
}
nextIdx++;
if (nextIdx === self.navigationAnnotations.length) {
nextIdx = 0;
}
self.navigationCurrentAnnotation = self.annotationManager.viewport.getAnnotations().find((x) => x.getId() === self.navigationAnnotations[nextIdx]);
}
var c = self.fancytree.findAll((n) => {
return n.extraClasses === "current-node";
});
c.forEach((cn) => {
cn.extraClasses = "";
cn.render();
});
var n = self.fancytree.findFirst((n) => {
return n.key.toString() === self.navigationCurrentAnnotation.getId().toString();
});
if (!n) {
return;
}
n.extraClasses = "current-node";
n.render();
n.li.scrollIntoView({ block: "center", inline: "nearest" });
$(`.image-analysis-${self.id} .navigate-info`).text(self.navigationCurrentAnnotation.metaData.Classification + " " + (nextIdx + 1));
self.annotationManager.viewport.focusToAnnotation(self.navigationCurrentAnnotation.getId(), self.zoomOnNavigation, [50, 50, 50, 50]);
self.annotationManager.highlightAnnotation(self.navigationCurrentAnnotation.getId());
}
$(`.image-analysis-${this.id} .navigate-previous-btn`).on("click", navigatePrevious);
$(`.image-analysis-${this.id} .navigate-next-btn`).on("click", navigateNext);
this.annotationManager.listen("annotationsSelectionChanged", () => {
var sel = self.annotationManager.getSelection();
if (!sel || sel.length === 0) {
return;
}
var c = self.fancytree.findAll((n) => {
return n.extraClasses === "current-node";
});
c.forEach((cn) => {
cn.extraClasses = "";
cn.render();
});
var n = self.fancytree.findFirst((n) => {
return n.key.toString() === sel[0].getId().toString();
});
if (!n) {
$(`.image-analysis-${self.id} .navigate-info`).text("No annotation is currently focused");
return;
}
n.extraClasses = "current-node";
n.render();
n.makeVisible({ scrollIntoView: true }).then(() => {
n.li.scrollIntoView({ block: "center", inline: "nearest" });
});
self.navigationCurrentAnnotation = sel[0];
self.navigationAnnotations = n.parent.data.id;
var curIdx = n.parent.data.id.findIndex((x) => self.navigationCurrentAnnotation.getId() === x);
$(`.image-analysis-${self.id} .navigate-info`).text(self.navigationCurrentAnnotation.metaData.Classification + " " + (curIdx + 1));
if (self.focusOnSelection) {
self.annotationManager.viewport.focusToAnnotation(self.navigationCurrentAnnotation.getId(), self.zoomOnNavigation, [50, 50, 50, 50]);
self.annotationManager.highlightAnnotation(self.navigationCurrentAnnotation.getId());
}
});
}
/**
* Gets the state of the zoomOnNavigation option
* @returns {boolean}
*/
getZoomOnNavigation() {
return this.zoomOnNavigation;
}
/**
* Toggles the zoomOnNavigation option
* @returns {boolean}
*/
setZoomOnNavigation(zoomOnNavigation) {
this.zoomOnNavigation = zoomOnNavigation === true;
}
/**
* Gets the state of the focusOnSelection option
* @returns {boolean}
*/
getFocusOnSelection() {
return this.focusOnSelection;
}
/**
* Toggles the focusOnSelection option
* @returns {boolean}
*/
setFocusOnSelection(focusOnSelection) {
this.focusOnSelection = focusOnSelection === true;
}
}