Source: paperitems/annotationitem.mjs

/**
 * OpenSeadragon paperjs overlay plugin based on paper.js
 * @version 0.7.6
 * 
 * Includes additional open source libraries which are subject to copyright notices
 * as indicated accompanying those segments of code.
 * 
 * Original code:
 * Copyright (c) 2022-2026, 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';
import { validateGeoJSONGeometry } from '../utils/geojsonGeometryShape.mjs';
import { validatePaperItemShapeContract } from '../utils/paperItemShapeContract.mjs';

/**
 * Checklist for new AnnotationItem subclasses (see `convertPaperItemToAnnotation` + `toGeoJSONGeometry`):
 * - If the tool uses `initializeGeoJSONFeature` with empty `coordinates`, the constructor may leave an incomplete
 *   paper graph until the user draws. `getCoordinates()`, `getProperties()`, and any `toGeoJSONGeometry()` override
 *   must not throw in that state—return empty arrays or zeroed metrics as appropriate.
 * - Avoid unguarded `paperItem.children[n]`, `.segments`, or `project` access when those may not exist yet.
 * - If `static paperItemShapeContract` returns `'compoundPath'`, polygon tools and history expect `paper.CompoundPath`;
 *   a plain `Path` (e.g. boolean result) triggers a bind-time warning unless the client wraps before assign.
 * - Export shape remains validated via `toGeoJSONGeometry()` + RFC 7946 nesting (`geojsonGeometryShape.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
    }

    /**
     * Optional editor contract for `paperItem` graph shape. Subclasses return `'compoundPath'` so bind can warn when
     * the item is not a `paper.CompoundPath` (e.g. boolean ops returning a plain `Path`).
     * @returns {'compoundPath'|null}
     */
    static get paperItemShapeContract() {
        return null;
    }

    /**
     * @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;
    }

    /**
     * Assign the enhanced replaceWith behavior to a paper item (preserves selection and emits item-replaced).
     * For internal use by convertPaperItemToAnnotation only. Subclasses should not call this; assign to
     * this.paperItem and the setter will run convertPaperItemToAnnotation, which applies this enhancement.
     * @static
     * @param {paper.Item} paperItem - The paper item to enhance.
     */
    static enhanceReplaceWith(paperItem) {
        paperItem.replaceWith = enhancedReplaceWith;
    }

}
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);
            annotationItem.paperItem.data.userdata = geoJSON.properties?.userdata;
            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 and strokeOpacity properties based on the properties object
    // If explicit opacity was provided, keep it; otherwise derive from the color alpha (back-compat).
    item.fillOpacity = (properties && properties.fillOpacity != null) ? Number(properties.fillOpacity) : (item.fillColor ? item.fillColor.alpha : 1);

    item.strokeOpacity = (properties && properties.strokeOpacity != null) ? Number(properties.strokeOpacity) : (item.strokeColor ? item.strokeColor.alpha : 1);

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

    item.annotationItem = annotationItem;
    
    AnnotationItem.enhanceReplaceWith(item);

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

    const shapeCheck = validatePaperItemShapeContract(annotationItem, item);
    if (!shapeCheck.ok) {
        const tkShape = item.project?.paperScope?.annotationToolkit;
        const strictShape = tkShape?.options?.strictPaperItemContract === true;
        const shapeMsg = `[AnnotationItem] ${shapeCheck.message}`;
        if (strictShape) {
            throw new Error(shapeMsg);
        }
        console.warn(shapeMsg, { annotationItemClass: annotationItem.constructor.name });
    }

    // After paperItem ↔ annotationItem wiring, validate export geometry (single pipeline: toGeoJSONGeometry only).
    // Does not validate ring orientation, bbox, or CRS; only RFC 7946 coordinate nesting vs geometry.type.
    let geom;
    try {
        geom = annotationItem.toGeoJSONGeometry();
    } catch (err) {
        if (err instanceof Error && err.message.startsWith('[AnnotationItem]')) {
            throw err;
        }
        console.warn(
            'AnnotationItem: toGeoJSONGeometry() threw during bind (incomplete geometry or bug; RFC nesting check was not run).',
            err,
        );
        return;
    }
    const check = validateGeoJSONGeometry(geom);
    if (!check.ok) {
        const tk = annotationItem.paperItem?.project?.paperScope?.annotationToolkit;
        const strict = tk?.options?.strictGeometry === true;
        const msg = `[AnnotationItem] ${check.message || 'GeoJSON coordinates do not match declared geometry type.'}`;
        if (strict) {
            throw new Error(msg);
        }
        console.warn(msg, { geometryType: geom?.type });
    }
}

/**
 * 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.');
        if(this.annotationItem){
            this.annotationItem.paperItem = newItem; // this will run convertPaperItemToAnnotation and set newItem to have a reference back to that annotationItem. 
        }
    }
    const selectedBeforeReplace = this.selected;
    const dataBeforeReplace = this.data || {};
    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);

    // Preserve Paper.js item.data for runtime continuity (shallow copy).
    // Old keys win if there is overlap.
    newItem.data = newItem.data || {};
    Object.assign(newItem.data, dataBeforeReplace);

    newItem.selected = selectedBeforeReplace;
    newItem.updateFillOpacity();
    newItem.applyRescale();
    newItem.project.view.update();
    return newItem;
}