/* * Copyright (C) 2014, 2015 Apple Inc. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF * THE POSSIBILITY OF SUCH DAMAGE. */ WI.TimelineRecordBar = class TimelineRecordBar extends WI.Object { constructor(delegate, records, renderMode) { super(); this._delegate = delegate || null; this._element = document.createElement("div"); this._element.classList.add("timeline-record-bar"); this._element[WI.TimelineRecordBar.ElementReferenceSymbol] = this; this._element.addEventListener("click", this._handleClick.bind(this)); this.renderMode = renderMode; this.records = records; this._cachedBarDuration = null; } static createCombinedBars(records, secondsPerPixel, graphDataSource, createBarCallback) { if (!records.length) return; var startTime = graphDataSource.startTime; var currentTime = graphDataSource.currentTime; var endTime = graphDataSource.endTime; var visibleRecords = []; var usesActiveStartTime = false; var lastRecordType = null; // FIXME: Do a binary search for records that fall inside start and current time. for (var i = 0; i < records.length; ++i) { var record = records[i]; if (isNaN(record.startTime)) continue; // If this bar is completely before the bounds of the graph, skip this record. if (record.endTime < startTime) continue; // If this record is completely after the current time or end time, break out now. // Records are sorted, so all records after this will be beyond the current or end time too. if (record.startTime > currentTime || record.startTime > endTime) break; if (record.usesActiveStartTime) usesActiveStartTime = true; // If one record uses active time the rest are assumed to use it. console.assert(record.usesActiveStartTime === usesActiveStartTime); // Only a single record type is supported right now. console.assert(!lastRecordType || record.type === lastRecordType); visibleRecords.push(record); lastRecordType = record.type; } if (!visibleRecords.length) return; if (visibleRecords.length === 1) { createBarCallback(visibleRecords, WI.TimelineRecordBar.RenderMode.Normal); return; } function compareByActiveStartTime(a, b) { return a.activeStartTime - b.activeStartTime; } var minimumDuration = secondsPerPixel * WI.TimelineRecordBar.MinimumWidthPixels; var minimumMargin = secondsPerPixel * WI.TimelineRecordBar.MinimumMarginPixels; if (usesActiveStartTime) { var inactiveStartTime = NaN; var inactiveEndTime = NaN; var inactiveRecords = []; for (var i = 0; i < visibleRecords.length; ++i) { var record = visibleRecords[i]; // Check if the previous record is far enough away to create the inactive bar. if (!isNaN(inactiveStartTime) && inactiveStartTime + Math.max(inactiveEndTime - inactiveStartTime, minimumDuration) + minimumMargin <= record.startTime) { createBarCallback(inactiveRecords, WI.TimelineRecordBar.RenderMode.InactiveOnly); inactiveRecords = []; inactiveStartTime = NaN; inactiveEndTime = NaN; } // If this is a new bar, peg the start time. if (isNaN(inactiveStartTime)) inactiveStartTime = record.startTime; // Update the end time to be the maximum we encounter. inactiveEndTime might be NaN, so "|| 0" to prevent Math.max from returning NaN. inactiveEndTime = Math.max(inactiveEndTime || 0, record.activeStartTime); inactiveRecords.push(record); } // Create the inactive bar for the last record if needed. if (!isNaN(inactiveStartTime)) createBarCallback(inactiveRecords, WI.TimelineRecordBar.RenderMode.InactiveOnly); visibleRecords.sort(compareByActiveStartTime); } var activeStartTime = NaN; var activeEndTime = NaN; var activeRecords = []; var startTimeProperty = usesActiveStartTime ? "activeStartTime" : "startTime"; for (var i = 0; i < visibleRecords.length; ++i) { var record = visibleRecords[i]; var startTime = record[startTimeProperty]; // Check if the previous record is far enough away to create the active bar. We also create it now if the current record has no active state time. if (!isNaN(activeStartTime) && (activeStartTime + Math.max(activeEndTime - activeStartTime, minimumDuration) + minimumMargin <= startTime || (isNaN(startTime) && !isNaN(activeEndTime)))) { createBarCallback(activeRecords, WI.TimelineRecordBar.RenderMode.ActiveOnly); activeRecords = []; activeStartTime = NaN; activeEndTime = NaN; } if (isNaN(startTime)) continue; // If this is a new bar, peg the start time. if (isNaN(activeStartTime)) activeStartTime = startTime; // Update the end time to be the maximum we encounter. activeEndTime might be NaN, so "|| 0" to prevent Math.max from returning NaN. if (!isNaN(record.endTime)) activeEndTime = Math.max(activeEndTime || 0, record.endTime); activeRecords.push(record); } // Create the active bar for the last record if needed. if (!isNaN(activeStartTime)) createBarCallback(activeRecords, WI.TimelineRecordBar.RenderMode.ActiveOnly); } static fromElement(element) { return element[WI.TimelineRecordBar.ElementReferenceSymbol] || null; } // Public get element() { return this._element; } get selected() { return this._element.classList.contains("selected"); } set selected(selected) { this._element.classList.toggle("selected", !!selected); } get renderMode() { return this._renderMode; } set renderMode(renderMode) { this._renderMode = renderMode || WI.TimelineRecordBar.RenderMode.Normal; } get records() { return this._records; } set records(records) { let oldRecordType; let oldRecordEventType; let oldRecordUsesActiveStartTime = false; if (this._records && this._records.length) { let oldRecord = this._records[0]; oldRecordType = oldRecord.type; oldRecordEventType = oldRecord.eventType; oldRecordUsesActiveStartTime = oldRecord.usesActiveStartTime; } records = records || []; this._records = records; // Assume all records in the group are the same type. if (this._records.length) { let newRecord = this._records[0]; if (newRecord.type !== oldRecordType) { this._element.classList.remove(oldRecordType); this._element.classList.add(newRecord.type); } // Although all records may not have the same event type, the first record is // sufficient to determine the correct style for the record bar. if (newRecord.eventType !== oldRecordEventType) { this._element.classList.remove(oldRecordEventType); this._element.classList.add(newRecord.eventType); } if (newRecord.usesActiveStartTime !== oldRecordUsesActiveStartTime) { if (!this._delegate || !this._delegate.timelineRecordBarCustomChildren) this._element.classList.toggle("has-inactive-segment", newRecord.usesActiveStartTime); } } else this._element.classList.remove(oldRecordType, oldRecordEventType, "has-inactive-segment"); } refresh(graphDataSource) { if (isNaN(graphDataSource.secondsPerPixel)) return false; console.assert(graphDataSource.zeroTime); console.assert(graphDataSource.startTime); console.assert(graphDataSource.currentTime); console.assert(graphDataSource.endTime); console.assert(graphDataSource.secondsPerPixel); if (!this._records || !this._records.length) return false; var firstRecord = this._records[0]; var barStartTime = firstRecord.startTime; // If this bar has no time info, return early. if (isNaN(barStartTime)) return false; var graphStartTime = graphDataSource.startTime; var graphEndTime = graphDataSource.endTime; var graphCurrentTime = graphDataSource.currentTime; var barEndTime = this._records.reduce(function(previousValue, currentValue) { return Math.max(previousValue, currentValue.endTime); }, 0); // If this bar is completely after the current time, return early. if (barStartTime > graphCurrentTime) return false; // If this bar is completely before or after the bounds of the graph, return early. if (barEndTime < graphStartTime || barStartTime > graphEndTime) return false; var barUnfinished = isNaN(barEndTime) || barEndTime >= graphCurrentTime; if (barUnfinished) barEndTime = graphCurrentTime; this._cachedBarDuration = barEndTime - barStartTime; var graphDuration = graphEndTime - graphStartTime; let newBarPosition = (barStartTime - graphStartTime) / graphDuration; let property = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "right" : "left"; this._updateElementPosition(this._element, newBarPosition, property); var newBarWidth = ((barEndTime - graphStartTime) / graphDuration) - newBarPosition; this._updateElementPosition(this._element, newBarWidth, "width"); if (this._delegate && this._delegate.timelineRecordBarCustomChildren) { this._element.removeChildren(); this._element.classList.add("has-custom-children"); this._element.classList.toggle("unfinished", barUnfinished); let children = this._delegate.timelineRecordBarCustomChildren(this); for (let child of children) { let childElement; if (child.image) { childElement = this._element.appendChild(document.createElement("img")); childElement.src = child.image; } else childElement = this._element.appendChild(document.createElement("div")); childElement.classList.add(...child.classNames); childElement.title = child.title; this._updateElementPosition(childElement, (child.startTime - barStartTime) / this._cachedBarDuration, property); if (typeof child.endTime === "number") { let childEndTime = !isNaN(child.endTime) ? child.endTime : barEndTime; this._updateElementPosition(childElement, (childEndTime - child.startTime) / this._cachedBarDuration, "width"); } } return true; } this._element.classList.remove("has-custom-children"); if (!this._activeBarElement && this._renderMode !== WI.TimelineRecordBar.RenderMode.InactiveOnly) { this._activeBarElement = document.createElement("div"); this._activeBarElement.classList.add("segment"); } if (!firstRecord.usesActiveStartTime) { this._element.classList.toggle("unfinished", barUnfinished); if (this._inactiveBarElement) this._inactiveBarElement.remove(); if (this._renderMode === WI.TimelineRecordBar.RenderMode.InactiveOnly) { if (this._activeBarElement) this._activeBarElement.remove(); return false; } // If this TimelineRecordBar is reused and had an inactive bar previously, clean it up. this._activeBarElement.style.removeProperty(WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "right" : "left"); this._activeBarElement.style.removeProperty("width"); if (!this._activeBarElement.parentNode) this._element.appendChild(this._activeBarElement); return true; } // Find the earliest active start time for active only rendering, and the latest for the other modes. // This matches the values that TimelineRecordBar.createCombinedBars uses when combining. if (this._renderMode === WI.TimelineRecordBar.RenderMode.ActiveOnly) var barActiveStartTime = this._records.reduce(function(previousValue, currentValue) { return Math.min(previousValue, currentValue.activeStartTime); }, Infinity); else var barActiveStartTime = this._records.reduce(function(previousValue, currentValue) { return Math.max(previousValue, currentValue.activeStartTime); }, 0); var inactiveUnfinished = isNaN(barActiveStartTime) || barActiveStartTime >= graphCurrentTime; this._element.classList.toggle("unfinished", inactiveUnfinished); if (inactiveUnfinished) barActiveStartTime = graphCurrentTime; else if (this._renderMode === WI.TimelineRecordBar.RenderMode.Normal) { // Hide the inactive segment when its duration is less than the minimum displayable size. let minimumSegmentDuration = graphDataSource.secondsPerPixel * WI.TimelineRecordBar.MinimumWidthPixels; if (barActiveStartTime - barStartTime < minimumSegmentDuration) { barActiveStartTime = barStartTime; if (this._inactiveBarElement) this._inactiveBarElement.remove(); } } let showInactiveSegment = barActiveStartTime > barStartTime; this._element.classList.toggle("has-inactive-segment", showInactiveSegment); let middlePercentage = (barActiveStartTime - barStartTime) / this._cachedBarDuration; if (showInactiveSegment && this._renderMode !== WI.TimelineRecordBar.RenderMode.ActiveOnly) { if (!this._inactiveBarElement) { this._inactiveBarElement = document.createElement("div"); this._inactiveBarElement.classList.add("segment"); this._inactiveBarElement.classList.add("inactive"); } let property = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "left" : "right"; this._updateElementPosition(this._inactiveBarElement, 1 - middlePercentage, property); this._updateElementPosition(this._inactiveBarElement, middlePercentage, "width"); if (!this._inactiveBarElement.parentNode) this._element.insertBefore(this._inactiveBarElement, this._element.firstChild); } if (!inactiveUnfinished && this._renderMode !== WI.TimelineRecordBar.RenderMode.InactiveOnly) { let property = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "right" : "left"; this._updateElementPosition(this._activeBarElement, middlePercentage, property); this._updateElementPosition(this._activeBarElement, 1 - middlePercentage, "width"); if (!this._activeBarElement.parentNode) this._element.appendChild(this._activeBarElement); } else if (this._activeBarElement) this._activeBarElement.remove(); return true; } // Private _updateElementPosition(element, newPosition, property) { newPosition *= 100; let newPositionAprox = Math.round(newPosition * 100); let currentPositionAprox = Math.round(parseFloat(element.style[property]) * 100); if (currentPositionAprox !== newPositionAprox) element.style[property] = (newPositionAprox / 100) + "%"; } _handleClick(event) { // Ensure that the container "click" listener added by `WI.TimelineOverview` isn't called. event.__timelineRecordClickEventHandled = true; if (!this._delegate?.timelineRecordBarClicked) return; if (!this._cachedBarDuration) return; if (this._records.length === 1) { this._delegate.timelineRecordBarClicked(this._records[0]); return; } let relativeMouseX = Number.constrain(event.offsetX / this._element.offsetWidth, 0, 1); let targetRecordTime = this._records[0].startTime + (this._cachedBarDuration * relativeMouseX); let closestRecord = null; let closestRecordTimeDelta = Infinity; for (let record of this._records) { if (record.children.length) continue; if (targetRecordTime >= record.startTime && targetRecordTime <= record.endTime) { closestRecord = record; break; } let timeBetweenRecordAndTargetTime = Math.min(Math.abs(record.startTime - targetRecordTime), Math.abs(record.endTime - targetRecordTime)); if (timeBetweenRecordAndTargetTime > closestRecordTimeDelta) break; closestRecord = record; closestRecordTimeDelta = timeBetweenRecordAndTargetTime; } console.assert(closestRecord); this._delegate.timelineRecordBarClicked(closestRecord); } }; WI.TimelineRecordBar.ElementReferenceSymbol = Symbol("timeline-record-bar"); WI.TimelineRecordBar.MinimumWidthPixels = 4; WI.TimelineRecordBar.MinimumMarginPixels = 1; WI.TimelineRecordBar.RenderMode = { Normal: "timeline-record-bar-normal-render-mode", InactiveOnly: "timeline-record-bar-inactive-only-render-mode", ActiveOnly: "timeline-record-bar-active-only-render-mode" };