Search code examples
javascriptreactjsvega-litevega

Fit Vega-Lite (react-vega) visualization to container size


I'm using react-vega (a React wrapper for Vega-Lite) to render visualizations from a JSON schema. It works well, except when I want to display a vertically concatenated view (using vconcat) that fits the container size and provides an interactive brush feature to select data on the visualization.

I have tested multiple approaches including:

  • Setting the width and height of the container as schema
  • Rescaling all visualizations manually (by modifying their width/height properties in the schema)

However, nothing works as expected. Even if the visualization fits the screen, the interactive brush is offset. To be fair, all solutions I've come up with feel "hacky," as the problem of fitting the visualization to the container size should be solved internally by the library itself.

Link to a minimal reproduction Sandbox with all approaches explained (React)

Could you point out any invalid logic in my approaches or suggest an alternative? This issue has been haunting me for a while now.

Expected

Visualization fits the container. The interactive brush works as expected. No content clipped.

Expected

Actual

Content clipped.

Actual

Generalization

Generalization

Minimal reproduction code with all my approaches to solve this problem:

import React from "react";
import { spec, data } from "./schema.js";
import { VegaLite } from "react-vega";
import useMeasure from "react-use-measure";
import { replaceAllFieldsInJson } from "./utils.ts";

import "./style.css";

