|
| 1 | +using CodeCasa.Lights.Extensions; |
| 2 | +using Occurify; |
| 3 | +using Occurify.Extensions; |
| 4 | +using Occurify.Reactive.Extensions; |
| 5 | +using System.Reactive.Concurrency; |
| 6 | +using System.Reactive.Linq; |
| 7 | + |
| 8 | +namespace CodeCasa.Lights.Timelines.Extensions |
| 9 | +{ |
| 10 | + /// <summary> |
| 11 | + /// Provides reactive extension methods for <see cref="Dictionary{TKey, TValue}"/> collections |
| 12 | + /// where the keys are <see cref="ITimeline"/> instances. |
| 13 | + /// </summary> |
| 14 | + public static class TimelineValueCollectionExtensions |
| 15 | + { |
| 16 | + /// <summary> |
| 17 | + /// Converts a timeline dictionary into an observable stream of <see cref="LightTransition"/> objects, |
| 18 | + /// including an immediate interpolated starting value. |
| 19 | + /// </summary> |
| 20 | + /// <param name="sceneTimeline">The dictionary mapping timeline points to <see cref="LightParameters"/>.</param> |
| 21 | + /// <param name="scheduler">The Rx scheduler used to manage timing and initial delay.</param> |
| 22 | + /// <param name="transitionTimeForTimelineState"> |
| 23 | + /// The duration of the initial fade from current state. Defaults to 500ms if null. |
| 24 | + /// </param> |
| 25 | + /// <returns>An observable that emits the current interpolated state, then follows the scheduled timeline.</returns> |
| 26 | + public static IObservable<LightTransition> ToLightTransitionObservableIncludingCurrent( |
| 27 | + this Dictionary<ITimeline, LightParameters> sceneTimeline, |
| 28 | + IScheduler scheduler, |
| 29 | + TimeSpan? transitionTimeForTimelineState = null) |
| 30 | + { |
| 31 | + return CreateTimelineObservableIncludingInitialInterpolatedValue(sceneTimeline, |
| 32 | + (lightParameters, transitionTime) => lightParameters.AsTransition(transitionTime), |
| 33 | + (previous, next, fraction) => previous.Interpolate(next, fraction), |
| 34 | + EqualityComparer<LightParameters>.Default, |
| 35 | + scheduler, |
| 36 | + transitionTimeForTimelineState); |
| 37 | + } |
| 38 | + |
| 39 | + /// <summary> |
| 40 | + /// Converts a nested timeline dictionary into an observable stream of light scenes, |
| 41 | + /// where each emission contains a dictionary of transitions for multiple light sources. |
| 42 | + /// </summary> |
| 43 | + /// <param name="sceneTimeline">A dictionary mapping timeline points to a collection of light states keyed by ID.</param> |
| 44 | + /// <param name="scheduler">The Rx scheduler used to manage timing and initial delay.</param> |
| 45 | + /// <param name="transitionTimeForTimelineState"> |
| 46 | + /// The duration of the initial fade for all lights in the scene. Defaults to 500ms if null. |
| 47 | + /// </param> |
| 48 | + /// <returns>An observable that emits a dictionary of transitions representing the current scene state, followed by scheduled updates.</returns> |
| 49 | + /// <remarks> |
| 50 | + /// This method utilizes a custom dictionary comparer to ensure updates are only emitted when |
| 51 | + /// at least one light in the scene has changed its parameters. |
| 52 | + /// </remarks> |
| 53 | + public static IObservable<Dictionary<string, LightTransition>> ToLightTransitionSceneObservableIncludingCurrent( |
| 54 | + this Dictionary<ITimeline, Dictionary<string, LightParameters>> sceneTimeline, |
| 55 | + IScheduler scheduler, |
| 56 | + TimeSpan? transitionTimeForTimelineState = null) |
| 57 | + { |
| 58 | + return CreateTimelineObservableIncludingInitialInterpolatedValue(sceneTimeline, |
| 59 | + (lightParametersDict, transitionTime) => lightParametersDict.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.AsTransition(transitionTime)), |
| 60 | + (previousDict, nextDict, fraction) => previousDict.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Interpolate(nextDict[kvp.Key], fraction)), |
| 61 | + new DictionaryComparer<string, LightParameters>(EqualityComparer<LightParameters>.Default), |
| 62 | + scheduler, |
| 63 | + transitionTimeForTimelineState); |
| 64 | + } |
| 65 | + |
| 66 | + /// <summary> |
| 67 | + /// Creates an observable that immediately emits an interpolated initial state based on <see cref="DateTime.UtcNow"/>, |
| 68 | + /// followed by the standard timeline transitions after a specified delay. |
| 69 | + /// </summary> |
| 70 | + private static IObservable<TOut> CreateTimelineObservableIncludingInitialInterpolatedValue<TIn, TOut>( |
| 71 | + this Dictionary<ITimeline, TIn> sceneTimeline, |
| 72 | + Func<TIn, TimeSpan, TOut> transformer, |
| 73 | + Func<TIn, TIn, double, TIn> interpolator, |
| 74 | + IEqualityComparer<TIn> comparer, |
| 75 | + IScheduler scheduler, |
| 76 | + TimeSpan? transitionTimeForTimelineState = null) |
| 77 | + { |
| 78 | + var transitionObservable = CreateTimelineObservable(sceneTimeline, transformer, comparer, scheduler); |
| 79 | + |
| 80 | + var utcNow = DateTime.UtcNow; |
| 81 | + var valuesAtPrevious = sceneTimeline.GetValuesAtPreviousUtcInstant(utcNow); |
| 82 | + var valuesAtCurrentOrNext = sceneTimeline.GetValuesAtCurrentOrNextUtcInstant(utcNow); |
| 83 | + |
| 84 | + if (valuesAtPrevious.Key == null || valuesAtCurrentOrNext.Key == null) |
| 85 | + { |
| 86 | + return transitionObservable; |
| 87 | + } |
| 88 | + |
| 89 | + var fraction = CalculateFraction(valuesAtPrevious.Key.Value, valuesAtCurrentOrNext.Key.Value, utcNow); |
| 90 | + |
| 91 | + var previousScene = valuesAtPrevious.Value.First(); |
| 92 | + var nextScene = valuesAtCurrentOrNext.Value.First(); |
| 93 | + |
| 94 | + var initialSceneTransition = interpolator(previousScene, nextScene, fraction); |
| 95 | + |
| 96 | + var timeSpan = transitionTimeForTimelineState ?? TimeSpan.FromMilliseconds(500); |
| 97 | + // We delay the timeline observable to allow the initial scene transition to be emitted/activated first. |
| 98 | + var delayedTimelineObservable = Observable |
| 99 | + .Timer(timeSpan, scheduler) |
| 100 | + .SelectMany(_ => transitionObservable); |
| 101 | + |
| 102 | + return Observable.Return(transformer(initialSceneTransition, timeSpan)).Concat(delayedTimelineObservable); |
| 103 | + } |
| 104 | + |
| 105 | + /// <summary> |
| 106 | + /// Creates an observable stream that emits transformed values based on state transitions |
| 107 | + /// between consecutive instants in a timeline. |
| 108 | + /// </summary> |
| 109 | + private static IObservable<TOut> CreateTimelineObservable<TIn, TOut>( |
| 110 | + Dictionary<ITimeline, TIn> timeline, |
| 111 | + Func<TIn, TimeSpan, TOut> transformer, |
| 112 | + IEqualityComparer<TIn> comparer, |
| 113 | + IScheduler scheduler) |
| 114 | + { |
| 115 | + return timeline |
| 116 | + .ToSampleObservable(scheduler, emitSampleUponSubscribe: false) |
| 117 | + .Select(s => |
| 118 | + { |
| 119 | + var instant = s.Key; |
| 120 | + var nextValues = timeline.GetValuesAtNextUtcInstant(instant); |
| 121 | + var nextInstant = nextValues.Key; |
| 122 | + if (nextInstant == null) |
| 123 | + { |
| 124 | + return Maybe<TOut>.None; |
| 125 | + } |
| 126 | + |
| 127 | + var current = s.Value.First(); |
| 128 | + var next = nextValues.Value.First(); |
| 129 | + if (comparer.Equals(current, next)) |
| 130 | + { |
| 131 | + return Maybe<TOut>.None; |
| 132 | + } |
| 133 | + var transitionTimeSpan = nextInstant.Value - instant; |
| 134 | + |
| 135 | + return Maybe<TOut>.Some(transformer(next, transitionTimeSpan)); |
| 136 | + }) |
| 137 | + .Where(s => s.HasValue) |
| 138 | + .Select(s => s.Value!); |
| 139 | + } |
| 140 | + |
| 141 | + private sealed record Maybe<T>(bool HasValue, T? Value) |
| 142 | + { |
| 143 | + public static Maybe<T> None => new(false, default); |
| 144 | + public static Maybe<T> Some(T value) => new(true, value); |
| 145 | + } |
| 146 | + |
| 147 | + private static double CalculateFraction(DateTime previous, DateTime next, DateTime current) |
| 148 | + { |
| 149 | + var timeFromPrevious = current - previous; |
| 150 | + var totalTransition = next - previous; |
| 151 | + return timeFromPrevious / totalTransition; |
| 152 | + } |
| 153 | + } |
| 154 | +} |
0 commit comments