should width be a view?

Many if not most images and diagrams on Observable depend on the width object exposed by the standard library, helping to make them responsive to different screen sizes.

For rendering on fixed (e.g. phone) displays of arbitrary size, this works great, but often the result when resizing a window on a desktop machine is to completely re-render all graphics at the new size and sometimes to do other computation from scratch repeatedly for every pixel the mouse moves as the window is resized, when sometimes the only difference in output is a few SVG element attributes or the like. Some people get around some of resource use (e.g. creation of new canvases) by using the this trick, but that doesn’t prevent a lot of unnecessary recomputation.

I wonder if it might be better to encourage (at least optionally) listening for width changes and not using the width object reactively.

Here’s a demonstration of a width view object,

viewof width = {
  const view = html`<span class="observablehq--inspect"><span class="observablehq--number">`;
  const resized = function resized() {
    const w = document.body.clientWidth;
    if (w !== view.value) {
      view.firstChild.textContent = view.value = w;
      view.dispatchEvent(new CustomEvent("input"));
    }
  }
  window.addEventListener('resize', resized);
  invalidation.then(() => window.removeEventListener('resize', resized));
  return resized(), view;
}

I recommend overriding the definition of width if you want different behavior. Here two patterns I sometimes use.

The first pattern is to set a fixed width and then use CSS to resize the content. In SVG, this is done using the viewBox attribute combined with styles width = 100%, height = auto.

width = 975 // set a fixed width
html`<svg viewBox="0 0 ${width} ${height}" style="width:100%;height:auto;">
  <circle cx="${width / 2}" cy="${height / 2}" r="100"></circle>
</svg>`

The second pattern is to use Generators.observe to control when the width changes. For example, this definition evaluates to either 480 or 640, depending on what will fit, and only yields a new value when the threshold is crossed.

width = Generators.observe(notify => {
  let width;
  function resized() {
    let w = document.body.clientWidth >= 640 ? 640 : 480;
    if (w !== width) notify(width = w);
  }
  resized();
  window.addEventListener("resize", resized);
  return () => window.removeEventListener("resize", resized);
})

Also, if you want to read the width non-reactively (meaning, without triggering cell invalidation), you can say

document.body.clientWidth
3 Likes

Yeah, the main reason to make a separate view would be so that changes specifically to body.clientWidth could be observed without getting every window resize event. But in practice that might not be worth the trouble. But I was also just trying to understand the semantics of promises, yield, await, etc. a bit better.

Generators.observe seems pretty handy. I spent a while looking around for some generic Javascript tool like that.

Here’s a generic “width observer” (which after a bunch of fiddling I am realizing turned out more or less identical to the one mike wrote above):

widthObserver = () => Generators.observe(callback => {
  let currentWidth, previousWidth; 
  const resized = () => {
    if ((currentWidth = document.body.clientWidth) !== previousWidth)
      callback(previousWidth = currentWidth); };
  resized(), window.addEventListener('resize', resized);
  return () => window.removeEventListener('resize', resized);
})

@mbostock Maybe this is a bit of a tangent here, and maybe my feeling is off-base, but the more I play with generators and async stuff, the more I feel that one thing missing is some built-in platform support for making references to some kind of wrapper around any arbitrary cell name which is not directly the cell content itself, but can be awaited or listened to for updates to that name (if the name doesn’t exist or is defined twice or is a broken cell, this could throw an error).

Otherwise for any cell where we want to listen for changes without completely re-rendering everything downstream, there is a requirement to explicitly wrap that cell in a View, which takes a decent amount of boilerplate for both the view and anyone listening, requires a fair bit of understanding to construct, and feels like a big step.

1 Like

If I call Generators.observe inside an async function, and then the cell is destroyed, will it get disposed? Or do I need to do additional work?

e.g. if I have…

foo = () => Generators.observe(cb => {
  // attach cb as an event listener
  // return cleanup function which removes it
});

(async function (){
  for await (const x of foo()) {
    // do something with each x
  }
})();

in a cell, and then the cell gets destroyed, is each version going to spawn another set of listening stuff?

1 Like

Looks to me like you do have to do additional cleanup. Here’s the notebook I used to play around with this:

1 Like

Aha. Then the next question is, if this is being done from a function (instead of from a cell directly) is it possible to trigger that cleanup?

It doen’t seem like invalidation can get triggered unless directly referenced in the cell. Is there some workaround or does every top-level cell that calls such a function need to explicitly invoke some extra cleanup line?

Edit: one method is to pass the invalidation object into the function as an argument.

1 Like