PMA.UI Documentation by Pathomation

components/js/aiImageAnalysisResults.js

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);
        }
    }
}