Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
letelete opened this issue Jun 21, 2024 · 1 comment

Comments

@letelete
Copy link

letelete commented Jun 21, 2024

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
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant