import { destroy, detach, getEnv, getParent, onPatch, types } from "mobx-state-tree"; import { Hotkey } from "../core/Hotkey"; import { isDefined } from "../utils/utilities"; import { AllRegionsType } from "../regions"; import { debounce } from "../utils/debounce"; import Tree, { TRAVERSE_STOP } from "../core/Tree"; import { FF_DEV_2755, isFF } from "../utils/feature-flags"; const hotkeys = Hotkey("RegionStore"); const localStorageKeys = { sort: "outliner:sort", sortDirection: "outliner:sort-direction", group: "outliner:group", view: "regionstore:view", }; const SelectionMap = types .model({ selected: types.optional(types.map(types.safeReference(AllRegionsType)), {}), drawingSelected: types.optional(types.map(types.safeReference(AllRegionsType)), {}), }) .views((self) => { return { get keys() { return Array.from(self.selected.keys()); }, get annotation() { return getParent(self).annotation; }, get highlighted() { return self.selected.size === 1 ? self.selected.values().next().value : null; }, get size() { return self.selected.size; }, get list() { return Array.from(self.selected.values()); }, isSelected(region) { return self.selected.has(region.id); }, }; }) .actions((self) => { const updateResultsFromSelection = debounce(() => { self._updateResultsFromSelection(); }, 0); return { beforeUnselect(region) { region.perRegionTags.forEach((tag) => tag.submitChanges?.()); }, afterUnselect(region) { region.afterUnselectRegion?.(); }, drawingSelect(region) { self.drawingSelected.put(region); }, drawingUnselect() { Array.from(self.drawingSelected.values()).forEach((region) => { self.drawingSelected.delete(region.id); }); }, select(region) { self.selected.put(region); region.selectRegion && region.selectRegion(); if (self.highlighted) { // @todo some backward compatibility, should be rewritten to state handling // @todo but there are some actions should be performed like scroll to region self.highlighted.perRegionTags.forEach((tag) => tag.updateFromResult?.(undefined)); // special case for Taxonomy as labeling tool self.highlighted.labelingTags.forEach((tag) => tag.updateFromResult?.(undefined)); updateResultsFromSelection(); } else { updateResultsFromSelection(); } // hook for side effects after region selected region.object?.afterRegionSelected?.(region); }, _updateResultsFromSelection() { self._updateResultsFromRegions(self.selected.values()); }, _updateResultsFromRegions(regions) { const valuesFromControls = {}; const controlsByName = {}; Array.from(regions).map((region) => { region.results.forEach((result) => { const controlName = result.from_name.name; const currentValue = valuesFromControls[controlName]; if (currentValue !== undefined) { valuesFromControls[controlName] = result.mergeMainValue(currentValue); } else { controlsByName[controlName] = result.from_name; valuesFromControls[controlName] = result.mainValue; } }); }); self.annotation.unselectStates(); for (const [controlName, value] of Object.entries(valuesFromControls)) { const control = controlsByName[controlName]; control.updateFromResult?.(value); } }, unselect(region) { self.beforeUnselect(region); self.selected.delete(region.id); self.afterUnselect(region); }, clear() { // clear() in the middle empties selected regions, so store them in separate array const regionEntries = [...self.selected.values()]; for (const region of regionEntries) { self.beforeUnselect(region); } self.selected.clear(); for (const region of regionEntries) { self.afterUnselect(region); } }, highlight(region) { self.clear(); self.select(region); }, }; }); export default types .model("RegionStore", { sort: types.optional( types.enumeration(["date", "score", "mediaStartTime"]), () => window.localStorage.getItem(localStorageKeys.sort) ?? "date", ), sortOrder: types.optional( types.enumeration(["asc", "desc"]), () => window.localStorage.getItem(localStorageKeys.sortDirection) ?? "asc", ), group: types.optional( types.enumeration(["type", "label", "manual"]), () => window.localStorage.getItem(localStorageKeys.group) ?? "manual", ), filter: types.maybeNull(types.array(types.safeReference(AllRegionsType)), null), view: types.optional( types.enumeration(["regions", "labels"]), window.localStorage.getItem(localStorageKeys.view) ?? "regions", ), selection: types.optional(SelectionMap, {}), }) .views((self) => { let lastClickedItem; const getShiftClickSelectedRange = (item, tree) => { const regions = []; let clickedRegionsFound = 0; Tree.traverseTree({ children: tree }, (node) => { if (!node.isArea) return; if (node.item === lastClickedItem || node.item === item || clickedRegionsFound === 1) { if (node.item) regions.push(node.item); if (node.item === lastClickedItem) ++clickedRegionsFound; if (node.item === item) ++clickedRegionsFound; } if (clickedRegionsFound >= 2) { return TRAVERSE_STOP; } }); return regions; }; const createClickRegionInTreeHandler = (tree) => { return (ev, item) => { if (ev.shiftKey) { const regions = getShiftClickSelectedRange(item, tree); regions.forEach((region) => { self.selection.select(region); }); lastClickedItem = null; return; } lastClickedItem = item; if (ev.metaKey || ev.ctrlKey) { self.toggleSelection(item); return; } if (self.selection.highlighted === item) { self.clearSelection(); return; } self.highlight(item); }; }; return { get annotation() { return getParent(self); }, get classifications() { const textAreas = Array.from(self.annotation.names.values()) .filter((t) => isDefined(t)) .filter((t) => t.type === "textarea" && !t.perregion) .map((t) => t.regions); return [].concat(...textAreas); }, get regions() { return Array.from(self.annotation.areas.values()).filter((area) => !area.classification); }, get filteredRegions() { return self.filter || self.regions; }, get suggestions() { return Array.from(self.annotation.suggestions.values()).filter((area) => !area.classification); }, get isAllHidden() { return !self.regions.find((area) => !area.hidden); }, get sortedRegions() { const sorts = { date: (isDesc) => [...self.filteredRegions].sort(isDesc ? (a, b) => b.ouid - a.ouid : (a, b) => a.ouid - b.ouid), score: (isDesc) => [...self.filteredRegions].sort(isDesc ? (a, b) => b.score - a.score : (a, b) => a.score - b.score), mediaStartTime: (isDesc) => [...self.filteredRegions].sort((a, b) => { const aTime = self.getRegionMediaTime(a); const bTime = self.getRegionMediaTime(b); // Handle regions without media time (put them at the end) if (aTime === null && bTime === null) return 0; if (aTime === null) return 1; if (bTime === null) return -1; return isDesc ? bTime - aTime : aTime - bTime; }), }; const sorted = sorts[self.sort](self.sortOrder === "desc"); return sorted; }, get regionIndexMap() { const map = {}; self.sortedRegions.forEach((region, idx) => { map[region.id] = idx + 1; }); return map; }, getRegionMediaTime(region) { // Handle audio regions - they have start time in seconds if ((region.type === "audioregion" || region.type === "timeseriesregion") && typeof region.start === "number") { return region.start; } // Handle timeline regions (video) - they have ranges with start frame if (region.type === "timelineregion" && region.ranges?.[0]) { return region.ranges[0].start; } // Handle video rectangle regions - they have sequence with frames if (region.type === "videorectangleregion" && region.sequence?.[0]) { return region.sequence[0].frame; } // Return null for regions without media time information return null; }, getRegionsTree(enrich) { if (self.group === null || self.group === "manual") { return self.asTree(enrich); } if (self.group === "label") { return self.asLabelsTree(enrich); } if (self.group === "type") { return self.asTypeTree(enrich); } console.error(`Grouping by ${self.group} is not implemented`); }, asTree(enrich) { const regions = self.sortedRegions; const tree = []; const lookup = new Map(); const onClick = createClickRegionInTreeHandler(tree); // every region has a parentID // parentID is an empty string - "" if it's top level // or it can contain a string key to the parent region // [ { id: "1", parentID: "" }, { id: "2", parentID: "1" } ] // would create a tree of two elements regions.forEach((el, idx) => { const result = enrich(el, idx, onClick); Object.assign(result, { item: el, children: [], isArea: true, }); lookup.set(el.cleanId, result); }); lookup.forEach((el) => { const pid = el.item.parentID; const parent = pid ? (lookup.get(pid) ?? lookup.get(pid.replace(/#(.+)/i, ""))) : null; if (parent) return parent.children.push(el); tree.push(el); }); return tree; }, asLabelsTree(enrich) { // collect all label states into two maps const groups = {}; const result = []; const onClick = createClickRegionInTreeHandler(result); let index = 0; const getLabelGroup = (label, key) => { const labelGroup = groups[key]; if (labelGroup) return labelGroup; return (groups[key] = { ...enrich(label, index, true), id: key, isGroup: true, isNotLabel: true, children: [], }); }; const getRegionLabel = (region) => region.labeling?.selectedLabels || (region.emptyLabel && [region.emptyLabel]); const addToLabelGroup = (key, label, region) => { const group = getLabelGroup(label, key); const groupId = group.id; const labelHotKey = getRegionLabel(region)?.[0]?.hotkey; if (isFF(FF_DEV_2755)) { group.hotkey = labelHotKey; group.pos = groupId.slice(0, groupId.indexOf("#")); } group.children.push({ ...enrich(region, index, false, null, onClick, groupId), item: region, isArea: true, }); }; const addRegionsToLabelGroup = (labels, region) => { if (labels) { for (const label of labels) { addToLabelGroup(`${label.value}#${label.id}`, label, region); } } else { addToLabelGroup("no-label", undefined, region); } }; for (const region of self.regions) { addRegionsToLabelGroup(region.labeling?.selectedLabels, region); index++; } const groupsArray = Object.values(groups); if (isFF(FF_DEV_2755)) { groupsArray.sort((a, b) => (a.hotkey > b.hotkey ? 1 : a.hotkey < b.hotkey ? -1 : 0)); } result.push(...groupsArray); return result; }, asTypeTree(enrich) { // collect all label states into two maps const groups = {}; const result = []; const onClick = createClickRegionInTreeHandler(result); let index = 0; const getTypeGroup = (region, key) => { const group = groups[key]; if (group) return group; const groupingEntity = { type: "tool", value: key.replace("region", ""), background: "#000", }; return (groups[key] = { ...enrich(groupingEntity, index, true), id: key, key, isArea: false, children: [], isGroup: true, entity: region, }); }; const addToLabelGroup = (region) => { const key = region.type; const group = getTypeGroup(region, key); group.children.push({ ...enrich(region, index, false, null, onClick), item: region, isArea: true, }); }; for (const region of self.regions) { addToLabelGroup(region); index++; } result.push(...Object.values(groups)); return result; }, get hasSelection() { return !!self.selection.size; }, isSelected(region) { return self.selection.isSelected(region); }, get selectedIds() { return Array.from(self.selection.selected.values()).map((reg) => reg.id); }, get persistantView() { return window.localStorage.getItem(localStorageKeys.view) ?? self.view; }, }; }) .actions((self) => ({ addRegion(region) { self.regions.push(region); getEnv(self).events.invoke("entityCreate", region); }, toggleSortOrder() { if (self.sortOrder === "asc") self.sortOrder = "desc"; else self.sortOrder = "asc"; }, setView(view) { if (isFF(FF_DEV_2755)) { window.localStorage.setItem(localStorageKeys.view, view); } self.view = view; }, setSort(sort) { if (self.sort === sort) { self.toggleSortOrder(); } else { self.sortOrder = "asc"; self.sort = sort; } window.localStorage.setItem(localStorageKeys.sort, self.sort); window.localStorage.setItem(localStorageKeys.sortDirection, self.sortOrder); self.initHotkeys(); self.annotation.updateAppearenceFromState(); }, setGrouping(group) { self.group = group; window.localStorage.setItem(localStorageKeys.group, self.group); }, setFilteredRegions(filter) { if (self.regions.length === filter.length) { self.filter = null; self.regions.forEach((region) => region.filtered && region.toggleFiltered()); } else { const filteredIds = filter.map((filter) => filter.id); self.filter = filter; self.regions.forEach((region) => { if (!region.hideable || (region.hidden && !region.filtered)) return; if (filteredIds.includes(region.id)) region.hidden && region.toggleFiltered(); else if (!region.hidden) region.toggleFiltered(); }); } self.annotation.updateAppearenceFromState(); }, /** * Delete region * @param {obj} region */ deleteRegion(region) { detach(region); // find regions that have that region as a parent const children = self.filterByParentID(region.id); children && children.forEach((r) => r.setParentID(region.parentID)); getEnv(self).events.invoke("entityDelete", region); destroy(region); self.initHotkeys(); }, findRegionID(id) { if (!id) return null; return self.regions.find((r) => r.id === id); }, findRegion(id) { return self.findRegionID(id); }, filterByParentID(id) { return self.regions.filter((r) => r.parentID === id); }, normalizeRegionID(regionId) { if (!regionId) return ""; if (!regionId.includes("#")) { regionId = `${regionId}#${self.annotation.id}`; } return regionId; }, afterCreate() { onPatch(self, (patch) => { if ((patch.op === "add" || patch.op === "delete") && patch.path.indexOf("/regions/") !== -1) { self.initHotkeys(); } }); self.view = window.localStorage.getItem(localStorageKeys.view) ?? (self.annotation.store.settings.displayLabelsByDefault ? "labels" : "regions"); }, // init Alt hotkeys for regions selection initHotkeys() { const PREFIX = "alt+shift+"; hotkeys.unbindAll(); self.sortedRegions.forEach((r, n) => { hotkeys.addKey(PREFIX + (n + 1), () => { self.unselectAll(); r.selectRegion(); }); }); // this is added just for the reference to show up in the // settings page hotkeys.addKey("alt+shift+$n", () => {}, "Select a region"); }, /** * @param {boolean} tryToKeepStates try to keep states selected if such settings enabled */ unselectAll() { self.annotation.unselectAll(); }, unhighlightAll() { self.regions.forEach((r) => r.setHighlight(false)); }, selectNext() { const { regions } = self; const idx = self.regions.findIndex((r) => r.selected); if (idx < 0) { const region = regions[0]; region && self.annotation.selectArea(region); } else { const next = isDefined(regions[idx + 1]) ? regions[idx + 1] : regions[0]; next && self.annotation.selectArea(next); } }, toggleVisibility() { const shouldBeHidden = !self.isAllHidden; self.regions.forEach((area) => { if (area.hidden !== shouldBeHidden) { area.toggleHidden(); } }); }, selectRegionByID(regionId) { const normalizedRegionId = self.normalizeRegionID(regionId); const targetRegion = self.findRegionID(normalizedRegionId); if (!targetRegion) return; self.toggleSelection(targetRegion, true); }, setRegionVisible(regionId) { const normalizedRegionId = self.normalizeRegionID(regionId); const targetRegion = self.findRegionID(normalizedRegionId); if (!targetRegion) return; self.regions.forEach((area) => { if (!area.hidden) { area.toggleHidden(); } }); targetRegion.toggleHidden(); }, setHiddenByTool(shouldBeHidden, label) { self.regions.forEach((area) => { if (area.hidden !== shouldBeHidden && area.type === label.type) { area.toggleHidden(); } }); }, setHiddenByLabel(shouldBeHidden, label) { self.regions.forEach((area) => { if (area.hidden !== shouldBeHidden) { const l = area.labeling; if (l) { const selected = l.selectedLabels; if (selected.includes(label)) { area.toggleHidden(); } } } }); }, highlight(area) { self.selection.highlight(area); }, clearSelection() { self.selection.clear(); }, selectRegionsByIds(ids) { self.regions.map((region) => { if (ids.indexOf(region.id) === -1) return; self.toggleSelection(region, true); }); }, toggleSelection(region, isSelected) { if (!isDefined(isSelected)) isSelected = !self.selection.isSelected(region); if (isSelected) { self.selection.select(region); } else { self.selection.unselect(region); } }, }));