responsive LaTeX formulas for small displays

Hi everyone.

In some notebooks, I have large LaTeX formulas which get chopped off for mobile displays. Has anyone figured out a convenient needs-no-manual-fiddling way to work around this?

My quick hacky workaround is a combination of width-based optional line breaks and CSS transformation based resizing.

For example, in the middle of a Markdown cell,

<div style="
  transform: scale(${Math.min(width/380, 1)});
  transform-origin: left;
  margin: ${Math.min(- 60*(1 - width/380), 0)}px 0;">
${tex.block`\begin{aligned}
p'(t) &= p'_0\bar{t}^{\,2} - (2p'_0 + 2p'_1 - 6(p_1 - p_0))\bar{t}t
         + p'_1t^2 \\[.2em]

p''(t) &= -(4p'_0 + 2p'_1 - 6(p_1 - p_0))\bar{t}
          ${(width < 500) ? '\\\\ &\\quad' : ''}
          + (2p'_0 + 4p'_1 - 6(p_1 - p_0))t \\[.2em]

p'''(t) &= 6(p'_0 + p'_1) -12(p_1 - p_0)
\end{aligned}`}
</div>

Notice that the second equation has an optional line break that gets inserted between two parts of the right hand side, and then the whole formula gets scaled down and some negative vertical margins inserted if the view gets too narrow. The numbers there are more or less based on trial and error until something more or less works, rather than being based on directly measuring the size of the rendered LaTeX.

It might be nice to figure out a way to build resizing into the tex.block template literal, or figure out a way to automate creating a wrapper div with the appropriate CSS.

Has anyone else dealt with this in their notebooks? What did you do?

For context, @tom has a nice notebook about other kinds of responsive content,

1 Like

It’s not great, but maybe overflow-x: scroll would work? The output would still be cropped, but you can scroll horizontally. (We’ve been considering adding this as a default style for KaTeX output, but it breaks margin collapsing, so we’re still looking for a better solution.)

Aside: the rainbow colors you’re using for the cyclic cubic spline are nice. How did you choose them?

Aside: the rainbow colors you’re using for the cyclic cubic spline are nice. How did you choose them?

Thanks! I chose them manually, with more effort than this kind of thing really should need because I don’t have proper tooling. Took about 15 minutes with a lot of copy/pasting of coordinates from one text box to another, when it really should be achievable in 2 minutes. (Also, I have spent many hours in the past on similar tasks, so am pretty efficient using hacky mishmashes of tools.)

One of these days I’ll get around to making some proper color pickers …

As for the idea of the color choices: CIELAB L* goes in increments of 10, and a*/b* were just eyeballed.

1 Like

It’s not great, but maybe overflow-x: scroll would work?

I think scaling a formula down is actually better than scrolling, at least for iPhone (I don’t have other smartphones to test). Even at half size or so the rendering is sharp enough to read if you look close, and mobile safari makes it pretty easy to zoom in on html/svg/etc. content using a pinch gesture. So a reader can see both a whole equation or a small zoomed section.

I use a similar scaling technique with SVG for visualizations that can’t be simplified for smaller screens, such as this choropleth. With SVG, it’s just a matter of setting a viewBox and width & height styles:

<svg viewBox="0 0 960 600" style="width: 100%; height: auto;">

Unfortunately this doesn’t work with KaTeX because it’s renders to HTML. Maybe it would be nice to fork KaTeX and implement a pure SVG renderer… but that’s a pretty big project.

I also tried using foreignObject to embed KaTeX within SVG to use the above technique, but it seems that KaTeX has some line-wrapping logic that’s based on the window width, and so it wraps the output before it can be scaled down by SVG.

Is there a way to get the equivalent of element.getBoundingClientRect() for an element that hasn’t been rendered onto the page yet?

I was trying to make a wrapper thing:

wrap = (katex_root) => {
  const katex_width = [...katex_root.querySelectorAll('.katex-html .base')]
    .reduce((sum, elem) => sum + elem.getBoundingClientRect().width, 0);
  const katex_height = katex_root.getBoundingClientRect().height;

  const scale = Math.min(width/katex_width, 1);
  const vertical_gap = (1-scale)*katex_height;
  
  return html`<div style="
    transform: scale(${scale});
    transform-origin: top left;
    margin-bottom: -${vertical_gap}px;">
      ${katex_root}
  </div>`
}

But I was calling this before the output had been rendered, and the width/height just get reported as 0, so it doesn’t really work.

This is kinda hacky, but seems to work more or less:

