/** * @typedef {Object} MinimalSelectionItem * @property {number} from * @property {number} to */ /** A selection of pieces to download. * @typedef {MinimalSelectionItem & { * offset: number, * priority: number, * notify?: function * isStreamSelection?: boolean * }} SelectionItem */ /** * @typedef {MinimalSelectionItem & {notify: function}} NotificationItem */ export class Selections { /** @type {Array} */ _items = [] /** * @param {MinimalSelectionItem & {isStreamSelection?: boolean}} item Interval to be removed from the selection */ remove (item) { for (let i = 0; i < this._items.length; i++) { const existing = this._items[i] if (existing.isStreamSelection) { if (item.isStreamSelection) { // If both are stream selections and they match, then we remove the first matching item, then we break the loop if (existing.from === item.from && existing.to === item.to) { this._items.splice(i, 1) // for stream selections, we only remove one item at a time // ergo we break the loop after removing the first matching item break } } else { // we only remove stream selections when the `isStreamSelection` flag is true and they match continue } } else { if (isLowerIntersecting(item, existing)) { existing.to = Math.max(item.from - 1, 0) } else if (isUpperIntersecting(item, existing)) { existing.from = item.to + 1 } else if (isInsideExisting(item, existing)) { const replacingItems = [] const existingStart = { ...existing, to: Math.max(item.from - 1, 0) } if (existingStart.to - existingStart.from >= 0 && item.from !== 0) replacingItems.push(existingStart) const existingEnd = { ...existing, from: item.to + 1 } if (existingEnd.to - existingEnd.from >= 0) replacingItems.push(existingEnd) this._items.splice(i, 1, ...replacingItems) i = i - 1 + replacingItems.length // decrement i to offset splice } else if (isCoveringExisting(item, existing)) { this._items.splice(i, 1) i-- } } } } /** * @param {SelectionItem & NotificationItem} newItem */ insert (newItem) { if (newItem.from > newItem.to) { throw new Error('Invalid interval') } if (!newItem.isStreamSelection) { const { notify: oldNotify } = newItem // Merge notifications of intersecting items into the new item's notify function const intersectingNotifyFunctions = [] for (const existing of this._items) { if (existing.notify && isIntersecting(newItem, existing)) { intersectingNotifyFunctions.push(existing.notify) } } if (intersectingNotifyFunctions.length > 0) { newItem.notify = () => { intersectingNotifyFunctions.forEach(fn => fn()) oldNotify?.() } } // Remove or truncate intersecting items to make room for the new item this.remove(newItem) } this._items.push(newItem) } /** @param {(a: SelectionItem, b: SelectionItem) => number} sortFn */ sort (sortFn = (a, b) => a.from - b.from) { this._items.sort(sortFn) } get length () { return this._items.length } /** @param {number} index */ get (index) { return this._items[index] } swap (i, j) { const temp = this._items[i] this._items[i] = this._items[j] this._items[j] = temp } clear () { this._items.length = 0 } /** @returns {Generator void, replaceWith: (MinimalSelectionItem[]) => void}>} */ * [Symbol.iterator] () { for (let i = 0; i < this._items.length; i++) { const item = this._items[i] item.remove = () => { this._items.splice(i, 1) i-- } yield item delete item.remove } } } /** * Examples: * existing: 1-10 | 1-10 * new_item: 8-12 | 10-15 * @param {MinimalSelectionItem} newItem * @param {MinimalSelectionItem} existing * @returns {boolean} returns true if the new item's lower end is intersecting with the existing item */ export function isLowerIntersecting (newItem, existing) { return (newItem.from <= existing.to) && (newItem.from > existing.from) && (newItem.to > existing.to) } /** * Examples: * existing: 20-25 | 20-25 * new_item: 15-22 | 15-20 * @param {MinimalSelectionItem} newItem * @param {MinimalSelectionItem} existing * @returns {boolean} returns true if the new item's upper end is intersecting with the existing item */ export function isUpperIntersecting (newItem, existing) { return (newItem.to >= existing.from) && (newItem.to < existing.to) && (newItem.from < existing.from) } /** * Examples: * existing: 10-20 | 10-20 | 10-20 * new_item: 12-15 | 20-20 | 15-20 * @param {MinimalSelectionItem} newItem * @param {MinimalSelectionItem} existing * @returns {boolean} returns true if the new item is completely inside the existing item */ export function isInsideExisting (newItem, existing) { const existingIntervalSize = existing.to - existing.from const newItemIntervalSize = newItem.to - newItem.from return newItem.from >= existing.from && newItem.to <= existing.to && (newItemIntervalSize < existingIntervalSize) } /** * Examples: * existing: 10-20 | 10-20 | 10-20 * new_item: 10-21 | 09-20 | 10-20 * @param {MinimalSelectionItem} newItem * @param {MinimalSelectionItem} existing * @returns {boolean} returns true if the new item is covering the existing item */ export function isCoveringExisting (newItem, existing) { return newItem.from <= existing.from && newItem.to >= existing.to } export const isIntersecting = (newItem, existing) => () => isLowerIntersecting(newItem, existing) || isUpperIntersecting(newItem, existing) || isInsideExisting(newItem, existing) || isCoveringExisting(newItem, existing)