funkia / turbine

Purely functional frontend framework for building web applications
MIT License
687 stars 27 forks source link

7GUIs Timer #96

Closed stevekrouse closed 5 years ago

stevekrouse commented 5 years ago

It seems like changes(time) or changes(f(time)) for any f doesn't work and fails silently. Is there a problem with changes() and continuous time?

I'm trying to do this problem and not sure how to do it without this. Should I use when()?

paldepind commented 5 years ago

Is there a problem with changes() and continuous time?

Yes. changes can never work on continuous time because it changes infinitely often so it would result in a stream an infinitely dense stream of occurrences.

I'm trying to do this problem and not sure how to do it without this. Should I use when()?

Which part exactly is the problem? when could in theory work with time and I've actually had the need for that myself. But implementing it is a bit tricky and not done yet. One stopgap solution may be to have something like sampleEvery(100, time) to turn continuous time into a stream of a fixed resolution.

stevekrouse commented 5 years ago

Yes. changes can never work on continuous time because it changes infinitely often so it would result in a stream an infinitely dense stream of occurrences.

Ok. Then shouldn't it error and not fail silently?

Which part exactly is the problem? when could in theory work with time and I've actually had the need for that myself. But implementing it is a bit tricky and not done yet. One stopgap solution may be to have something like sampleEvery(100, time) to turn continuous time into a stream of a fixed resolution.

Maybe we can stop with this XY Problem and you can just help me solve for X 😛 It looks behaves like this: https://andreasgruenh.github.io/7guis/#/timer

The key difficulty is that the timer stops when it's reached the end, but it can restart if you change the length of the timer. That is, the length of the timer is a Behavior (the scrubber below).

Start with the length of time at 5 seconds. Wait 9 seconds. The timer is stuck at 5 seconds. Then increase the timer to 7 seconds. It should now count up from 5 to 7 and then pause again. It shouldn't count the 4 seconds in between hitting the 5 max and you changing the max to 7.

It's a lot easier to get if you play with it via the live example link shared above.

stevekrouse commented 5 years ago

Figured it out! The key insight was that whenever the scrubber changes the maxTime, I should "create a new timer" from the previous value of the timer. The key data structure is a Stream<Behavior<Float>>, which represents the creation of "new timers" on every occurrence of the outer stream. The inner stream is the new timer. This was quite tricky!

https://codesandbox.io/s/o9p4j4p759

const initialMaxTime = 10;
const initialStartTime = Date.now();
const timer = loop(
  ({ timeChange, maxTime, elapsedTime }) =>
    function*() {
      yield h1("Timer");
      yield span("0");
      yield progress({
        value: elapsedTime,
        max: maxTime
      });
      yield span(maxTime);
      yield div(
        elapsedTime.map(s => "Elapsed seconds: " + Math.round(s * 10) / 10)
      );
      const { input: timeChange_ } = yield input({
        type: "range",
        min: 0,
        max: 60,
        value: 10
      });
      const maxTime_ = yield liftNow(
        sample(stepper(initialMaxTime, timeChange.map(e => e.target.value)))
      );
      const newTimers = snapshotWith(
        (a, b) => [a].concat(b),
        lift((e, t) => [e, t], elapsedTime, time),
        timeChange_.map(e => e.target.value)
      ).map(([newMaxTime, elapsed, newStart]) =>
        time.map(currentT =>
          elapsed > newMaxTime
            ? elapsed
            : Math.min(elapsed + currentT / 1000 - newStart / 1000, newMaxTime)
        )
      );
      const elapsedTime_ = yield liftNow(
        sample(
          switcher(
            time.map(t =>
              Math.min((t - initialStartTime) / 1000, initialMaxTime)
            ),
            newTimers
          )
        )
      );
      return {
        timeChange: timeChange_,
        maxTime: maxTime_,
        elapsedTime: elapsedTime_
      };
    }
);

So while I don't need changes(time), like #91, I'd like it to error if we know it will never return anything useful.

paldepind commented 5 years ago

Hi @stevekrouse. That solution looks nice. I think we need to add something to Hareactive to make this part less awkward:

const newTimers = snapshotWith(
  (a, b) => [a].concat(b),
  lift((e, t) => [e, t], elapsedTime, time),
  timeChange_.map(e => e.target.value)
).map(([newMaxTime, elapsed, newStart]) =>
   time.map(currentT =>
    elapsed > newMaxTime
      ? elapsed
      : Math.min(elapsed + currentT / 1000 - newStart / 1000, newMaxTime)
  )
);

In Hareactive PureScript that can be done a lot easier. We'll need an easier way to snapshot several behaviors at the same time.

stevekrouse commented 5 years ago

Thanks! Could you show me what this would look like in Hareactive Purescript? (Or is the short answer that it would look like it would in Haskell?)

Also, I don't want us to loose track of the fact that changes(time) fails silently. Throwing an error would be fine, but sampling every X time would work too like discussed in #91