width_observer = () => Generators.observe(callback => {
  let current_width, previous_width; 
  const resized = () => {
    if ((current_width = document.body.clientWidth) !== previous_width)
      callback(previous_width = current_width); };
  resized(), window.addEventListener('resize', resized);
  return () => window.removeEventListener('resize', resized);
})
responsive_katex = (katex_root) => {
  const div = html`<div>${katex_root}`;
  div.style.transformOrigin = `top left`;

  (async function (){
    // wait 10ms for the content to render before measuring size
    await new Promise(cb => setTimeout(cb, 10));
    const katex_width = [...katex_root.querySelectorAll('.katex-html .base')]
      .reduce((acc, elem) => acc + elem.getBoundingClientRect().width, 0);
    const katex_height = katex_root.getBoundingClientRect().height;

    // listen to changes in width and rescale as necessary.
    for await (const w of width_observer()) {
      const scale = Math.min(w/katex_width, 1);
      const vertical_gap = (1-scale)*(katex_height);

      div.style.transform = `scale(${scale})`;
      div.style.marginBottom = `-${vertical_gap}px`;
    }
  })();

  return div;
}

I have added this one to https://observablehq.com/@jrus/misc but reserve the right to change it at any time if I come up with a better method.

See it in use at

1 Like

If it’s a top-level cell value, you can use yield to put it into the DOM and then run some code after:

function* wrap(katex_root) {
  const div = html`<div>${katex_root}</div>`;
  yield div;
  const katex_width = [...katex_root.querySelectorAll('.katex-html .base')]
    .reduce((sum, elem) => sum + elem.getBoundingClientRect().width, 0);
  const katex_height = katex_root.getBoundingClientRect().height;
  div.style.transform = `scale(${Math.min(width/katex_width, 1)})`;
  div.style.transformOrigin = "top left";
  div.style.marginBottom = `-${(1-scale)*katex_height}px`;
}

Alternatively, you could document.body.appendChild(element) and then element.remove after you’ve measured the width and height. Or, you could defer the getBoundingClientRect using requestAnimationFrame (or possibly Promise.resolve().then would be sufficient).

1 Like

It seems that when I call document.body.appendChild(elem), I get a different width than when it gets added in place. I can’t quickly figure out why.

But await new Promise(callback => requestAnimationFrame(callback)); seems to be fine instead of waiting 10ms. (Though in practice there isn’t too much difference as re-rendering all of the math elements on the page usually takes a good bit longer than that anyway.)

Okay, to get cleanup working, the new way to call this is e.g. responsive_katex(invalidation, tex.block`a + b = c`)

Code is

responsive_katex = (cell_invalidation, katex_root) => {
  const div = html`<div>${katex_root}`;
  div.style.transformOrigin = `top left`;

  const width_updates = width_observer();
  cell_invalidation.then(() => width_updates.return());
  
  (async function (){
    // wait for the content to render before measuring size
    await new Promise(callback => requestAnimationFrame(callback));
    const katex_width = [...katex_root.querySelectorAll('.katex-html .base')]
      .reduce((acc, elem) => acc + elem.getBoundingClientRect().width, 0);
    const katex_height = katex_root.getBoundingClientRect().height;

    // listen to changes in width and rescale as necessary.
    for await (const width of width_updates) {
      const scale = Math.min(width/katex_width, 1);
      const vertical_gap = (1-scale)*katex_height;
      div.style.transform = `scale(${scale})`;
      div.style.marginBottom = `-${vertical_gap}px`;
    }
  })();
  
  return div;
}
1 Like

Just to add a few things I’ve tried.

(1) I wrapped any math I knew would be too wide with a div with class “fitToWidth”. After the math has been processed by KaTeX (to save rendering the whole page again), I measured the width of the div.fitToWidth, then dropped the font-size of the div, measured again, dropped again, until it fitted.

However, it often looked funny being smaller than the surrounding math.

(2) I now mostly use overflow-x:auto. When I first used it, the problem for the user was it wasn’t always obvious that horizontal scrolling was required (especially with certain screen widths that looked on first sight that the math was “complete” already). A horizontal scroll element only appears once scrolling starts, in most browsers. So I use the pseudo element ::-webkit-scrollbar to make it obvious a scroll is possible. (While MDN advises against its use, it’s better than nothing.)

There are some examples on this page, starting about 2/3 of the way down (horizontal scrolling is needed for most phone widths):

https://www.intmath.com/vectors/3d-earth-geometry.php

ASIDE: I promoted Observable in my recent Newsletter:
https://www.intmath.com/blog/letters/intmath-newsletter-implicit-observable-navier-stokes-11826

3 Likes