This is my take on @maxime1992's challenge for a functional-reactive split-flap display.
For a start, I've chosen to use Rimmel.js, a template engine created (disclosure: by me) with streams in mind, which you can just feed promises and observables and they will seamlessly be subscribed and sinked to the DOM, helping you get rid of the very latest bits of imperative code that still linger around in many Rx apps.
Next, @maxime1992 I want to congratulate with you for the code. I really loved its elegance and I first learnt about the switchScan
operator.
For those who haven't read the challenge, and perhaps Maxime's implementation, I warmly recommend having a look before continuing.
The focus of the challenge is RxJS, so I'll focus on that, too, with a few words around the way the solution below uses Rimmel sources and sinks.
Async generators
To make things fun, I also created a new getLettersFromTo()
as an async generator function, so it can stay lazy and emit at the pace we want.
export async function* getLettersFromTo(from, to) {
const a = LETTERS.indexOf(from) +1;
const b = LETTERS.indexOf(to);
const l = LETTERS.length;
const carry = b < a ? l : 0;
for (var i = a; i <= b +carry; i++) {
yield LETTERS[i % l];
await delay(DELAY_AMOUNT);
}
}
Actually, I didn't make it just for fun, but to solve another issue I was having, then I realised it enabled me to simplify the utils.js by a few functions, so I kept it, although I might need to retire it if it doesn't qualify for the FRP requirement set for the challenge.
The focus is not so much on this function, though.
Push and pull models together
RxJS can beautifully turn async generators into observables, giving us some sort of lazy-push-model based stream.
import { from as ObservableFrom } from 'rxjs';
const sequence = ([prev, next]) =>
ObservableFrom(getLettersFromTo(prev, next))
Personal note: I can't make myself like that from()
, the way it is. It used to be called Observable.from()
, which made sense to my ears and I'm missing it a lot, so importing it as ObservableFrom
helps reminding of the good old Rx5 days. :)
So, the function above essentially returns an observable sequence of all the letters the split-flap display needs to loop through.
The main stream
Following is input$$
, a Subject
we'll feed the data we want the board to display from the UI.
const input$$ = new Subject().pipe(
debounceTime(300),
map(e => inputToBoardLetters(e.target.value)),
share(),
startWith(BASE),
);
Quite simple so far, it will just take an input string, convert it to uppercase letters and make the stream "shareable" for reduced processing.
One stream per letter
Then we have a stream for each letter on the board. Yes, one observable each, we'll come back to that later.
const nextLetter = index => input$$.pipe(
map(str => str[index]),
distinctUntilChanged(),
pairwise(),
switchMap(sequence),
);
This one takes the whole string to display, picks a letter we specify by its index
, and returns, through switchMap
the sequence of characters to display, which will be provided by the sequence
function above.
We're using pairwise
, which returns the previous and the next value every time. That helps generating the sequence in terms of the current letter on the board and the next one to show.
switchScan vs switchMap
This I think is the most interesting difference from Maxime's solution. He used switchScan
, I've used switchMap
.
The idea behind switchScan
is that you can progressively change your already-in-progress transformation. Like you start "reducing" the stream in a way, then something happens (like the user gives a new input to render), then you can adapt to that change accordingly, whilst the whole board is flipping, starting from its current state, but not starting over altogether.
That's certainly the most fascinating way to solve the problem to me, but I thought I'd still try another way, and realised swtichMap
could do the trick, as well.
UI
The UI is made by two Rimmel components. They are very similar to React's function components, except you can use tagged templates and just seamlessly assign observables as either sources or sinks to HTML attributes.
Rimmel will call fromEvent
, next()
or subscribe()
accordingly.
The letter component
const board = initial => Object.keys(initial).map(i => render`
<span class="letter">${nextLetter(i)}</span>
`).join('')
This component loops through each letter and renders a span
tag, in which we sink the observable created by nextLetter
.
Performance?
Back to the reason why I've created one observable sequence per letter on the board. That was to keep the functional-reactive style and avoid having to create an imperative sink to update each one.
In an imperative fashion, you loop through your letters and set them in the DOM:
for(i=0;i<letters.length;i++) {
some.dom.element[i].innerHTML = letters[i];
}
In a more functional-reactive style, each letter component in the DOM can be declared as a sink for a corresponding stream on its own.
<span class="letter">${nextLetter(i)}</span>
There will be performance considerations in this case, of course. The imperative style will beat anything else if raw speed is at stake, although if we had a (n extremely) huge board to update at very high frequency, that would break the framerate budget, so I would rather make use of Schedulers in order to keep the FRP style, all its benefits, and high performance.
The main template
And finally we have our main template, featuring a text box input$$ will be sourcing data from.
const page = () => render`
Input String: <input oninput="${input$$}" onmount="${input$$}" value="${INITIAL_STRING}">
<div class="letters">
${board(BASE)}
</div>
<div>Bye</div>
`;
document.body.innerHTML = page();
Essentially, whenever oninput
or the custom onmount
events fire, they will call input$$.next(event)
behind the scenes.
Conclusion
@maxime1992 I loved this challenge, thanks for the fun, and hope others will join, too!
The full code is available here: https://stackblitz.com/edit/rxjs-xubnby