OiO.lk Community platform!

Oio.lk is an excellent forum for developers, providing a wide range of resources, discussions, and support for those in the developer community. Join oio.lk today to connect with like-minded professionals, share insights, and stay updated on the latest trends and technologies in the development field.
  You need to log in or register to access the solved answers to this problem.
  • You have reached the maximum number of guest views allowed
  • Please register below to remove this limitation

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

  • Thread starter Thread starter Bruno Kawka
  • Start date Start date
B

Bruno Kawka

Guest
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

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


Code:
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:

Code:
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!

<p>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.</p>
<p>I have tested multiple approaches including:</p>
<ul>
<li>Setting the width and height of the container as schema</li>
<li>Rescaling all visualizations manually (by modifying their width/height properties in the schema)</li>
</ul>
<p>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.</p>
<p><a href="https://codesandbox.io/p/sandbox/bold-volhard-w354zq?file=/src/App.js:1,1-218,1" rel="nofollow noreferrer">Link to a minimal reproduction Sandbox with all approaches explained (React)</a></p>
<p>Could you point out any invalid logic in my approaches or suggest an alternative? This issue has been haunting me for a while now.</p>
<h1>Expected</h1>
<p>Visualization fits the container. The interactive brush works as expected. No content clipped.</p>
<p><a href="https://i.sstatic.net/eAsJHAbv.png" rel="nofollow noreferrer"><img src="https://i.sstatic.net/eAsJHAbv.png" alt="Expected" /></a></p>
<h1>Actual</h1>
<p>Content clipped.</p>
<p><a href="https://i.sstatic.net/oQkmwZA4.png" rel="nofollow noreferrer"><img src="https://i.sstatic.net/oQkmwZA4.png" alt="Actual" /></a></p>
<h1>Minimal reproduction code with all my approaches to solve this problem:</h1>
<pre><code>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} />;
}

</code></pre>
<p>Schema:</p>
<pre><code>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,
},
],
},
],
};
</code></pre>
<p><a href="https://pastebin.com/hzYgQp7c" rel="nofollow noreferrer">Link to the Dataset</a></p>
<p><a href="https://gist.github.com/letelete/9aad9a998c779cb098d43f064dfec422" rel="nofollow noreferrer">Compiled schema in the Vega Editor</a></p>
<p>All solutions are welcome!</p>
 

Latest posts

Top