Source: paperitems/annotationitem.mjs

/**
 * OpenSeadragon paperjs overlay plugin based on paper.js
 * @version 0.4.13
 * 
 * Includes additional open source libraries which are subject to copyright notices
 * as indicated accompanying those segments of code.
 * 
 * Original code:
 * Copyright (c) 2022-2024, Thomas Pearce
 * All rights reserved.
 * 
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * 
 * * Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 * 
 * * Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 * 
 * * Neither the name of osd-paperjs-annotation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 * 
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 * 
 */

import { OpenSeadragon } from '../osd-loader.mjs';
import { paper } from '../paperjs.mjs';
import { AnnotationToolkit } from '../annotationtoolkit.mjs';

/**
 * Represents an annotation item that can be used in a map.
 * @class
 * @memberof OSDPaperjsAnnotation
 */
class AnnotationItem{
    /**
     * Creates a new AnnotationItem instance.
     * @param {Object} feature - The GeoJSON feature containing annotation data.
     * @throws {string} Throws an error if the GeoJSON geometry type is invalid.
     * @property {paper.Item|null} _paperItem - The associated paper item of the annotation.
     * @property {Object} _props - The properties of the annotation.
     * @description This constructor initializes a new annotation item based on the provided GeoJSON feature. It validates the GeoJSON geometry type and sets up the associated paper item and properties.
     */
    constructor(feature){
        if(GeometryTypes.includes( (feature.geometry?.type) || feature.geometry ) === false){
            throw('Bad GeoJSON Geometry type');
        }
        this._paperItem = null;
        this._props = feature.properties || {};
    }
    
    /**
     * Tests whether the geojson type and (optional) subtype are supported by this type of annotation item
     * @param { String } type 
     * @param  { String } [subtype] 
     * @returns { Boolean } The base class always returns false; inheritinc classes override this with class-specific logic
     */
    static supportsGeoJSONType(type, subtype){
        return false; // base class returns false
    }

    /**
     * @param {Object} obj the GeoJSON object to test
     * @returns {Boolean} whether this object is supported
     */
    _supportsGeoJSONObj(obj){
        return this.constructor.supportsGeoJSONType(obj.geometry?.type, obj.geometry?.properties?.subtype);
    }

    /**
     * Retrieves the coordinates of the annotation item.
     * @returns {Array} An array of coordinates.
     * @description This method returns an array of coordinates representing the position of the annotation item.
     */
    getCoordinates(){
        return []
    }
    /**
     * Retrieves the properties of the annotation item.
     * @returns {Object} The properties object.
     * @description This method returns the properties associated with the annotation item.
     */
    getProperties(){
        return {}
    }
    /**
     * Retrieves the style properties of the annotation item.
     * @returns {Object} The style properties in JSON format.
     * @description This method returns the style properties of the annotation item in JSON format.
     */
    getStyleProperties(){
        return this.paperItem.style.toJSON();
    }
    // static getGeometry(){}
    static onTransform(){}

    /**
     * Tests whether a given GeoJSON geometry type and optional `properties.subtype` are supported
     * @param { String } type
     * @param { String } [subtype]
     */
    supportsGeoJSONType(type, subtype){
        return this.constructor.supportsGeoJSONType(type, subtype);
    }
    /**
     * 
     * @returns {Object} object with fields 'type' and optionally 'subtype'
     */
    getGeoJSONType(){
        return {
            type: undefined,
            subtype: undefined
        }
    }
    /**
     * Retrieves the label of the annotation item.
     * @returns {string} The label.
     * @description This method returns the label associated with the annotation item. It looks for the
     * display name of the associated paper item or falls back to the subtype or type from supported types.
     */
    getLabel(){
        if(this.paperItem.displayName){
            return this.paperItem.displayName;
        } else {
            const typeInfo = this.getGeoJSONType();
            return typeInfo.subtype || typeInfo.type;
        }
    }
    /**
     * Retrieves the type of the annotation item.
     * @type {string}
     * @description This property returns the type from the supported types associated with the annotation item.
     */
    get type(){
        return this.getGeoJSONType().type;
    }
    /**
     * Retrieves the subtype of the annotation item.
     * @type {string}
     * @description This property returns the subtype from the supported types associated with the annotation item.
     */
    get subtype(){
        return this.getGeoJSONType().subtype;
    }

    get paperItem(){
        return this._paperItem;
    }
    
