Interconnected fields in Observable
Observable allows you to synchronise input fields already. But what if you want inputs to be interconnected in more sophisticated ways rather than perfectly synchronised?
Synchronised inputs: what are they?
Observable’s synchronised inputs are really handy if you want to build some redundancy into your Observable notebook.
Let’s say you have quite a long notebook and you have a slider field at the top for users to choose a numerical value within a certain range. The value users choose on this slider has numerous effects further down the notebook. You want them to be able to play with the slider and see those effects at points much further down – and you don’t want them to have to keep scrolling back up to change the setting with the slider and then scroll back down again to see the effect.
Synchronising inputs means you can put another slider further down, or even several sliders throughout the page, so there’s one wherever the user might want to engage with it. By synchronising these inputs they all have the same value, and the same value is used to produce consistent effects.
This synchronisation is achieved with the method Inputs.bind()
which takes two inputs makes them work together. So updating one will update the other.
If the value translates, you can even have different types of input synchronised – the most obvious example being a number input and a range input working together.
Limitations of synchronised inputs
While you can get different types of synchronised inputs to work together, there’s one issue. They all need to share the same value. It’s in the name.
But what if you wanted to update an input and have the value of that help calculate a different value for a different output.
I had a really obvious use case for this recently for a bigger notebook project I’ve been working on in the background.
I’ll simplify it further here for illustration purposes.
Let’s say you have a slider that goes from 0 to 1 and starts in the middle at 0.5.
If I created another slider and synchronised them, then whatever I do in one slider, will also happen to the other.
But what if I wanted the other slider to show the remainder of the first slider. So if I were to move the first slider to 0.7, the second one would show 0.3.
And crucially what if I wanted the action to work reciprocally? So moving the second slider to 0.4 would make the first one show 0.6.
Input and output fields
I experimented but it certainly looked to me after quite a bit of playing around that you couldn’t do this with standard synchronisation approaches.
About as far as I got was a sort of input/output field where I could change one slider and it would update the other but not the other way around.
I achieved this fairly simply.
- Create a lightweight input container called
i
– usingInputs.input()
with an initialised value. - Create
input
andoutput
containers using formula to describe the relationship to the original. Input would be simplyi * 1
whereas output would be1 - i
to give the remainder. - Bind a range slider input to the
input
container. - Bind a range slider input to the
output
container.
But I might as well have made the output
slider readonly since it didn’t reciprocate.
What I wanted was interdependent fields.
Functions and inverse functions
After some experimentation I came to realise that what I needed was a master View
and certain functional relationships that described how a change in one input would affect or be affected by the value in the master View.
These relationships would be described by functions and inverse functions. In order for a function to have an inverse, that function needs to be bijective – basically it has to be possible to undo the function with its inverse and end up with a single result. Bijective functions have one-to-one mappings between input and output – unlike say squares and square roots (where a square root can’t determine whether it originated with a positive or negative number and therefore isn’t a proper inverse).
But even after playing around with that, I realised using a new View
class together and with Inputs.bind()
wouldn’t do the job – the issue was with how Inputs.bind()
itself set up the relationships.
So I found the source code for Inputs.bind()
and reverse engineered it. It’s relatively simple.
The bind
function itself takes a source and a target.
export function bind(target, source, invalidation = disposal(target)) {
const sourceEvent = eventof(source);
const onsource = () => set(target, source);
const ontarget = () => (set(source, target), source.dispatchEvent(new Event(sourceEvent, bubbles)));
onsource();
target.addEventListener(eventof(target), ontarget);
source.addEventListener(sourceEvent, onsource);
invalidation.then(() => source.removeEventListener(sourceEvent, onsource));
return target;
}
As you may notice, functions are created for what happens in the event that either the source or the target is clicked. These functions rely on a function called set
which sets the value of the other input. There’s a little bit of verbosity here to help translate values into different input types but otherwise it’s fairly simple.
function set(target, source) {
const value = get(source);
switch (target.type) {
case "range":
case "number": target.valueAsNumber = value; break;
case "date": target.valueAsDate = value; break;
case "checkbox": target.checked = value; break;
case "file": target.multiple ? (target.files = value) : (target.files = [value]); break;
default: target.value = value; break;
}
}
Basically this sets the value of the target to whatever the value of source is.
I realised this is the point at which I needed to intervene. I needed to have some sort of translation between the value for the triggered input and the value of the destination input. That translation would be the function to provide to the new set
function I’d create.
Not only that but I also needed to determine when the set
function needed to translate and where it needed to do the reverse of that.
Ultimately, I wrote my own specialised bind
function into a notebook, that enabled this translation to happen at the right point and in the right way.
Rewriting bind
My new bind function has a simple signature. It needs
- an array of target objects which each contain
- the
viewof
for the target input - a function to describe what needs to happen to the value of this input before it’s updated and displayed
- a function to describe the inverse of the first function – what needs to happen to the raw input’s value before it is passed onto others
- the
- a source view
- an invalidation argument
And here it is in action:
I think I still have a little more playing around and tidying up to do with this simple idea.
But you can see and use the code in my “Interconnected fields” Observable notebook. Let me know how you get on.