@helioviewer/event-tree
Version:
A react component for grouping and listing and selecting solar events.
334 lines (263 loc) • 8.49 kB
JSX
/**
* Stores event information in an accessible way.
* IDs are generated for each event and are accessible on this event tree via
* EventTree[event_id]
* Each event contains the ID of its parent and a list of child events.
*/
class EventTree {
constructor(props) {
Object.assign(this, props);
}
/**
* Build the tree from the `format=simpletree` response: a flattree keyed by
* 2-piece category paths ("HEK>>Active Region") whose values are arrays of
* events. Empty categories arrive as `[]` so they remain visible in the
* tree. Each event carries its own `path` (e.g. "HEK>>Active Region>>HMI
* SHARP") describing the deeper hierarchy; group nodes for those deeper
* segments are created on demand and reused across events.
*
* @param {Object} eventsByCategory Dict {categoryPath: [event, ...]}.
* @param {string} source Top level event group name (HEK, CCMC, ...).
*/
static make(eventsByCategory, source) {
const flattree = {
[source]: {
label: source,
path: source,
id: source,
state: "unchecked",
parent_id: null,
expand: true,
children: []
}
};
// Seed every category branch the server reports, even when its list is
// empty, so the user always sees the full catalogue.
Object.keys(eventsByCategory).forEach((categoryPath) => {
if (!flattree.hasOwnProperty(categoryPath)) {
const segments = categoryPath.split(">>");
flattree[categoryPath] = {
label: segments[segments.length - 1],
path: categoryPath,
id: categoryPath,
state: "unchecked",
parent_id: source,
expand: true,
children: []
};
flattree[source].children.push(categoryPath);
}
});
// Fold each event in using its own `path` field. Walks the cumulative
// segments, creating any missing group nodes, then appends the event leaf.
Object.values(eventsByCategory).forEach((eventList) => {
eventList.forEach((e) => {
const parts = e.path.split(">>");
let parentId = null;
let cumulativePath = "";
parts.forEach((segment, idx) => {
cumulativePath = idx === 0 ? segment : `${cumulativePath}>>${segment}`;
if (!flattree.hasOwnProperty(cumulativePath)) {
flattree[cumulativePath] = {
label: segment,
path: cumulativePath,
id: cumulativePath,
state: "unchecked",
parent_id: parentId,
expand: idx < 2,
children: []
};
if (parentId != null) {
flattree[parentId].children.push(cumulativePath);
}
}
parentId = cumulativePath;
});
const eventLabel = e.short_label ?? e.label;
const eventId = `${parentId}>>${e.id}`;
flattree[eventId] = {
label: eventLabel,
path: `${parentId}>>${eventLabel}`,
id: eventId,
state: "unchecked",
parent_id: parentId,
expand: false,
data: e
};
flattree[parentId].children.push(eventId);
});
});
return new EventTree(flattree);
}
toggleCheckbox(id) {
const stateCycle = {
checked: "unchecked",
unchecked: "checked",
undecided: "checked"
};
const newState = stateCycle[this[id]["state"]];
const nodes = this.getNodes(id);
for (const n of nodes) {
this[n].state = newState;
}
let parentId = this[id].parent_id;
while (parentId != null) {
let siblingsStates = this[parentId].children.map((cid) => this[cid].state);
this[parentId].state = siblingsStates.reduce(
(finalState, eventState) => (finalState != eventState ? "undecided" : finalState),
siblingsStates[0]
);
parentId = this[parentId].parent_id;
}
return new EventTree({ ...this });
}
toggleExpand(id) {
if (this.hasOwnProperty(id)) {
this[id].expand = !this[id].expand;
}
return new EventTree({ ...this });
}
removeSelections() {
for (const n in this) {
this[n].state = "unchecked";
}
return new EventTree({ ...this });
}
applySelections(selections) {
selections.forEach((id) => {
if (this.hasOwnProperty(id)) {
const nodes = this.getNodes(id);
for (const n of nodes) {
this[n].state = "checked";
}
let parentId = this[id].parent_id;
while (parentId != null) {
let siblingsStates = this[parentId].children.map((cid) => this[cid].state);
this[parentId].state = siblingsStates.reduce(
(finalState, eventState) => (finalState != eventState ? "undecided" : finalState),
siblingsStates[0]
);
this[parentId].expand =
this[parentId].state == "undecided" || this.isRoot(parentId) || this.isFirstLevel(parentId);
parentId = this[parentId].parent_id;
}
} else {
console.warn(id, " selection problem , it is not in tree!");
}
});
return new EventTree({ ...this });
}
selectedEvents() {
const res = [];
for (const n in this) {
let node = this[n];
let isEvent = node.hasOwnProperty("data");
if (isEvent) {
if (node.state == "checked") {
res.push({
event_data: node.data,
label: node.label,
id: node.id
});
}
}
}
return res;
}
extractSelections() {
const res = [];
for (const n in this) {
let node = this[n];
// We only care checked ones
if (node.state == "checked") {
// its parent could be null, then this means root node is selected
// or if anynode is selected but its parent not selected
if (node.parent_id == null || this[node.parent_id].state != "checked") {
res.push(node.id);
}
}
}
return res;
}
getNodes(id) {
const isEvent = this[id].hasOwnProperty("data");
if (isEvent) {
return [id];
}
const allNodes = [id];
this[id].children.forEach((cid) => {
allNodes.push(...this.getNodes(cid));
});
return allNodes;
}
getEventsOfNode(id) {
const isEvent = this[id].hasOwnProperty("data");
if (isEvent) {
return [this[id]];
}
const allEvents = [];
this[id].children.forEach((cid) => {
allEvents.push(...this.getEventsOfNode(cid));
});
return allEvents;
}
getEventCount(id) {
return this.getEventsOfNode(id).length;
}
/**
* An event branch a branch which contains an event data array.
* Branches containing groups are not event branches.
*/
isEventBranch(id) {
const isEvent = this[id].hasOwnProperty("data");
if (isEvent) {
return false;
}
if (this[id].children.length <= 0) {
return false;
}
const firstChildrenId = this[id].children[0];
return this[firstChildrenId].hasOwnProperty("data");
}
getLevel(id) {
// trigger exception , if it is not in tree, we handle all exceptions
const nodeID = this[id].id;
return nodeID.split(">>").length;
}
isRoot(id) {
return this.getLevel(id) == 1;
}
isFirstLevel(id) {
return this.getLevel(id) == 2;
}
adjustSelectionsWithPath(selections) {
const adjustedSelections = [];
selections.forEach((s) => {
// if it is tree no problem
if (this.hasOwnProperty(s)) {
adjustedSelections.push(s);
} else {
// if it is not in tree
// we are checking if it is in tree by path
// if there can multiple ids to match
// we will be just adding those into the selections with their ids.
// We track if there is a node like exists
let matchedWithPath = false;
for (const id in this) {
if(this[id].path == s) {
adjustedSelections.push(id);
matchedWithPath = true;
}
}
// if this selection is not pathed one and still not in tree
// We still keep it since selections doesn't have to be in the tree always
// but when they are in tree , they will be used to mark nodes,
if(!matchedWithPath) {
adjustedSelections.push(s);
}
}
});
return adjustedSelections;
}
}
export default EventTree;