    /**
     * 
     * Sets the associated paper item of the annotation item.
     * @param {paper.Item|null} paperItem - The paper item.
     * @description This method sets the associated paper item of the annotation item. It also applies special properties
     * to the paper item to convert it into an annotation item.
     */
    set paperItem(paperItem){
        this._paperItem = paperItem;
        //apply special properties that make the paper.Item an AnnotationItem
        convertPaperItemToAnnotation(this);
    }

    // default implmentation; can be overridden for custom behavior by subclasses
    /**
     * Sets the style properties of the annotation item.
     * @param {Object} properties - The style properties to set.
     * @description This method sets the style properties of the annotation item using the provided properties object.
     */
    setStyle(properties){
        this._paperItem && this._paperItem.style.set(properties);
    }

    // default implementation; can be overridden for custom behavior by subclasses
    /**
     * Converts the annotation item to a GeoJSON feature.
     * @returns {Object} The GeoJSON feature.
     * @description This method converts the annotation item into a GeoJSON feature object. It includes the geometry,
     * properties, style, and other relevant information.
     */
    toGeoJSONFeature(){
        let geoJSON = {
            type:'Feature',
            geometry:this.toGeoJSONGeometry(),
            properties:{
                label:this.paperItem.displayName,
                selected:this.paperItem.selected,
                ...this.getStyleProperties(),
                userdata:this.paperItem.data.userdata,
            }
        }

        return geoJSON;
    }

    // default implementation; can be overridden for custom behavior by subclasses
    /**
     * Converts the annotation item to a GeoJSON geometry.
     * @returns {Object} The GeoJSON geometry.
     * @description This method converts the annotation item into a GeoJSON geometry object, which includes the type,
     * properties, and coordinates of the annotation.
     */
    toGeoJSONGeometry(){
        let geom = {
            type: this.type,
            properties: this.getProperties(),
            coordinates: this.getCoordinates(),
        }
        if(this.subtype){
            geom.properties = Object.assign(geom.properties, {subtype: this.subtype});
        }
        return geom;
    }

}
export{AnnotationItem};

/**
 * Array of valid geometry types for GeoJSON.
 * @constant
 * @type {string[]}
 * @private
 * @description This array contains valid geometry types that can be used in GeoJSON features.
 */
const GeometryTypes = ['Point', 'LineString', 'Polygon', 'MultiPoint', 'MultiLineString', 'MultiPolygon', 'GeometryCollection', null];

/**
 * Array of registered constructors for creating AnnotationItem instances.
 * @private
 * @type {Function[]}
 * @description This array stores the registered constructors that can be used to create AnnotationItem instances.
 */
const _constructors = [];

/**
 * Represents a factory for creating and managing AnnotationItem instances.
 * @class
 * @memberof OSDPaperjsAnnotation
 */
