import { add, differenceInSeconds, sub } from 'date-fns'; import { LitElement } from 'lit'; import { isEqual, throttle } from 'lodash'; import { ViewContext } from 'view'; import { IdType, Timeline, TimelineEventPropertiesResult, TimelineFormatOption, TimelineItem, TimelineOptions, TimelineOptionsCluster, TimelineWindow, } from 'vis-timeline'; import { CameraManager } from '../../camera-manager/manager'; import { rangesOverlap } from '../../camera-manager/range'; import { MediaQuery } from '../../camera-manager/types'; import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly'; import { ViewItemManager } from '../../card-controller/view/item-manager'; import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context'; import { ViewManagerEpoch } from '../../card-controller/view/types'; import { CameraConfig } from '../../config/schema/cameras'; import { AdvancedCameraCardView } from '../../config/schema/common/const'; import { ThumbnailsControlBaseConfig } from '../../config/schema/common/controls/thumbnails'; import { TimelineCoreConfig, TimelinePanMode, } from '../../config/schema/common/controls/timeline'; import { configDefaults } from '../../config/schema/types'; import { HomeAssistant } from '../../ha/types'; import { stopEventFromActivatingCardWideActions } from '../../utils/action'; import { formatDateAndTime, isHoverableDevice, isTruthy, setOrRemoveAttribute, } from '../../utils/basic'; import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index'; import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event'; import { ViewMedia } from '../../view/item'; import { ViewItemClassifier } from '../../view/item-classifier'; import { EventMediaQuery, MediaQueries, RecordingMediaQuery } from '../../view/query'; import { QueryClassifier, QueryType } from '../../view/query-classifier'; import { QueryResults } from '../../view/query-results'; import { mergeViewContext } from '../../view/view'; import { AdvancedCameraCardTimelineItem, TimelineDataSource } from './source'; import { ExtendedTimeline, TimelineItemClickAction, TimelineKey, TimelineRangeChange, } from './types'; // An event used to fetch data required for thumbnail rendering. See special // note below on why this is necessary. interface ThumbnailDataRequest { item: IdType; hass?: HomeAssistant; cameraManager?: CameraManager; cameraConfig?: CameraConfig; media?: ViewMedia; viewManagerEpoch?: ViewManagerEpoch; viewItemManager?: ViewItemManager; } class ThumbnailDataRequestEvent extends CustomEvent {} interface TimelineControllerOptions { hass?: HomeAssistant; cameraManager?: CameraManager; viewItemManager?: ViewItemManager; timelineConfig?: TimelineCoreConfig; mini?: boolean; thumbnailConfig?: ThumbnailsControlBaseConfig; keys?: TimelineKey[]; } const TIMELINE_TARGET_BAR_ID = 'target_bar'; export class TimelineController { private _host: LitElement; private _timelineElement: HTMLElement | null = null; private _source: TimelineDataSource | null = null; private _timeline: ExtendedTimeline | null = null; private _hass: HomeAssistant | null = null; private _cameraManager: CameraManager | null = null; private _viewItemManager: ViewItemManager | null = null; private _viewManagerEpoch: ViewManagerEpoch | null = null; private _timelineConfig: TimelineCoreConfig | null = null; private _mini = false; private _panMode: TimelinePanMode | null = null; private _targetBarVisible = false; private _itemClickAction: TimelineItemClickAction = 'play'; private _thumbnailConfig: ThumbnailsControlBaseConfig | null = null; private _keys: TimelineKey[] = []; private readonly _isHoverableDevice = isHoverableDevice(); // Range changes are volumonous: throttle the calls on seeking. private _throttledSetViewDuringRangeChange = throttle( this._setViewDuringRangeChange.bind(this), 1000 / 10, ); // Need a way to separate when a user clicks (to pan the timeline) vs when a // user clicks (to choose a recording (non-event) to play). private _pointerHeld: | (TimelineEventPropertiesResult & { window?: TimelineWindow }) | null = null; private _ignoreClick = false; constructor(host: LitElement) { this._host = host; } public setHass(hass: HomeAssistant | null): void { this._hass = hass; } public destroyTimeline(): void { this._timeline?.destroy(); this._timeline = null; this._targetBarVisible = false; this._pointerHeld = null; } public setOptions(options: TimelineControllerOptions): void { if ( this._cameraManager !== (options?.cameraManager ?? null) || this._timelineConfig !== (options?.timelineConfig ?? null) || !isEqual(this._keys, options?.keys ?? null) ) { this.destroyTimeline(); if (options.keys && options.cameraManager && options.timelineConfig) { this._source = new TimelineDataSource( options.cameraManager, options.keys, options.timelineConfig.events_media_type, options.timelineConfig.show_recordings, ); } else { this._source = null; } } if (this._thumbnailConfig !== (options.thumbnailConfig ?? null)) { if (options.thumbnailConfig) { this._host.style.setProperty( '--advanced-camera-card-thumbnail-size', `${options?.thumbnailConfig.size}px`, ); } else { this._host.style.removeProperty('--advanced-camera-card-thumbnail-size'); } } if (this._timelineConfig !== (options.timelineConfig ?? null)) { this._timelineConfig = options?.timelineConfig ?? null; setOrRemoveAttribute( this._host, !!this._timelineConfig?.show_recordings, 'recordings', ); setOrRemoveAttribute( this._host, this._timelineConfig?.style === 'ribbon', 'ribbon', ); setOrRemoveAttribute(this._host, this._timelineConfig?.style === 'stack', 'stack'); } this._thumbnailConfig = options?.thumbnailConfig ?? null; this._cameraManager = options?.cameraManager ?? null; this._viewItemManager = options?.viewItemManager ?? null; this._timelineConfig = options?.timelineConfig ?? null; this._mini = options?.mini ?? false; this._keys = options?.keys ?? []; setOrRemoveAttribute(this._host, !this._mini || this._keys.length > 1, 'groups'); } public async setView(viewManagerEpoch: ViewManagerEpoch | null): Promise { if (this._viewManagerEpoch === viewManagerEpoch) { return; } this._viewManagerEpoch = viewManagerEpoch ?? null; await this._updateTimelineFromView(); } public handleThumbnailDataRequest = (request: ThumbnailDataRequestEvent): void => { const itemID = request.detail.item; const media = this._source?.dataset.get(itemID)?.media; const cameraConfig = media ? this._cameraManager?.getStore().getCameraConfigForMedia(media) ?? undefined : undefined; request.detail.hass = this._hass ?? undefined; request.detail.cameraConfig = cameraConfig; request.detail.cameraManager = this._cameraManager ?? undefined; request.detail.viewItemManager = this._viewItemManager ?? undefined; request.detail.media = media; request.detail.viewManagerEpoch = this._viewManagerEpoch ?? undefined; }; public getEffectivePanMode(): TimelinePanMode { return this._panMode ?? this._timelineConfig?.pan_mode ?? 'pan'; } public cyclePanMode(): void { const panMode = this.getEffectivePanMode(); this._panMode = panMode === 'pan' ? 'seek' : panMode === 'seek' ? 'seek-in-media' : panMode === 'seek-in-media' ? 'seek-in-camera' : 'pan'; this._host.requestUpdate(); } public setTimelineDate(date: Date): void { this._timeline?.moveTo(date); } public shouldSupportSeeking(): boolean { return this._mini; } public setTimelineElement(element?: HTMLElement): boolean { if ( !this._source || !this._timelineConfig || (this._timeline && this._timelineElement === (element ?? null)) ) { return false; } this.destroyTimeline(); this._timelineElement = element ?? null; if (!this._timelineElement) { return false; } const options = this._getOptions(); if (!options) { return false; } if (this._shouldShowGroups()) { this._timeline = new Timeline( this._timelineElement, this._source.dataset, options, ); } else { this._timeline = new Timeline( this._timelineElement, this._source.dataset, this._source.groups, options, ); } this._timeline.on('rangechanged', this._timelineRangeChangedHandler.bind(this)); this._timeline.on('click', this._timelineClickHandler.bind(this)); this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this)); // This complexity exists to ensure we can tell between a click that // causes the timeline zoom/range to change, and a 'static' click on the // // timeline (which may need to trigger a card wide event). this._timeline.on('mouseDown', (ev: TimelineEventPropertiesResult) => { const window = this._timeline?.getWindow(); this._pointerHeld = { ...ev, ...(window && { window: window }), }; this._ignoreClick = false; }); this._timeline.on('mouseUp', () => { this._pointerHeld = null; this._removeTargetBar(); }); return true; } private _shouldShowGroups(): boolean { return !this._mini || this._keys.length > 1; } private _setTargetBarAppropriately(targetTime: Date): void { if (!this._timeline) { return; } const view = this._viewManagerEpoch?.manager.getView(); const panMode = this.getEffectivePanMode(); const targetBarOn = this.shouldSupportSeeking() && (panMode === 'seek' || ((panMode === 'seek-in-camera' || panMode === 'seek-in-media') && this._timeline.getSelection().some((id) => { const item = this._source?.dataset?.get(id); return ( panMode !== 'seek-in-camera' || item?.media?.getCameraID() === view?.camera, item && item.start && item.end && targetTime.getTime() >= item.start && targetTime.getTime() <= item.end ); }))); if (targetBarOn) { if (!this._targetBarVisible) { this._timeline?.addCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); this._targetBarVisible = true; } else { this._timeline?.setCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); } const window = this._timeline.getWindow(); const markerProportion = (targetTime.getTime() - window.start.getTime()) / (window.end.getTime() - window.start.getTime()); // Position the marker proportionally to how 'far' the pointer is being // held relative to the timeline window. this._host.setAttribute( 'target-bar-marker-direction', markerProportion < 0.25 ? 'right' : markerProportion > 0.75 ? 'left' : 'center', ); this._timeline?.setCustomTimeMarker?.( formatDateAndTime(targetTime, true), TIMELINE_TARGET_BAR_ID, ); } else { this._removeTargetBar(); } } private _removeTargetBar(): void { this._host.removeAttribute('target-bar-direction'); if (this._targetBarVisible) { this._timeline?.removeCustomTime(TIMELINE_TARGET_BAR_ID); this._targetBarVisible = false; } } /** * Called whenever the range is in the process of being changed. * @param properties */ private _timelineRangeChangeHandler(properties: TimelineRangeChange): void { if (this._pointerHeld) { this._ignoreClick = true; } if ( this.shouldSupportSeeking() && this._timeline && properties.byUser && // Do not adjust select/seek media during zoom events. properties.event.type !== 'wheel' && properties.event.additionalEvent !== 'pinchin' && properties.event.additionalEvent !== 'pinchout' ) { const targetTime = this._pointerHeld?.window ? add(properties.start, { seconds: (this._pointerHeld.time.getTime() - this._pointerHeld.window.start.getTime()) / 1000, }) : properties.end; if (this._pointerHeld) { this._setTargetBarAppropriately(targetTime); } this._throttledSetViewDuringRangeChange(targetTime, properties); } } private async _setViewDuringRangeChange( targetTime: Date, properties: TimelineRangeChange, ): Promise { const view = this._viewManagerEpoch?.manager.getView(); const results = view?.queryResults; const media = results?.getResults(); const panMode = this.getEffectivePanMode(); if ( !media || !results || !this._timeline || !view || !this._hass || !this._cameraManager || panMode === 'pan' ) { return; } const canSeek = this.shouldSupportSeeking(); let newResults: QueryResults | null = null; if (panMode === 'seek') { newResults = results .clone() .selectBestResult( (mediaArray) => findBestMediaTimeIndex(mediaArray, targetTime, view?.camera), { allCameras: true, main: true, }, ); } else if (panMode === 'seek-in-camera') { newResults = results .clone() .selectBestResult( (mediaArray) => findBestMediaTimeIndex(mediaArray, targetTime), { cameraID: view.camera, }, ) .promoteCameraSelectionToMainSelection(view.camera); } else if (panMode === 'seek-in-media') { newResults = results; } const desiredView: AdvancedCameraCardView = this._mini ? targetTime >= new Date() ? 'live' : 'media' : view.view; const selectedItem = newResults?.getSelectedResult(); const selectedCamera = ViewItemClassifier.isMedia(selectedItem) ? selectedItem.getCameraID() : null; this._viewManagerEpoch?.manager.setViewByParameters({ params: { ...(selectedCamera && { camera: selectedCamera }), view: desiredView, queryResults: newResults, }, modifiers: [ new MergeContextViewModifier({ ...(canSeek && { mediaViewer: { seek: targetTime } }), ...this._getTimelineContext({ start: properties.start, end: properties.end }), }), ], }); } private _getTimelineContext(window?: TimelineWindow): ViewContext { const view = this._viewManagerEpoch?.manager.getView(); const newWindow = window ?? this._timeline?.getWindow(); return { timeline: { ...view?.context?.timeline, ...(newWindow && { window: newWindow }), }, }; } private async _timelineClickHandler( properties: TimelineEventPropertiesResult, ): Promise { // Calls to stopEventFromActivatingCardWideActions() are included for // completeness. Timeline does not support card-wide events and they are // disabled in card.ts in `_getMergedActions`. if ( this._ignoreClick || (properties.what && ['item', 'background', 'group-label', 'axis'].includes(properties.what)) ) { stopEventFromActivatingCardWideActions(properties.event); } const view = this._viewManagerEpoch?.manager.getView(); if ( this._ignoreClick || !view || !this._viewManagerEpoch || !this._source || !properties.what ) { return; } let drawerAction: 'open' | 'close' = 'close'; if ( this._timelineConfig?.show_recordings && properties.time && ['background', 'axis'].includes(properties.what) ) { const query = this._createMediaQueries('recording'); if (query) { await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ baseView: view, params: { view: 'recording', query: query }, queryExecutorOptions: { selectResult: { time: { time: properties.time, }, }, }, }); } } else if (properties.item && properties.what === 'item') { const cameraID = String(properties.group); const id = String(properties.item); const criteria = { main: true, ...(cameraID && view.isGrid() && { cameraID: cameraID }), }; const newResults = view.queryResults ?.clone() .resetSelectedResult() .selectResultIfFound((media) => media.getID() === properties.item, criteria); const context: ViewContext = mergeViewContext(this._getTimelineContext(), { mediaViewer: { seek: properties.time }, }); if (!newResults || !newResults.hasSelectedResult()) { // This can happen in a few situations: // - If this is a recording query (with recorded hours) and an event is // clicked on the timeline // - If the current thumbnails/results is a filtered view from the media // gallery (i.e. any case where the thumbnails may not be match the // events on the timeline, e.g. in the snapshots viewer but // mini-timeline showing all media). const query = this._createMediaQueries('event'); if (query) { await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ params: { view: 'media', query: query }, queryExecutorOptions: { selectResult: { id: id, }, rejectResults: (results) => !results.hasResults(), }, modifiers: [new MergeContextViewModifier(context)], }); } } else { this._viewManagerEpoch.manager.setViewByParameters({ params: { queryResults: newResults, view: this._itemClickAction === 'play' ? 'media' : view.view, }, modifiers: [new MergeContextViewModifier(context)], }); } if (this._itemClickAction === 'select') { drawerAction = 'open'; } } fireAdvancedCameraCardEvent(this._host, `thumbnails:${drawerAction}`); this._ignoreClick = false; } /** * Get a broader prefetch window from a start and end basis. * @param window The window to broaden. * @returns A broader timeline. */ private _getPrefetchWindow(window: TimelineWindow): TimelineWindow { const delta = differenceInSeconds(window.end, window.start); return { start: sub(window.start, { seconds: delta }), end: add(window.end, { seconds: delta }), }; } private _createMediaQueries( type: QueryType, options?: { window?: TimelineWindow; }, ): MediaQueries | null { if (!this._timeline || !this._source) { return null; } const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes( this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()), ); if (type === 'event') { const queries = this._source.getTimelineEventQueries(cacheFriendlyWindow); return queries ? new EventMediaQuery(queries) : null; } else if (type === 'recording') { const queries = this._source.getTimelineRecordingQueries(cacheFriendlyWindow); return queries ? new RecordingMediaQuery(queries) : null; } return null; } private _timelineRangeChangedHandler = async (properties: { start: Date; end: Date; byUser: boolean; event: Event & { additionalEvent: string }; }): Promise => { this._removeTargetBar(); const view = this._viewManagerEpoch?.manager.getView(); if ( !this._timeline || !view || // When in mini mode, something else is in charge of the primary media // population (e.g. the live view), in this case only act when the user // themselves are interacting with the timeline. (this._mini && !properties.byUser) ) { return; } await this._source?.refresh(this._getPrefetchWindow(properties)); const queryType = QueryClassifier.getQueryType(view.query); if (!queryType) { return; } const mediaQuery = this._createMediaQueries(queryType); if (!mediaQuery || this._alreadyHasAcceptableMediaQuery(mediaQuery)) { return; } await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ params: { query: mediaQuery, }, queryExecutorOptions: { selectResult: { id: this._viewManagerEpoch?.manager .getView() ?.queryResults?.getSelectedResult() ?.getID() ?? undefined, }, }, modifiers: [new MergeContextViewModifier(this._getTimelineContext())], }); }; private _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean { const view = this._viewManagerEpoch?.manager.getView(); const query = view?.query; if (!this._cameraManager || !query || !QueryClassifier.isMediaQuery(query)) { return false; } const currentQueries = query?.getQuery(); const currentResultTimestamp = view?.queryResults?.getResultsTimestamp(); return ( !!currentQueries && !!currentResultTimestamp && !!query?.isSupersetOf(freshMediaQuery) && this._cameraManager.areMediaQueriesResultsFresh( currentQueries, currentResultTimestamp, ) ); } private async _updateTimelineFromView(): Promise { const view = this._viewManagerEpoch?.manager.getView(); if (!view || !this._timelineConfig || !this._source || !this._timeline) { return; } const timelineWindow = this._timeline.getWindow(); // Calculate the timeline window to show. If there is a window set in the // view context, always honor that. Otherwise, if there's a selected media // item that is already within the current window (even if it's not // perfectly positioned) -- leave it as is. Otherwise, change the window to // perfectly center on the media. let desiredWindow = timelineWindow; const item = view.queryResults?.getSelectedResult(); const media = item && ViewItemClassifier.isMedia(item) ? item : null; const mediaStartTime = media?.getStartTime() ?? null; const mediaEndTime = media?.getEndTime() ?? null; const mediaIsEvent = media ? ViewItemClassifier.isEvent(media) : false; const mediaWindow: TimelineWindow | null = media && mediaStartTime ? // If this media has no end time, it's just a "point" in time so the // range effectively starts/ends at the same time. { start: mediaStartTime, end: mediaEndTime ?? mediaStartTime } : null; const context = view.context?.timeline; if (context && context.window) { desiredWindow = context.window; } else if (mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) { const perfectMediaWindow = this._getPerfectWindowFromMediaStartAndEndTime( mediaIsEvent, mediaStartTime, mediaEndTime, ); if (perfectMediaWindow) { desiredWindow = perfectMediaWindow; } } const prefetchedWindow = this._getPrefetchWindow(desiredWindow); if (!this._pointerHeld) { // Don't fetch any data or touch the timeline in any way if the user is // currently interacting with it. Without this the subsequent data fetches // (via fetchIfNecessary) may update the timeline contents which causes // the visjs timeline to stop dragging/panning operations which is very // disruptive to the user. await this._source?.refresh(prefetchedWindow, view); this._source.addEventMediaToDataset(view.queryResults?.getResults()); } const currentSelection = this._timeline.getSelection(); const mediaIDsToSelect = this._getAllSelectedMediaIDsFromView(); const needToSelect = mediaIDsToSelect.some( (mediaID) => !currentSelection.includes(mediaID), ); if (needToSelect) { if (this._isClustering()) { // Hack: Clustering may not update unless the dataset changes, artifically // update the dataset to ensure the newly selected item cannot be included // in a cluster. for (const mediaID of mediaIDsToSelect) { // Need to this rewrite prior to setting the selection (just below), or // the selection will be lost on rewrite. this._source?.rewriteEvent(mediaID); } } this._timeline?.setSelection(mediaIDsToSelect, { focus: false, animation: { animation: false, zoom: false, }, }); } // Set the timeline window if necessary. if (!this._pointerHeld && !isEqual(desiredWindow, timelineWindow)) { this._timeline.setWindow(desiredWindow.start, desiredWindow.end); } // Only generate thumbnails if the existing query is not an acceptable // match, to avoid getting stuck in a loop (the subsequent fetches will not // actually fetch since the data will have been cached). // // Timeline receives a new `view` // -> Events fetched // -> Thumbnails generated // -> New view dispatched (to load thumbnails into outer carousel). // -> New view received ... [loop] // // Also don't generate thumbnails in mini-timelines (they will already have // been generated). const queryType = QueryClassifier.getQueryType(view.query); if (!queryType) { return; } const freshMediaQuery = this._createMediaQueries(queryType, { window: desiredWindow, }); if ( !this._mini && freshMediaQuery && !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) ) { const currentlySelectedResult = this._viewManagerEpoch?.manager .getView() ?.queryResults?.getSelectedResult(); await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({ params: { query: freshMediaQuery, }, queryExecutorOptions: { selectResult: { id: currentlySelectedResult?.getID() ?? undefined, }, }, modifiers: [ new MergeContextViewModifier(this._getTimelineContext(desiredWindow)), ], }); } } private _getAllSelectedMediaIDsFromView(): IdType[] { const view = this._viewManagerEpoch?.manager.getView(); return ( view?.queryResults?.getMultipleSelectedResults({ main: true, ...(view.isGrid() && { allCameras: true }), }) ?? [] ) .filter((media) => ViewItemClassifier.isEvent(media)) .map((media) => media.getID()) .filter(isTruthy); } private _isClustering(): boolean { return ( this._timelineConfig?.style === 'stack' && !!this._timelineConfig?.clustering_threshold && this._timelineConfig.clustering_threshold > 0 ); } private _getPerfectWindowFromMediaStartAndEndTime( isEvent: boolean, startTime: Date | null, endTime: Date | null, ): TimelineWindow | null { if (isEvent) { const windowSeconds = this._getConfiguredWindowSeconds(); if (startTime && endTime) { if (endTime.getTime() - startTime.getTime() > windowSeconds * 1000) { // If the event is larger than the configured window, only show the most // recent portion of the event that fits in the window. return { start: sub(endTime, { seconds: windowSeconds }), end: endTime, }; } else { // If the event is shorter than the configured window, center the event // in the window. const gap = windowSeconds - (endTime.getTime() - startTime.getTime()) / 1000; return { start: sub(startTime, { seconds: gap / 2 }), end: add(endTime, { seconds: gap / 2 }), }; } } else if (startTime) { // If there's no end-time yet, place the start-time in the center of the // time window. return { start: sub(startTime, { seconds: windowSeconds / 2 }), end: add(startTime, { seconds: windowSeconds / 2 }), }; } } else if (startTime && endTime) { return { start: startTime, end: endTime, }; } return null; } private _getConfiguredWindowSeconds(): number { return ( this._timelineConfig?.window_seconds ?? configDefaults.timeline.window_seconds ); } /** * Get desired timeline start/end time. * @returns A tuple of start/end date. */ private _getDefaultStartEnd(): TimelineWindow { const end = new Date(); const start = sub(end, { seconds: this._getConfiguredWindowSeconds(), }); return { start: start, end: end }; } private _getDateTimeFormat(): TimelineFormatOption { const format24Hour = !!this._timelineConfig?.format?.['24h']; // See: https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options return { minorLabels: { minute: format24Hour ? 'HH:mm' : 'h:mm A', hour: format24Hour ? 'HH:mm' : 'h:mm A', }, majorLabels: { millisecond: format24Hour ? 'HH:mm:ss' : 'h:mm:ss A', second: format24Hour ? 'D MMMM HH:mm' : 'D MMMM h:mm A', }, }; } private _getOptions(): TimelineOptions | null { if (!this._timelineConfig) { return null; } const defaultWindow = this._getDefaultStartEnd(); const stack = this._timelineConfig.style === 'stack'; // Configuration for the Timeline, see: // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options return { cluster: this._isClustering() ? { // It would be better to automatically calculate `maxItems` from the // rendered height of the timeline (or group within the timeline) so // as to not waste vertical space (e.g. after the user changes to // fullscreen mode). Unfortunately this is not easy to do, as we // don't know the height of the timeline until after it renders -- // and if we adjust `maxItems` then we can get into an infinite // resize loop. Adjusting the `maxItems` of a timeline, after it's // created, also does not appear to work as expected. maxItems: this._timelineConfig.clustering_threshold, clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => { const selectedIDs = this._getAllSelectedMediaIDsFromView(); const firstMedia = (first).media; const secondMedia = (second).media; // Never include the currently selected item in a cluster, and // never group different object types together (e.g. person and // car). return ( first.type !== 'background' && first.type === second.type && !selectedIDs.includes(first.id) && !selectedIDs.includes(second.id) && !!firstMedia && !!secondMedia && ViewItemClassifier.isEvent(firstMedia) && ViewItemClassifier.isEvent(secondMedia) && firstMedia.isGroupableWith(secondMedia) ); }, } : // Timeline type information is incorrect requiring this 'as'. (false as unknown as TimelineOptionsCluster), minHeight: '100%', maxHeight: '100%', zoomMax: 1 * 24 * 60 * 60 * 1000, zoomMin: 1 * 1000, margin: { item: { // In ribbon mode, a 20px item is reduced to 6px, so need to add a // 14px margin to ensure items line up with subgroups. vertical: stack ? 10 : 24, }, }, selectable: true, stack: stack, start: defaultWindow.start, end: defaultWindow.end, groupHeightMode: 'auto', tooltip: { followMouse: true, overflowMethod: 'cap', template: this._getTooltip.bind(this), }, format: this._getDateTimeFormat(), xss: { disabled: false, filterOptions: { whiteList: { 'advanced-camera-card-timeline-thumbnail': ['details', 'item'], div: ['title'], span: ['style'], }, }, }, }; } /** * Get a tooltip for a given timeline event. * @param item The TimelineItem in question. * @returns The tooltip as a string to render. */ private _getTooltip(item: TimelineItem): string { if (!this._isHoverableDevice) { // Don't display tooltips on touch devices, they just get in the way of // the drawer. return ''; } // Cannot use Lit data-bindings as visjs requires a string for tooltips. // Note that changes to attributes here must be mirrored in the xss // whitelist in `_getOptions()` . return ` `; } }