/* Copyright (c) 2015-present The Open Source Geospatial Foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* A store that is synchronized with a GeoExt.data.store.Layers. It can be
* used by an {Ext.tree.Panel}.
*
* @class GeoExt.data.store.LayersTree
*/
Ext.define('GeoExt.data.store.LayersTree', {
extend: 'Ext.data.TreeStore',
alternateClassName: ['GeoExt.data.TreeStore'],
requires: ['GeoExt.util.Layer'],
mixins: ['GeoExt.mixin.SymbolCheck'],
// <debug>
symbols: [
'ol.Collection',
'ol.Collection#getArray',
'ol.Collection#once',
'ol.Collection#un',
'ol.layer.Base',
'ol.layer.Base#get',
'ol.layer.Group',
'ol.layer.Group#get',
'ol.layer.Group#getLayers',
],
// </debug>
model: 'GeoExt.data.model.LayerTreeNode',
config: {
/**
* The ol.layer.Group that the tree is derived from.
*
* @cfg {ol.layer.Group}
*/
layerGroup: null,
/**
* Configures the behaviour of the checkbox of an `ol.layer.Group`
* (folder). Possible values are `'classic'` or `'ol3'`.
*
* * `'classic'` forwards the checkstate to the children of the folder.
* * Check a leaf => all parent nodes are checked
* * Uncheck all leafs in a folder => parent node is unchecked
* * Check a folder Node => all children are checked
* * Uncheck a folder Node => all children are unchecked
* * `'ol3'` emulates the behaviour of `ol.layer.Group`. So a layerGroup
* can be invisible but can have visible children.
* * Emulates the behaviour of an `ol.layer.Group,` so a parentfolder
* can be unchecked but still contain checked leafs and vice versa.
*
* @cfg
*/
folderToggleMode: 'classic',
},
statics: {
/**
* A string which we'll us for child nodes to detect if they are removed
* because their parent collapsed just recently. See the private
* method #onBeforeGroupNodeToggle for an explanation.
*
* @private
*/
KEY_COLLAPSE_REMOVE_OPT_OUT: '__remove_by_collapse__',
},
/**
* Defines if the order of the layers added to the store will be
* reversed. The default behaviour and what most users expect is
* that mapLayers on top are also on top in the tree.
*
* @property {boolean} inverseLayerOrder Reverse order of layers in tree
*/
inverseLayerOrder: true,
/**
* Whether the treestore currently shall handle openlayers collection
* change events. See #suspendCollectionEvents and #resumeCollectionEvents.
*
* @property {boolean} collectionEventsSuspended Suspend OL collection events
* @private
*/
collectionEventsSuspended: false,
/**
* @cfg
* @inheritdoc
*/
proxy: {
type: 'memory',
reader: {
type: 'json',
},
},
root: {
expanded: true,
},
/**
* Constructs a LayersTree store.
*/
constructor: function () {
const me = this;
me.onLayerCollectionRemove = me.onLayerCollectionRemove.bind(me);
me.onLayerCollectionAdd = me.onLayerCollectionAdd.bind(me);
me.bindGroupLayerCollectionEvents =
me.bindGroupLayerCollectionEvents.bind(me);
me.unbindGroupLayerCollectionEvents =
me.unbindGroupLayerCollectionEvents.bind(me);
me.callParent(arguments);
const collection = me.layerGroup.getLayers();
Ext.each(
collection.getArray(),
function (layer) {
me.addLayerNode(layer);
},
me,
me.inverseLayerOrder,
);
me.bindGroupLayerCollectionEvents(me.layerGroup);
me.on({
remove: me.handleRemove,
noderemove: me.handleNodeRemove,
nodeappend: me.handleNodeAppend,
nodeinsert: me.handleNodeInsert,
scope: me,
});
},
/**
* Applies the #folderToggleMode to the treenodes.
*
* @param {string} folderToggleMode The folderToggleMode that was set.
* @return {string} The folderToggleMode that was set.
* @private
*/
applyFolderToggleMode: function (folderToggleMode) {
if (folderToggleMode === 'classic' || folderToggleMode === 'ol3') {
const rootNode = this.getRootNode();
if (rootNode) {
rootNode.cascadeBy({
before: function (child) {
child.set('__toggleMode', folderToggleMode);
},
});
}
return folderToggleMode;
}
Ext.raise(
'Invalid folderToggleMode set in ' +
this.self.getName() +
': ' +
folderToggleMode +
"; 'classic' or 'ol3' are valid.",
);
},
/**
* Listens to the `remove` event and syncs the attached layergroup.
*
* @param {GeoExt.data.store.LayersTree} store The layer store.
* @param {Array<GeoExt.data.model.LayerTreeNode>} records An array of the
* removed nodes.
* @private
*/
handleRemove: function (store, records) {
const me = this;
const keyRemoveOptOut = me.self.KEY_COLLAPSE_REMOVE_OPT_OUT;
me.suspendCollectionEvents();
Ext.each(records, function (record) {
if (keyRemoveOptOut in record && record[keyRemoveOptOut] === true) {
delete record[keyRemoveOptOut];
return;
}
const layerOrGroup = record.getOlLayer();
if (layerOrGroup instanceof ol.layer.Group) {
me.unbindGroupLayerCollectionEvents(layerOrGroup);
}
let group = GeoExt.util.Layer.findParentGroup(
layerOrGroup,
me.getLayerGroup(),
);
if (!group) {
group = me.getLayerGroup();
}
if (group) {
group.getLayers().remove(layerOrGroup);
}
});
me.resumeCollectionEvents();
},
/**
* Listens to the `noderemove` event. Updates the tree with the current
* map state.
*
* @param {GeoExt.data.model.LayerTreeNode} parentNode The parent node.
* @param {GeoExt.data.model.LayerTreeNode} removedNode The removed node.
* @private
*/
handleNodeRemove: function (parentNode, removedNode) {
const me = this;
let layerOrGroup = removedNode.getOlLayer();
if (!layerOrGroup) {
layerOrGroup = me.getLayerGroup();
}
if (layerOrGroup instanceof ol.layer.Group) {
removedNode.un('beforeexpand', me.onBeforeGroupNodeToggle);
removedNode.un('beforecollapse', me.onBeforeGroupNodeToggle);
me.unbindGroupLayerCollectionEvents(layerOrGroup);
}
const group = GeoExt.util.Layer.findParentGroup(
layerOrGroup,
me.getLayerGroup(),
);
if (group) {
me.suspendCollectionEvents();
group.getLayers().remove(layerOrGroup);
me.resumeCollectionEvents();
}
},
/**
* Listens to the `nodeappend` event. Updates the tree with the current
* map state.
*
* @param {GeoExt.data.model.LayerTreeNode} parentNode The parent node.
* @param {GeoExt.data.model.LayerTreeNode} appendedNode The appended node.
* @private
*/
handleNodeAppend: function (parentNode, appendedNode) {
const me = this;
let group = parentNode.getOlLayer();
const layer = appendedNode.getOlLayer();
if (!group) {
group = me.getLayerGroup();
}
// check if the layer is possibly already at the desired index:
const layerInGroupIdx = GeoExt.util.Layer.getLayerIndex(layer, group);
if (layerInGroupIdx === -1) {
me.suspendCollectionEvents();
if (me.inverseLayerOrder) {
group.getLayers().insertAt(0, layer);
} else {
group.getLayers().push(layer);
}
me.resumeCollectionEvents();
}
},
/**
* Listens to the `nodeinsert` event. Updates the tree with the current
* map state.
*
* @param {GeoExt.data.model.LayerTreeNode} parentNode The parent node.
* @param {GeoExt.data.model.LayerTreeNode} insertedNode The inserted node.
* @param {GeoExt.data.model.LayerTreeNode} insertedBefore The node we were
* inserted before.
* @private
*/
handleNodeInsert: function (parentNode, insertedNode, insertedBefore) {
const me = this;
let group = parentNode.getOlLayer();
if (!group) {
// can only happen if a node was dragged before the visible root.
group = me.getLayerGroup();
}
const layer = insertedNode.getOlLayer();
const beforeLayer = insertedBefore.getOlLayer();
const groupLayers = group.getLayers();
const beforeIdx = GeoExt.util.Layer.getLayerIndex(beforeLayer, group);
let insertIdx = beforeIdx;
if (me.inverseLayerOrder) {
insertIdx += 1;
}
// check if the layer is possibly already at the desired index:
const currentLayerInGroupIdx = GeoExt.util.Layer.getLayerIndex(
layer,
group,
);
if (
currentLayerInGroupIdx !== insertIdx &&
!Ext.Array.contains(groupLayers.getArray(), layer)
) {
me.suspendCollectionEvents();
groupLayers.insertAt(insertIdx, layer);
me.resumeCollectionEvents();
}
},
/**
* Adds a layer as a node to the store. It can be an `ol.layer.Base`.
*
* @param {ol.layer.Base} layerOrGroup The layer or layer group to add.
*/
addLayerNode: function (layerOrGroup) {
const me = this;
// 2. get group to which the layer was added
const group = GeoExt.util.Layer.findParentGroup(
layerOrGroup,
me.getLayerGroup(),
);
// 3. get index of layer in that group
let layerIdx = GeoExt.util.Layer.getLayerIndex(layerOrGroup, group);
// 3.1 the index must probably be changed because of inverseLayerOrder
// TODO Check
if (me.inverseLayerOrder) {
const totalInGroup = group.getLayers().getLength();
layerIdx = totalInGroup - layerIdx - 1;
}
// 4. find the node that represents the group
let parentNode;
if (group === me.getLayerGroup()) {
parentNode = me.getRootNode();
} else {
parentNode = me.getRootNode().findChildBy(
function (candidate) {
return candidate.getOlLayer() === group;
},
me,
true,
);
}
if (!parentNode) {
return;
}
// 5. insert a new layer node at the specified index to that node
const layerNode = parentNode.insertChild(layerIdx, layerOrGroup);
if (layerOrGroup instanceof ol.layer.Group) {
// See onBeforeGroupNodeToggle for an explanation why we have this
layerNode.on('beforeexpand', me.onBeforeGroupNodeToggle, me);
layerNode.on('beforecollapse', me.onBeforeGroupNodeToggle, me);
const childLayers = layerOrGroup.getLayers().getArray();
Ext.each(childLayers, me.addLayerNode, me, me.inverseLayerOrder);
}
},
/**
* Bound as an eventlistener for layer nodes which are a folder / group on
* the beforecollapse event. Whenever a folder gets collapsed, ExtJS seems
* to actually remove the children from the store, triggering the removal
* of the actual layers in the map. This is an undesired behaviour. We
* handle this as follows: Before the collapsing happens, we mark the
* childNodes, so we effectively opt-out in #handleRemove.
*
* @param {Ext.data.NodeInterface} node The collapsible folder node.
* @private
*/
onBeforeGroupNodeToggle: function (node) {
const keyRemoveOptOut = this.self.KEY_COLLAPSE_REMOVE_OPT_OUT;
node.cascadeBy(function (child) {
child[keyRemoveOptOut] = true;
});
},
/**
* A utility method which binds collection change events to the passed layer
* if it is a `ol.layer.Group`.
*
* @param {ol.layer.Base} layerOrGroup The layer to probably bind event
* listeners for collection change events to.
* @private
*/
bindGroupLayerCollectionEvents: function (layerOrGroup) {
const me = this;
if (layerOrGroup instanceof ol.layer.Group) {
const collection = layerOrGroup.getLayers();
collection.on('remove', me.onLayerCollectionRemove);
collection.on('add', me.onLayerCollectionAdd);
collection.forEach(me.bindGroupLayerCollectionEvents);
}
},
/**
* A utility method which unbinds collection change events from the passed
* layer if it is a `ol.layer.Group`.
*
* @param {ol.layer.Base} layerOrGroup The layer to probably unbind event
* listeners for collection change events from.
* @private
*/
unbindGroupLayerCollectionEvents: function (layerOrGroup) {
const me = this;
if (layerOrGroup instanceof ol.layer.Group) {
const collection = layerOrGroup.getLayers();
collection.un('remove', me.onLayerCollectionRemove);
collection.un('add', me.onLayerCollectionAdd);
collection.forEach(me.unbindGroupLayerCollectionEvents);
}
},
/**
* Handles the `add` event of a managed `ol.layer.Group` and eventually
* removes the appropriate node.
*
* @param {ol.CollectionEvent} evt The event object holding a reference to
* the relevant `ol.layer.Base`.
* @private
*/
onLayerCollectionAdd: function (evt) {
const me = this;
if (me.collectionEventsSuspended) {
return;
}
const layerOrGroup = evt.element;
me.addLayerNode(layerOrGroup);
me.bindGroupLayerCollectionEvents(layerOrGroup);
},
/**
* Handles the `remove` event of a managed `ol.layer.Group` and eventually
* removes the appropriate node.
*
* @param {ol.CollectionEvent} evt The event object holding a reference to
* the relevant `ol.layer.Base`.
* @private
*/
onLayerCollectionRemove: function (evt) {
const me = this;
if (me.collectionEventsSuspended) {
return;
}
const layerOrGroup = evt.element;
// 1. find the node that existed for that layer
const node = me.getRootNode().findChildBy(
function (candidate) {
return candidate.getOlLayer() === layerOrGroup;
},
me,
true,
);
if (!node) {
return;
}
// 2. if grouplayer: cascade down and remove any possible listeners
if (layerOrGroup instanceof ol.layer.Group) {
me.unbindGroupLayerCollectionEvents(layerOrGroup);
}
// 3. find the parent
const parent = node.parentNode;
// 4. remove the node from the parent
parent.removeChild(node);
},
/**
* Allows for temporarily unlistening to change events on the underlying
* OpenLayers collections. Use #resumeCollectionEvents to start listening
* again.
*/
suspendCollectionEvents: function () {
this.collectionEventsSuspended = true;
},
/**
* Undoes the effect of #suspendCollectionEvents; so that the store is now
* listening to change events on the underlying OpenLayers collections
* again.
*/
resumeCollectionEvents: function () {
this.collectionEventsSuspended = false;
},
});