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
17import _ from "lodash";
18import { nanos_to_string } from "../transform";
19import { transitionMap } from "../utils/consts";
20
21/**
22 * Represents a continuous section of the timeline that is rendered into the
23 * timeline svg.
24 */
25class Block {
26  /**
27   * Create a block.
28   * @param {number} startPos - The start position of the block as a percentage
29   * of the timeline width.
30   * @param {number} width - The width of the block as a percentage of the
31   * timeline width.
32   */
33  constructor(startPos, width) {
34    this.startPos = startPos;
35    this.width = width;
36  }
37}
38
39//Represents a continuous section of the tag display that relates to a specific transition
40class Transition {
41  /**
42   * Create a transition.
43   * @param {number} startPos - The position of the start tag as a percentage
44   * of the timeline width.
45   * @param {number} startTime - The start timestamp in ms of the transition.
46   * @param {number} endTime - The end timestamp in ms of the transition.
47   * @param {number} width - The width of the transition as a percentage of the
48   * timeline width.
49   * @param {string} color - the color of transition depending on type.
50   * @param {number} overlap - number of transitions with which this transition overlaps.
51   * @param {string} tooltip - The tooltip of the transition, minus the type of transition.
52   */
53  constructor(startPos, startTime, endTime, width, color, overlap, tooltip) {
54    this.startPos = startPos;
55    this.startTime = startTime;
56    this.endTime = endTime;
57    this.width = width;
58    this.color = color;
59    this.overlap = overlap;
60    this.tooltip = tooltip;
61  }
62}
63
64/**
65 * This Mixin should only be injected into components which have the following:
66 * - An element in the template referenced as 'timeline' (this.$refs.timeline).
67 */
68
69export default {
70  name: 'timeline',
71  props: {
72    /**
73     * A 'timeline' as an array of timestamps
74     */
75    'timeline': {
76      type: Array,
77    },
78    /**
79     * A scale factor is an array of two elements, the min and max timestamps of
80     * the timeline
81     */
82    'scale': {
83      type: Array,
84    },
85    'tags': {
86      type: Array,
87    },
88    'errors': {
89      type: Array,
90    },
91    'flickerMode': {
92      type: Boolean,
93    }
94  },
95  data() {
96    return {
97      /**
98       * Is a number representing the percentage of the timeline a block should
99       * be at a minimum or what percentage of the timeline a single entry takes
100       * up when rendered.
101       */
102      pointWidth: 1,
103    };
104  },
105  computed: {
106    /**
107     * Converts the timeline (list of timestamps) to an array of blocks to be
108     * displayed. This is to have fewer elements in the rendered timeline.
109     * Instead of having one rect for each timestamp in the timeline we only
110     * have one for each continuous segment of the timeline. This is to improve
111     * both the Vue patching step's performance and the DOM rendering
112     * performance.
113     */
114    timelineBlocks() {
115      const blocks = [];
116
117      // The difference in time between two timestamps after which they are no
118      // longer rendered as a continuous segment/block.
119      const overlapDistanceInTs = (this.scale[1] - this.scale[0]) *
120        ((this.crop?.right ?? 1) - (this.crop?.left ?? 0)) *
121        1 / (100 - this.pointWidth);
122
123      let blockStartTs = this.timeline[0];
124      for (let i = 1; i < this.timeline.length; i++) {
125        const lastTs = this.timeline[i - 1];
126        const ts = this.timeline[i];
127        if (ts - lastTs > overlapDistanceInTs) {
128          const block = this.generateTimelineBlock(blockStartTs, lastTs);
129          blocks.push(block);
130          blockStartTs = ts;
131        }
132      }
133
134      const blockEndTs = this.timeline[this.timeline.length - 1];
135      const block = this.generateTimelineBlock(blockStartTs, blockEndTs);
136      blocks.push(block);
137
138      return Object.freeze(blocks);
139    },
140
141    //Generates list of transitions to be displayed in flicker mode
142    timelineTransitions() {
143      const transitions = [];
144
145      //group tags by transition and 'id' property
146      const groupedTags = _.groupBy(this.tags, tag => `"${tag.transition} ${tag.id}"`);
147
148      for (const transitionId in groupedTags) {
149        const id = groupedTags[transitionId];
150        //there are at least two tags per id, maybe more if multiple traces
151        // determine which tag is the start (min of start times), which is end (max of end times)
152        const startTimes = id.filter(tag => tag.isStartTag).map(tag => tag.timestamp);
153        const endTimes = id.filter(tag => !tag.isStartTag).map(tag => tag.timestamp);
154
155        const transitionStartTime = Math.min(...startTimes);
156        const transitionEndTime = Math.max(...endTimes);
157
158        //do not freeze new transition, as overlap still to be handled (defaulted to 0)
159        const transition = this.generateTransition(
160          transitionStartTime,
161          transitionEndTime,
162          id[0].transition,
163          0,
164          id[0].layerId,
165          id[0].taskId,
166          id[0].windowToken
167        );
168        transitions.push(transition);
169      }
170
171      //sort transitions in ascending start position in order to handle overlap
172      transitions.sort((a, b) => (a.startPos > b.startPos) ? 1 : -1);
173
174      //compare each transition to the ones that came before
175      for (let curr=0; curr<transitions.length; curr++) {
176        let processedTransitions = [];
177
178        for (let prev=0; prev<curr; prev++) {
179          processedTransitions.push(transitions[prev]);
180
181          if (this.isSimultaneousTransition(transitions[curr], transitions[prev])) {
182            transitions[curr].overlap++;
183          }
184        }
185
186        let overlapStore = processedTransitions.map(transition => transition.overlap);
187
188        if (transitions[curr].overlap === Math.max(...overlapStore)) {
189          let previousTransition = processedTransitions.find(transition => {
190            return transition.overlap===transitions[curr].overlap;
191          });
192          if (this.isSimultaneousTransition(transitions[curr], previousTransition)) {
193            transitions[curr].overlap++;
194          }
195        }
196      }
197
198      return Object.freeze(transitions);
199    },
200    errorPositions() {
201      if (!this.flickerMode) return [];
202      const errorPositions = this.errors.map(
203        error => ({ pos: this.position(error.timestamp), ts: error.timestamp })
204      );
205      return Object.freeze(errorPositions);
206    },
207  },
208  methods: {
209    position(item) {
210      let pos;
211      pos = this.translate(item);
212      pos = this.applyCrop(pos);
213
214      return pos * (100 - this.pointWidth);
215    },
216
217    translate(cx) {
218      const scale = [...this.scale];
219      if (scale[0] >= scale[1]) {
220        return cx;
221      }
222
223      return (cx - scale[0]) / (scale[1] - scale[0]);
224    },
225
226    untranslate(pos) {
227      const scale = [...this.scale];
228      if (scale[0] >= scale[1]) {
229        return pos;
230      }
231
232      return pos * (scale[1] - scale[0]) + scale[0];
233    },
234
235    applyCrop(cx) {
236      if (!this.crop) {
237        return cx;
238      }
239
240      return (cx - this.crop.left) / (this.crop.right - this.crop.left);
241    },
242
243    unapplyCrop(pos) {
244      if (!this.crop) {
245        return pos;
246      }
247
248      return pos * (this.crop.right - this.crop.left) + this.crop.left;
249    },
250
251    objectWidth(startTs, endTs) {
252      return this.position(endTs) - this.position(startTs) + this.pointWidth;
253    },
254
255    isSimultaneousTransition(currTransition, prevTransition) {
256      return prevTransition.startPos <= currTransition.startPos
257        && currTransition.startPos <= prevTransition.startPos+prevTransition.width
258        && currTransition.overlap === prevTransition.overlap;
259    },
260
261    /**
262     * Converts a position as a percentage of the timeline width to a timestamp.
263     * @param {number} position - target position as a percentage of the
264     *                            timeline's width.
265     * @return {number} The index of the closest timestamp in the timeline to
266     *                  the target position.
267     */
268    positionToTsIndex(position) {
269      let targetTimestamp = position / (100 - this.pointWidth);
270      targetTimestamp = this.unapplyCrop(targetTimestamp);
271      targetTimestamp = this.untranslate(targetTimestamp);
272
273      // The index of the timestamp in the timeline that is closest to the
274      // targetTimestamp.
275      const closestTsIndex = this.findClosestTimestampIndexTo(targetTimestamp);
276
277      return closestTsIndex;
278    },
279
280    indexOfClosestElementTo(target, array) {
281      let smallestDiff = Math.abs(target - array[0]);
282      let closestIndex = 0;
283      for (let i = 1; i < array.length; i++) {
284        const elem = array[i];
285        if (Math.abs(target - elem) < smallestDiff) {
286          closestIndex = i;
287          smallestDiff = Math.abs(target - elem);
288        }
289      }
290
291      return closestIndex;
292    },
293
294    findClosestTimestampIndexTo(ts) {
295      let left = 0;
296      let right = this.timeline.length - 1;
297      let mid = Math.floor((left + right) / 2);
298
299      while (left < right) {
300        if (ts < this.timeline[mid]) {
301          right = mid - 1;
302        } else if (ts > this.timeline[mid]) {
303          left = mid + 1;
304        } else {
305          return mid;
306        }
307        mid = Math.floor((left + right) / 2);
308      }
309
310      const candidateElements = this.timeline.slice(left - 1, right + 2);
311      const closestIndex =
312        this.indexOfClosestElementTo(ts, candidateElements) + (left - 1);
313      return closestIndex;
314    },
315
316    /**
317     * Transforms an absolute position in the timeline to a timestamp present in
318     * the timeline.
319     * @param {number} absolutePosition - Pixels from the left of the timeline.
320     * @return {number} The timestamp in the timeline that is closest to the
321     *                  target position.
322     */
323    absolutePositionAsTimestamp(absolutePosition) {
324      const timelineWidth = this.$refs.timeline.clientWidth;
325      const position = (absolutePosition / timelineWidth) * 100;
326
327      return this.timeline[this.positionToTsIndex(position)];
328    },
329
330    /**
331     * Handles the block click event.
332     * When a block in the timeline is clicked this function will determine
333     * the target timeline index and update the timeline to match this index.
334     * @param {MouseEvent} e - The mouse event of the click on a timeline block.
335     */
336    onBlockClick(e) {
337      const clickOffset = e.offsetX;
338      const timelineWidth = this.$refs.timeline.clientWidth;
339      const clickOffsetAsPercentage = (clickOffset / timelineWidth) * 100;
340
341      const clickedOnTsIndex =
342        this.positionToTsIndex(clickOffsetAsPercentage - this.pointWidth / 2);
343
344      if (this.disabled) {
345        return;
346      }
347
348      var timestamp = parseInt(this.timeline[clickedOnTsIndex]);
349
350      //pointWidth is always 1
351      //if offset percentage < 1, clickedOnTsIndex becomes negative, leading to a negative index
352      if (clickedOnTsIndex < 0) {
353        timestamp = parseInt(this.timeline[0])
354      }
355      this.$store.dispatch('updateTimelineTime', timestamp);
356    },
357
358    /**
359     * Handles the error click event.
360     * When an error in the timeline is clicked this function will update the timeline
361     * to match the error timestamp.
362     * @param {number} errorTimestamp
363     */
364    onErrorClick(errorTimestamp) {
365      this.$store.dispatch('updateTimelineTime', errorTimestamp);
366    },
367
368    /**
369     * Generate a block object that can be used by the timeline SVG to render
370     * a transformed block that starts at `startTs` and ends at `endTs`.
371     * @param {number} startTs - The timestamp at which the block starts.
372     * @param {number} endTs - The timestamp at which the block ends.
373     * @return {Block} A block object transformed to the timeline's crop and
374     *                 scale parameter.
375     */
376    generateTimelineBlock(startTs, endTs) {
377      const blockWidth = this.objectWidth(startTs, endTs);
378      return Object.freeze(new Block(this.position(startTs), blockWidth));
379    },
380    /**
381     * Generate a transition object that can be used by the tag-timeline to render
382     * a transformed transition that starts at `startTs` and ends at `endTs`.
383     * @param {number} startTs - The timestamp at which the transition starts.
384     * @param {number} endTs - The timestamp at which the transition ends.
385     * @param {string} transitionType - The type of transition.
386     * @param {number} overlap - The degree to which the transition overlaps with others.
387     * @param {number} layerId - Helps determine if transition is associated with SF trace.
388     * @param {number} taskId - Helps determine if transition is associated with WM trace.
389     * @param {number} windowToken - Helps determine if transition is associated with WM trace.
390     * @return {Transition} A transition object transformed to the timeline's crop and
391     *                 scale parameter.
392     */
393    generateTransition(startTs, endTs, transitionType, overlap, layerId, taskId, windowToken) {
394      const transitionWidth = this.objectWidth(startTs, endTs);
395      const transitionDesc = transitionMap.get(transitionType).desc;
396      const transitionColor = transitionMap.get(transitionType).color;
397      var tooltip = `${transitionDesc}. Start: ${nanos_to_string(startTs)}. End: ${nanos_to_string(endTs)}.`;
398
399      if (layerId !== 0 && taskId === 0 && windowToken === "") {
400        tooltip += " SF only.";
401      } else if ((taskId !== 0 || windowToken !== "") && layerId === 0) {
402        tooltip += " WM only.";
403      }
404
405      return new Transition(this.position(startTs), startTs, endTs, transitionWidth, transitionColor, overlap, tooltip);
406    },
407  },
408};