/**
* OpenSeadragon annotation 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-2023, 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 this project 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 { AnnotationUI } from './annotationui.mjs';
import { AnnotationToolset } from './annotationtoolset.mjs';
import { AnnotationToolbar } from './annotationtoolbar.mjs';
import { LayerUI } from './layerui.mjs';
import { AnnotationLayout } from './annotationlayout.mjs';
import { attachAnnotationToolkitConfigurationWidget } from './overlays/annotations/index.mjs';
import { PaperOverlay } from './paper-overlay.mjs';
import { AnnotationItemFactory } from './paperitems/annotationitem.mjs';
import { MultiPolygon } from './paperitems/multipolygon.mjs';
import { Placeholder } from './paperitems/placeholder.mjs';
import { Linestring } from './paperitems/linestring.mjs';
import { MultiLinestring } from './paperitems/multilinestring.mjs';
import { RulerMeasurement } from './paperitems/rulermeasurement.mjs';
import { Raster } from './paperitems/raster.mjs';
import { Point } from './paperitems/point.mjs';
import { PointText } from './paperitems/pointtext.mjs';
import { Rectangle } from './paperitems/rectangle.mjs';
import { Ellipse } from './paperitems/ellipse.mjs';
import { cyrb53 } from './utils/hash.mjs';
import { ANNOTATION_UI_SCOPE_CLASS } from './utils/annotationUIScope.mjs';
import { ensureAnnotationUIStyles } from './utils/annotationUIStyles.mjs';
//extend paper prototypes to add functionality
//property definitions
Object.defineProperty(paper.Item.prototype, 'displayName', displayNamePropertyDef());
Object.defineProperty(paper.Item.prototype, 'featureCollection', featureCollectionPropertyDef());
Object.defineProperty(paper.TextItem.prototype, 'content', textItemContentPropertyDef());
Object.defineProperty(paper.Project.prototype, 'descendants', descendantsDefProject());
//extend remove function to emit events for GeoJSON type annotation objects
let origRemove=paper.Item.prototype.remove;
paper.Item.prototype.remove=function(){
const childrenToFireRemove = this.getItems({match: item=>item.isGeoJSONFeatureCollection});
(this.isGeoJSONFeature || this.isGeoJSONFeatureCollection) && this.project.emit('item-removed',{item: this});
childrenToFireRemove.forEach(fc => this.project.emit('item-removed', {item: fc}));
origRemove.call(this);
(this.isGeoJSONFeature || this.isGeoJSONFeatureCollection) && this.emit('removed',{item: this});
childrenToFireRemove.forEach(fc => fc.emit('removed', {item: fc}));
}
//function definitions
paper.Group.prototype.insertChildren=getInsertChildrenDef();
paper.Color.prototype.toJSON = paper.Color.prototype.toCSS;//for saving/restoring colors as JSON
paper.Style.prototype.toJSON = styleToJSON;
paper.View.prototype.getImageData = paperViewGetImageData;
paper.PathItem.prototype.toCompoundPath = toCompoundPath;
paper.PathItem.prototype.applyBounds = applyBounds;
paper.Item.prototype.select = paperItemSelect;
paper.Item.prototype.deselect = paperItemDeselect;
paper.Item.prototype.toggle = paperItemToggle;
//to do: should these all be installed on project instead of scope?
paper.PaperScope.prototype.findSelectedNewItem = findSelectedNewItem;
paper.PaperScope.prototype.findSelectedItems = findSelectedItems;
paper.PaperScope.prototype.findSelectedItem = findSelectedItem;
paper.PaperScope.prototype.scaleByCurrentZoom = function (v) { return v / this.view.getZoom(); };
paper.PaperScope.prototype.getActiveTool = function(){ return this.tool ? this.tool._toolObject : null; }
/**
* A class for creating and managing annotation tools on an OpenSeadragon viewer.
* @class
* @memberof OSDPaperjsAnnotation
* @extends OpenSeadragon.EventSource
*/
class AnnotationToolkit extends OpenSeadragon.EventSource{
/**
* Create a new AnnotationToolkit instance.
* @constructor
* @param {OpenSeadragon.Viewer} openSeadragonViewer - The OpenSeadragon viewer object.
* @param {object} [opts]
* @param {object|boolean} [opts.addUI] - Deprecated. If set, annotation UI is created via addAnnotationUI. Prefer toolbar/layerUI/layout for new code.
* @param {object|boolean} [opts.toolbar] - If set, toolbar is created at construction (same as getToolbar after). Value: true or { tools?: string[] }.
* @param {object|boolean} [opts.layerUI] - If set, layer UI is created at construction (same as getLayerUI after). Value: true or { addFileButton?: boolean }.
* @param {object|boolean} [opts.layout] - If set, AnnotationLayout (grid + optional toggle) is created at construction (same as addAnnotationLayout after). Value: true or { addButton?, initialOpen?, buttonTogglesToolbar?, buttonTogglesLayerUI? }.
* @param {object} [opts.overlay] a PaperOverlay object to use
* @param {object} [opts.destroyOnViewerClose] whether to destroy the toolkit and its overlay when the viewer closes
* @param {object} [opts.cacheAnnotations] whether to keep annotations in memory for images which aren't currently open
* @param {boolean} [opts.strictGeometry=false] when true, `convertPaperItemToAnnotation` throws if `toGeoJSONGeometry()`
* has coordinates whose array nesting does not match RFC 7946 for the declared `geometry.type` (e.g. Polygon vs MultiPolygon).
* When false (default), the same mismatch only logs a console warning. Does not replace a full GeoJSON schema validator.
* @param {boolean} [opts.strictPaperItemContract=false] when true, `convertPaperItemToAnnotation` throws if `paperItem`
* violates `AnnotationItem.constructor.paperItemShapeContract` (e.g. MultiPolygon requires `paper.CompoundPath`).
* When false (default), a violation only logs a console warning.
*/
constructor(openSeadragonViewer, opts = {}) {
super();
if(!opts){
opts = {};
}
this._defaultOptions = {
addUI: false,
toolbar: false,
layerUI: false,
layout: false,
overlay: null,
destroyOnViewerClose: false,
cacheAnnotations: false,
strictGeometry: false,
strictPaperItemContract: false,
events: {
project: true,
toolkit: false,
},
}
this.options = Object.assign({}, this._defaultOptions, opts);
// normalize nested options (Object.assign is shallow)
if (opts && typeof opts.events === 'object') {
this.options.events = Object.assign({}, this._defaultOptions.events, opts.events);
}
this._defaultStyle = {
fillColor: new paper.Color('white'),
strokeColor: new paper.Color('black'),
fillOpacity:1,
strokeOpacity:1,
strokeWidth: 1,
rescale: {
strokeWidth: 1
}
};
this.viewer = openSeadragonViewer;
if (this.viewer?.element) {
this.viewer.element.classList.add(ANNOTATION_UI_SCOPE_CLASS);
}
// set up overlay. If one is passed in, use it. Otherwise, create one.
if(this.options.overlay){
if(this.options.overlay instanceof PaperOverlay){
this.overlay = this.options.overlay;
}
} else {
this.overlay = new PaperOverlay(this.viewer, { overlayType: 'image' });
}
this.paperScope.project.defaultStyle = new paper.Style();
this.paperScope.project.defaultStyle.set(this.defaultStyle);
// set the overlay to auto rescale items
this.overlay.autoRescaleItems(true);
// optionally destroy the annotation toolkit when the viewer closes
if(this.options.destroyOnViewerClose){
this.viewer.addOnceHandler('close', ()=>this.destroy());
}
//bind a reference to this to the viewer and the paperScope, for convenient access
this.viewer.annotationToolkit = this;
this.paperScope.annotationToolkit = this;
this.viewer.world.addHandler('add-item',ev=>{
if(this.options.cacheAnnotations){
this._loadCachedAnnotations(ev.item);
}
})
this.viewer.world.addHandler('remove-item',ev=>{
if(this.options.cacheAnnotations){
this._cacheAnnotations(ev.item);
}
}, false, 1);
//register item constructors
AnnotationItemFactory.register(MultiPolygon);
AnnotationItemFactory.register(Placeholder);
AnnotationItemFactory.register(Linestring);
AnnotationItemFactory.register(MultiLinestring);
AnnotationItemFactory.register(RulerMeasurement);
AnnotationItemFactory.register(Raster);
AnnotationItemFactory.register(Point);
AnnotationItemFactory.register(PointText);
AnnotationItemFactory.register(Rectangle);
AnnotationItemFactory.register(Ellipse);
paper.Item.fromGeoJSON = AnnotationItemFactory.itemFromGeoJSON;
paper.Item.fromAnnotationItem = AnnotationItemFactory.itemFromAnnotationItem;
this._cached = {};
if (this.options.addUI) {
let uiOpts = {};
if (typeof this.options.addUI === 'object') {
uiOpts = this.options.addUI;
}
this.addAnnotationUI(uiOpts);
} else {
if (this.options.toolbar) {
this.getToolbar(this.options.toolbar === true ? {} : this.options.toolbar);
}
if (this.options.layerUI) {
this.getLayerUI(this.options.layerUI === true ? {} : this.options.layerUI);
}
if (this.options.layout) {
this.addAnnotationLayout(this.options.layout === true ? {} : this.options.layout);
}
}
}
/**
* Emit an integration hook event to the configured targets.
* This is the single entrypoint for toolkit-owned "public hook" events so we can
* keep payloads consistent and optionally avoid extra calls.
* @param {string} name - event name (kebab-case)
* @param {Object} payload - event payload (will be shallow-cloned and enriched)
* @param {Object} [ctx]
* @param {OSDPaperjsAnnotation.ToolBase} [ctx.tool] - tool instance associated with the event
*/
_emitIntegrationEvent(name, payload = {}, ctx = {}) {
const tool = ctx && ctx.tool ? ctx.tool : null;
const enriched = Object.assign({}, payload, {
tool: payload.tool ?? tool ?? undefined,
toolName: payload.toolName ?? tool?.toolName ?? undefined,
});
const ev = this.options?.events || this._defaultOptions.events;
if (ev?.project !== false) {
this.paperScope?.project?.emit?.(name, enriched);
}
if (ev?.toolkit) {
this.raiseEvent(name, enriched);
}
}
/**
* Subscribe to Paper.js project events without reaching into paperScope internals.
* Equivalent to: `tk.paperScope.project.on(name, fn)`.
* @param {string} name
* @param {Function} fn
* @returns {this}
*/
on(name, fn) {
this.paperScope?.project?.on?.(name, fn);
return this;
}
/**
* Unsubscribe from Paper.js project events.
* Equivalent to: `tk.paperScope.project.off(name, fn)`.
* @param {string} name
* @param {Function} fn
* @returns {this}
*/
off(name, fn) {
this.paperScope?.project?.off?.(name, fn);
return this;
}
/**
* Get the default style for the annotation items.
*
* @returns {object} The default style object.
*/
get defaultStyle(){
return this._defaultStyle;
}
/**
* Get the default style for the annotation items.
*
* @returns {object} The default style object.
*/
get annotationUI(){
return this._annotationUI;
}
/**
* Get the paperScope associated with this toolkit
*
* @returns {object} The paperScope object for this toolkit's PaperOverlay.
*/
get paperScope(){
return this.overlay.paperScope;
}
/**
* Empty any cached annotations
*/
clearCache(){
this._cached = {};
}
/**
* save the current feature collections to the cache
* @param {TiledImage} tiledImage
* @private
*/
_cacheAnnotations(tiledImage){
try{
const key = cyrb53(JSON.stringify(tiledImage.source));
const tileLayer = this.overlay.getPaperLayer(tiledImage);
if (!tileLayer) {
return;
}
const featureCollections = tileLayer.getItems({match: item=>item.isGeoJSONFeatureCollection});
this._cached[key] = featureCollections;
} catch(e){
console.error('Error with caching', e);
}
}
_loadCachedAnnotations(tiledImage){
try{
const key = cyrb53(JSON.stringify(tiledImage.source));
const featureCollections = this._cached[key] || [];
const tileLayer = this.overlay.getPaperLayer(tiledImage);
if (!tileLayer) {
return;
}
for(const fcGroup of featureCollections){
this._addFeatureCollectionGroupToLayer(fcGroup, tileLayer);
}
} catch(e){
console.error('Error with fetching from cache', e);
}
}
/**
* Add an annotation UI to the toolkit.
*
* @param {object} [opts={}] - The options for the annotation UI.
* @returns {AnnotationUI} The annotation UI object.
*/
addAnnotationUI(opts = {}){
if (!this._annotationUI) {
console.warn('[osd-paperjs-annotation] addAnnotationUI is deprecated. Use getToolbar(), getLayerUI(), and addAnnotationLayout() (or place the returned .element nodes in your own layout). See the layout configuration demo.');
this._toolset = this._toolset || new AnnotationToolset(this.paperScope, opts.tools);
if (opts.addToolbar !== false) this.getToolbar({ tools: opts.tools });
if (opts.addLayerUI !== false) this.getLayerUI({ addFileButton: opts.addFileButton });
this._annotationUI = new AnnotationUI(this, this._toolset, opts);
}
return this._annotationUI;
}
/**
* Get the toolkit's toolbar, creating it on first call with the given opts. Use .element to get the root DOM node (official API; do not look up by CSS class or id).
* @param {Object} [opts] - Used only when creating: opts.tools - optional array of tool names.
* @returns {AnnotationToolbar|null} The toolbar instance, or null if addAnnotationUI exists but did not create a toolbar.
*/
getToolbar(opts = {}) {
if (this._toolbar) return this._toolbar;
if (this._annotationUI) return this._toolbar ?? null;
ensureAnnotationUIStyles();
this._toolset = this._toolset || new AnnotationToolset(this.paperScope, opts.tools ?? null);
this._toolbar = new AnnotationToolbar(this._toolset);
return this._toolbar;
}
/**
* Get the toolkit's layer UI, creating it on first call with the given opts. Use .element to get the root DOM node (official API; do not look up by CSS class or id).
* @param {Object} [opts] - Used only when creating: opts.addFileButton - optional boolean.
* @returns {LayerUI|null} The layer UI instance, or null if addAnnotationUI exists but did not create a layer UI.
*/
getLayerUI(opts = {}) {
if (this._layerUI) return this._layerUI;
if (this._annotationUI) return this._layerUI ?? null;
ensureAnnotationUIStyles();
this._layerUI = new LayerUI(this, opts.addFileButton);
return this._layerUI;
}
/**
* Get the root DOM element for the toolbar (official API). Returns null if no toolbar exists.
* @param {Object} [opts] - Passed to getToolbar(opts) when lazy-creating.
* @returns {HTMLElement|null}
*/
getToolbarElement(opts = {}) {
return this.getToolbar(opts)?.element ?? null;
}
/**
* Get the root DOM element for the layer UI (official API). Returns null if no layer UI exists.
* @param {Object} [opts] - Passed to getLayerUI(opts) when lazy-creating.
* @returns {HTMLElement|null}
*/
getLayerUIElement(opts = {}) {
return this.getLayerUI(opts)?.element ?? null;
}
/**
* Build the annotation layout (grid, resize, toggle button) using AnnotationLayout.
* Uses getToolbar() and getLayerUI() (lazy-creating with default opts if needed). Mutually exclusive with addAnnotationUI.
* @param {Object} [opts] - addButton, buttonTogglesToolbar, buttonTogglesLayerUI, initialOpen (all optional).
* @returns {AnnotationLayout|null} The layout, or null if addAnnotationUI already exists.
*/
addAnnotationLayout(opts = {}) {
if (this._annotationUI) return null;
if (this._annotationLayout) return this._annotationLayout;
const toolbar = this.getToolbar();
const layerUI = this.getLayerUI();
const toolbarRef = toolbar ? {
element: toolbar.element,
show: () => toolbar.show(),
hide: () => toolbar.hide(),
} : null;
const layerUIRef = layerUI ? {
element: layerUI.element,
show: () => layerUI.show(),
hide: () => layerUI.hide(),
} : null;
this._annotationLayout = new AnnotationLayout(this.viewer, {
toolbar: toolbarRef,
layerUI: layerUIRef,
addButton: opts.addButton !== false,
addViewerButton: (config) => this.overlay.addViewerButton(config),
buttonTogglesToolbar: opts.buttonTogglesToolbar !== false,
buttonTogglesLayerUI: opts.buttonTogglesLayerUI !== false,
initialOpen: opts.initialOpen !== false,
});
if (opts.initialOpen !== false) {
toolbar && toolbar.show();
layerUI && layerUI.show();
} else {
toolbar && toolbar.hide();
layerUI && layerUI.hide();
}
return this._annotationLayout;
}
/**
* Opt in to a {@link ConfigurationWidget} by adding an "Annotations" custom section (generic addSection only).
* The toolkit owns the section DOM (toolbar visibility toggles for the pencil and save/load buttons when present).
* Requires `addAnnotationUI()` first. Idempotent when called again with the same widget. Unregisters automatically on `destroy()`.
*
* @param {OSDPaperjsAnnotation.ConfigurationWidget} configurationWidget
*/
registerWithConfigurationWidget(configurationWidget) {
if (this._annotationConfigSectionEl?.isConnected && this._annotationConfigSectionWidget === configurationWidget) {
return;
}
this._unregisterConfigurationWidgetSection();
const root = attachAnnotationToolkitConfigurationWidget(this, configurationWidget);
if (root) {
this._annotationConfigSectionEl = root;
this._annotationConfigSectionWidget = configurationWidget;
}
}
_unregisterConfigurationWidgetSection() {
const el = this._annotationConfigSectionEl;
const w = this._annotationConfigSectionWidget;
this._annotationConfigSectionEl = null;
this._annotationConfigSectionWidget = null;
if (el && w && typeof w.removeSection === 'function') {
w.removeSection(el);
}
}
/**
* Add a set of tools without the full UI (toolbar). Use when addUI is false.
* Creates a toolset (tool layer + tool instances) so getTool(name) and activation work.
* @param {string[]} [toolNames] - Array of tool names (e.g. ['default', 'ruler']). Default tool is always included.
*/
addTools(toolNames) {
if (!this._toolset) this._toolset = new AnnotationToolset(this.paperScope, toolNames);
}
/**
* Get a tool instance by name (e.g. 'ruler', 'default'). Works with full UI or headless addTools().
* @param {string} name - Tool name.
* @returns {OSDPaperjsAnnotation.ToolBase|null}
*/
getTool(name) {
return this._toolset ? this._toolset.getTool(name) : null;
}
/**
* Destroy the toolkit and its components.
*/
destroy() {
this.raiseEvent('before-destroy');
let tool=this.paperScope && this.paperScope.getActiveTool();
if(tool) tool.deactivate(true);
this._unregisterConfigurationWidgetSection();
this.viewer.annotationToolkit = null;
if (this.viewer?.element) {
this.viewer.element.classList.remove(ANNOTATION_UI_SCOPE_CLASS);
}
if (this._annotationLayout) {
this._annotationLayout.destroy();
this._annotationLayout = null;
}
this._annotationUI && this._annotationUI.destroy();
this._annotationUI = null;
if (this._toolbar) {
this._toolbar.destroy();
this._toolbar = null;
}
if (this._layerUI) {
this._layerUI.destroy();
this._layerUI = null;
}
if (this._toolset) this._toolset.destroy();
this._toolset = null;
this.overlay.destroy();
this.raiseEvent('destroy');
}
/**
* Close the toolkit and remove its feature collections.
*/
close() {
this.raiseEvent('before-close');
let tool=this.paperScope && this.paperScope.getActiveTool();
if(tool) tool.deactivate(true);
this.addFeatureCollections([],true);
}
/**
* Set the global visibility of the toolkit.
* @param {boolean} [show=false] - Whether to show or hide the toolkit.
*/
setGlobalVisibility(show = false){
this.paperScope.view._element.setAttribute('style', 'visibility:' + (show ? 'visible;' : 'hidden;'));
}
/**
* Add feature collections to the toolkit from GeoJSON objects.
* @param {object[]} featureCollections - The array of GeoJSON objects representing feature collections.
* @param {boolean} replaceCurrent - Whether to replace the current feature collections or not.
* @param {OpenSeadragon.TiledImage | OpenSeadragon.Viewport | false} [parentImage] - which image to add the feature collections to
*/
addFeatureCollections(featureCollections,replaceCurrent, parentImage){
this.loadGeoJSON(featureCollections,replaceCurrent, parentImage);
this.overlay.rescaleItems();
this.paperScope.project.emit('items-changed');
}
/**
* Get the feature collection groups that the toolkit is managing.
* @param {paper.Layer} [parentLayer] The layer to find feature collections within. If not specified, finds across all layers.
* @returns {paper.Group[]} The array of paper groups representing feature collections.
*/
getFeatureCollectionGroups(parentLayer){
// return this.overlay.paperScope.project.layers.filter(l=>l.isGeoJSONFeatureCollection);
return this.paperScope.project.getItems({match: item=>item.isGeoJSONFeatureCollection && (parentLayer ? item.layer === parentLayer : true)});
}
/**
* Get the features in the toolkit.
* @returns {paper.Item[]} The array of paper item objects representing features.
*/
getFeatures(){
return this.paperScope.project.getItems({match:i=>i.isGeoJSONFeature});
}
/**
* Register an item as a GeoJSONFeature that the toolkit should track
* @param {paper.Item} item - The item to track as a geoJSONFeature
*/
static registerFeature(item){
item.isGeoJSONFeature = true;
}
/**
* Register a group as a GeoJSONFeatureCollection that the toolkit should track
* @param {paper.Group} group - The group to track as a geoJSONFeatureCollection
*/
static registerFeatureCollection(group){
group.isGeoJSONFeatureCollection = true;
}
/**
* Convert the feature collections in the toolkit to GeoJSON objects.
* @param {Object} [options]
* @param {Layer} [options.layer] The specific layer to use
* @returns {Object[]} The array of GeoJSON objects representing feature collections.
*/
toGeoJSON(options){
const defaults = {
layer:null,
}
options = Object.assign(defaults, options);
const parent = options.layer || this.paperScope.project;
//find all featureCollection items and convert to GeoJSON compatible structures
return parent.getItems({match:i=>i.isGeoJSONFeatureCollection}).map(grp=>{
let geoJSON = {
type:'FeatureCollection',
features: grp.descendants.filter(d=>d.annotationItem).map(d=>d.annotationItem.toGeoJSONFeature()),
properties:{
defaultStyle: grp.defaultStyle.toJSON(),
userdata: grp.data.userdata,
},
label:grp.displayName,
}
return geoJSON;
})
}
/**
* Convert the feature collections in the project to a JSON string.
* @param {function} [replacer] - The replacer function for JSON.stringify().
* @param {number|string} [space] - The space argument for JSON.stringify().
* @returns {string} The JSON string representing the feature collections.
*/
toGeoJSONString(replacer,space){
return JSON.stringify(this.toGeoJSON(),replacer,space);
}
/**
* Load feature collections from GeoJSON objects and add them to the project.
* @param {object[]} geoJSON - The array of GeoJSON objects representing feature collections.
* @param {boolean} replaceCurrent - Whether to replace the current feature collections or not.
* @param {OpenSeadragon.TiledImage | OpenSeadragon.Viewport | false} [parentImage] - Which image (or viewport) to add the object to
* @param {boolean} [pixelCoordinates]
*/
loadGeoJSON(geoJSON, replaceCurrent, parentImage){
let parentLayer = parentImage ? this.overlay.getPaperLayer(parentImage) : false;
if(replaceCurrent){
this.getFeatureCollectionGroups(parentImage).forEach(grp=>grp.remove());
}
if(!Array.isArray(geoJSON)){
geoJSON = [geoJSON];
}
geoJSON.forEach(obj=>{
if(obj.type=='FeatureCollection'){
let group = this._createFeatureCollectionGroup({label: obj.label, parent: parentLayer});
let props = (obj.properties || {});
group.data.userdata = Object.assign({},props.userdata);
group.defaultStyle.set(props.defaultStyle);
obj.features.forEach(feature=>{
let item = paper.Item.fromGeoJSON(feature);
group.addChild(item);
})
}
else{
console.warn('GeoJSON object not loaded: wrong type. Only FeatureCollection objects are currently supported');
}
})
}
/**
* Add a new, empty FeatureCollection with default label and parent
* @returns {paper.Group} The paper group object representing the feature collection.
*/
addEmptyFeatureCollectionGroup(){
return this._createFeatureCollectionGroup();
}
/**
* Create a new feature collection group in the project scope.
* @private
* @param {Object} [opts] - Object with fields label and parent
* @returns {paper.Group} The paper group object representing the feature collection.
*/
_createFeatureCollectionGroup(opts = {}) {
let defaultOpts = {
label:null,
parent:null
}
opts = Object.assign({}, defaultOpts, opts);
let displayLabel = opts.label;
let parent = opts.parent;
if(!parent){
let numItems = this.viewer.world.getItemCount();
if( numItems == 1){
parent = this.overlay.getPaperLayer(this.viewer.world.getItemAt(0));
} else if (numItems == 0){
parent = this.overlay.getPaperLayer(this.viewer.viewport);
} else {
//TODO: Update the UI and associated APIs to allow selecting specific tiled images for multi-image use
console.warn('Use of AnnotationToolkit with multi-image is not yet fully supported. All annotations will be added to the top-level tiled image.');
parent = this.overlay.getPaperLayer(this.viewer.world.getItemAt(numItems - 1));
}
}
if(!parent){
console.error('Failed to create feature collection group: no parent could be found');
return;
}
let grp = new paper.Group();
this._addFeatureCollectionGroupToLayer(grp, parent);
let grpNum = this.getFeatureCollectionGroups().length;
grp.name = grp.displayName = displayLabel!==null ? displayLabel : `Annotation Group ${grpNum}`;
grp.defaultStyle = new paper.Style(this.paperScope.project.defaultStyle);
return grp;
}
_addFeatureCollectionGroupToLayer(fcGroup, layer){
layer.addChild(fcGroup);
AnnotationToolkit.registerFeatureCollection(fcGroup);
this.paperScope.project.emit('feature-collection-added',{group:fcGroup});
// re-insert children to trigger events
if(fcGroup.children){
fcGroup.insertChildren(0, fcGroup.children);
}
}
/**
* Ensure there is a selected placeholder in a feature collection so a tool can draw into it (e.g. when activating with createNewItem and no layer UI).
* @private
* @param {Object} [style] - Optional style for the placeholder (e.g. strokeColor). If omitted, project default is used.
*/
_ensureNewItemForTool(style) {
const groups = this.getFeatureCollectionGroups();
const fcGroup = groups.length > 0 ? groups[0] : this._createFeatureCollectionGroup();
if (!fcGroup) return;
// Ensure a visible default style for headless use when no explicit style is provided.
// Use the feature collection's defaultStyle if available; otherwise fall back to toolkit defaultStyle.
const effectiveStyle = (style != null)
? style
: (fcGroup.defaultStyle?.toJSON ? fcGroup.defaultStyle.toJSON() : this.defaultStyle);
const placeholder = this.makePlaceholderItem(effectiveStyle);
fcGroup.addChild(placeholder.paperItem);
AnnotationToolkit.registerFeature(placeholder.paperItem);
placeholder.paperItem.select();
}
/**
* Make a placeholder annotation item
* @param {Object} style - options (e.g strokeColor) to pass to the paper item
*/
makePlaceholderItem(style){
return new Placeholder(style);
}
};
export {AnnotationToolkit as AnnotationToolkit};
// private functions
/**
* Create a compound path from a path item.
* @private
* @returns {paper.CompoundPath} The compound path object.
*/
function toCompoundPath() {
if (this.constructor !== paper.CompoundPath) {
let np = new paper.CompoundPath({ children: [this], fillRule: 'evenodd' });
np.selected = this.selected;
this.selected = false;
return np;
}
return this;
}
/**
* Apply bounds to a path item.
* @private
* @param {paper.Item[]} boundingItems - The array of paper items to use as bounds.
*/
function applyBounds(boundingItems) {
if (boundingItems.length == 0)
return;
let intersection;
if (boundingItems.length == 1) {
let bounds = boundingItems[0];
intersection = bounds.intersect(this, { insert: false });
}
else if (boundingItems.length > 1) {
let bounds = new paper.CompoundPath(boundingItems.map(b => b.clone().children).flat());
intersection = bounds.intersect(this, { insert: false });
bounds.remove();
}
if (this.children) {
//compound path
this.removeChildren();
this.addChildren(intersection.children ? intersection.children : [intersection]);
}
else {
//simple path
this.segments = intersection.segments ? intersection.segments : intersection.firstChild.segments;
}
}
/**
* Select a paper item and emit events.
* @private
* @param {boolean} [keepOtherSelectedItems=false] - Whether to keep other selected items or not.
*/
function paperItemSelect(keepOtherSelectedItems) {
if(!keepOtherSelectedItems){
this.project._scope.findSelectedItems().forEach(item => item.deselect());
}
this.selected = true;
this.emit('selected');
this.project.emit('item-selected', { item: this });
const tk = this.project?._scope?.annotationToolkit;
if (tk && tk._emitIntegrationEvent) {
const selected = this.project?._scope?.findSelectedItems?.() ?? [];
tk._emitIntegrationEvent('selection-changed', {
selectedCount: selected.length,
primary: selected.length ? selected[0] : null,
});
}
}
/**
* Deselect a paper item and emit events.
* @private
* @param {boolean} [keepOtherSelectedItems=false] - Whether to keep other selected items or not.
*/
function paperItemDeselect(keepOtherSelectedItems) {
if(!keepOtherSelectedItems){
this.project._scope.findSelectedItems().forEach(item => item.deselect(true));
return;
}
this.selected = false;
this.emit('deselected');
this.project.emit('item-deselected', { item: this });
const tk = this.project?._scope?.annotationToolkit;
if (tk && tk._emitIntegrationEvent) {
const selected = this.project?._scope?.findSelectedItems?.() ?? [];
tk._emitIntegrationEvent('selection-changed', {
selectedCount: selected.length,
primary: selected.length ? selected[0] : null,
});
}
}
/**
* Toggle the selection of a paper item and emit events.
* @private
* @param {boolean} [keepOtherSelectedItems=false] - Whether to keep other selected items or not.
*/
function paperItemToggle(keepOtherSelectedItems) {
this.selected ? this.deselect(keepOtherSelectedItems) : this.select(keepOtherSelectedItems);
}
/**
* Find the selected new item in the project scope.
* @private
* @returns {paper.Item} The selected new item, or null if none exists.
*/
function findSelectedNewItem() {
//to do: change this to use type=='Feature' and geometry==null to match GeoJSON spec and AnnotationItemPlaceholder definition
return this.project.getItems({ selected:true, match: function (i) { return i.isGeoJSONFeature && i.initializeGeoJSONFeature; } })[0];
}
/**
* Find the selected items in the project scope.
* @private
* @returns {paper.Item[]} The array of selected items, or an empty array if none exists.
*/
function findSelectedItems() {
return this.project.getItems({ selected: true, match: function (i) { return i.isGeoJSONFeature; } });
}
/**
* Find the first selected item in the project scope.
* @private
* @returns {paper.Item} The first selected item, or null if none exists.
*/
function findSelectedItem() {
return this.findSelectedItems()[0];
}
/**
* Define the display name property for a paper item object.
* The display name property defines the name used to identify a paper item object.
* @private
* @returns {object} The property descriptor object.
* @property {function} set - The setter function for the display name property.
* @param {string} input - The display name value.
* @property {function} get - The getter function for the display name property.
* @returns {string} The display name value.
*/
function displayNamePropertyDef(){
return {
set: function displayName(input){
if(Array.isArray(input)){
this._displayName = new String(input[0]);
this._displayName.source=input[1];
}
else{
this._displayName = input;
}
this.name = this._displayName;
this.emit('display-name-changed',{displayName:this._displayName});
},
get: function displayName(){
return this._displayName;
}
}
}
/**
* Define the featureCollection property for a paper item object.
* @private
*/
function featureCollectionPropertyDef(){
return {
get: function fc(){
return this.hierarchy.filter(i=>i.isGeoJSONFeatureCollection)[0];
}
}
}
/**
* Define the descendants property for a paper project object.
* The descendants property represents all the descendants (layers and their children) of a paper project object.
* @private
* @returns {object} The property descriptor object.
* @property {function} get - The getter function for the descendants property.
* @returns {paper.Item[]} The array of paper item objects representing the descendants.
*/
function descendantsDefProject(){
return {
get: function descendants(){
// return this.layers ? this.layers.filter(layer=>layer.isGeoJSONFeatureCollection).map(child=>child.descendants).flat() : [this];
return this.layers ? this.getItems({match: item=>item.isGeoJSONFeatureCollection}).map(child=>child.descendants).flat() : [this];
}
}
}
/**
* Convert a paper style object to a JSON object.
* @private
* @returns {object} The JSON object representing the style.
*/
function styleToJSON(){
let output={};
Object.keys(this._values).forEach(key=>{
output[key] = this[key];//invoke getter
})
return output;
}
/**
* Get the image data of a paper view element.
* @private
* @returns {ImageData} The image data object of the view element.
*/
function paperViewGetImageData(){
return this.element.getContext('2d').getImageData(0,0,this.element.width, this.element.height);
}
/**
* Get the insert children method definition for a paper group object.
* The insert children method emits events when children are added to the paper group object.
* @private
* @returns {function} The insert children method that emits events when children are added.
*/
function getInsertChildrenDef(){
let origInsertChildren = paper.Group.prototype.insertChildren.original || paper.Group.prototype.insertChildren;
function insertChildren(){
let output = origInsertChildren.apply(this,arguments);
let index = arguments[0], children=Array.from(arguments[1]);
children&&children.forEach((child,i)=>{
if(child.isGeoJSONFeature){
let idx = typeof index !== 'undefined' ? index+1 : -1;
this.emit('child-added',{item:child,index:idx});
}
});
return output;
}
insertChildren.original = origInsertChildren;
return insertChildren;
}
/**
* Define the fill opacity property for a paper style object.
* @private
* @returns {object} The property descriptor object with the following properties:
* - get: A function that returns the text of the item.
* - set: A function that sets the text of the item and causes the 'content-changed' event to be fired.
*/
function textItemContentPropertyDef(){
let _set = paper.TextItem.prototype._setContent || Object.getOwnPropertyDescriptor(paper.TextItem.prototype, 'content').set;
paper.TextItem.prototype._setContent = _set;
return{
get: function() {
return this._content;
},
set: function(content) {
_set.call(this, content);
this.emit('content-changed');
},
}
}