class AnnotationItemFactory{
    constructor(){
        // this._constructors=[];
    }
    /**
     * Register a constructor to the AnnotationItemFactory.
     * @static
     * @param {Function} ctor - The constructor function for creating AnnotationItem instances.
     * @throws {string} Throws an error if the provided constructor does not implement the necessary API.
     * @description This static method registers a constructor to the AnnotationItemFactory. It checks whether the constructor implements the required static accessor supportsGeoJSONType.
     */
    static register(ctor){
        //test whether the object has implemented the necessary API
        if(ctor.supportsGeoJSONType === AnnotationItem.supportsGeoJSONType){
            console.error('Static accessor supportsGeoJSONType must be implemented');
            throw('Static accessor supportsGeoJSONType must be implemented');
        }
        if(!_constructors.includes(ctor)){
            _constructors.push(ctor);
        }
    }
    /**
     * Get a constructor for creating an AnnotationItem instance based on a GeoJSON feature.
     * @static
     * @param {Object} geoJSON - The GeoJSON feature object.
     * @returns {Function|undefined} A constructor function or undefined if no matching constructor is found.
     * @description This static method retrieves a constructor from the registered constructors based on the provided GeoJSON feature. It matches the geometry type and subtype to determine the appropriate constructor.
     */
    static getConstructor(geoJSON){
        if(!('geometry' in geoJSON && 'properties' in geoJSON)){
            console.error('Invalid GeoJSON Feature object. Returning undefined.');
            return;
        }

        let geomType = geoJSON.geometry?.type;
        let geomSubtype = geoJSON.geometry?.properties?.subtype;

        let constructors = _constructors.filter(c=>c.supportsGeoJSONType(geomType, geomSubtype) );
        
        return constructors.slice(-1)[0]; //return the most recent constructor that supports this type
    }
    /**
     * Create an AnnotationItem instance from a GeoJSON feature.
     * @static
     * @param {Object} geoJSON - The GeoJSON feature object.
     * @returns {paper.Item|undefined} A paper.Item instance or undefined if no matching constructor is found.
     * @description This static method creates an AnnotationItem instance from a GeoJSON feature. It retrieves a matching constructor based on the GeoJSON geometry type and subtype, and then creates an AnnotationItem instance using that constructor.
     */
    static itemFromGeoJSON(geoJSON){ 
        if(GeometryTypes.includes(geoJSON.type)){
            geoJSON = {
                type: 'Feature',
                geometry: geoJSON,
                properties: {},
            }
        }
        let ctor = AnnotationItemFactory.getConstructor(geoJSON);
        if(ctor){
            let annotationItem = new ctor(geoJSON);
            return annotationItem.paperItem;
        }
    }
    /**
     * Create an AnnotationItem instance from an existing AnnotationItem.
     * @static
     * @param {paper.Item} item - The paper.Item instance associated with an AnnotationItem.
     * @returns {paper.Item|undefined} A paper.Item instance created from the AnnotationItem, or undefined if the item is not associated with an AnnotationItem.
     * @description This static method creates a new paper.Item instance based on an existing AnnotationItem. It retrieves the underlying AnnotationItem and converts it to a GeoJSON feature. Then, it creates a new paper.Item using the `itemFromGeoJSON` method of the AnnotationItemFactory.
     */
    static itemFromAnnotationItem(item){
        if(!item.annotationItem){
            error('Only paper.Items constructed by AnnotationItem implementations are supported');
            return;
        }
        let geoJSON = {
            type:'Feature',
            geometry: item.annotationItem.toGeoJSONGeometry(),
            properties:item.annotationItem._props,
        };
        return AnnotationItemFactory.itemFromGeoJSON(geoJSON);
    }

}

export{AnnotationItemFactory};



/**
 * Convert a Paper.js item into an AnnotationItem.
 * @private
 * @param {AnnotationItem} annotationItem - The AnnotationItem instance.
 * @description This function takes an AnnotationItem instance and converts the associated paper item into an
 * AnnotationItem by enhancing it with special properties and behaviors.
 */
function convertPaperItemToAnnotation(annotationItem){
    let item = annotationItem.paperItem;
    let constructor = annotationItem.constructor;
    let properties = annotationItem._props;

    AnnotationToolkit.registerFeature(item);
    item.onTransform = constructor.onTransform;

    //style
    annotationItem.setStyle(properties);

    //set fillOpacity property based on initial fillColor alpha value
    item.fillOpacity = item.fillColor ? item.fillColor.alpha : 1;

    //displayName
    item.displayName = properties.label || annotationItem.getLabel();

    item.annotationItem = annotationItem;
    
    //enhance replaceWith functionatily
    item.replaceWith = enhancedReplaceWith;

    //selected or not
    if('selected' in properties){
        item.selected = properties.selected;
    }
}

/**
 * Enhance the `replaceWith` functionality of Paper.js items.
 * @private
 * @param {paper.Item} newItem - The new item to replace with.
 * @returns {paper.Item} The replaced item.
 * @description This function enhances the `replaceWith` functionality of Paper.js items by providing additional
 * behaviors and properties for the replacement item.
 */
function enhancedReplaceWith(newItem){
    if(!newItem.isGeoJSONFeature){
        console.warn('An item with isGeoJSONFeature==false was used to replace an item.');
    }
    newItem._callbacks = this._callbacks;
    let rescale = OpenSeadragon.extend(true,this.rescale,newItem.rescale);
    newItem.style = this.style; //to do: make this work with rescale properties, so that rescale.strokeWidth doesn't overwrite other props
    newItem.rescale=rescale;
    //replace in the paper hierarchy
    this.emit('item-replaced',{item:newItem});
    newItem.project.emit('item-replaced',{item:newItem});
    paper.Item.prototype.replaceWith.call(this, newItem);
    newItem.selected = this.selected;
    newItem.updateFillOpacity();
    newItem.applyRescale();
    newItem.project.view.update();
    return newItem;
}