1/* 2 * Copyright 2020, The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 18/** 19 * Represents a continuous section of the timeline that is rendered into the 20 * timeline svg. 21 */ 22class Block { 23 /** 24 * Create a block. 25 * @param {number} startPos - The start position of the block as a percentage 26 * of the timeline width. 27 * @param {number} width - The width of the block as a percentage of the 28 * timeline width. 29 */ 30 constructor(startPos, width) { 31 this.startPos = startPos; 32 this.width = width; 33 } 34} 35 36/** 37 * This Mixin should only be injected into components which have the following: 38 * - An element in the template referenced as 'timeline' (this.$refs.timeline). 39 */ 40export default { 41 name: 'timeline', 42 props: { 43 /** 44 * A 'timeline' as an array of timestamps 45 */ 46 'timeline': { 47 type: Array, 48 }, 49 /** 50 * A scale factor is an array of two elements, the min and max timestamps of 51 * the timeline 52 */ 53 'scale': { 54 type: Array, 55 }, 56 }, 57 data() { 58 return { 59 /** 60 * Is a number representing the percentage of the timeline a block should 61 * be at a minimum or what percentage of the timeline a single entry takes 62 * up when rendered. 63 */ 64 pointWidth: 1, 65 }; 66 }, 67 computed: { 68 /** 69 * Converts the timeline (list of timestamps) to an array of blocks to be 70 * displayed. This is to have fewer elements in the rendered timeline. 71 * Instead of having one rect for each timestamp in the timeline we only 72 * have one for each continuous segment of the timeline. This is to improve 73 * both the Vue patching step's performance and the DOM rendering 74 * performance. 75 */ 76 timelineBlocks() { 77 const blocks = []; 78 79 // The difference in time between two timestamps after which they are no 80 // longer rendered as a continuous segment/block. 81 const overlapDistanceInTs = (this.scale[1] - this.scale[0]) * 82 ((this.crop?.right ?? 1) - (this.crop?.left ?? 0)) * 83 1 / (100 - this.pointWidth); 84 85 let blockStartTs = this.timeline[0]; 86 for (let i = 1; i < this.timeline.length; i++) { 87 const lastTs = this.timeline[i - 1]; 88 const ts = this.timeline[i]; 89 if (ts - lastTs > overlapDistanceInTs) { 90 const block = this.generateTimelineBlock(blockStartTs, lastTs); 91 blocks.push(block); 92 blockStartTs = ts; 93 } 94 } 95 96 const blockEndTs = this.timeline[this.timeline.length - 1]; 97 const block = this.generateTimelineBlock(blockStartTs, blockEndTs); 98 blocks.push(block); 99 100 return Object.freeze(blocks); 101 }, 102 }, 103 methods: { 104 position(item) { 105 let pos; 106 pos = this.translate(item); 107 pos = this.applyCrop(pos); 108 109 return pos * (100 - this.pointWidth); 110 }, 111 112 translate(cx) { 113 const scale = [...this.scale]; 114 if (scale[0] >= scale[1]) { 115 return cx; 116 } 117 118 return (cx - scale[0]) / (scale[1] - scale[0]); 119 }, 120 121 untranslate(pos) { 122 const scale = [...this.scale]; 123 if (scale[0] >= scale[1]) { 124 return pos; 125 } 126 127 return pos * (scale[1] - scale[0]) + scale[0]; 128 }, 129 130 applyCrop(cx) { 131 if (!this.crop) { 132 return cx; 133 } 134 135 return (cx - this.crop.left) / (this.crop.right - this.crop.left); 136 }, 137 138 unapplyCrop(pos) { 139 if (!this.crop) { 140 return pos; 141 } 142 143 return pos * (this.crop.right - this.crop.left) + this.crop.left; 144 }, 145 146 /** 147 * Converts a position as a percentage of the timeline width to a timestamp. 148 * @param {number} position - target position as a percentage of the 149 * timeline's width. 150 * @return {number} The index of the closest timestamp in the timeline to 151 * the target position. 152 */ 153 positionToTsIndex(position) { 154 let targetTimestamp = position / (100 - this.pointWidth); 155 targetTimestamp = this.unapplyCrop(targetTimestamp); 156 targetTimestamp = this.untranslate(targetTimestamp); 157 158 // The index of the timestamp in the timeline that is closest to the 159 // targetTimestamp. 160 const closestTsIndex = this.findClosestTimestampIndexTo(targetTimestamp); 161 162 return closestTsIndex; 163 }, 164 165 indexOfClosestElementTo(target, array) { 166 let smallestDiff = Math.abs(target - array[0]); 167 let closestIndex = 0; 168 for (let i = 1; i < array.length; i++) { 169 const elem = array[i]; 170 if (Math.abs(target - elem) < smallestDiff) { 171 closestIndex = i; 172 smallestDiff = Math.abs(target - elem); 173 } 174 } 175 176 return closestIndex; 177 }, 178 179 findClosestTimestampIndexTo(ts) { 180 let left = 0; 181 let right = this.timeline.length - 1; 182 let mid = Math.floor((left + right) / 2); 183 184 while (left < right) { 185 if (ts < this.timeline[mid]) { 186 right = mid - 1; 187 } else if (ts > this.timeline[mid]) { 188 left = mid + 1; 189 } else { 190 return mid; 191 } 192 mid = Math.floor((left + right) / 2); 193 } 194 195 const candidateElements = this.timeline.slice(left - 1, right + 2); 196 const closestIndex = 197 this.indexOfClosestElementTo(ts, candidateElements) + (left - 1); 198 return closestIndex; 199 }, 200 201 /** 202 * Transforms an absolute position in the timeline to a timestamp present in 203 * the timeline. 204 * @param {number} absolutePosition - Pixels from the left of the timeline. 205 * @return {number} The timestamp in the timeline that is closest to the 206 * target position. 207 */ 208 absolutePositionAsTimestamp(absolutePosition) { 209 const timelineWidth = this.$refs.timeline.clientWidth; 210 const position = (absolutePosition / timelineWidth) * 100; 211 212 return this.timeline[this.positionToTsIndex(position)]; 213 }, 214 215 /** 216 * Handles the block click event. 217 * When a block in the timeline is clicked this function will determine 218 * the target timeline index and update the timeline to match this index. 219 * @param {MouseEvent} e - The mouse event of the click on a timeline block. 220 */ 221 onBlockClick(e) { 222 const clickOffset = e.offsetX; 223 const timelineWidth = this.$refs.timeline.clientWidth; 224 const clickOffsetAsPercentage = (clickOffset / timelineWidth) * 100; 225 226 const clickedOnTsIndex = 227 this.positionToTsIndex(clickOffsetAsPercentage - this.pointWidth / 2); 228 229 if (this.disabled) { 230 return; 231 } 232 const timestamp = parseInt(this.timeline[clickedOnTsIndex]); 233 this.$store.dispatch('updateTimelineTime', timestamp); 234 }, 235 236 /** 237 * Generate a block object that can be used by the timeline SVG to render 238 * a transformed block that starts at `startTs` and ends at `endTs`. 239 * @param {number} startTs - The timestamp at which the block starts. 240 * @param {number} endTs - The timestamp at which the block ends. 241 * @return {Block} A block object transformed to the timeline's crop and 242 * scale parameter. 243 */ 244 generateTimelineBlock(startTs, endTs) { 245 const blockWidth = this.position(endTs) - this.position(startTs) + 246 this.pointWidth; 247 return Object.freeze(new Block(this.position(startTs), blockWidth)); 248 }, 249 }, 250}; 251