paldepind commented 5 years ago

Thanks! Could you show me what this would look like in Hareactive Purescript? (Or is the short answer that it would look like it would in Haskell?)

If f3 is a function from three arguments it can be applied to two behaviors and a stream like this:

f3 <$> b1 <*> b2 <~> s

The <~> operator is an alias for applyS. It is documented here.

For TypeScript/JavaScript I was thinking something likes this:

liftFoo(f3, [b1, b2], s);

That is, liftFoo (working title :wink:) takes a function, a list of behaviors and a stream and lifts the function over them.

Then this code

const newTimers = snapshotWith(
  (a, b) => [a].concat(b),
  lift((e, t) => [e, t], elapsedTime, time),
  timeChange_.map(e => e.target.value)
).map(([newMaxTime, elapsed, newStart]) =>
   time.map(currentT =>
    elapsed > newMaxTime
      ? elapsed
      : Math.min(elapsed + currentT / 1000 - newStart / 1000, newMaxTime)
  )
);

would become

const newTimers = liftFoo(
  (elapsed, newStart, newMaxTime) =>
    time.map(currentT =>
      elapsed > newMaxTime
        ? elapsed
        : Math.min(elapsed + currentT / 1000 - newStart / 1000, newMaxTime)
    ),
  [elapsedTime, time],
  timeChange_.map(e => e.target.value)
);

which I think is a lot easier to read.

stevekrouse commented 5 years ago

It's so easy and natural to apply a map to a stream, that I prefer it just giving me all the data and allowing me to do that myself. In other words the current snapshot removes too much data, and snapshotWith makes me apply a function when I just want to retain the data. Ditto for liftFoo

Here's my proposal: instead of snapshot or snapshotWith, I want a function snapshot<A,B,C,D>([b1: Behavior<A>, b2: Behavior<B>, b3: Behavior<C>], s: Stream<D>): Stream<[A, B, C, D]> (but you can also pass it just one Behavior instead of a list and it'll work, and you can also pass it a list of more than 3 Behaviors and it'll also work).

Which would produce:

const newTimers = snapshot(
  [elapsedTime, time],
  timeChange_.map(e => e.target.value)
).map(([newMaxTime, elapsed, newStart]) =>
   time.map(currentT =>
    elapsed > newMaxTime
      ? elapsed
      : Math.min(elapsed + currentT / 1000 - newStart / 1000, newMaxTime)
  )
);
paldepind commented 5 years ago

@stevekrouse I've been thinking a bit about the 7GUI timer a bit. I've created an implementation that I think is very elegant. It features a "Reset" button as well. The logic is pretty much only 2 lines of code and two very simple reusable functions.

The secret sauce is H.integrate. The implementation relies heavily on recursively dependent behaviors and I had to fix a bunch of bugs in Hareactive before it worked :sweat_smile:

For some reason that I do not understand I couldn't get the code working on Codesandbox, but it works flawlessly on my machine. Edit: I made a silly mistake, @limemloh has created a working sandbox here: https://codesandbox.io/s/48xxz5m889

Here is a picture and the complete code.

image

import * as H from "@funkia/hareactive";
import { lift } from "@funkia/jabz";
import { runComponent, modelView, elements as e } from "@funkia/turbine";

const initialMaxTime = 10;

function resetOn(b, reset) {
  return b.chain(bi => H.switcher(bi, H.snapshot(b, reset)));
}

function momentNow(f) {
  return H.sample(H.moment(f));
}

const timer = modelView(
  input =>
    momentNow(at => {
      const change = lift((max, cur) => (cur < max ? 1 : 0), input.maxTime, input.elapsed);
      const elapsed = at(resetOn(H.integrate(change), input.resetTimer));
      return { maxTime: input.maxTime, elapsed };
    }),
  input =>
    e
      .div([
        e.h1("Timer"),
        e.span(0),
        e.progress({ value: input.elapsed, max: input.maxTime }),
        e.span(input.maxTime),
        e.div(["Elapsed seconds: ", input.elapsed.map(Math.round)]),
        e
          .input({ type: "range", min: 0, max: 60, value: initialMaxTime })
          .output({ maxTime: "value" }),
        e.div({}, e.button("Reset").output({ resetTimer: "click" }))
      ])
      .output(o => ({ elapsed: input.elapsed }))
);

runComponent("#mount", timer());
stevekrouse commented 5 years ago

Beautiful! resetOn makes a ton of sense.

I am a bit lost with momentNow. I've never seen H.moment before, and my understanding of sample is shakey enough already.

Integrating over change makes sense in theory but I am surprised that it works in javascript!

stevekrouse commented 5 years ago

I found the moment explanation! https://github.com/funkia/turbine/issues/51#issuecomment-306897232

stevekrouse commented 5 years ago

I changed the name of this issue to reflect that it's just about the 7GUIs Timer. The other part of this issue, that changes(time) fails silently, I will move to #91.