import rbush from "rbush";
/**
* @summary create a label gun instance with a hide and show label callback
* @param {function} hideLabel the function responsible for hiding the label on hide event
* @param {function} showLabel the function responsible for showing the label on show event
* @param {number} entries Higher value relates to faster insertion and slower search, and vice versa
*/
class labelgun {
constructor(hideLabel, showLabel, entries) {
const usedEntries = entries || 10;
this.tree = rbush(usedEntries);
this.allLabels = {};
this.hasChanged = [];
this.allChanged = false;
this.hideLabel = hideLabel;
this.showLabel = showLabel;
}
/**
* @name _total
* @summary get the total hidden or shown labels in the tree
* @memberof labelgun.prototype
* @param {string} state whether to return 'hide' or 'show' state label totals
* @returns {number} total number of labels of that state
* @private
*/
_total(state) {
let total = 0;
for (let key in this.allLabels) {
if (this.allLabels[key].state == state) {
total += 1;
}
}
return total;
}
/**
* @name totalShown
* @memberof labelgun
* @method
* @summary Return the total number of shown labels
* @returns {number} Return total number of labels shown
* @public
*/
totalShown() {
return this._total("show");
}
/**
* @name totalHidden
* @memberof labelgun
* @method
* @summary Return the total number of hidden labels
* @returns {number} Return total number of labels hidden
* @public
*/
totalHidden() {
return this._total("hide");
}
/**
* @name getLabelsByState
* @summary Provided a state get all labels of that state
* @param {string} state - the state of the labels to get (show or hide)
* @returns {array} Labels that match the given state (show or hide)
* @private
*/
_getLabelsByState(state) {
const labels = [];
for (let key in this.allLabels) {
if (this.allLabels[key].state == state) {
labels.push(this.allLabels[key]);
}
}
return labels;
}
/**
* @name getHidden
* @memberof labelgun
* @method
* @summary Return an array of all the hidden labels
* @returns {array} An array of hidden labels
*/
getHidden() {
return this._getLabelsByState("hide");
}
/**
* @name getShown
* @memberof labelgun
* @method
* @summary Return an array of all shown labels
* @returns {array} An array of shown label
*/
getShown() {
return this._getLabelsByState("show");
}
/**
* @name getCollisions
* @memberof labelgun
* @method
* @summary Return a set of collisions (hidden and shown) for a given label
* @param {string} id - the ID of the label to get
* @returns {array} The list of collisions
*/
getCollisions(id) {
const label = this.allLabels[id];
if (label === undefined) {
throw Error("Label doesn't exist :" + JSON.stringify(id));
}
const collisions = this.tree.search(label);
const self = collisions.indexOf(label);
// Remove the label if it's colliding with itself
if (self !== undefined) collisions.splice(self, 1);
return collisions;
}
/**
* @name getLabel
* @memberof labelgun
* @method
* @summary Convenience function to return a label by ID
* @param {string} id the ID of the label to get
* @returns {object} The label object for the id
*/
getLabel(id) {
return this.allLabels[id];
}
/**
* @name reset
* @memberof labelgun
* @method
* @summary Destroy the collision tree and labels
* @returns {undefined}
*/
reset() {
this.tree.clear();
this.allLabels = {};
this.hasChanged = [];
this.allChanged = false;
}
/**
* @name _callLabelCallbacks
* @memberof labelgun
* @method
* @summary Perform the related callback for a label depending on where its state is 'show' or 'hide'
* @param {string} [forceState] - the class of which to change the label to
* @returns {undefined}
* @private
*/
_callLabelCallbacks(forceState) {
for(let key in this.allLabels) {
this._callLabelStateCallback(this.allLabels[key], forceState);
}
}
/**
* @name _callLabelStateCallback
* @summary Calls the correct callback for a particular label depending on its state (hidden or shown)
* @param {string} label the label to update
* @param {string} forceState the state of which to change the label to ('show' or 'hide')
* @returns {undefined}
* @private
*/
_callLabelStateCallback(label, forceState) {
const state = forceState || label.state;
if (state === "show") this.showLabel(label);
if (state === "hide") this.hideLabel(label);
}
/**
* @name _compareLabels
* @memberof labelgun
* @method
* @summary Calculates which labels should show and which should hide
* @returns {undefined}
* @private
*/
_compareLabels() {
// Map all the labels to an array and sort based on weight
// highest to lowest
this.orderedLabels = [];
for(let key in this.allLabels) {
this.orderedLabels.push(this.allLabels[key]);
}
this.orderedLabels.sort(this._compare);
this.orderedLabels.forEach((label) => {
const collisions = this.tree.search(label);
if (collisions.length === 0 || label.isDragged || this._allLower(collisions, label)) {
this.allLabels[label.id].state = "show";
}
});
}
/**
* @name _allLower
* @memberof labelgun
* @method
* @param {array} collisions - An array of collisions (label objects)
* @param {object} label - The label to check
* @summary Checks if labels are of a lower weight, currently showing, or dragged
* @returns {boolean} - Whether collision are lower or contain already shown or dragged labels
* @private
*/
_allLower(collisions, label) {
let collision;
for (let i = 0; i < collisions.length; i++) {
collision = collisions[i];
if (
collision.state === "show" ||
collision.weight > label.weight ||
collision.isDragged
) {
return false;
}
}
return true;
}
/**
* @name _compare
* @memberof labelgun
* @method
* @param {object} a - First object to compare
* @param {object} b - Second object to compare
* @summary Compares labels weights for sorting
* @returns {number} - The sort value
* @private
*/
_compare(a,b) {
// High to Low
return b.weight - a.weight;
}
/**
* @name _setupLabels
* @memberof labelgun
* @method
* @summary Sets up the labels depending on whether all have changed or some have changed
* @returns {undefined}
* @private
*/
_setupLabels() {
if(this.allChanged) {
this.allChanged = false;
this.hasChanged = [];
this.tree.clear();
let labels = [];
for(let key in this.allLabels) {
this._handleLabelIngestion(key);
labels.push(this.allLabels[key]);
}
this.tree.load(labels);
}
else if(this.hasChanged.length > 0) {
this.hasChanged.forEach(id => {
this._handleLabelIngestion(id);
this.tree.insert(this.allLabels[id]);
});
this.hasChanged = [];
}
}
/**
* @name _handleLabelIngestion
* @memberof labelgun
* @method
* @summary DRY function for ingesting labels
* @returns {undefined}
* @param {string} id - ID of the label to ingest
* @private
*/
_handleLabelIngestion(id) {
const label = this.allLabels[id];
this.ingestLabel(
{
bottomLeft: [label.minX, label.minY],
topRight: [label.maxX, label.maxY]
},
label.id,
label.weight,
label.labelObject,
label.name,
label.isDragged
);
}
/**
* @name update
* @memberof labelgun
* @method
* @param {boolean} onlyChanges - Whether to only update the changes made by labelHasChanged
* @summary Sets all or some of the labels to change and reruns the whole show/hide procedure
* @returns {undefined}
*/
update(onlyChanges) {
if (onlyChanges) {
this.allChanged = false;
} else {
this.allChanged = true;
}
this._setupLabels();
this._compareLabels();
this._callLabelCallbacks();
}
/**
* @name removeLabel
* @memberof labelgun
* @method
* @param {string} id - The label id for the label to remove from the tree
* @param {object} label - The label to remove from the tree
* @summary Removes label from tree and allLabels object
* @returns {undefined}
*/
removeLabel(id, label) {
this.tree.remove(label || this.allLabels[id]);
delete this.allLabels[id];
}
/**
* @name ingestLabel
* @memberof labelgun
* @method
* @param {object} boundingBox - The bounding box object with bottomLeft and topRight properties
* @param {string} id - The idea of the label
* @param {number} weight - The weight to compareLabels in the collision resolution
* @param {object} labelObject - The object representing the actual label object from your mapping library
* @param {string} labelName - A string depicting the name of the label
* @param {boolean} isDragged - A flag to say whether the lable is being dragged
* @summary Creates a label if it does not already exist, then adds it to the tree, and renders it based on whether it can be shown
* @returns {undefined}
* @public
*/
ingestLabel(boundingBox, id, weight, labelObject, labelName, isDragged) {
// Add the new label to the tree
if (weight === undefined || weight === null) {
weight = 0;
}
if (!boundingBox || !boundingBox.bottomLeft || !boundingBox.topRight) {
throw Error("Bounding box must be defined with bottomLeft and topRight properties");
}
if (typeof id !== "string" && typeof id !== "number") {
throw Error("Label IDs must be a string or a number");
}
// If there is already a label in the tree, remove it
const oldLabel = this.allLabels[id];
if (oldLabel) this.removeLabel(oldLabel.id, oldLabel);
const label = {
minX: boundingBox.bottomLeft[0],
minY: boundingBox.bottomLeft[1],
maxX: boundingBox.topRight[0],
maxY: boundingBox.topRight[1],
state: "hide",
id : id,
weight: weight,
labelObject : labelObject,
name : labelName,
isDragged : isDragged
};
//We just store the label into the array. The tree is built when updated
this.allLabels[label.id] = label;
}
/**
* @name labelHasChanged
* @memberof labelgun
* @param {string} id - The id of the label that has changed in some way
* @method
* @summary Let labelgun know the label has changed in some way (i.e. it's state for example, or that it is dragged)
* @returns {undefined}
*/
labelHasChanged(id) {
if (this.hasChanged.indexOf(id) === -1) {
this.hasChanged.push(id);
}
}
}
export default labelgun;