react-vega icon indicating copy to clipboard operation
react-vega copied to clipboard

Fit vertically-concatenated Vega-Lite (react-vega) visualization to container size, and keep the interactive brush feature

Open letelete opened this issue 1 year ago • 1 comments
trafficstars

Hello!

I'm using react-vega 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:

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!

letelete avatar Jun 21 '24 13:06 letelete

Crossposted on the stackoverlow with more details https://stackoverflow.com/questions/78652538/fit-vega-lite-react-vega-visualization-to-container-size/78658727?noredirect=1#comment138722454_78658727. Any ideas?

image

letelete avatar Jun 28 '24 10:06 letelete

If you are still experiencing issues, have you migrated to v8? If so and this is still a problem, it's likely an issue with vega-embed, not this library.

JakeAdler avatar Sep 03 '25 04:09 JakeAdler

I'm going to close this issue since it is over a year old and v8 has since been released. @letelete If you are still experiencing this issue, please open a new issue with a minimal repro using v8.

JakeAdler avatar Sep 05 '25 00:09 JakeAdler