export default function App() {
  return (
    <div className="App">
      <VisualizationContainer
        style={{ overflow: "hidden" }}
        title="VegaLite + useMeasure"
        description="Interactive brush works as expected, but visualization is clipped"
        invalid
      >
        <VegaLiteAndUseMeasure spec={spec} data={data} />
      </VisualizationContainer>

      <VisualizationContainer
        style={{ overflow: "scroll" }}
        title="VegaLite + overflow-scroll"
        description="Interactive brush works as expected, content can be accessed, but scrollable container is not an ideal solution"
      >
        <VegaLiteAndOverflowScroll spec={spec} data={data} />
      </VisualizationContainer>

      <VisualizationContainer
        style={{ overflow: "hidden" }}
        title="VegaLite + useMeasure + manual re-scaling"
        description="Interactive brush works as expected, visualization fits the container (width), height is clipped"
        invalid
      >
        <VegaLiteAndManualRescaling spec={spec} data={data} />
      </VisualizationContainer>
    </div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * VegaLite + useMeasure
 * -----------------------------------------------------------------------------------------------*/

function VegaLiteAndUseMeasure(props) {
  const [measureRef, geometry] = useMeasure();

  const [spec, setSpec] = React.useState(props.spec);
  const view = React.useRef(undefined);

  React.useEffect(() => {
    if (geometry) {
      setSpec((spec) => ({
        ...spec,
        width: geometry.width,
        height: geometry.height,
      }));
      view.current?.resize?.();
    }
  }, [geometry]);

  return (
    <div style={{ width: "100%", height: "100%" }} ref={measureRef}>
      <VegaRenderer spec={spec} {...props} />
    </div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * VegaLite + overflow-scroll
 * -----------------------------------------------------------------------------------------------*/

function VegaLiteAndOverflowScroll(props) {
  return (
    <div style={{ width: "100%", height: "100%" }}>
      <VegaRenderer spec={spec} {...props} />
    </div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * VegaLite + manual re-scaling
 * -----------------------------------------------------------------------------------------------*/

function rescaleSchema(schema, widthScaleFactor, heightScaleFactor) {
  const INTERNAL_INITIAL_WIDTH_KEY = "_initial-width";
  const INTERNAL_INITIAL_HEIGHT_KEY = "_initial-height";

  const persistInternalVariable = (json, key, value) => {
    if (typeof json !== "object" || Array.isArray(json)) {
      return undefined;
    }
    if (!(key in json)) {
      json[key] = value;
    }
    return json[key];
  };

  return replaceAllFieldsInJson(schema, [
    {
      key: "width",
      strategy(key, json) {
        const currentWidth = Number(json[key]);
        const initialWidth = persistInternalVariable(
          json,
          INTERNAL_INITIAL_WIDTH_KEY,
          currentWidth
        );

        if (initialWidth && !Number.isNaN(initialWidth)) {
          json[key] = Math.floor(initialWidth * widthScaleFactor);
        }
      },
    },
    {
      key: "height",
      strategy(key, json) {
        const currentHeight = Number(json[key]);
        const initialHeight = persistInternalVariable(
          json,
          INTERNAL_INITIAL_HEIGHT_KEY,
          currentHeight
        );

        if (initialHeight && !Number.isNaN(initialHeight)) {
          json[key] = Math.floor(initialHeight * heightScaleFactor);
        }
      },
    },
  ]);
}

/* -----------------------------------------------------------------------------------------------*/

function VegaLiteAndManualRescaling(props) {
  const [measureRef, geometry] = useMeasure();

  const [spec, setSpec] = React.useState(props.spec);

  const [initialWidth, setInitialWidth] = React.useState(null);
  const [initialHeight, setInitialHeight] = React.useState(null);

  const expectedWidth = geometry?.width;
  const expectedHeight = geometry?.height;

  const widthScaleFactor = React.useMemo(
    () => (expectedWidth && initialWidth ? expectedWidth / initialWidth : 1),
    [expectedWidth, initialWidth]
  );
  const heightScaleFactor = React.useMemo(
    () =>
      expectedHeight && initialHeight ? expectedHeight / initialHeight : 1,
    [expectedHeight, initialHeight]
  );

  React.useEffect(() => {
    if (geometry) {
      setSpec((spec) => ({
        ...rescaleSchema({ ...spec }, widthScaleFactor, heightScaleFactor),
        width: geometry.width,
        height: geometry.height,
      }));
    }
  }, [geometry, widthScaleFactor, heightScaleFactor]);

  return (
    <div style={{ width: "100%", height: "100%" }} ref={measureRef}>
      <VegaRenderer
        {...props}
        key={`vega-renderer-manual-rescaling:${widthScaleFactor}:${heightScaleFactor}`}
        spec={spec}
        onNewView={(view) => {
          if (!initialWidth) {
            setInitialWidth(view._viewWidth ?? null);
          }
          if (!initialHeight) {
            setInitialHeight(view._viewHeight ?? null);
          }
          view?.resize?.();
        }}
      />
    </div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * VisualizationContainer
 * -----------------------------------------------------------------------------------------------*/

function VisualizationContainer(props) {
  return (
    <figure className="vis-container">
      <header>
        <h1>{props.title}</h1>

        {props.description ? (
          <p className="vis-container__description">
            <span>{props.invalid ? "❌" : "✅"}</span>
            {props.description}
          </p>
        ) : null}
      </header>

      <div className="vis-container__wrapper" style={{ ...props.style }}>
        {props.children}
      </div>
    </figure>
  );
}

/* -------------------------------------------------------------------------------------------------
 * VegaRenderer
 * -----------------------------------------------------------------------------------------------*/

function VegaRenderer(props) {
  return <VegaLite actions={true} padding={24} {...props} />;
}

Schema:

export const spec = {
  $schema: "https://vega.github.io/schema/vega-lite/v5.json",
  data: { name: "table" },
  vconcat: [
    {
      encoding: {
        color: {
          type: "quantitative",
          field: "calculated pI",
          title: "Calculated Isoelectric Point",
        },
        tooltip: [
          {
            type: "quantitative",
            field: "deltaSol_F",
          },
          {
            type: "quantitative",
            field: "deltaSol_D",
          },
          {
            type: "quantitative",
            field: "calculated pI",
          },
        ],
        x: {
          type: "quantitative",
          field: "deltaSol_F",
          title: "Change in solubility due to Ficoll 70 (deltaSol_F)",
        },
        y: {
          type: "quantitative",
          field: "deltaSol_D",
          title: "Change in solubility due to dextran 70 (deltaSol_D)",
        },
      },
      height: 300,
      mark: "point",
      selection: {
        brush: {
          type: "interval",
        },
      },
      title: "Effects of Ficoll 70 and Dextran 70 on Protein Solubility",
      width: 1200,
    },
    {
      hconcat: [
        {
          encoding: {
            color: {
              type: "quantitative",
              field: "Total aa",
              title: "Total number of amino acids",
            },
            tooltip: [
              {
                type: "quantitative",
                field: "Sol_noMCR",
              },
              {
                type: "quantitative",
                field: "MW (kDa)",
              },
              {
                type: "quantitative",
                field: "Total aa",
              },
            ],
            x: {
              type: "quantitative",
              field: "Sol_noMCR",
              title: "Solubility in the absence of MCRs (Sol_noMCR)",
            },
            y: {
              type: "quantitative",
              field: "MW (kDa)",
              title: "Molecular weight (MW (kDa))",
            },
          },
          height: 300,
          mark: "point",
          selection: {
            brush: {
              type: "interval",
            },
          },
          title: "Protein Solubility vs. Molecular Weight in Absence of MCRs",
          width: 600,
        },
        {
          encoding: {
            color: {
              type: "quantitative",
              field: "MW (kDa)",
              title: "Molecular weight (MW (kDa))",
            },
            tooltip: [
              {
                type: "quantitative",
                field: "deltaSol_D",
              },
              {
                type: "quantitative",
                field: "calculated pI",
              },
              {
                type: "quantitative",
                field: "MW (kDa)",
              },
            ],
            x: {
              type: "quantitative",
              field: "deltaSol_D",
              title: "Change in solubility due to dextran 70 (deltaSol_D)",
            },
            y: {
              type: "quantitative",
              field: "calculated pI",
              title: "Calculated Isoelectric Point (calculated pI)",
            },
          },
          height: 300,
          mark: "point",
          selection: {
            brush: {
              type: "interval",
            },
          },
          title: "Solubility Changes by Dextran 70 vs. Isoelectric Point",
          width: 600,
        },
      ],
    },
    {
      hconcat: [
        {
          encoding: {
            color: {
              type: "quantitative",
              field: "MW (kDa)",
              title: "Molecular weight (MW (kDa))",
            },
            tooltip: [
              {
                type: "quantitative",
                field: "deltaY_F (uM)",
              },
              {
                type: "quantitative",
                field: "deltaY_D (uM)",
              },
              {
                type: "quantitative",
                field: "MW (kDa)",
              },
            ],
            x: {
              type: "quantitative",
              field: "deltaY_F (uM)",
              title:
                "Change in synthetic yield due to Ficoll 70 (deltaY_F (uM))",
            },
            y: {
              type: "quantitative",
              field: "deltaY_D (uM)",
              title:
                "Change in synthetic yield due to dextran 70 (deltaY_D (uM))",
            },
          },
          height: 300,
          mark: "point",
          selection: {
            brush: {
              type: "interval",
            },
          },
          title: "Synthetic Yield Changes by Ficoll 70 and Dextran 70",
          width: 600,
        },
        {
          encoding: {
            color: {
              type: "quantitative",
              field: "calculated pI",
              title: "Calculated Isoelectric Point (calculated pI)",
            },
            tooltip: [
              {
                type: "quantitative",
                field: "Total aa",
              },
              {
                type: "quantitative",
                field: "deltaSol_F",
              },
              {
                type: "quantitative",
                field: "calculated pI",
              },
            ],
            x: {
              type: "quantitative",
              field: "Total aa",
              title: "Total number of amino acids (Total aa)",
            },
            y: {
              type: "quantitative",
              field: "deltaSol_F",
              title: "Change in solubility due to Ficoll 70 (deltaSol_F)",
            },
          },
          height: 300,
          mark: "point",
          selection: {
            brush: {
              type: "interval",
            },
          },
          title: "Total Amino Acids vs. Solubility Change by Ficoll 70",
          width: 600,
        },
      ],
    },
  ],
};

Link to the Dataset

Compiled schema in the Vega Editor

All solutions are welcome!


EDIT:


Here's the adjusted solution from APB Reports. Unfortunately, it doesn't fit the criteria. I can make it work by manually adjusting the width and height to fit the container, but that’s not ideal. I'm looking for a solution that dynamically handles all cases.

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
    <meta http-equiv="refresh" content="3600" />
    <script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
    <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
    <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>

    <style>
      body {
        background-color: #f0f5fa;
        padding-top: 30px;
        margin: 0px;
        width: 100%;
        height: 100%;
        margin-left: auto;
        margin-right: auto;
        scolling: auto;
      }
      .test {
        width: 800px;
        height: 800px;
        border: 1px dashed red;
        border-radius: 13px;
        overflow: hidden;
      }
    </style>
  </head>
  <body>
    <div class="test" id="test">
      <div class="vega-container" id="vis1"></div>
    </div>

    <script id="jsCode" type="text/javascript">
      const vegaWrapper = document.querySelector("#test");
      var pageWidthBody = vegaWrapper.clientWidth;
      var pageWidthBodyMinusx = pageWidthBody - 200;
      var PageWidth = pageWidthBodyMinusx;
      console.log(PageWidth);

      var pageHeightBody = vegaWrapper.clientHeight;
      var pageHeightBodyMinusx = (pageHeightBody - 50) / 2;
      var PageHeight = pageHeightBodyMinusx;
      console.log(PageHeight);

      var V1Spec = {
        $schema: "https://vega.github.io/schema/vega-lite/v5.json",
        description: "A dashboard with cross-highlighting.",
        data: {
          url: "https://gist.githubusercontent.com/letelete/4b88fcdb37f0ade26e92687c1de8c1ae/raw/1402aa849e584e008868256e4c7b085fbac6294f/raw_data.json",
        },
        vconcat: [
          {
            encoding: {
              color: {
                type: "quantitative",
                field: "calculated pI",
                title: "Calculated Isoelectric Point",
              },
              tooltip: [
                {
                  type: "quantitative",
                  field: "deltaSol_F",
                },
                {
                  type: "quantitative",
                  field: "deltaSol_D",
                },
                {
                  type: "quantitative",
                  field: "calculated pI",
                },
              ],
              x: {
                type: "quantitative",
                field: "deltaSol_F",
                title: "Change in solubility due to Ficoll 70 (deltaSol_F)",
              },
              y: {
                type: "quantitative",
                field: "deltaSol_D",
                title: "Change in solubility due to dextran 70 (deltaSol_D)",
              },
            },
            height: PageHeight,
            width: PageWidth,
            mark: "point",
            selection: {
              brush: {
                type: "interval",
              },
            },
            title: "Effects of Ficoll 70 and Dextran 70 on Protein Solubility",
          },
          {
            hconcat: [
              {
                encoding: {
                  color: {
                    type: "quantitative",
                    field: "Total aa",
                    title: "Total number of amino acids",
                  },
                  tooltip: [
                    {
                      type: "quantitative",
                      field: "Sol_noMCR",
                    },
                    {
                      type: "quantitative",
                      field: "MW (kDa)",
                    },
                    {
                      type: "quantitative",
                      field: "Total aa",
                    },
                  ],
                  x: {
                    type: "quantitative",
                    field: "Sol_noMCR",
                    title: "Solubility in the absence of MCRs (Sol_noMCR)",
                  },
                  y: {
                    type: "quantitative",
                    field: "MW (kDa)",
                    title: "Molecular weight (MW (kDa))",
                  },
                },
                mark: "point",
                selection: {
                  brush: {
                    type: "interval",
                  },
                },
                title:
                  "Protein Solubility vs. Molecular Weight in Absence of MCRs",
              },
              {
                encoding: {
                  color: {
                    type: "quantitative",
                    field: "MW (kDa)",
                    title: "Molecular weight (MW (kDa))",
                  },
                  tooltip: [
                    {
                      type: "quantitative",
                      field: "deltaSol_D",
                    },
                    {
                      type: "quantitative",
                      field: "calculated pI",
                    },
                    {
                      type: "quantitative",
                      field: "MW (kDa)",
                    },
                  ],
                  x: {
                    type: "quantitative",
                    field: "deltaSol_D",
                    title:
                      "Change in solubility due to dextran 70 (deltaSol_D)",
                  },
                  y: {
                    type: "quantitative",
                    field: "calculated pI",
                    title: "Calculated Isoelectric Point (calculated pI)",
                  },
                },
                mark: "point",
                selection: {
                  brush: {
                    type: "interval",
                  },
                },
                title: "Solubility Changes by Dextran 70 vs. Isoelectric Point",
              },
            ],
          },
          {
            hconcat: [
              {
                encoding: {
                  color: {
                    type: "quantitative",
                    field: "MW (kDa)",
                    title: "Molecular weight (MW (kDa))",
                  },
                  tooltip: [
                    {
                      type: "quantitative",
                      field: "deltaY_F (uM)",
                    },
                    {
                      type: "quantitative",
                      field: "deltaY_D (uM)",
                    },
                    {
                      type: "quantitative",
                      field: "MW (kDa)",
                    },
                  ],
                  x: {
                    type: "quantitative",
                    field: "deltaY_F (uM)",
                    title:
                      "Change in synthetic yield due to Ficoll 70 (deltaY_F (uM))",
                  },
                  y: {
                    type: "quantitative",
                    field: "deltaY_D (uM)",
                    title:
                      "Change in synthetic yield due to dextran 70 (deltaY_D (uM))",
                  },
                },
                mark: "point",
                selection: {
                  brush: {
                    type: "interval",
                  },
                },
                title: "Synthetic Yield Changes by Ficoll 70 and Dextran 70",
              },
              {
                encoding: {
                  color: {
                    type: "quantitative",
                    field: "calculated pI",
                    title: "Calculated Isoelectric Point (calculated pI)",
                  },
                  tooltip: [
                    {
                      type: "quantitative",
                      field: "Total aa",
                    },
                    {
                      type: "quantitative",
                      field: "deltaSol_F",
                    },
                    {
                      type: "quantitative",
                      field: "calculated pI",
                    },
                  ],
                  x: {
                    type: "quantitative",
                    field: "Total aa",
                    title: "Total number of amino acids (Total aa)",
                  },
                  y: {
                    type: "quantitative",
                    field: "deltaSol_F",
                    title: "Change in solubility due to Ficoll 70 (deltaSol_F)",
                  },
                },
                mark: "point",
                selection: {
                  brush: {
                    type: "interval",
                  },
                },
                title: "Total Amino Acids vs. Solubility Change by Ficoll 70",
              },
            ],
          },
        ],
      };
      vegaEmbed("#vis1", V1Spec, { actions: false, renderer: "svg" });
    </script>
  </body>
</html>


Solution

  • This is something I ended up doing:

    Objective: A visualization's width must always fit the container's width that wraps the visualization.

    Idea:

    1. Define an initial width for your visualization schema to be large enough to render all the details.
    2. Calculate the width of the visualization wrapper. (Using `element.getBoundingClientRect() for instance)
    3. Calculate the width of the rendered visualization. (Using `element.getBoundingClientRect() for instance)
    4. Define scaleFactor that applied on the visualization, will resize it to match wrapper's width. Let scaleFactor be scaleFactor=wrapper_width / original_visualization_width.
    5. Apply transform: scale(scaleFactor) CSS property for the visualization root element (not wrapper).

    Problem: When transform: scale is applied, vega mouse interactions are broken due to mismatched mouse positions. If your visualizations don't need to interpret mouse signals, you can ignore the Solution below.

    Solution: I found that the Vega library ignores an offset of the element when performing UI calculations. I've proposed a solution in the PR: https://github.com/vega/vega/pull/3978.

    You can patch your project's Vega dependency with one from my commit hash until this won't be reviewed by the core Vega team.