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 {function} options.aiMarketplaceContextEndpointFunction - The function that provides the endpoint to fetch AI analysis context, used for linking to the AI service if available in the annotation metadata.
* @param {string} [options.aiMarketplaceToken] - The token to access the AI marketplace, used for linking to the AI service if available in the annotation metadata.
* @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.
* @param {boolean} [options.fillPoints=false] - Whether or not to fill points in the annotation geometries when navigating to them.
*/
class AIImageAnalysisResults {
constructor(options) {
if (!options.annotationManager) {
console.error("No annotation manager provided!");
return;
}
if (!options.aiMarketplaceContextEndpointFunction) {
console.error("No AI marketplace context endpoint function provided!");
return;
}
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.annotationManager = options.annotationManager;
this.aiMarketplaceToken = options.aiMarketplaceToken;
this.aiMarketplaceContextEndpointFunction = options.aiMarketplaceContextEndpointFunction;
this.zoomOnNavigation = options.zoomOnNavigation === true;
this.focusOnSelection = options.focusOnSelection === true;
this.fillPoints = options.fillPoints === true;
this.fillPointsTransformer = {
id: "fill-ai-analysis-points",
transformerFunction: function (annotation) {
if (!!annotation.Classification && annotation.DrawingType.toLowerCase() === "point") {
annotation.FillColor = annotation.Color;
}
return annotation;
}
};
if (this.fillPoints) {
this.annotationManager.viewport.addAnnotationsTransformer(this.fillPointsTransformer);
}
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}">
<div style="position: relative; width: 100%; text-align: center;">
<h4 style="width: 100%; text-align: center;">AI Image Analysis Results</h4>
<button type="button" class="copy-ai-results-btn btn btn-secondary" title="Copy AI analysis results"
style="position: absolute; right: 8px; top: 5px; border: 1px solid; cursor: pointer; line-height: 1; background: white !important; color: black !important;">
<i class="fa fa-copy" ></i>
</button>
</div>
<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 && x.metaData.Context.indexOf("AIAnalysisOn") !== -1)
.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);
this.analysisSummary = [];
var sourceData = Object.entries(aiGroups).map((analysis) => {
const classifications = groupBy(["AIClassification"])(analysis[1]);
const f = analysis[1][0];
const classificationEntries = 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) => accumulator + currentValue, 0);
const totalArea = classification[1].map((x) => (x.area ? x.area : 0)).reduce((accumulator, currentValue) => 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,
unselectable: false,
summary: {
name: classification[0],
count: classification[1].length,
points: noOfPoints,
totalArea: totalArea,
},
};
});
this.analysisSummary.push({
vendor: f.data.AIVendor,
analysisOn: f.data.AIAnalysisOn,
analysisType: f.data.AIAnalysisType,
service: f.data.AIService,
scenarioType: f.data.AIScenarioType,
scenarioVersion: f.data.AIScenarioVersion,
positivityThreshold: f.data.PositivityThreshold,
proportionScoreFloat: f.data.ProportionScoreFloat,
proportionScore: f.data.ProportionScore,
intensityScore: f.data.IntensityScore,
her2Score: f.data.Her2Score,
tumorProportionScoreFloat: f.data.TumorProportionScoreFloat,
combinedPositiveScoreFloat: f.data.CombinedPositiveScoreFloat,
proliferationScore: f.data.ProliferationScore,
classifications: classificationEntries.map((c) => c.summary),
});
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,
unselectable: false,
children: classificationEntries,
// 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,
// };
// }),
};
});
const existingAnalysisKeys = new Set(sourceData.map((entry) => entry.key));
function getContextValue(context, primaryKey, fallbackKey) {
if (context[primaryKey] !== null && context[primaryKey] !== undefined) {
return context[primaryKey];
}
if (fallbackKey && context[fallbackKey] !== null && context[fallbackKey] !== undefined) {
return context[fallbackKey];
}
return null;
}
function formatAnalysisDate(analysisOn) {
if (!analysisOn) {
return "";
}
try {
return new Date(analysisOn).toLocaleString("en-GB", {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
} catch {
return "";
}
}
function parseClassificationCountsColors(rawClassifications) {
if (!rawClassifications) {
return [];
}
let parsedClassifications = rawClassifications;
if (typeof parsedClassifications === "string") {
try {
parsedClassifications = JSON.parse(parsedClassifications);
} catch {
return [];
}
}
if (!parsedClassifications || typeof parsedClassifications !== "object" || Array.isArray(parsedClassifications)) {
return [];
}
return Object.entries(parsedClassifications).map((classification) => {
let count = 0;
let color = null;
const classificationValue = classification[1];
if (classificationValue && typeof classificationValue === "object" && !Array.isArray(classificationValue)) {
count = Number(classificationValue.Count);
color = typeof classificationValue.Color === "string" ? classificationValue.Color : null;
} else {
count = Number(classificationValue);
}
return {
name: classification[0],
count: Number.isFinite(count) ? count : 0,
color: color,
};
});
}
const appendMarketplaceContexts = async () => {
let endpoint = null;
try {
endpoint = self.aiMarketplaceContextEndpointFunction(this.annotationManager.path);
} catch (e) {
console.warn("Failed to compute AI context endpoint", e);
return;
}
if (!endpoint) {
return;
}
let response;
try {
response = await fetch(
endpoint,
this.aiMarketplaceToken
? {
headers: { Authorization: "Bearer " + this.aiMarketplaceToken },
}
: undefined,
);
} catch (e) {
console.warn("Failed to fetch additional AI contexts", e);
return;
}
if (response.status === 404) {
return;
}
if (!response.ok) {
console.warn("Failed to fetch additional AI contexts", response.status);
return;
}
let contexts = null;
try {
contexts = await response.json();
} catch (e) {
console.warn("Failed to parse additional AI contexts", e);
return;
}
if (!Array.isArray(contexts)) {
return;
}
contexts.forEach((context) => {
if (!context || typeof context !== "object") {
return;
}
const aiTaskId = getContextValue(context, "aiTaskId", "AITaskID") || "unknown_task";
const model = getContextValue(context, "model", "AIModel");
const analysisOn = getContextValue(context, "analysisOn", "AIAnalysisOn");
const service = getContextValue(context, "service", "AIService");
const analysisKey = (model || service || "ai-context") + "_" + (analysisOn || "unknown");
if (existingAnalysisKeys.has(analysisKey)) {
return;
}
const vendor = getContextValue(context, "vendor", "AIVendor");
const analysisType = getContextValue(context, "analysisType", "AIAnalysisType");
const scenarioType = getContextValue(context, "scenarioType", "AIScenarioType");
const scenarioVersion = getContextValue(context, "scenarioVersion", "AIScenarioVersion");
const positivityThreshold = getContextValue(context, "positivityThreshold", "PositivityThreshold");
const proportionScoreFloat = getContextValue(context, "proportionScoreFloat", "ProportionScoreFloat");
const proportionScore = getContextValue(context, "proportionScore", "ProportionScore");
const intensityScore = getContextValue(context, "intensityScore", "IntensityScore");
const her2Score = getContextValue(context, "her2Score", "Her2Score");
const tumorProportionScoreFloat = getContextValue(context, "tumorProportionScoreFloat", "TumorProportionScoreFloat");
const combinedPositiveScoreFloat = getContextValue(context, "combinedPositiveScoreFloat", "CombinedPositiveScoreFloat");
const proliferationScore = getContextValue(context, "proliferationScore", "ProliferationScore");
const classificationCounts = parseClassificationCountsColors(getContextValue(context, "classifications", "Classifications"));
const classificationEntries = classificationCounts.map((classification) => {
return {
title: `<strong style="text-transform: capitalize;">${classification.name}</strong> (${classification.count})<span class="color-switcher-wrapper">
<button class="color-switcher" data-value="${classification.color}"
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="${classification.color}"
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>
${classification.count > 1 ? "<div>No of points: <i>" + classification.count + "</i></div>" : ""}`,
key: analysisKey + "_" + classification.name,
id: [],
taskId: aiTaskId,
classification: classification.name,
type: "classification",
selected: true,
unselectable: false,
summary: {
name: classification.name,
count: classification.count,
color: classification.color,
points: classification.count,
totalArea: 0,
},
};
});
self.analysisSummary.push({
aiTaskId: aiTaskId,
vendor: vendor,
analysisOn: analysisOn,
analysisType: analysisType,
service: service,
scenarioType: scenarioType,
scenarioVersion: scenarioVersion,
positivityThreshold: positivityThreshold,
proportionScoreFloat: proportionScoreFloat,
proportionScore: proportionScore,
intensityScore: intensityScore,
her2Score: her2Score,
tumorProportionScoreFloat: tumorProportionScoreFloat,
combinedPositiveScoreFloat: combinedPositiveScoreFloat,
proliferationScore: proliferationScore,
classifications: classificationEntries.map((entry) => entry.summary),
});
const analysisDate = formatAnalysisDate(analysisOn);
sourceData.push({
title: `<strong>${vendor || "Unknown vendor"}${analysisDate ? " on " + analysisDate : ""}</strong>
<div class="pma-ui-ai-analysis-info">
${analysisType ? "Analysis type: <i>" + analysisType + "</i><br />" : ""}
${service ? "AI service: <i>" + service + "</i><br />" : ""}
${scenarioType ? "AI scenario: <i>" + scenarioType + (scenarioVersion ? ` (v.${scenarioVersion})` : "") + "</i><br />" : ""}
${positivityThreshold !== null && positivityThreshold !== undefined ? "Positivity threshold: <i>" + positivityThreshold + "</i><br />" : ""}
${proportionScoreFloat !== null && proportionScoreFloat !== undefined ? "Proportion score (float): <i>" + proportionScoreFloat + "</i><br />" : ""}
${proportionScore !== null && proportionScore !== undefined ? "Proportion score: <i>" + proportionScore + "</i><br />" : ""}
${intensityScore !== null && intensityScore !== undefined ? "Intensity score: <i>" + intensityScore + "</i><br />" : ""}
${her2Score !== null && her2Score !== undefined ? "HER2 score: <i>" + her2Score + "</i><br />" : ""}
${tumorProportionScoreFloat !== null && tumorProportionScoreFloat !== undefined ? "Tumor proportion score (float): <i>" + tumorProportionScoreFloat + "</i><br />" : ""}
${combinedPositiveScoreFloat !== null && combinedPositiveScoreFloat !== undefined ? "Combined positive score (float): <i>" + combinedPositiveScoreFloat + "</i><br />" : ""}
${proliferationScore !== null && proliferationScore !== undefined ? "Proliferation score: <i>" + proliferationScore + "</i>" : ""}
</div>`,
key: analysisKey,
id: [],
taskId: aiTaskId,
classification: null,
type: "analysis",
selected: true,
unselectable: false,
children: classificationEntries,
});
existingAnalysisKeys.add(analysisKey);
});
};
const initializeFancytree = async () => {
await appendMarketplaceContexts();
self.fancytree = createTree(`.image-analysis-${self.id} .ai-analysis-tree`, {
checkbox: true,
selectMode: 3,
toggleEffect: false,
source: sourceData,
select: function (event, data) {
const nodesToExclude = data.tree.findAll(n =>
n.data && n.data.taskId && n.data.classification && !n.isSelected()
);
if (nodesToExclude.length === 0) {
self.annotationManager.viewport.options.annotations.excludedContexts = null;
} else {
self.annotationManager.viewport.options.annotations.excludedContexts = nodesToExclude.map(
(n) => `{"AITaskId":"${n.data.taskId}","AIClassification":"${n.data.classification}"}`,
);
}
self.annotationManager.viewport.reloadAnnotations();
},
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) => {
if (!data.node.data.id.length || data.node.data.id.length === 0) {
if (data.node.data.taskId) {
if (data.node.data.classification) {
const features = self.annotationManager.viewport.annotationsLayer
.getSource()
.getFeatures()
.filter((f) => {
return (
f.metaData.Context &&
f.metaData.Classification &&
f.metaData.Context.indexOf(data.node.data.taskId) !== -1 &&
f.metaData.Classification.toLowerCase() === data.node.data.classification.toLowerCase()
);
});
if (features.length) {
features.forEach((f) => {
self.annotationManager.hoverInteraction.getFeatures().push(f);
});
}
} else {
const features = self.annotationManager.viewport.annotationsLayer
.getSource()
.getFeatures()
.filter((f) => {
return f.metaData.Context && f.metaData.Context.indexOf(data.node.data.taskId) !== -1;
});
if (features.length) {
features.forEach((f) => {
self.annotationManager.hoverInteraction.getFeatures().push(f);
});
}
}
}
} else {
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");
}
if (data.node.data.id && data.node.data.id.length > 0) {
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);
} else {
if (!data.node.data.taskId || !data.node.data.classification) {
return;
}
const annotationsTransformer = {
id: `${data.node.data.taskId}_${data.node.data.classification}_color_transformer`,
transformerFunction: (annotation) => {
let taskId = data.node.data.taskId;
if (annotation.Classification && annotation.Classification.toLowerCase() === data.node.data.classification.toLowerCase() && annotation.Context && annotation.Context.indexOf(taskId) !== -1) {
const currentColor = annotation.Color;
annotation.Color = swr[0].jscolor.targetElement.dataset.currentColor;
if (annotation.FillColor === currentColor) {
annotation.FillColor = annotation.Color;
}
}
return annotation;
}
};
self.annotationManager.viewport.addAnnotationsTransformer(annotationsTransformer);
}
},
});
if (cr && cr.length > 0) {
cr.off("click");
cr.on("click", (e) => {
let oldColor = $(e.currentTarget).data("value");
if (/^#([0-9A-Fa-f]{6})$/.test(oldColor)) {
oldColor += "ff";
}
myPicker.fromString(oldColor);
$(e.currentTarget).addClass("hidden");
if (data.node.data.id && data.node.data.id.length > 0) {
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);
} else {
if (!data.node.data.taskId || !data.node.data.classification) {
return;
}
const transformerId = `${data.node.data.taskId}_${data.node.data.classification}_color_transformer`;
self.annotationManager.viewport.removeAnnotationsTransformer(transformerId);
}
});
}
}
},
});
};
initializeFancytree();
function navigatePrevious() {
if (!self.fancytree || !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.fancytree || !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());
}
function getResultsAsText() {
if (!self.analysisSummary || self.analysisSummary.length === 0) {
return "";
}
const lines = [];
self.analysisSummary.forEach((analysis, idx) => {
lines.push("");
const analysisDate = analysis.analysisOn
? new Date(analysis.analysisOn).toLocaleString("en-GB", {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
: "";
lines.push((analysis.vendor || "Unknown vendor") + (analysisDate ? " on " + analysisDate : ""));
if (analysis.analysisType) {
lines.push("Analysis type: " + analysis.analysisType);
}
if (analysis.service) {
lines.push("AI service: " + analysis.service);
}
if (analysis.scenarioType) {
lines.push("AI scenario: " + analysis.scenarioType + (analysis.scenarioVersion ? " (v." + analysis.scenarioVersion + ")" : ""));
}
if (analysis.positivityThreshold !== null && analysis.positivityThreshold !== undefined) {
lines.push("Positivity threshold: " + analysis.positivityThreshold);
}
if (analysis.proportionScoreFloat !== null && analysis.proportionScoreFloat !== undefined) {
lines.push("Proportion score (float): " + analysis.proportionScoreFloat);
}
if (analysis.proportionScore !== null && analysis.proportionScore !== undefined) {
lines.push("Proportion score: " + analysis.proportionScore);
}
if (analysis.intensityScore !== null && analysis.intensityScore !== undefined) {
lines.push("Intensity score: " + analysis.intensityScore);
}
if (analysis.her2Score !== null && analysis.her2Score !== undefined) {
lines.push("HER2 score: " + analysis.her2Score);
}
if (analysis.tumorProportionScoreFloat !== null && analysis.tumorProportionScoreFloat !== undefined) {
lines.push("Tumor proportion score (float): " + analysis.tumorProportionScoreFloat);
}
if (analysis.combinedPositiveScoreFloat !== null && analysis.combinedPositiveScoreFloat !== undefined) {
lines.push("Combined positive score (float): " + analysis.combinedPositiveScoreFloat);
}
if (analysis.proliferationScore !== null && analysis.proliferationScore !== undefined) {
lines.push("Proliferation score: " + analysis.proliferationScore);
}
if (analysis.classifications && analysis.classifications.length > 0) {
analysis.classifications.forEach((c) => {
lines.push("- " + c.name + " (" + c.count + ")");
if (c.points > 1) {
lines.push(" No of points: " + c.points);
}
if (c.totalArea > 0) {
lines.push(" Area: " + formatArea(c.totalArea));
}
});
}
if (idx < self.analysisSummary.length - 1) {
lines.push("");
}
});
return lines.join("\n").trim();
}
async function copyResults() {
const text = getResultsAsText();
if (!text) {
console.warn("No AI analysis results available to copy");
return false;
}
try {
await navigator.clipboard.writeText(text);
return true;
} catch (e) {
console.error("Failed to copy AI analysis results", e);
return false;
}
}
function showCopyFeedback(success) {
const btn = $(`.image-analysis-${self.id} .copy-ai-results-btn`);
if (!btn.length) {
return;
}
btn.attr("disabled", true);
btn.css("opacity", 0.75);
if (success) {
btn.find("i").attr("class", "fa fa-check");
} else {
btn.find("i").attr("class", "fa fa-exclamation-triangle");
}
if (self.copyFeedbackTimeout) {
clearTimeout(self.copyFeedbackTimeout);
}
self.copyFeedbackTimeout = setTimeout(() => {
btn.find("i").attr("class", "fa fa-copy");
btn.attr("disabled", false);
btn.css("opacity", "");
}, 1500);
}
$(`.image-analysis-${this.id} .copy-ai-results-btn`).off("click");
$(`.image-analysis-${this.id} .copy-ai-results-btn`).on("click", async () => {
const res = await copyResults();
showCopyFeedback(res);
});
$(`.image-analysis-${this.id} .navigate-previous-btn`).on("click", navigatePrevious);
$(`.image-analysis-${this.id} .navigate-next-btn`).on("click", navigateNext);
this.annotationManager.listen("annotationsSelectionChanged", () => {
if (!self.fancytree) {
return;
}
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
* @param {boolean} zoomOnNavigation - Whether to zoom when navigating to an annotation
*/
setZoomOnNavigation(zoomOnNavigation) {
this.zoomOnNavigation = zoomOnNavigation === true;
}
/**
* Gets the state of the focusOnSelection option
* @returns {boolean}
*/
getFocusOnSelection() {
return this.focusOnSelection;
}
/**
* Toggles the focusOnSelection option
* @param {boolean} focusOnSelection - Whether to focus on the selected annotation when the selection changes
*/
setFocusOnSelection(focusOnSelection) {
this.focusOnSelection = focusOnSelection === true;
}
/**
* Gets the fill points for the AI analysis annotations
* @returns {boolean}
*/
getFillPoints() {
return this.fillPoints;
}
/**
* Toggles the fillPoints option for the AI analysis annotations
* @param {boolean} fillPoints - Whether to fill the points for the AI analysis annotations
*/
setFillPoints(fillPoints) {
this.fillPoints = fillPoints === true;
if (fillPoints === true) {
this.annotationManager.viewport.addAnnotationsTransformer(this.fillPointsTransformer);
} else {
this.annotationManager.viewport.removeAnnotationsTransformer(this.fillPointsTransformer.id);
}
}
}