|
| 1 | +/* |
| 2 | +This objective of this sorting algorithm is to allow cards to be moved and result in the minimum number of updates |
| 3 | +E.g. if a card is moved between to cards, the other cards should not be updated |
| 4 | +only the moved card should be updated to be half the distance between the two cards |
| 5 | +
|
| 6 | +It is used when the 'Custom Sort Order' is selected on the control, and an attribute |
| 7 | +is provided that holds the sort position value |
| 8 | +*/ |
| 9 | + |
| 10 | +import { CurrentItem } from './CurrentItemSchema'; |
| 11 | + |
| 12 | +// Only the ItemId, OriginalDropZoneId & DropZoneId are required to re-order and detect changes |
| 13 | +export type ReOrderableItem = Pick<CurrentItem, 'ItemId' | 'OriginalDropZoneId' | 'DropZoneId'> & |
| 14 | + Partial<Pick<CurrentItem, 'Position' | 'OriginalPosition' | 'HasMovedZone' | 'HasMovedPosition'>> & { |
| 15 | + Index?: number; |
| 16 | + }; |
| 17 | + |
| 18 | +export interface CustomSortPositionsOptions { |
| 19 | + // The direction that custom positions are sorted to establish if they are in sequence or not |
| 20 | + sortOrder: 'asc' | 'desc'; |
| 21 | + // The amount items will be incremented by if there is space |
| 22 | + positionIncrement: number; |
| 23 | + // Allow custom sort positions to be negative when moving items below items that have a position less than the increment |
| 24 | + allowNegative: boolean; |
| 25 | + // Round to nearest integer - if true this will mean that multiple items can have the same position |
| 26 | + maxDecimalPlaces: number; |
| 27 | + // The minimum increment that will be used when ordering items. Below this, then positionIncrement/10 will be used |
| 28 | + minimumIncrement?: number; |
| 29 | +} |
| 30 | + |
| 31 | +const defaultConfig = { |
| 32 | + positionIncrement: 100, |
| 33 | + sortOrder: 'asc', |
| 34 | + allowNegative: false, |
| 35 | + maxDecimalPlaces: 4, |
| 36 | +} as CustomSortPositionsOptions; |
| 37 | + |
| 38 | +export class CustomSortPositionStrategy { |
| 39 | + private config = defaultConfig; |
| 40 | + private items: Partial<ReOrderableItem>[] = []; |
| 41 | + |
| 42 | + public SetOptions(options?: Partial<CustomSortPositionsOptions>) { |
| 43 | + this.config = { ...defaultConfig, ...options }; |
| 44 | + } |
| 45 | + public updateSortPosition(itemsToSort: Partial<ReOrderableItem>[]) { |
| 46 | + this.items = itemsToSort; |
| 47 | + |
| 48 | + let firstPositionValue = this.getFirstPositionValue(); |
| 49 | + |
| 50 | + this.items.forEach((item, index) => { |
| 51 | + // Has the position already been set in a previous loop iteration? If so, skip |
| 52 | + if (item.Position !== undefined) { |
| 53 | + firstPositionValue = Math.max(firstPositionValue, item.Position); |
| 54 | + item.HasMovedPosition = item.Position !== item.OriginalPosition; |
| 55 | + item.HasMovedZone = item.DropZoneId !== item.OriginalDropZoneId; |
| 56 | + return; |
| 57 | + } |
| 58 | + |
| 59 | + const previousItem = index === 0 ? null : this.items[index - 1]; |
| 60 | + const previousPosition = this.getPreviousPosition(previousItem, firstPositionValue); |
| 61 | + const nextItem = this.getNextNonOutOfSequenceItem(index, previousPosition); |
| 62 | + const isPreviousOutOfSequence = this.isItemOutOfSequence(item, 'previous', previousPosition); |
| 63 | + const isNextOutOfSequence = this.isItemOutOfSequence(item, 'next', nextItem?.OriginalPosition); |
| 64 | + |
| 65 | + if (isPreviousOutOfSequence || isNextOutOfSequence || item.OriginalPosition === undefined) { |
| 66 | + const subIncrement = this.getSubIncrement(index, nextItem, previousItem, previousPosition); |
| 67 | + let newPosition = previousPosition; |
| 68 | + // Set the position of the item and all items up until the next out of sequence item |
| 69 | + const endIndex = nextItem?.Index ?? this.items.length; |
| 70 | + for (let i = index; i < endIndex; i++) { |
| 71 | + newPosition += subIncrement; |
| 72 | + this.items[i].Position = Number(newPosition.toFixed(this.config.maxDecimalPlaces)); |
| 73 | + } |
| 74 | + } else { |
| 75 | + item.Position = item.OriginalPosition; |
| 76 | + } |
| 77 | + |
| 78 | + firstPositionValue = Math.max(firstPositionValue, item.Position ?? 0); |
| 79 | + item.HasMovedPosition = item.Position !== item.OriginalPosition; |
| 80 | + item.HasMovedZone = item.DropZoneId !== item.OriginalDropZoneId; |
| 81 | + }); |
| 82 | + |
| 83 | + return this.items; |
| 84 | + } |
| 85 | + |
| 86 | + private getFirstPositionValue() { |
| 87 | + let firstPositionValue = 0; |
| 88 | + if (this.config.sortOrder === 'desc') { |
| 89 | + const firstDecreasingItem = this.items.find((item, index) => { |
| 90 | + if (index === this.items.length - 1) { |
| 91 | + return false; // skip last item |
| 92 | + } |
| 93 | + return (item.OriginalPosition ?? 0) > (this.items[index + 1].OriginalPosition ?? 0); |
| 94 | + }); |
| 95 | + firstPositionValue = firstDecreasingItem?.OriginalPosition |
| 96 | + ? firstDecreasingItem.OriginalPosition + |
| 97 | + this.config.positionIncrement * (this.items.indexOf(firstDecreasingItem) + 1) |
| 98 | + : this.config.positionIncrement * (this.items.length + 1); |
| 99 | + } |
| 100 | + return firstPositionValue; |
| 101 | + } |
| 102 | + |
| 103 | + private getSubIncrement( |
| 104 | + index: number, |
| 105 | + nextItem: Partial<ReOrderableItem> | null, |
| 106 | + previousItem: Partial<ReOrderableItem> | null, |
| 107 | + previousPosition: number, |
| 108 | + ) { |
| 109 | + const sortDirectionMultiplier = this.config.sortOrder === 'asc' ? 1 : -1; |
| 110 | + const increment = this.config.positionIncrement * sortDirectionMultiplier; |
| 111 | + const numberOfItemsBetweenOrEnd = (nextItem?.Index ?? this.items.length) - index; |
| 112 | + const nextPosition = nextItem?.OriginalPosition ?? previousPosition + numberOfItemsBetweenOrEnd * increment; |
| 113 | + |
| 114 | + let subIncrement = (nextPosition - previousPosition) / (numberOfItemsBetweenOrEnd + (nextItem ? 1 : 0)); |
| 115 | + |
| 116 | + // Special case for when we are sequencing all the way to the end of the list |
| 117 | + if (this.config.sortOrder === 'desc' && !nextItem && previousItem) { |
| 118 | + subIncrement = increment / (numberOfItemsBetweenOrEnd + 1); |
| 119 | + } |
| 120 | + |
| 121 | + if (this.config.minimumIncrement && Math.abs(subIncrement) < this.config.minimumIncrement) { |
| 122 | + subIncrement = this.config.minimumIncrement * 2 * sortDirectionMultiplier; |
| 123 | + } |
| 124 | + |
| 125 | + // Special case when we do not allow negative numbers, the increment is squashed |
| 126 | + // This can result in duplicate positions |
| 127 | + if ( |
| 128 | + !this.config.allowNegative && |
| 129 | + this.config.sortOrder === 'desc' && |
| 130 | + previousPosition + numberOfItemsBetweenOrEnd * subIncrement <= 0 |
| 131 | + ) { |
| 132 | + subIncrement = -previousPosition / (numberOfItemsBetweenOrEnd + 1); |
| 133 | + } |
| 134 | + |
| 135 | + return subIncrement; |
| 136 | + } |
| 137 | + |
| 138 | + private getNextNonOutOfSequenceItem(index: number, previousPosition: number) { |
| 139 | + const nextItemIndexRelative = this.items |
| 140 | + .slice(index + 1) |
| 141 | + .findIndex( |
| 142 | + (i) => |
| 143 | + i.OriginalPosition && |
| 144 | + (this.config.sortOrder === 'asc' |
| 145 | + ? i.OriginalPosition > previousPosition |
| 146 | + : i.OriginalPosition < previousPosition), |
| 147 | + ); |
| 148 | + const nextItemIndexAbsolute = |
| 149 | + nextItemIndexRelative > -1 ? index + 1 + nextItemIndexRelative : this.items.length; |
| 150 | + const nextItem = nextItemIndexRelative > -1 ? this.items[nextItemIndexAbsolute] : null; |
| 151 | + if (nextItem) nextItem.Index = nextItemIndexAbsolute; |
| 152 | + return nextItem; |
| 153 | + } |
| 154 | + |
| 155 | + private getPreviousPosition(previousItem: Partial<ReOrderableItem> | null, firstPositionValue: number) { |
| 156 | + return previousItem?.Position ?? previousItem?.OriginalPosition ?? firstPositionValue; |
| 157 | + } |
| 158 | + |
| 159 | + private isItemOutOfSequence( |
| 160 | + item: Partial<ReOrderableItem>, |
| 161 | + direction: 'previous' | 'next', |
| 162 | + comparePosition?: number, |
| 163 | + ) { |
| 164 | + return ( |
| 165 | + item.OriginalPosition && |
| 166 | + comparePosition !== undefined && |
| 167 | + ((this.config.sortOrder === 'asc' && direction === 'next') || |
| 168 | + (this.config.sortOrder === 'desc' && direction === 'previous') |
| 169 | + ? item.OriginalPosition >= comparePosition |
| 170 | + : item.OriginalPosition <= comparePosition) |
| 171 | + ); |
| 172 | + } |
| 173 | +} |
0 commit comments