1027 lines
33 KiB
TypeScript
1027 lines
33 KiB
TypeScript
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<ThumbnailDataRequest> {}
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
// 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<void> => {
|
|
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<MediaQuery>(
|
|
currentQueries,
|
|
currentResultTimestamp,
|
|
)
|
|
);
|
|
}
|
|
|
|
private async _updateTimelineFromView(): Promise<void> {
|
|
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 = (<AdvancedCameraCardTimelineItem>first).media;
|
|
const secondMedia = (<AdvancedCameraCardTimelineItem>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 `
|
|
<advanced-camera-card-timeline-thumbnail
|
|
item='${item.id}'
|
|
${this._thumbnailConfig?.show_details ? 'details' : ''}
|
|
>
|
|
</advanced-camera-card-timeline-thumbnail>`;
|
|
}
|
|
}
|