Skip to content

Commit b605da9

Browse files
committed
perf(timeline): optimized vertical position check + don't recompute flamecharts
1 parent d0c9c16 commit b605da9

File tree

5 files changed

+177
-77
lines changed

5 files changed

+177
-77
lines changed

packages/app-frontend/src/features/timeline/TimelineView.vue

Lines changed: 101 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { onKeyUp } from '@front/util/keyboard'
2929
import { useDarkMode } from '@front/util/theme'
3030
import { dimColor, boostColor } from '@front/util/color'
3131
import { formatTime } from '@front/util/format'
32+
import { Queue } from '@front/util/queue'
33+
import { nonReactive } from '@front/util/reactivity'
3234
3335
PIXI.settings.ROUND_PIXELS = true
3436
@@ -46,8 +48,15 @@ export default defineComponent({
4648
const { startTime, endTime, minTime, maxTime } = useTime()
4749
const { darkMode } = useDarkMode()
4850
51+
// Optimize for read in loops
52+
const nonReactiveTime = {
53+
startTime: nonReactive(startTime),
54+
endTime: nonReactive(endTime),
55+
minTime: nonReactive(minTime),
56+
}
57+
4958
function getTimePosition (time: number) {
50-
return (time - minTime.value) / (endTime.value - startTime.value) * app.view.width
59+
return (time - nonReactiveTime.minTime.value) / (nonReactiveTime.endTime.value - nonReactiveTime.startTime.value) * app.view.width
5160
}
5261
5362
// Reset
@@ -303,34 +312,33 @@ export default defineComponent({
303312
304313
let events: TimelineEvent[] = []
305314
306-
const updateEventPositionQueue = new Set<TimelineEvent>()
307-
let currentEventPositionUpdate: TimelineEvent = null
308-
let updateEventPositionQueued = false
315+
const updateEventPositionQueue = new Queue<TimelineEvent>()
316+
let eventPositionUpdateInProgress = false
309317
310-
function queueEventPositionUpdate (...events: TimelineEvent[]) {
318+
function queueEventPositionUpdate (events: TimelineEvent[], force = false) {
311319
for (const e of events) {
312320
if (!e.container) continue
313321
const ignored = isEventIgnored(e)
314322
e.container.visible = !ignored
315323
if (ignored) continue
316324
// Update horizontal position immediately
317325
e.container.x = Math.round(getTimePosition(e.time))
326+
if (!force && e.layer.groupsOnly) continue
318327
// Queue vertical position compute
319328
updateEventPositionQueue.add(e)
320329
}
321-
if (!updateEventPositionQueued) {
322-
updateEventPositionQueued = true
330+
if (!eventPositionUpdateInProgress) {
331+
eventPositionUpdateInProgress = true
323332
Vue.nextTick(() => {
324333
nextEventPositionUpdate()
325-
updateEventPositionQueued = false
334+
eventPositionUpdateInProgress = false
326335
})
327336
}
328337
}
329338
330339
function nextEventPositionUpdate () {
331-
if (currentEventPositionUpdate) return
332-
const event = currentEventPositionUpdate = updateEventPositionQueue.values().next().value
333-
if (event) {
340+
let event: TimelineEvent
341+
while ((event = updateEventPositionQueue.shift())) {
334342
computeEventVerticalPosition(event)
335343
}
336344
}
@@ -340,53 +348,73 @@ export default defineComponent({
340348
}
341349
342350
function computeEventVerticalPosition (event: TimelineEvent) {
343-
// Skip if the event is not visible
344-
// or if the group graphics is not visible
345-
if ((event.time >= startTime.value && event.time <= endTime.value) ||
346-
(event.group?.firstEvent === event && event.group.lastEvent.time >= startTime.value && event.group.lastEvent.time <= endTime.value)) {
351+
let y = 0
352+
if (event.group && event !== event.group.firstEvent) {
353+
// If the event is inside a group, just use the group position
354+
y = event.group.y
355+
} else {
356+
const firstEvent = event.group ? event.group.firstEvent : event
357+
const lastEvent = event.group ? event.group.lastEvent : event
358+
347359
// Collision offset for non-flamecharts
348360
const offset = event.layer.groupsOnly ? 0 : 12
349-
350-
let y = 0
351-
if (event.group && event !== event.group.firstEvent) {
352-
// If the event is inside a group, just use the group position
353-
y = event.group.y
354-
} else {
355-
const firstEvent = event.group ? event.group.firstEvent : event
356-
const lastEvent = event.group ? event.group.lastEvent : event
357-
const lastOffset = event.layer.groupsOnly && event.group?.duration > 0 ? -1 : 0
358-
// Check for 'collision' with other event groups
359-
const l = event.layer.groups.length
360-
let checkAgain = true
361-
while (checkAgain) {
362-
checkAgain = false
363-
for (let i = 0; i < l; i++) {
364-
const otherGroup = event.layer.groups[i]
365-
if (
366-
// Different group
367-
(
368-
!event.group ||
369-
event.group !== otherGroup
370-
) &&
371-
// Same row
372-
otherGroup.y === y &&
373-
(
374-
// Horizontal intersection (first event)
375-
(
376-
getTimePosition(firstEvent.time) >= getTimePosition(otherGroup.firstEvent.time) - offset &&
377-
getTimePosition(firstEvent.time) <= getTimePosition(otherGroup.lastEvent.time) + offset + lastOffset
378-
) ||
379-
// Horizontal intersection (last event)
361+
// For flamechart allow 1-pixel overlap at the end of a group
362+
const lastOffset = event.layer.groupsOnly && event.group?.duration > 0 ? -1 : 0
363+
// Flamechart uses time instead of pixel position
364+
const getPos = event.layer.groupsOnly ? (time: number) => time : getTimePosition
365+
366+
const firstPos = getPos(firstEvent.time)
367+
const lastPos = event.group ? getPos(lastEvent.time) : firstPos
368+
369+
// Check for 'collision' with other event groups
370+
const l = event.layer.groups.length
371+
let checkAgain = true
372+
while (checkAgain) {
373+
checkAgain = false
374+
for (let i = 0; i < l; i++) {
375+
const otherGroup = event.layer.groups[i]
376+
377+
if (
378+
// Different group
379+
(
380+
!event.group ||
381+
event.group !== otherGroup
382+
) &&
383+
// Same row
384+
otherGroup.y === y
385+
) {
386+
// // eslint-disable-next-line no-console
387+
// if (event.layer.groupsOnly) console.log('checking collision with', otherGroup.firstEvent.id, otherGroup.firstEvent.title)
388+
389+
const otherGroupFirstPos = getPos(otherGroup.firstEvent.time)
390+
const otherGroupLastPos = getPos(otherGroup.lastEvent.time)
391+
392+
// First position is inside other group
393+
const firstEventIntersection = (
394+
firstPos >= otherGroupFirstPos - offset &&
395+
firstPos <= otherGroupLastPos + offset + lastOffset
396+
)
397+
398+
if (firstEventIntersection || (
399+
// Additional checks if group
400+
event.group && (
380401
(
381-
getTimePosition(lastEvent.time) >= getTimePosition(otherGroup.firstEvent.time) - offset - lastOffset &&
382-
getTimePosition(lastEvent.time) <= getTimePosition(otherGroup.lastEvent.time) + offset
402+
// Last position is inside other group
403+
lastPos >= otherGroupFirstPos - offset - lastOffset &&
404+
lastPos <= otherGroupLastPos + offset
405+
) || (
406+
// Other group is inside current group
407+
firstPos < otherGroupFirstPos - offset &&
408+
lastPos > otherGroupLastPos + offset
383409
)
384410
)
385-
) {
411+
)) {
386412
// Collision!
387413
if (event.group && event.group.duration > otherGroup.duration && firstEvent.time <= otherGroup.firstEvent.time) {
388414
// Invert positions because current group has higher priority
389-
queueEventPositionUpdate(otherGroup.firstEvent)
415+
if (!updateEventPositionQueue.has(otherGroup.firstEvent)) {
416+
queueEventPositionUpdate([otherGroup.firstEvent], event.layer.groupsOnly)
417+
}
390418
} else {
391419
// Offset the current group/event
392420
y++
@@ -397,29 +425,24 @@ export default defineComponent({
397425
}
398426
}
399427
}
428+
}
400429
401-
// If the event is the first in a group, update group position
402-
if (event.group) {
403-
event.group.y = y
404-
}
430+
// If the event is the first in a group, update group position
431+
if (event.group) {
432+
event.group.y = y
433+
}
405434
406-
// Might update the layer's height as well
407-
if (y + 1 > event.layer.height) {
408-
const oldLayerHeight = event.layer.height
409-
const newLayerHeight = event.layer.height = y + 1
410-
if (oldLayerHeight !== newLayerHeight) {
411-
updateLayerPositions()
412-
drawLayerBackgroundEffects()
413-
}
435+
// Might update the layer's height as well
436+
if (y + 1 > event.layer.height) {
437+
const oldLayerHeight = event.layer.height
438+
const newLayerHeight = event.layer.height = y + 1
439+
if (oldLayerHeight !== newLayerHeight) {
440+
updateLayerPositions()
441+
drawLayerBackgroundEffects()
414442
}
415443
}
416-
event.container.y = (y + 1) * LAYER_SIZE
417444
}
418-
419-
// Next
420-
updateEventPositionQueue.delete(event)
421-
currentEventPositionUpdate = null
422-
nextEventPositionUpdate()
445+
event.container.y = (y + 1) * LAYER_SIZE
423446
}
424447
425448
function addEvent (event: TimelineEvent, layerContainer: PIXI.Container) {
@@ -456,7 +479,11 @@ export default defineComponent({
456479
events.push(event)
457480
458481
refreshEventGraphics(event)
459-
queueEventPositionUpdate(event)
482+
if (event.container) {
483+
queueEventPositionUpdate([event], true)
484+
} else {
485+
queueEventPositionUpdate([event.group.firstEvent], true)
486+
}
460487
461488
return event
462489
}
@@ -527,10 +554,12 @@ export default defineComponent({
527554
528555
function updateEvents () {
529556
for (const layer of layers.value) {
530-
layer.height = 1
557+
if (!layer.groupsOnly) {
558+
layer.height = 1
559+
}
531560
}
532561
updateLayerPositions()
533-
queueEventPositionUpdate(...events)
562+
queueEventPositionUpdate(events)
534563
for (const event of events) {
535564
if (event.groupG) {
536565
drawEventGroup(event)
@@ -826,7 +855,7 @@ export default defineComponent({
826855
if (event.layer.groupsOnly && event.title && size > 32) {
827856
let t = event.groupT
828857
if (!t) {
829-
t = event.groupT = new PIXI.Text(`${event.title} ${event.subtitle}`, {
858+
t = event.groupT = new PIXI.Text(`${event.id} ${event.title} ${event.subtitle}`, {
830859
fontSize: 10,
831860
fill: darkMode.value ? 0xffffff : 0,
832861
})

packages/app-frontend/src/features/timeline/composable/events.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,18 @@ export function onEventAdd (cb: AddEventCb) {
3434
addEventCbs.push(cb)
3535
}
3636

37-
export function addEvent (appId: string, event: TimelineEvent, layer: Layer) {
37+
export function addEvent (appId: string, eventOptions: TimelineEvent, layer: Layer) {
38+
// Non-reactive content
39+
const event = {} as TimelineEvent
40+
for (const key in eventOptions) {
41+
Object.defineProperty(event, key, {
42+
value: eventOptions[key],
43+
writable: true,
44+
enumerable: true,
45+
configurable: false,
46+
})
47+
}
48+
3849
if (layer.eventsMap[event.id]) return
3950

4051
if (timelineIsEmpty.value) {

packages/app-frontend/src/features/timeline/composable/setup.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import cloneDeep from 'lodash/cloneDeep'
21
import Vue from 'vue'
32
import { Bridge, BridgeEvents, parse } from '@vue-devtools/shared-utils'
43
import { getApps } from '@front/features/apps'
@@ -33,7 +32,7 @@ export function setupTimelineBridgeEvents (bridge: Bridge) {
3332
return
3433
}
3534

36-
addEvent(appId, cloneDeep(event), layer)
35+
addEvent(appId, event, layer)
3736
}
3837
})
3938

@@ -53,7 +52,7 @@ export function setupTimelineBridgeEvents (bridge: Bridge) {
5352
const pendingKey = `${appId}:${layer.id}`
5453
if (pendingEvents[pendingKey] && pendingEvents[pendingKey].length) {
5554
for (const event of pendingEvents[pendingKey]) {
56-
addEvent(appId, cloneDeep(event), getLayers(appId).find(l => l.id === layer.id))
55+
addEvent(appId, event, getLayers(appId).find(l => l.id === layer.id))
5756
}
5857
pendingEvents[pendingKey] = []
5958
}
@@ -91,7 +90,7 @@ export function setupTimelineBridgeEvents (bridge: Bridge) {
9190
}
9291

9392
for (const event of events) {
94-
addEvent(appId, cloneDeep(event), layer)
93+
addEvent(appId, event, layer)
9594
}
9695
})
9796

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export class Queue<T = any> {
2+
private existsMap: Map<T, boolean> = new Map()
3+
private firstItem: QueueItem<T> | null = null
4+
private lastItem: QueueItem<T> | null = null
5+
6+
add (value: T) {
7+
if (!this.existsMap.has(value)) {
8+
this.existsMap.set(value, true)
9+
const item = {
10+
current: value,
11+
next: null,
12+
}
13+
if (!this.firstItem) {
14+
this.firstItem = item
15+
}
16+
if (this.lastItem) {
17+
this.lastItem.next = item
18+
}
19+
this.lastItem = item
20+
}
21+
}
22+
23+
shift (): T | null {
24+
if (this.firstItem) {
25+
const item = this.firstItem
26+
this.firstItem = item.next
27+
if (!this.firstItem) {
28+
this.lastItem = null
29+
}
30+
this.existsMap.delete(item.current)
31+
return item.current
32+
}
33+
return null
34+
}
35+
36+
isEmpty () {
37+
return !this.firstItem
38+
}
39+
40+
has (value: T) {
41+
return this.existsMap.has(value)
42+
}
43+
}
44+
45+
interface QueueItem<T> {
46+
current: T
47+
next: QueueItem<T> | null
48+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Ref, watch } from '@vue/composition-api'
2+
3+
export function nonReactive<T> (ref: Ref<T>) {
4+
const holder = {
5+
value: ref.value,
6+
}
7+
8+
watch(ref, value => {
9+
holder.value = value
10+
})
11+
12+
return holder
13+
}

0 commit comments

Comments
 (0)