diff --git a/README.md b/README.md index 798025cd..7f6ee0c2 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ const main = () => { - `provVis` (optional): [Sidebar options](#sidebar-options) for the provenance visualization sidebar. See [Trrack-Vis](https://github.com/Trrack/trrackvis) for more information about Trrack provenance visualization. - `elementSidebar` (optional): [Sidebar options](#sidebar-options) for the element visualization sidebar. This sidebar is used for element queries, element selection datatable, and supplimental plot generation. - `altTextSidebar` (optional): [Sidebar options](#sidebar-options) for the text description sidebar. This sidebar is used to display the generated text descriptions for an Upset 2.0 plot, given that the `generateAltText` function is provided. +- `footerHeight` (optional)(`number`): Height of the footer overlayed on the upset plot, in px, if one exists. Used to prevent the bottom of the sidebars from overlapping with the footer. - `generateAltText` (optional)(`() => Promise`): Async function which should return a generated AltText object. See [Alt Text Generation](#alt-text-generation) for more information about Alt Text generation. ##### Configuration (Grammar) options diff --git a/e2e-tests/attributeSelector.spec.ts b/e2e-tests/attributeSelector.spec.ts index 13f13cca..4953ff2b 100644 --- a/e2e-tests/attributeSelector.spec.ts +++ b/e2e-tests/attributeSelector.spec.ts @@ -3,6 +3,18 @@ import { beforeTest } from './common'; test.beforeEach(beforeTest); +/** + * Selects or deselects an attribute from the attribute dropdown + * @param page the page to interact with + * @param attributeName the name of the attribute to toggle + * @param checked whether to select or deselect the attribute + */ +async function toggleAttribute(page, attributeName, checked) { + await page.getByLabel('Attributes').first().click(); + await page.getByRole('option', { name: attributeName }).getByRole('checkbox').setChecked(checked); + await page.locator('#menu- > .MuiBackdrop-root').click(); +} + test('Attribute Dropdown', async ({ page }) => { await page.goto('http://localhost:3000/?workspace=Upset+Examples&table=simpsons&sessionId=193'); @@ -10,44 +22,34 @@ test('Attribute Dropdown', async ({ page }) => { // Age /// ///////////////// // Deseslect and assert that it's removed from the plot - await page.getByLabel('Attributes selection menu').click(); - await page.getByRole('checkbox', { name: 'Age' }).uncheck(); - await page.locator('.MuiPopover-root > .MuiBackdrop-root').click(); + await toggleAttribute(page, 'Age', false); await expect(page.getByLabel('Age').locator('rect')).toHaveCount(0); // Reselect and assert that it's added back to the plot - await page.getByLabel('Attributes selection menu').click(); - await page.getByLabel('Age').check(); - await page.locator('.MuiPopover-root > .MuiBackdrop-root').click(); - await expect(page.getByText('Age', { exact: true })).toBeVisible(); + await toggleAttribute(page, 'Age', true); + // This doesn't make sense but it works to find the Age column header + await expect(page.locator('g').filter({ hasText: /^Age2020404060608080$/ }).locator('rect')).toBeVisible(); /// ///////////////// // Degree /// ///////////////// // Deselect and assert that it's removed from the plot - await page.getByLabel('Attributes selection menu').click(); - await page.getByRole('checkbox', { name: 'Degree' }).uncheck(); - await page.locator('.MuiPopover-root > .MuiBackdrop-root').click(); + await toggleAttribute(page, 'Degree', false); await expect(page.locator('#upset-svg').getByLabel('Number of intersecting sets').locator('rect')).toHaveCount(0); // Reselect and assert that it's added back to the plot - await page.getByLabel('Attributes selection menu').click(); - await page.getByRole('checkbox', { name: 'Degree' }).check(); - await page.locator('.MuiPopover-root > .MuiBackdrop-root').click(); + await toggleAttribute(page, 'Degree', true); await expect(page.locator('#upset-svg').getByLabel('Number of intersecting sets').locator('rect')).toBeVisible(); /// ///////////////// // Deviation /// ///////////////// // Deselect and assert that it's removed from the plot - await page.getByLabel('Attributes selection menu').click(); - await page.getByRole('checkbox', { name: 'Deviation' }).uncheck(); - await page.locator('.MuiPopover-root > .MuiBackdrop-root').click(); + await toggleAttribute(page, 'Deviation', false); await expect(page.getByLabel('Deviation', { exact: true }).locator('rect')).toHaveCount(0); // Reselect and assert that it's added back to the plot - await page.getByLabel('Attributes selection menu').click(); - await page.getByRole('checkbox', { name: 'Deviation' }).check(); - await page.locator('.MuiPopover-root > .MuiBackdrop-root').click(); - await expect(page.getByText('Deviation', { exact: true })).toBeVisible(); + await toggleAttribute(page, 'Deviation', true); + // This also doesn't make sense but uniquely selects the Deviation column header + await expect(page.locator('g').filter({ hasText: /^#Deviation-10%-10%-5%-5%0%0%5%5%10%10%Age2020404060608080$/ }).locator('rect').nth(1)).toBeVisible(); }); diff --git a/e2e-tests/datatable.spec.ts b/e2e-tests/datatable.spec.ts index 85c00f26..bf9d9b52 100644 --- a/e2e-tests/datatable.spec.ts +++ b/e2e-tests/datatable.spec.ts @@ -9,8 +9,9 @@ test('Datatable', async ({ page }) => { // ////////////////// // Open the datatable // ////////////////// + await page.getByLabel('Additional options menu').click(); const page1Promise = page.waitForEvent('popup'); - await page.getByRole('button', { name: 'Data Table' }).click(); + await page.getByLabel('Data Tables (raw and computed)').click(); // ////////////////// // Test downloads diff --git a/e2e-tests/elementView.spec.ts b/e2e-tests/elementView.spec.ts index a808409b..59b0b004 100644 --- a/e2e-tests/elementView.spec.ts +++ b/e2e-tests/elementView.spec.ts @@ -59,7 +59,7 @@ test('Element View', async ({ page, browserName }) => { await row.dispatchEvent('click'); // test expansion buttons - await page.getByLabel('Expand the sidebar in full').click(); + await page.getByRole('button', { name: 'Expand the sidebar in full' }).click(); await page.getByLabel('Reduce the sidebar to normal').click(); // Ensure all headings are visible @@ -78,8 +78,9 @@ test('Element View', async ({ page, browserName }) => { // Check that the datatable is visible and populated const dataTable = page.getByText( - 'LabelAgeSchoolBlue HairDuff FanEvilMalePower PlantBart10yesnononoyesnoRalph8yesnononoyesnoMartin Prince10yesnononoyesnoRows per page:1001–3 of', + 'LabelDegreeDeviationAgeSchoolBlue HairDuff FanEvilBart10yesnononoRalph8yesnononoMartin Prince10yesnononoRows per page:1001–3 of', ); + dataTable.scrollIntoViewIfNeeded(); await expect(dataTable).toBeVisible(); const nameCell = await page.getByRole('cell', { name: 'Bart' }); await expect(nameCell).toBeVisible(); @@ -122,7 +123,7 @@ test('Element View', async ({ page, browserName }) => { await downloadPromise; // Check that the close button is visible and works - const elementViewClose = await page.getByLabel('Close the sidebar'); + const elementViewClose = await page.getByRole('button', { name: 'Close the sidebar' }); await expect(elementViewClose).toBeVisible(); await elementViewClose.click(); diff --git a/e2e-tests/provenance.spec.ts b/e2e-tests/provenance.spec.ts index c825a877..2db66ca8 100644 --- a/e2e-tests/provenance.spec.ts +++ b/e2e-tests/provenance.spec.ts @@ -24,9 +24,11 @@ test('Selection History', async ({ page }) => { // Testing history for an aggregate row selection & deselection await page.getByRole('radio', { name: 'Degree' }).check(); - await page.locator('g').filter({ hasText: /^Degree 3Degree 3$/ }).locator('rect').nth(0).click(); + await page.locator('g').filter({ hasText: /^Degree 3Degree 3$/ }).locator('rect').nth(0) + .click(); await expect(page.locator('div').filter({ hasText: /^Select intersection "Degree 3"$/ }).nth(2)).toBeVisible(); - await page.locator('g').filter({ hasText: /^Degree 3Degree 3$/ }).locator('rect').nth(0).click(); + await page.locator('g').filter({ hasText: /^Degree 3Degree 3$/ }).locator('rect').nth(0) + .click(); await expect(page.getByText('Deselect intersection').nth(1)).toBeVisible(); // Check that selections are maintained after de-aggregation diff --git a/packages/app/.eslintrc.js b/packages/app/.eslintrc.js new file mode 100644 index 00000000..04cf3aa1 --- /dev/null +++ b/packages/app/.eslintrc.js @@ -0,0 +1,72 @@ +module.exports = { + env: { + browser: true, + es2021: true, + }, + extends: [ + 'plugin:react/recommended', + 'plugin:import/recommended', + 'plugin:@typescript-eslint/recommended', + 'airbnb', + 'plugin:import/typescript', + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 13, + sourceType: 'module', + }, + plugins: ['react', '@typescript-eslint'], + root: true, + rules: { + 'react/jsx-filename-extension': [ + 2, + { extensions: ['.js', '.jsx', '.ts', '.tsx'] }, + ], + 'import/prefer-default-export': 'off', + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: ['.storybook/**', '**/stories/**'], + }, + ], + 'react/function-component-definition': 'off', + 'no-plusplus': ['warn', { allowForLoopAfterthoughts: true }], + 'dot-notation': 'off', + 'import/extensions': [ + 'error', + 'ignorePackages', + { + js: 'never', + jsx: 'never', + ts: 'never', + tsx: 'never', + }, + ], + 'react/jsx-props-no-spreading': 'off', + 'react/require-default-props': 'off', + 'react/react-in-jsx-scope': 'off', + 'react/jsx-wrap-multilines': 'off', + 'react/no-unknown-property': 'off', + 'operator-linebreak': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', // or "error" + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-explicit-any': 'off', + 'max-len': 'off', + 'no-unused-vars': 'off', + 'no-param-reassign': 'off', + 'import/no-cycle': 'off', + 'no-underscore-dangle': 'off', + 'no-nested-ternary': 'off', + 'jsx-a11y/tabindex-no-positive': 'off', + 'no-bitwise': 'warn', + }, +}; diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 6a2e8922..bc859cf8 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -1,17 +1,21 @@ -import { createContext, useEffect, useMemo, useState } from 'react'; +import { + createContext, useEffect, useMemo, useState, +} from 'react'; -import { UpsetProvenance, UpsetActions, getActions, initializeProvenanceTracking } from '@visdesignlab/upset2-react'; +import { + UpsetProvenance, UpsetActions, getActions, initializeProvenanceTracking, +} from '@visdesignlab/upset2-react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { convertConfig, DefaultConfig, UpsetConfig } from '@visdesignlab/upset2-core'; +import { CircularProgress } from '@mui/material'; +import { ProvenanceGraph } from '@trrack/core/graph/graph-slice'; import { dataSelector, encodedDataAtom } from './atoms/dataAtom'; import { Root } from './components/Root'; -import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { DataTable } from './components/DataTable'; -import { convertConfig, DefaultConfig, UpsetConfig } from '@visdesignlab/upset2-core'; import { configAtom } from './atoms/configAtoms'; import { queryParamAtom } from './atoms/queryParamAtom'; import { getMultinetSession } from './api/session'; -import { CircularProgress } from '@mui/material'; -import { ProvenanceGraph } from '@trrack/core/graph/graph-slice'; /** @jsxImportSource @emotion/react */ // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -29,60 +33,60 @@ function App() { const multinetData = useRecoilValue(dataSelector); const encodedData = useRecoilValue(encodedDataAtom); const setState = useSetRecoilState(configAtom); - const data = (encodedData === null) ? multinetData : encodedData + const data = (encodedData === null) ? multinetData : encodedData; const { workspace, sessionId } = useRecoilValue(queryParamAtom); const [sessionState, setSessionState] = useState(null); // null is not tried to load, undefined is tried and no state to load, and value is loaded value const conf = useMemo(() => { - const config: UpsetConfig = { ...DefaultConfig } + const config: UpsetConfig = { ...DefaultConfig }; if (data !== null) { - const conf: UpsetConfig = JSON.parse(JSON.stringify(config)) + const newConf: UpsetConfig = JSON.parse(JSON.stringify(config)); if (config.visibleSets.length === 0) { const setList = Object.entries(data.sets); - conf.visibleSets = setList.slice(0, defaultVisibleSets).map((set) => set[0]) // get first 6 set names - conf.allSets = setList.map((set) => {return { name: set[0], size: set[1].size }}) + newConf.visibleSets = setList.slice(0, defaultVisibleSets).map((set) => set[0]); // get first 6 set names + newConf.allSets = setList.map((set) => ({ name: set[0], size: set[1].size })); } // Add first 4 attribute columns (deviation + 3 attrs) to visibleAttributes - conf.visibleAttributes = [...DefaultConfig.visibleAttributes, ...data.attributeColumns.slice(0, 4)]; + newConf.visibleAttributes = [...DefaultConfig.visibleAttributes, ...data.attributeColumns.slice(0, 4)]; // Default: a histogram for each attribute if no plots exist - if (conf.plots.histograms.length + conf.plots.scatterplots.length === 0) { - conf.plots.histograms = data.attributeColumns.map((attr) => { - return { - attribute: attr, - bins: 20, // 20 bins is the default used in upset/.../AddPlot.tsx - type: 'Histogram', - frequency: false, - id: Date.now().toString() // Same calculation as in upset/.../AddPlot.tsx - } - }) + if (newConf.plots.histograms.length + newConf.plots.scatterplots.length === 0) { + newConf.plots.histograms = data.attributeColumns.map((attr) => ({ + attribute: attr, + bins: 20, // 20 bins is the default used in upset/.../AddPlot.tsx + type: 'Histogram', + frequency: false, + id: Date.now().toString(), // Same calculation as in upset/.../AddPlot.tsx + })); } - return conf; + return newConf; } + + return config; }, [data]); // Initialize Provenance and pass it setter to connect const { provenance, actions } = useMemo(() => { if (sessionState) { - const provenance: UpsetProvenance = initializeProvenanceTracking(conf); - const actions: UpsetActions = getActions(provenance); + const prov: UpsetProvenance = initializeProvenanceTracking(conf ?? undefined); + const act: UpsetActions = getActions(prov); // Make sure the provenance state gets converted every time this is called - (provenance as UpsetProvenance & {_getState: typeof provenance.getState})._getState = provenance.getState; - provenance.getState = () => convertConfig( - (provenance as UpsetProvenance & {_getState: typeof provenance.getState})._getState() + (prov as UpsetProvenance & {_getState: typeof prov.getState})._getState = prov.getState; + prov.getState = () => convertConfig( + (prov as UpsetProvenance & {_getState: typeof prov.getState})._getState(), ); if (sessionState && sessionState !== 'not found') { - provenance.importObject(structuredClone(sessionState)); + prov.importObject(structuredClone(sessionState)); } // Make sure the config atom stays up-to-date with the provenance - provenance.currentChange(() => setState(provenance.getState())); + prov.currentChange(() => setState(prov.getState())); - return { provenance: provenance, actions: actions }; + return { provenance: prov, actions: act }; } return { provenance: null, actions: null }; }, [conf, setState, sessionState]); @@ -108,17 +112,16 @@ function App() { update(); }, [sessionId, workspace]); + const provContext = useMemo(() => (provenance && actions ? { provenance, actions } : null), [provenance, actions]); + // Update the state on first render and if the provenance object changes - useEffect(() => {if (provenance?.getState()) setState(provenance?.getState())}, [provenance, setState]); + useEffect(() => { if (provenance?.getState()) setState(provenance?.getState()); }, [provenance, setState]); return ( - {provenance ? + {(provenance && provContext) ? } /> @@ -126,13 +129,12 @@ function App() { } /> - : + : } /> } /> } /> - - } + } ); } diff --git a/packages/app/src/components/AccessiblityStatement.tsx b/packages/app/src/components/AccessiblityStatement.tsx index db213efa..ff29511b 100644 --- a/packages/app/src/components/AccessiblityStatement.tsx +++ b/packages/app/src/components/AccessiblityStatement.tsx @@ -1,61 +1,86 @@ -import { Box, Button, Dialog, Link, Typography } from "@mui/material"; +import { + Box, Button, Dialog, Link, Typography, +} from '@mui/material'; +import { Column, CoreUpsetData } from '@visdesignlab/upset2-core'; +import { useContext } from 'react'; +import { useRecoilValue } from 'recoil'; +import { DataTableLink } from '../utils/DataTableLink'; +import { ProvenanceContext } from '../App'; +import { rowsSelector } from '../atoms/selectors'; type Props = { open: boolean; close: () => void; + data: CoreUpsetData } -// MUI dialog to display accessibility statement -// https://www.w3.org/WAI/planning/statements/ -export const AccessibilityStatement = ({open, close}: Props) => { - return ( - - - UpSet 2 Accessibility Statement -

- The Visualization Design Lab at the University of Utah is committed to ensuring accessibility for all individuals, including those with disabilities. - We strive to make our software user-friendly, accessible, and compliant with the Web Content Accessibility Guidelines (WCAG) 2.1 Level AA. -

-

- Despite our ongoing efforts to provide an inclusive experience, we would like to acknowledge that certain aspects of our software may currently pose accessibility challenges. - We are actively working to improve the accessibility of the following features: -

-
    -
  1. - UpSet2 Visualization: -

    - The UpSet2 Visualization is currently not accessible to all users. - We understand the importance of making this feature accessible and are actively implementing measures to make UpSet2 accessible for all users. - Our primary effort in this regard is the generation of alt-text/captioning for the UpSet2 visualization. We appreciate your patience and understanding. -

    -
  2. -
  3. - Provenance History: -

    - The "History" sidebar is not currently accessible to all users. - We are diligently working to address this issue to ensure that everyone can access and utilize this feature. -

    -
  4. -
  5. - Data Table: -

    The current data table is keyboard navigable and screen-reader accessible; however, the current data is not comprehensive. We are actively working on improving the data coverage included in the data table in an effort to make this feature more useful for all users.

    -
  6. -
-

- We sincerely apologize for any difficulties you may encounter while using these specific features. - Please be assured that we have plans in place to address these accessibility limitations and make UpSet2 fully inclusive. -

-

- To report any accessibility issues you may encounter or to provide suggestions for improvement, please contact us at vdl-faculty@sci.utah.edu. - We value your feedback and are committed to continuously enhancing the accessibility and usability of our software. -

-

- Thank you for your understanding and support as we work towards creating software that is accessible to all individuals, regardless of ability. -

-
- - - -
- ) +/** + * MUI Dialog to display accessibility statement + * @see https://www.w3.org/WAI/planning/statements/ + */ +export const AccessibilityStatement = ({ open, close, data }: Props) => { + const { provenance } = useContext(ProvenanceContext); + const rows = useRecoilValue(rowsSelector); + const { visibleSets } = provenance.getState(); + const hiddenSets = provenance.getState().allSets.filter((set: Column) => !visibleSets.includes(set.name)); + + return ( + + + UpSet 2 Accessibility Statement +

+ The Visualization Design Lab at the University of Utah is committed to ensuring accessibility for all individuals, including those with disabilities. + We strive to make our software user-friendly, accessible, and compliant with the + {' '} + Web Content Accessibility Guidelines (WCAG) + {' '} + 2.1 Level AA. +

+

+ Despite our ongoing efforts to provide an inclusive experience, we would like to acknowledge that certain aspects of our software may currently pose accessibility challenges. + We are actively working to improve the accessibility of the following features: +

+
    +
  1. + UpSet2 Visualization: +

    + The UpSet2 Visualization is currently not accessible to all users. + We understand the importance of making this feature accessible and are actively implementing measures to make UpSet2 accessible for all users. + Our primary effort in this regard is the generation of alt-text/captioning for the UpSet2 visualization. We appreciate your patience and understanding. +

    +
  2. +
  3. + Provenance History: +

    + The "History" sidebar is not currently accessible to all users. + We are diligently working to address this issue to ensure that everyone can access and utilize this feature. +

    +
  4. +
  5. + +

    Data Table:

    +

    The current data table is keyboard navigable and screen-reader accessible; however, the current data is not comprehensive. We are actively working on improving the data coverage included in the data table in an effort to make this feature more useful for all users.

    +
    +
  6. +
+

+ We sincerely apologize for any difficulties you may encounter while using these specific features. + Please be assured that we have plans in place to address these accessibility limitations and make UpSet2 fully inclusive. +

+

+ To report any accessibility issues you may encounter or to provide suggestions for improvement, please contact us at + {' '} + vdl-faculty@sci.utah.edu + . + We value your feedback and are committed to continuously enhancing the accessibility and usability of our software. +

+

+ Thank you for your understanding and support as we work towards creating software that is accessible to all individuals, regardless of ability. +

+
+ + + +
+ ); }; diff --git a/packages/app/src/components/AttributeDropdown.tsx b/packages/app/src/components/AttributeDropdown.tsx deleted file mode 100644 index 8a9260fe..00000000 --- a/packages/app/src/components/AttributeDropdown.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { - Menu, - Checkbox, - FormControlLabel, - FormGroup, - Typography, - TableRow, - Table, - TableCell, - TableBody, - TableHead, - Container, - TextField, -} from "@mui/material" -import { useContext, useMemo } from "react" -import { ProvenanceContext } from "../App" -import { dataSelector } from "../atoms/dataAtom"; -import { useRecoilValue } from "recoil"; -import { useState } from "react"; -import { DefaultConfig } from "@visdesignlab/upset2-core"; -import { countValuesForAttributes } from "../atoms/selectors"; - -/** - * Dropdown component for selecting attributes. - * @param props - The component props. - * @param props.anchorEl - The anchor element for the dropdown. - * @param props.close - Function to close the dropdown. - * @returns The AttributeDropdown component. - */ -export const AttributeDropdown = (props: {anchorEl: HTMLElement, close: () => void}) => { - const { provenance, actions } = useContext(ProvenanceContext); - const data = useRecoilValue(dataSelector); - const [ checked, setChecked ] = useState( - (data) ? - provenance.getState().visibleAttributes.map( - (attr) => attr - ): - [] - ); - - const [ searchTerm, setSearchTerm ] = useState(""); - const attributes = useMemo( - () => data ? [...DefaultConfig.visibleAttributes, ...data.attributeColumns]: [...DefaultConfig.visibleAttributes], - [data] - ); - const attributeCounts = useRecoilValue(countValuesForAttributes(attributes)); - - /** - * Handle checkbox toggle: add or remove the attribute from the visible attributes - * and update the provenance state and plot. - * @param e - The event object. - */ - const handleToggle = (e: any) => { - const attr = e.labels[0].textContent; - let newChecked = [...checked]; - - if (checked.includes(attr)) { - newChecked = checked.filter((a) => a !== attr); - } else { - newChecked.push(attr); - } - - // move 'Degree' to the first element if it is selected - const degreeIndex = newChecked.indexOf('Degree'); - if (degreeIndex !== -1) { - newChecked.splice(degreeIndex, 1); // Remove 'Degree' from its current position - newChecked.unshift('Degree'); // Move 'Degree' to the beginning - } - - setChecked(newChecked); - actions.addMultipleAttributes(newChecked); - } - - /** - * Handle search input change. - * @param e - The event object. - */ - const handleSearchChange = (e: any) => { - setSearchTerm(e.target.value); - } - - /** - * Get the rows to display in the table. - * @returns The filtered rows based on the search term. - */ - const rows = useMemo(() => { - if (data === undefined || data === null) { - return [] - } - return attributes.map((attr, index) => { - return { - id: index, - attribute: attr, - itemCount: attributeCounts[attr] - } - }).filter((row) => row.attribute.toLowerCase().includes(searchTerm.toLowerCase())) - }, [data, attributes, searchTerm, attributeCounts]); - - return ( - - - - - - - - - Attribute - # Items - - - - {rows.map((row) => { - return ( - - - } label={row.attribute} onChange={(e) => handleToggle(e.target)}/> - - {row.itemCount > 0 ? row.itemCount : ''} - - ) - }) - } - -
-
-
- ) -} \ No newline at end of file diff --git a/packages/app/src/components/Body.tsx b/packages/app/src/components/Body.tsx index 11972041..521610d9 100644 --- a/packages/app/src/components/Body.tsx +++ b/packages/app/src/components/Body.tsx @@ -1,23 +1,26 @@ import { AltText, Upset, getAltTextConfig } from '@visdesignlab/upset2-react'; -import { UpsetConfig } from '@visdesignlab/upset2-core'; +import { CoreUpsetData, UpsetConfig } from '@visdesignlab/upset2-core'; import { useRecoilValue, useRecoilState } from 'recoil'; +import { + useCallback, useContext, useEffect, useState, +} from 'react'; +import { Backdrop, CircularProgress } from '@mui/material'; import { encodedDataAtom } from '../atoms/dataAtom'; import { doesHaveSavedQueryParam, queryParamAtom, saveQueryParam } from '../atoms/queryParamAtom'; import { ErrorModal } from './ErrorModal'; import { ProvenanceContext } from '../App'; -import { useContext, useEffect, useState } from 'react'; import { provenanceVisAtom } from '../atoms/provenanceVisAtom'; import { elementSidebarAtom } from '../atoms/elementSidebarAtom'; import { altTextSidebarAtom } from '../atoms/altTextSidebarAtom'; import { loadingAtom } from '../atoms/loadingAtom'; -import { Backdrop, CircularProgress } from '@mui/material'; import { updateMultinetSession } from '../api/session'; import { generateAltText } from '../api/generateAltText'; import { api } from '../api/api'; import { rowsSelector } from '../atoms/selectors'; +import { FOOTER_HEIGHT } from './Root'; type Props = { - data: any; + data: CoreUpsetData; config?: UpsetConfig; }; @@ -25,31 +28,31 @@ export const Body = ({ data, config }: Props) => { const { workspace, table, sessionId } = useRecoilValue(queryParamAtom); const provObject = useContext(ProvenanceContext); const encodedData = useRecoilValue(encodedDataAtom); - const [ isProvVisOpen, setIsProvVisOpen ] = useRecoilState(provenanceVisAtom); - const [ isElementSidebarOpen, setIsElementSidebarOpen ] = useRecoilState(elementSidebarAtom); - const [ isAltTextSidebarOpen, setIsAltTextSidebarOpen ] = useRecoilState(altTextSidebarAtom); + const [isProvVisOpen, setIsProvVisOpen] = useRecoilState(provenanceVisAtom); + const [isElementSidebarOpen, setIsElementSidebarOpen] = useRecoilState(elementSidebarAtom); + const [isAltTextSidebarOpen, setIsAltTextSidebarOpen] = useRecoilState(altTextSidebarAtom); const loading = useRecoilValue(loadingAtom); const rows = useRecoilValue(rowsSelector); const provVis = { open: isProvVisOpen, - close: () => { setIsProvVisOpen(false) } - } + close: () => { setIsProvVisOpen(false); }, + }; const elementSidebar = { open: isElementSidebarOpen, - close: () => { setIsElementSidebarOpen(false) } - } + close: () => { setIsElementSidebarOpen(false); }, + }; const altTextSidebar = { open: isAltTextSidebarOpen, - close: () => { setIsAltTextSidebarOpen(false) } - } + close: () => { setIsAltTextSidebarOpen(false); }, + }; useEffect(() => { provObject.provenance.currentChange(() => { updateMultinetSession(workspace || '', sessionId || '', provObject.provenance.exportObject()); - }) + }); }, [provObject.provenance, sessionId, workspace]); // Check if the user has permissions to edit the plot @@ -62,8 +65,7 @@ export const Body = ({ data, config }: Props) => { // https://api.multinet.app/swagger/?format=openapi#/definitions/PermissionsReturn for possible permissions returns setPermissions(r.permission_label === 'owner' || r.permission_label === 'maintainer'); } catch (e) { - setPermissions(false) - return; + setPermissions(false); } }; @@ -78,33 +80,33 @@ export const Body = ({ data, config }: Props) => { * @throws Error with descriptive message if an error occurs while generating the alttxt * @returns A promise that resolves to the generated alt text. */ - async function getAltText(): Promise { + const getAltText: () => Promise = useCallback(async () => { const state = provObject.provenance.getState(); // Rows must be cloned to avoid a recoil error triggered far down in this call chain when a function writes rows - const config = getAltTextConfig(state, data, structuredClone(rows)); + const ATConfig = getAltTextConfig(state, data, structuredClone(rows)); - if (config.firstAggregateBy !== "None") { + if (ATConfig.firstAggregateBy !== 'None') { throw new Error("Alt text generation is not yet supported for aggregated plots. To generate an alt text, set aggregation to 'None' in the left sidebar."); } - if (!['Size', 'Degree', 'Deviation'].includes(config.sortBy)) { - throw new Error(`Alt text generation is not yet supported for ${config.sortBy.includes("Set_") ? 'set' : 'attribute'} sorting. To generate an alt text, sort by Size, Degree, or Deviation.`); + if (!['Size', 'Degree', 'Deviation'].includes(ATConfig.sortBy)) { + throw new Error(`Alt text generation is not yet supported for ${ATConfig.sortBy.includes('Set_') ? 'set' : 'attribute'} sorting. To generate an alt text, sort by Size, Degree, or Deviation.`); } let response; try { - response = await generateAltText(config); + response = await generateAltText(ATConfig); } catch (e: any) { if (e.response.status === 500) { - throw Error("Server error while generating alt text. Please try again later. If the issue persists, please contact an UpSet developer at vdl-faculty@sci.utah.edu."); + throw Error('Server error while generating alt text. Please try again later. If the issue persists, please contact an UpSet developer at vdl-faculty@sci.utah.edu.'); } else if (e.response.status === 400) { - throw Error("Error generating alt text. Contact an upset developer at vdl-faculty@sci.utah.edu."); + throw Error('Error generating alt text. Contact an upset developer at vdl-faculty@sci.utah.edu.'); } else { - throw Error("Unknown error while generating alt text: " + e.response.statusText + ". Please contact an UpSet developer at vdl-faculty@sci.utah.edu."); + throw Error(`Unknown error while generating alt text: ${e.response.statusText}. Please contact an UpSet developer at vdl-faculty@sci.utah.edu.`); } } return response.alttxt; - } + }, [provObject.provenance, data, rows]); if (data === null) return null; @@ -118,11 +120,11 @@ export const Body = ({ data, config }: Props) => { } return ( -
+
{ data.setColumns.length === 0 ? - : + :
- + { provVis={provVis} elementSidebar={elementSidebar} altTextSidebar={altTextSidebar} + footerHeight={FOOTER_HEIGHT} generateAltText={getAltText} visualizeUpsetAttributes allowAttributeRemoval /> -
- } +
}
); }; diff --git a/packages/app/src/components/DataTable.tsx b/packages/app/src/components/DataTable.tsx index cbc030e3..adad5bab 100644 --- a/packages/app/src/components/DataTable.tsx +++ b/packages/app/src/components/DataTable.tsx @@ -1,24 +1,26 @@ -import { Backdrop, Box, Button, CircularProgress } from "@mui/material" -import { AccessibleDataEntry, CoreUpsetData, SixNumberSummary } from "@visdesignlab/upset2-core"; -import { useEffect, useMemo, useState } from "react"; +import { + Backdrop, Box, Button, CircularProgress, +} from '@mui/material'; +import { AccessibleDataEntry, CoreUpsetData, SixNumberSummary } from '@visdesignlab/upset2-core'; +import { useEffect, useMemo, useState } from 'react'; import { DataGrid, GridColDef } from '@mui/x-data-grid'; -import { getAccessibleData } from "@visdesignlab/upset2-react"; +import { getAccessibleData } from '@visdesignlab/upset2-react'; import DownloadIcon from '@mui/icons-material/Download'; -import localforage from "localforage"; -import { rowsSelector } from "../atoms/selectors"; -import { useRecoilValue } from "recoil"; +import localforage from 'localforage'; +import { useRecoilValue } from 'recoil'; +import { rowsSelector } from '../atoms/selectors'; const downloadCSS = { - m: "4px", - height: "40%", -} + m: '4px', + height: '40%', +}; const headerCSS = { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - m: "2px" -} + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + m: '2px', +}; /** * Represents the columns configuration for the data table. @@ -32,7 +34,7 @@ const setColumns: GridColDef[] = [ headerName: 'Set', width: 250, editable: false, - description: 'The name of the set.' + description: 'The name of the set.', }, /** * Represents the column for the set size. @@ -42,9 +44,9 @@ const setColumns: GridColDef[] = [ headerName: 'Size', width: 250, editable: false, - description: 'The number of elements within the set.' - } -] + description: 'The number of elements within the set.', + }, +]; /** * Converts an AccessibleDataEntry object into a row data object. @@ -54,18 +56,18 @@ const setColumns: GridColDef[] = [ const getRowData = (row: AccessibleDataEntry) => { const retVal: { [key: string]: any } = { id: row.id, - elementName: `${(row.type === "Aggregate") ? "Aggregate: " : ""}${row.elementName.replaceAll("~&~", " & ")}`, + elementName: `${(row.type === 'Aggregate') ? 'Aggregate: ' : ''}${row.elementName.replaceAll('~&~', ' & ')}`, size: row.size, deviation: row.attributes?.deviation.toFixed(2), - } + }; for (const key in row.attributes) { - if (key === "deviation" || key === "degree") continue; + if (key === 'deviation' || key === 'degree') continue; retVal[key] = (row.attributes[key] as SixNumberSummary).mean?.toFixed(2); } return retVal; -} +}; /** * Retrieves the aggregated rows from the given row of accessible data. @@ -79,13 +81,13 @@ const getAggRows = (row: AccessibleDataEntry) => { Object.values(row.items).forEach((r: AccessibleDataEntry) => { retVal.push(getRowData(r)); - if (r.type === "Aggregate") { + if (r.type === 'Aggregate') { retVal.push(...getAggRows(r)); } }); return retVal; -} +}; /** * Generates a CSV file and downloads it. @@ -99,18 +101,18 @@ function downloadElementsAsCSV(items: any[], columns: string[], name: string) { const saveText: string[] = []; - saveText.push(columns.map(h => (h.includes(',') ? `"${h}"` : h)).join(',')); + saveText.push(columns.map((h) => (h.includes(',') ? `"${h}"` : h)).join(',')); - items.forEach(item => { + items.forEach((item) => { const row: string[] = []; - columns.forEach(col => { + columns.forEach((col) => { row.push(item[col]?.toString() || '-'); }); - saveText.push(row.map(r => (r.includes(',') ? `"${r}"` : r)).join(',')); + saveText.push(row.map((r) => (r.includes(',') ? `"${r}"` : r)).join(',')); }); - + const blob = new Blob([saveText.join('\n')], { type: 'text/csv' }); const blobUrl = URL.createObjectURL(blob); @@ -139,21 +141,19 @@ type DownloadButtonProps = { * @param {Function} props.onClick - The click event handler for the button. * @returns {JSX.Element} The DownloadButton component. */ -const DownloadButton = ({onClick}: DownloadButtonProps) => { - return ( - - ) -} +const DownloadButton = ({ onClick }: DownloadButtonProps) => ( + +); /** * Renders a data table component that displays intersection data, visible sets, and hidden sets. @@ -163,7 +163,7 @@ const DownloadButton = ({onClick}: DownloadButtonProps) => { * @returns The DataTable component. */ export const DataTable = () => { - const [data , setData] = useState(null); + const [data, setData] = useState(null); const flatRows = useRecoilValue(rowsSelector); const [visibleSets, setVisibleSets] = useState(null); const [hiddenSets, setHiddenSets] = useState(null); @@ -179,14 +179,14 @@ export const DataTable = () => { useEffect(() => { setLoading(true); Promise.all([ - localforage.getItem("data"), - localforage.getItem("rows"), - localforage.getItem("visibleSets"), - localforage.getItem("hiddenSets") + localforage.getItem('data'), + localforage.getItem('rows'), + localforage.getItem('visibleSets'), + localforage.getItem('hiddenSets'), ]).then(([storedData, storedRows, storedVisibleSets, storedHiddenSets]) => { if (storedData === null || storedRows === null || storedVisibleSets === null || storedHiddenSets === null) { - console.error("Data not found in local storage") - setError("Error: Data not found in local storage"); + console.error('Data not found in local storage'); + setError('Error: Data not found in local storage'); } else { setData(storedData as CoreUpsetData); setVisibleSets(storedVisibleSets as string[]); @@ -220,15 +220,15 @@ export const DataTable = () => { headerName: 'Size', width: 150, editable: false, - description: 'The number of intersections within the subset or aggregate.' + description: 'The number of intersections within the subset or aggregate.', }, { field: 'deviation', headerName: 'Deviation', width: 150, editable: false, - description: 'The deviation of the intersection from the expected value.' - } + description: 'The deviation of the intersection from the expected value.', + }, ]; // add the attributes to the dataColumns object @@ -237,17 +237,17 @@ export const DataTable = () => { for (const key in r.attributes) { if (!cols.find((m) => m.field === key)) { // skip deviation and degree. Deviation is added above and degree is not needed in the table - if (key === "deviation" || key === "degree") continue; + if (key === 'deviation' || key === 'degree') continue; cols.push({ field: key, headerName: key, width: 150, editable: false, - description: `Attribute: ${key}` + description: `Attribute: ${key}`, }); } } - }) + }); } return cols; @@ -272,7 +272,7 @@ export const DataTable = () => { Object.values(rows.values).forEach((r: AccessibleDataEntry) => { retVal.push(getRowData(r)); - if (r.type === "Aggregate") { + if (r.type === 'Aggregate') { retVal.push(...getAggRows(r)); } }); @@ -290,12 +290,10 @@ export const DataTable = () => { */ const getSetRows = (sets: string[], data: CoreUpsetData) => { const retVal: { setName: string, size: number }[] = []; - retVal.push(...sets.map((s: string) => { - return { id: s, setName: s.replace('Set_', ''), size: data.sets[s].size }; - })); + retVal.push(...sets.map((s: string) => ({ id: s, setName: s.replace('Set_', ''), size: data.sets[s].size }))); return retVal; - } + }; /** * Returns an array of visible set rows. @@ -328,16 +326,16 @@ export const DataTable = () => { return ( <> - { error ? -

{error}

: - - + { error ? +

{error}

: + + - +

Intersection Data

- downloadElementsAsCSV(tableRows, dataColumns.map((m) => m.field), "upset2_intersection_data")} /> + downloadElementsAsCSV(tableRows, dataColumns.map((m) => m.field), 'upset2_intersection_data')} />
{ }} paginationMode="client" rowsPerPageOptions={[5, 10, 20]} - > + />
- +

Visible Sets

- downloadElementsAsCSV(visibleSetRows, ["setName", "size"], "upset2_visiblesets_table")} /> + downloadElementsAsCSV(visibleSetRows, ['setName', 'size'], 'upset2_visiblesets_table')} />
{ }} paginationMode="client" rowsPerPageOptions={[5, 10, 20]} - > + />
- +

Hidden Sets

- downloadElementsAsCSV(hiddenSetRows, ["setName", "size"], "upset2_hiddensets_table")} /> + downloadElementsAsCSV(hiddenSetRows, ['setName', 'size'], 'upset2_hiddensets_table')} />
{ }} paginationMode="client" rowsPerPageOptions={[5, 10, 20]} - > + />
-
- } +
} - ) -} \ No newline at end of file + ); +}; diff --git a/packages/app/src/components/Footer/index.tsx b/packages/app/src/components/Footer/index.tsx index 26b2876b..d0b800ba 100644 --- a/packages/app/src/components/Footer/index.tsx +++ b/packages/app/src/components/Footer/index.tsx @@ -1,82 +1,102 @@ -import { AccessibilityNew, BugReport } from "@mui/icons-material"; -import { Box, Button, Link } from "@mui/material"; -import vdl_logo from "../../assets/vdl_logo.svg"; -import { accessibilityStatementAtom } from "../../atoms/accessibilityStatementAtom"; -import { useRecoilState } from "recoil"; -import { AccessibilityStatement } from "../AccessiblityStatement"; -import { About } from "../About"; -import { aboutAtom } from "../../atoms/aboutAtom"; +import { AccessibilityNew, BugReport } from '@mui/icons-material'; +import { Box, Button, Link } from '@mui/material'; +import { useRecoilState } from 'recoil'; +import { CoreUpsetData } from '@visdesignlab/upset2-core'; +import { FC } from 'react'; +import vdlLogo from '../../assets/vdl_logo.svg'; +import { accessibilityStatementAtom } from '../../atoms/accessibilityStatementAtom'; +import { AccessibilityStatement } from '../AccessiblityStatement'; +import { About } from '../About'; +import { aboutAtom } from '../../atoms/aboutAtom'; +import { FOOTER_HEIGHT } from '../Root'; -const Footer = () => { +/** + * Props for the Footer component + */ +type Props = { + /** The data for this plot */ + data: CoreUpsetData; +} - const categoryCSS = { - display: "flex", - justifyContent: "space-around", - alignItems: "center", - } +/** + * The footer below the plot + */ +const Footer: FC = ({ data }) => { + const categoryCSS = { + display: 'flex', + justifyContent: 'space-around', + alignItems: 'center', + }; - const [ accessibilityStatement, setAccessibilityStatement ] = useRecoilState(accessibilityStatementAtom); - const [ aboutModal, setAboutModal ] = useRecoilState(aboutAtom); + const [accessibilityStatement, setAccessibilityStatement] = useRecoilState(accessibilityStatementAtom); + const [aboutModal, setAboutModal] = useRecoilState(aboutAtom); - return ( - -
- - - - - + return ( + +
+ + + + + - + - {/* Accessibility Statement dialog */} - setAccessibilityStatement(false)} /> - {/* About dialog */} - setAboutModal(false)} /> - -
+ {/* Accessibility Statement dialog */} + setAccessibilityStatement(false)} /> + {/* About dialog */} + setAboutModal(false)} />
- ) -} +
+
+ ); +}; export default Footer; diff --git a/packages/app/src/components/Header/index.tsx b/packages/app/src/components/Header/index.tsx index 1067f70a..22956159 100644 --- a/packages/app/src/components/Header/index.tsx +++ b/packages/app/src/components/Header/index.tsx @@ -1,52 +1,55 @@ -import { exportState, getAccessibleData, downloadSVG } from '@visdesignlab/upset2-react'; -import { Column } from '@visdesignlab/upset2-core'; +import { exportState, downloadSVG } from '@visdesignlab/upset2-react'; +import { Column, CoreUpsetData } from '@visdesignlab/upset2-core'; import { UserSpec } from 'multinet'; import RedoIcon from '@mui/icons-material/Redo'; import UndoIcon from '@mui/icons-material/Undo'; -import MoreVertIcon from '@mui/icons-material/MoreVert'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { AccountCircle, ErrorOutline } from '@mui/icons-material'; -import { AppBar, Avatar, Box, Button, ButtonGroup, IconButton, Menu, MenuItem, Toolbar, Tooltip, Typography } from '@mui/material'; -import { useRecoilValue, useRecoilState, useSetRecoilState } from 'recoil'; -import React, { useContext, useEffect, useState } from 'react'; -import localforage from 'localforage'; -import { getQueryParam, queryParamAtom, saveQueryParam } from '../../atoms/queryParamAtom'; +import { + AppBar, Avatar, Box, Button, ButtonGroup, IconButton, Menu, MenuItem, Toolbar, Tooltip, Typography, +} from '@mui/material'; +import { useRecoilValue, useRecoilState } from 'recoil'; +import React, { + useContext, useEffect, useState, +} from 'react'; +import SettingsIcon from '@mui/icons-material/Settings'; +import { + queryParamAtom, restoreQueryParam, +} from '../../atoms/queryParamAtom'; import { provenanceVisAtom } from '../../atoms/provenanceVisAtom'; import { elementSidebarAtom } from '../../atoms/elementSidebarAtom'; import { ProvenanceContext } from '../../App'; import { ImportModal } from '../ImportModal'; -import { AttributeDropdown } from '../AttributeDropdown'; import { importErrorAtom } from '../../atoms/importErrorAtom'; -import { Link } from 'react-router-dom'; -import { restoreQueryParam } from '../../atoms/queryParamAtom'; import { altTextSidebarAtom } from '../../atoms/altTextSidebarAtom'; -import { loadingAtom } from '../../atoms/loadingAtom'; import { getMultinetDataUrl } from '../../api/getMultinetDataUrl'; import { getUserInfo } from '../../api/getUserInfo'; import { oAuth } from '../../api/auth'; import { rowsSelector } from '../../atoms/selectors'; +import { DataTableLink } from '../../utils/DataTableLink'; +import vdlFlask from '../../assets/vdl_flask.svg'; -const Header = ({ data }: { data: any }) => { +/** + * Header component; displays above the plot + */ +const Header = ({ data }: { data: CoreUpsetData }) => { const { workspace } = useRecoilValue(queryParamAtom); - const [ isProvVisOpen, setIsProvVisOpen ] = useRecoilState(provenanceVisAtom); - const [ isElementSidebarOpen, setIsElementSidebarOpen ] = useRecoilState(elementSidebarAtom); - const [ isAltTextSidebarOpen, setIsAltTextSidebarOpen ] = useRecoilState(altTextSidebarAtom); + const [isProvVisOpen, setIsProvVisOpen] = useRecoilState(provenanceVisAtom); + const [isElementSidebarOpen, setIsElementSidebarOpen] = useRecoilState(elementSidebarAtom); + const [isAltTextSidebarOpen, setIsAltTextSidebarOpen] = useRecoilState(altTextSidebarAtom); const importError = useRecoilValue(importErrorAtom); - const setLoading = useSetRecoilState(loadingAtom); - + const { provenance } = useContext(ProvenanceContext); const rows = useRecoilValue(rowsSelector); - - const [ attributeDialog, setAttributeDialog ] = useState(false); - const [ showImportModal, setShowImportModal ] = useState(false); - const [ isMenuOpen, setIsMenuOpen ] = useState(false); - const [ loginMenuOpen, setLoginMenuOpen ] = useState(false); + + const [showImportModal, setShowImportModal] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [loginMenuOpen, setLoginMenuOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(); - - const visibleSets = provenance.getState().visibleSets; + + const { visibleSets } = provenance.getState(); const hiddenSets = provenance.getState().allSets.filter((set: Column) => !visibleSets.includes(set.name)); - /** + /** * Number of keyboard tab indices in the alttext sidebar; used to calculate the tab index of the other buttons * because alttext sidebar should get priority when open. This should be updated if more tab indices are added. * "Tab indicies" refers to the number of tabIndex properties on elements in the sidebar @@ -56,7 +59,12 @@ const Header = ({ data }: { data: any }) => { const handleImportModalClose = () => { setShowImportModal(false); - } + }; + + const handleMenuOpen = (target: EventTarget) => { + setAnchorEl(target as HTMLElement); + setIsMenuOpen(true); + }; const handleMenuClick = (target: EventTarget) => { handleMenuOpen(target); @@ -68,11 +76,6 @@ const Header = ({ data }: { data: any }) => { } }; - const handleMenuOpen = (target: EventTarget) => { - setAnchorEl(target as HTMLElement); - setIsMenuOpen(true); - }; - const handleMenuClose = () => { setAnchorEl(null); setIsMenuOpen(false); @@ -81,21 +84,11 @@ const Header = ({ data }: { data: any }) => { const handleLoginOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); setLoginMenuOpen(true); - } + }; const handleLoginClose = () => { setAnchorEl(null); setLoginMenuOpen(false); - } - - const handleAttributeClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - setAttributeDialog(true); - }; - - const handleAttributeClose = () => { - setAnchorEl(null); - setAttributeDialog(false); }; const closeAnySidebar = () => { @@ -108,65 +101,66 @@ const Header = ({ data }: { data: any }) => { if (isAltTextSidebarOpen) { setIsAltTextSidebarOpen(false); } - } - - /** - * Dispatches the state by saving relevant data to the local storage. - * This function saves the 'data', 'rows', 'visibleSets', 'hiddenSets', and query parameters to the local storage. - */ - async function dispatchState() { - setLoading(true); - await Promise.all([ - localforage.clear(), - localforage.setItem('data', data), - localforage.setItem('rows', getAccessibleData(rows, true)), - localforage.setItem('visibleSets', visibleSets), - localforage.setItem('hiddenSets', hiddenSets.map((set: Column) => set.name)) - ]); - - saveQueryParam(); - setLoading(false); }; - const [ userInfo, setUserInfo ] = useState(null); - + const [userInfo, setUserInfo] = useState(null); + useEffect(() => { const fetchInfo = async () => { - const userInfo = await getUserInfo(); - setUserInfo(userInfo); - } + const retrievedInfo = await getUserInfo(); + setUserInfo(retrievedInfo); + }; fetchInfo(); - }, []) + }, []); - const [ trrackPosition, setTrrackPosition ] = useState({ + const [trrackPosition, setTrrackPosition] = useState({ isAtLatest: true, - isAtRoot: true + isAtRoot: true, }); useEffect(() => { provenance.currentChange(() => { setTrrackPosition({ isAtLatest: provenance.current.children.length === 0, - isAtRoot: provenance.current.id === provenance.root.id - }) - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - + isAtRoot: provenance.current.id === provenance.root.id, + }); + }); + }, []); + return ( - - - - {/* TEMPORARY REMOVAL UNTIL CHI SUBMISSION */} - {/* */} - - Upset - Visualizing Intersecting Sets + + + + + + Upset — Visualizing Intersecting Sets provenance.undo()} disabled={trrackPosition.isAtRoot} aria-label="Undo"> @@ -177,49 +171,29 @@ const Header = ({ data }: { data: any }) => { - + {data !== null && - <> - - - - - - - } - {attributeDialog && - - } + } {importError && - - - - } + + + } - - - setShowImportModal(true) } color="inherit" aria-label="Import UpSet JSON state file"> - Import State + + { if (window) { window.location.href = getMultinetDataUrl(workspace); } }}> + Load Data + + + + Data Table - exportState(provenance)} color="inherit" aria-label="UpSet JSON state file download"> - Export State - - exportState(provenance, data, rows)} aria-label="Download UpSet JSON state file with table data included"> - Export State + Data - - downloadSVG()} aria-label="SVG Download of this upset plot"> - Download SVG - - { - closeAnySidebar(); + + { + closeAnySidebar(); - setIsProvVisOpen(true); - handleMenuClose(); - }} - aria-label="History tree sidebar" - > - Show History - - + setIsProvVisOpen(true); + handleMenuClose(); + }} + aria-label="History tree sidebar" + > + Show History + + downloadSVG()} aria-label="SVG Download of this upset plot"> + Download SVG + + setShowImportModal(true)} color="inherit" aria-label="Import UpSet JSON state file"> + Import State + + exportState(provenance)} color="inherit" aria-label="UpSet JSON state file download"> + Export State + + exportState(provenance, data, rows)} aria-label="Download UpSet JSON state file with table data included"> + Export State + Data + + { handleLoginOpen(e); }} aria-label="Login menu" aria-haspopup="menu" > - + {userInfo !== null ? `${userInfo.first_name.charAt(0)}${userInfo.last_name.charAt(0)}` - : - } + : } { onClose={handleLoginClose} anchorEl={anchorEl} > - {userInfo === null ? - { - restoreQueryParam(); - oAuth.redirectToLogin(); - }} - >Login - : { + restoreQueryParam(); + oAuth.redirectToLogin(); + }} + > + Login + + : + { oAuth.logout(); window.location.reload(); }} - >Log out - } + > + Log out + } diff --git a/packages/app/src/components/Root.tsx b/packages/app/src/components/Root.tsx index 15b9155e..688bc417 100644 --- a/packages/app/src/components/Root.tsx +++ b/packages/app/src/components/Root.tsx @@ -1,55 +1,46 @@ -import { UpsetActions, UpsetProvenance } from "@visdesignlab/upset2-react" -import { UpsetConfig } from "@visdesignlab/upset2-core" -import { Box, css } from "@mui/material" -import { Body } from "./Body" -import Header from "./Header" -import { useRef, useState, useEffect, useMemo } from "react" -import Footer from "./Footer" -import { Home } from "./Home" +import { UpsetActions, UpsetProvenance } from '@visdesignlab/upset2-react'; +import { CoreUpsetData, UpsetConfig } from '@visdesignlab/upset2-core'; +import { Box, css } from '@mui/material'; +import { + useMemo, +} from 'react'; +import { Body } from './Body'; +import Header from './Header'; +import Footer from './Footer'; +import { Home } from './Home'; type Props = { provenance: UpsetProvenance, actions: UpsetActions, - data: any, + data: CoreUpsetData | null, config?: UpsetConfig } -export const Root = ({provenance, actions, data, config}: Props) => { - const headerDiv = useRef(null); - const [headerHeight, setHeaderHeight] = useState(-1); +/** Height for the footer */ +export const FOOTER_HEIGHT = 46.5; - const AppCss = useMemo(() => { - return css` - overflow: ${data === null ? "auto" : "hidden"}; +export const Root = ({ + provenance, actions, data, config, +}: Props) => { + const AppCss = useMemo(() => css` + overflow: ${data === null ? 'auto' : 'hidden'}; height: 100vh; display: grid; grid-template-rows: min-content auto; - `; - }, [data]); + `, [data]); - useEffect(() => { - const { current } = headerDiv; - if (!current) return; - - if (headerHeight > 0) return; - - setHeaderHeight(current.clientHeight); - }, [headerHeight, headerDiv]); - - return ( -
- theme.zIndex.drawer + 1, - position: 'relative', - }} - ref={headerDiv} - > -
- - {data === null && } - -
-
- ) -} \ No newline at end of file + return ( +
+ theme.zIndex.drawer + 1, + position: 'relative', + }} + > + {data &&
} + + {data ? : } + {data &&
} +
+ ); +}; diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 7b6c8bfc..f9ece5b9 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -18,8 +18,11 @@ code { margin-right: 10px; } -/* Ensure that the Upset title is hidden on smaller screens to not compete with other menu bar items */ -@media only screen and (max-width: 1060px) { +/* + * Ensure that the Upset title is hidden on smaller screens to not compete with other menu bar items + * This value is exact; the title overlaps at narrower widths + */ +@media only screen and (max-width: 820px) { #upset-title { display: none; } diff --git a/packages/app/src/utils/DataTableLink.tsx b/packages/app/src/utils/DataTableLink.tsx new file mode 100644 index 00000000..e5e339ed --- /dev/null +++ b/packages/app/src/utils/DataTableLink.tsx @@ -0,0 +1,78 @@ +import { Column, CoreUpsetData, Rows } from '@visdesignlab/upset2-core'; +import { getAccessibleData } from '@visdesignlab/upset2-react'; +import localforage from 'localforage'; +import { SetterOrUpdater, useRecoilState } from 'recoil'; +import { Link } from 'react-router-dom'; +import { FC, PropsWithChildren } from 'react'; +import { getQueryParam, saveQueryParam } from '../atoms/queryParamAtom'; +import { loadingAtom } from '../atoms/loadingAtom'; + +/** + * Dispatches the plot state to local storage before redirecting to the data table; + * this allows the data table to display without calling the Multinet API again + * @param data The upset data to be stored in the 'data' field + * @param rows The rows to be stored in the 'rows' field + * @param visibleSets The visible sets to be stored in the 'visibleSets' field + * @param hiddenSets The hidden sets to be stored in the 'hiddenSets' field + * @param setLoading The setter for the loadingAtom + */ +async function dispatchState( + data: CoreUpsetData, + rows: Rows, + visibleSets: string[], + hiddenSets: Column[], + setLoading: SetterOrUpdater, +) { + setLoading(true); + await Promise.all([ + localforage.clear(), + localforage.setItem('data', data), + localforage.setItem('rows', getAccessibleData(rows, true)), + localforage.setItem('visibleSets', visibleSets), + localforage.setItem('hiddenSets', hiddenSets.map((set: Column) => set.name)), + ]); + + saveQueryParam(); + setLoading(false); +} + +type Props = { + /** The Upset data to tabulate */ + data: CoreUpsetData; + /** Rows of the plot */ + rows: Rows; + /** Names of visible sets */ + visibleSets: string[]; + /** Names of hidden sets */ + hiddenSets: Column[]; + /** Tab index of the component */ + tabIndex?: number; +} + +/** + * A link to the data table, + * which automatically dispatches the state needed by the table to local storage + * @returns + */ +export const DataTableLink: FC> = ({ + data, rows, visibleSets, hiddenSets, tabIndex, children, +}) => { + const [loading, setLoading] = useRecoilState(loadingAtom); + return loading ? ( +

Loading...

+ ) : ( + { + dispatchState(data, rows, visibleSets, hiddenSets, setLoading); + }} + style={{ textDecoration: 'none', color: 'inherit' }} + aria-label="Data Tables (raw and computed)" + tabIndex={tabIndex} + > + {children} + + ); +}; diff --git a/packages/core/src/convertConfig.ts b/packages/core/src/convertConfig.ts index 9098aec7..c4eea08e 100644 --- a/packages/core/src/convertConfig.ts +++ b/packages/core/src/convertConfig.ts @@ -1,8 +1,12 @@ import { AggregateBy, Bookmark, NumericalBookmark, Column, ColumnName, Histogram, PlotInformation, Row, Scatterplot, SortByOrder, SortVisibleBy, UpsetConfig, + AttributePlots, + ElementSelection, + AltText, } from './types'; import { isUpsetConfig } from './typecheck'; +import { DefaultConfig } from './defaultConfig'; /** * Developer notes: @@ -13,7 +17,7 @@ import { isUpsetConfig } from './typecheck'; * 2. Make your changes to UpsetConfig in types.ts * 3. Change the return type of the most recent version conversion function to the old type that you copied. * 4. Implement a version conversion function that takes the old type and returns the new type (UpsetConfig). - * This function must modify the input config in place. + * This function must modify the input config in place; it should not create a new object. * 5. Add a new case to the switch statement in convertConfig. * - The case version number should be the current version number, as your conversion function starts * with the previous version (which the current version will become) and converts it to the current version. @@ -23,6 +27,39 @@ import { isUpsetConfig } from './typecheck'; * 7. Bump the version number in the UpsetConfig type, all package.json files, the README, and defaultConfig.ts. */ +type Version0_1_0 = { + plotInformation: PlotInformation; + horizontal: boolean; + firstAggregateBy: AggregateBy; + firstOverlapDegree: number; + secondAggregateBy: AggregateBy; + secondOverlapDegree: number; + sortVisibleBy: SortVisibleBy; + sortBy: string; + sortByOrder: SortByOrder; + filters: { + maxVisible: number; + minVisible: number; + hideEmpty: boolean; + hideNoSet: boolean; + }; + visibleSets: ColumnName[]; + visibleAttributes: ColumnName[]; + attributePlots: AttributePlots; + bookmarks: Bookmark[]; + collapsed: string[]; + plots: { + scatterplots: Scatterplot[]; + histograms: Histogram[]; + }; + allSets: Column[]; + selected: Row | null; + elementSelection: ElementSelection | null; + version: '0.1.0'; + useUserAlt: boolean; + userAltText: AltText | null; +} + /** * Config type before versioning was implemented. */ @@ -55,28 +92,42 @@ type PreVersionConfig = { elementSelection: NumericalBookmark | null; }; +/** + * Converts a 0.1.0 config to the current version. + * @param config The config to convert. + * @returns The converted config. + */ +// eslint-disable-next-line camelcase +function convert0_1_0(config: Version0_1_0): UpsetConfig { + (config as unknown as UpsetConfig).version = '0.1.1'; + (config as unknown as UpsetConfig).intersectionSizeLabels = DefaultConfig.intersectionSizeLabels; + (config as unknown as UpsetConfig).setSizeLabels = DefaultConfig.setSizeLabels; + (config as unknown as UpsetConfig).showHiddenSets = DefaultConfig.showHiddenSets; + return (config as unknown as UpsetConfig); +} + /** * Converts a pre-versioned config to the current version. * @param config The config to convert. * @returns The converted config. */ -function preVersionConversion(config: PreVersionConfig): UpsetConfig { +function preVersionConversion(config: PreVersionConfig): Version0_1_0 { // TS won't allow a conversion directly to UpsetConfig, so we have to cast it to unknown first. // This is necessary to add and remove properties from the object. - (config as unknown as UpsetConfig).version = '0.1.0'; - (config as unknown as UpsetConfig).elementSelection = null; - (config as unknown as UpsetConfig).bookmarks = config.bookmarkedIntersections; - (config as unknown as UpsetConfig).useUserAlt = false; - (config as unknown as UpsetConfig).userAltText = null; - (config as unknown as UpsetConfig).attributePlots = {}; + (config as unknown as Version0_1_0).version = '0.1.0'; + (config as unknown as Version0_1_0).elementSelection = DefaultConfig.elementSelection; + (config as unknown as Version0_1_0).bookmarks = config.bookmarkedIntersections; + (config as unknown as Version0_1_0).useUserAlt = DefaultConfig.useUserAlt; + (config as unknown as Version0_1_0).userAltText = DefaultConfig.userAltText; + (config as unknown as Version0_1_0).attributePlots = DefaultConfig.attributePlots; // Any cast required because bookmarkedIntersections isn't optional in PreversionConfig delete (config as any).bookmarkedIntersections; - (config as unknown as UpsetConfig).bookmarks.forEach((bookmark) => { + (config as unknown as Version0_1_0).bookmarks.forEach((bookmark) => { bookmark.type = 'intersection'; }); - return config as unknown as UpsetConfig; + return config as unknown as Version0_1_0; } /** @@ -93,15 +144,16 @@ export function convertConfig(config: unknown): UpsetConfig { if (!Object.hasOwn(config, 'version')) preVersionConversion(config as PreVersionConfig); /* eslint-disable no-void */ + /* eslint-disable no-fallthrough */ // Switch case is designed to fallthrough to the next version's conversion function // so that all versions are converted cumulatively. switch ((config as {version: string}).version) { case '0.1.0': - void 0; // This will be replaced by the next version's conversion function - // falls through + convert0_1_0(config as Version0_1_0); default: void 0; } + /* eslint-enable no-fallthrough */ /* eslint-enable no-void */ if (!isUpsetConfig(config)) { diff --git a/packages/core/src/defaultConfig.ts b/packages/core/src/defaultConfig.ts index 9fe6bb99..2eb87d35 100644 --- a/packages/core/src/defaultConfig.ts +++ b/packages/core/src/defaultConfig.ts @@ -37,5 +37,8 @@ export const DefaultConfig: UpsetConfig = { useUserAlt: false, userAltText: null, elementSelection: null, - version: '0.1.0', + version: '0.1.1', + intersectionSizeLabels: true, + setSizeLabels: true, + showHiddenSets: true, }; diff --git a/packages/core/src/typecheck.ts b/packages/core/src/typecheck.ts index e2f41169..82252755 100644 --- a/packages/core/src/typecheck.ts +++ b/packages/core/src/typecheck.ts @@ -352,6 +352,9 @@ export function isUpsetConfig(config: unknown): config is UpsetConfig { && Object.hasOwn(config, 'version') && Object.hasOwn(config, 'useUserAlt') && Object.hasOwn(config, 'userAltText') + && Object.hasOwn(config, 'intersectionSizeLabels') + && Object.hasOwn(config, 'setSizeLabels') + && Object.hasOwn(config, 'showHiddenSets') )) { console.warn('Upset config is missing required fields'); return false; @@ -361,7 +364,8 @@ export function isUpsetConfig(config: unknown): config is UpsetConfig { const { plotInformation, horizontal, firstAggregateBy, firstOverlapDegree, secondAggregateBy, secondOverlapDegree, sortVisibleBy, sortBy, sortByOrder, filters, visibleSets, visibleAttributes, attributePlots, bookmarks, collapsed, - plots, allSets, selected, elementSelection, version, useUserAlt, userAltText, + plots, allSets, selected, elementSelection, version, useUserAlt, userAltText, intersectionSizeLabels, setSizeLabels, + showHiddenSets, } = config as UpsetConfig; // Check that the fields are of the correct type @@ -546,7 +550,7 @@ export function isUpsetConfig(config: unknown): config is UpsetConfig { } // version - if (version !== '0.1.0') { + if (version !== '0.1.1') { console.warn('Upset config error: Invalid version'); return false; } @@ -569,6 +573,24 @@ export function isUpsetConfig(config: unknown): config is UpsetConfig { return false; } + // intersectionSizeLabels + if (typeof intersectionSizeLabels !== 'boolean') { + console.warn('Upset config error: Intersection size labels is not a boolean'); + return false; + } + + // setSizeLabels + if (typeof setSizeLabels !== 'boolean') { + console.warn('Upset config error: Set size labels is not a boolean'); + return false; + } + + // showHiddenSets + if (typeof showHiddenSets !== 'boolean') { + console.warn('Upset config error: Show hidden sets is not a boolean'); + return false; + } + return true; /* eslint-enable no-console */ } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c1be753b..34d22888 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -472,9 +472,21 @@ export type UpsetConfig = { * Selected elements (data points) in the Element View. */ elementSelection: ElementSelection | null; - version: '0.1.0'; + version: '0.1.1'; useUserAlt: boolean; userAltText: AltText | null; + /** + * Whether to display numerical size labels on the intersection size bars. + */ + intersectionSizeLabels: boolean; + /** + * Whether to display numerical size labels on the set size bars. + */ + setSizeLabels: boolean; + /** + * Whether to display the hidden sets & their sizes above the plot + */ + showHiddenSets: boolean; }; export type AccessibleDataEntry = { diff --git a/packages/upset/src/atoms/attributeAtom.ts b/packages/upset/src/atoms/attributeAtom.ts index 4fc9b04e..ca95ad5e 100644 --- a/packages/upset/src/atoms/attributeAtom.ts +++ b/packages/upset/src/atoms/attributeAtom.ts @@ -1,12 +1,26 @@ -import { atom, selectorFamily } from 'recoil'; +import { atom, selector, selectorFamily } from 'recoil'; import { itemsAtom } from './itemsAtoms'; +/** + * All attributes, including degree and deviation + */ export const attributeAtom = atom({ key: 'attribute-columns', default: [], }); +/** + * All attribute columns except Degree and Deviation + */ +export const dataAttributeSelector = selector({ + key: 'data-attribute-columns', + get: ({ get }) => { + const atts = get(attributeAtom); + return atts.filter((att) => att !== 'Degree' && att !== 'Deviation'); + }, +}); + /** * Gets all non-NaN values for a given attribute * @param {string} attribute Attribute name diff --git a/packages/upset/src/atoms/config/displayAtoms.ts b/packages/upset/src/atoms/config/displayAtoms.ts new file mode 100644 index 00000000..28a5f068 --- /dev/null +++ b/packages/upset/src/atoms/config/displayAtoms.ts @@ -0,0 +1,26 @@ +import { selector } from 'recoil'; +import { upsetConfigAtom } from './upsetConfigAtoms'; + +/** + * Whether to show the intersection size labels; from the config. + */ +export const showIntersectionSizesSelector = selector({ + key: 'show-intersection-size-labels', + get: ({ get }) => get(upsetConfigAtom).intersectionSizeLabels, +}); + +/** + * Whether to show the set size labels; from the config. + */ +export const showSetSizesSelector = selector({ + key: 'show-set-size-labels', + get: ({ get }) => get(upsetConfigAtom).setSizeLabels, +}); + +/** + * Whether to show hidden sets; from the config. + */ +export const showHiddenSetsSelector = selector({ + key: 'show-hidden-sets', + get: ({ get }) => get(upsetConfigAtom).showHiddenSets, +}); diff --git a/packages/upset/src/atoms/dimensionsAtom.ts b/packages/upset/src/atoms/dimensionsAtom.ts index 92c537ba..7c38029f 100644 --- a/packages/upset/src/atoms/dimensionsAtom.ts +++ b/packages/upset/src/atoms/dimensionsAtom.ts @@ -1,4 +1,4 @@ -import { selector } from 'recoil'; +import { atom, selector } from 'recoil'; import { calculateDimensions } from '../dimensions'; import { visibleAttributesSelector } from './config/visibleAttributes'; import { hiddenSetSelector, visibleSetSelector } from './config/visibleSetsAtoms'; @@ -22,3 +22,14 @@ ReturnType ); }, }); + +/** + * The spacing height necessary to prevent Upset sidebars from overlapping the footer in px. + * This is some multiple of the footer height provided to the Upset component; + * I don't know why it has to be multiplied but it does. + * @default 'auto' + */ +export const footerHeightAtom = atom({ + key: 'footerHeight', + default: 0, +}); diff --git a/packages/upset/src/components/AltTextSidebar.tsx b/packages/upset/src/components/AltTextSidebar.tsx index 83dca27c..5d1852d2 100644 --- a/packages/upset/src/components/AltTextSidebar.tsx +++ b/packages/upset/src/components/AltTextSidebar.tsx @@ -1,15 +1,11 @@ import { Box, Button, - Divider, - Drawer, Icon, TextField, Typography, - css, } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; -import CloseIcon from '@mui/icons-material/Close'; import { useState, useEffect, FC, useContext, useMemo, @@ -25,6 +21,8 @@ import { PlotInformation } from './custom/PlotInformation'; import { UpsetActions } from '../provenance'; import { plotInformationSelector } from '../atoms/config/plotInformationAtom'; import { canEditPlotInformationAtom } from '../atoms/config/canEditPlotInformationAtom'; +import { Sidebar } from './custom/Sidebar'; +import { UpsetHeading } from './custom/theme/heading'; /** * Props for the AltTextSidebar component. @@ -47,8 +45,6 @@ type Props = { generateAltText: () => Promise; } -const initialDrawerWidth = 450; - /** * Displays a sidebar for generating alternative text * and editing the alt text, caption, title, and plot information. @@ -148,15 +144,6 @@ export const AltTextSidebar: FC = ({ open, close, generateAltText }) => { return userShortText ?? altText?.shortDescription ?? ''; }, [useLong, userLongText, userShortText, altText?.shortDescription, altText?.longDescription]); - const divider = ; - /** * Whether to display the plot information section */ @@ -166,48 +153,16 @@ export const AltTextSidebar: FC = ({ open, close, generateAltText }) => { ); return ( - -
-
- - - {displayPlotInfo ? plotInfo.title ?? 'Editing Plot Information' : 'Text Description'} - - - - {divider} - {displayPlotInfo && + + {displayPlotInfo ? plotInfo.title ?? 'Editing Plot Information' : 'Text Description'} + + {displayPlotInfo && // We only want to display plotInfo if the user is editing OR if they've entered some field other than title (plotInfoEditing || Object.entries(plotInfo).filter(([k, _]) => k !== 'title').some(([_, v]) => !!v)) ? ( = ({ open, close, generateAltText }) => { editing={plotInfoEditing} setEditing={setPlotInfoEditing} /> - ) : ( - // only show "Add Plot Information" if the user has edit permissions - canEditPlotInformation ? ( + ) : ( + // only show "Add Plot Information" if the user has edit permissions + canEditPlotInformation ? ( + + ) : null + )} + {displayPlotInfo && ( + Text Description + )} + {/* 0.875em for default 16px = 1em makes 14px, which is the standard for much of the UI */} + + {textGenErr && !userLongText && !userShortText ? ( + {textGenErr} + ) : ( + textEditing ? ( + <> - ) : null - )} - {displayPlotInfo && ( - <> - - Text Description - - {divider} - - )} - - {textGenErr && !userLongText && !userShortText ? ( - {textGenErr} + +
+ (useLong ? setUserLongText(e.target.value) : setUserShortText(e.target.value))} + value={(displayAltText)} + tabIndex={6} + aria-flowto="saveAltTextButton" + /> +
+ ) : ( - textEditing ? ( - <> - - -
- (useLong ? setUserLongText(e.target.value) : setUserShortText(e.target.value))} - value={(displayAltText)} - tabIndex={6} - aria-flowto="saveAltTextButton" - /> -
- - ) : ( - + {canEditPlotInformation && ( + // Only show the edit button if the user has edit permissions + - )} - - - ) - )} - -
-
-
+ + + + + )} + +
+ ) + )} + +
+ ); }; diff --git a/packages/upset/src/components/Columns/SizeBar.tsx b/packages/upset/src/components/Columns/SizeBar.tsx index ca4fd915..e069bab9 100644 --- a/packages/upset/src/components/Columns/SizeBar.tsx +++ b/packages/upset/src/components/Columns/SizeBar.tsx @@ -10,6 +10,7 @@ import { maxSize } from '../../atoms/maxSizeAtom'; import { useScale } from '../../hooks/useScale'; import translate from '../../utils/transform'; import { newShade } from '../../utils/colors'; +import { showIntersectionSizesSelector } from '../../atoms/config/displayAtoms'; /** * A bar that represents the size of a row in the upset plot. @@ -47,6 +48,9 @@ type Rect = { const colors = ['rgb(189, 189, 189)', 'rgb(136, 136, 136)', 'rgb(37, 37, 37)']; +/** + * Size bar for a row in the upset plot, showing number of elements in the subset. + */ export const SizeBar: FC = ({ row, size, selected }) => { const dimensions = useRecoilValue(dimensionsSelector); const sizeDomain = useRecoilValue(maxSize); @@ -55,6 +59,7 @@ export const SizeBar: FC = ({ row, size, selected }) => { const bookmarkedColorPallete = useRecoilValue(bookmarkedColorPalette); const nextColor = useRecoilValue(nextColorSelector); const elementSelectionColor = useRecoilValue(elementColorSelector); + const showText = useRecoilValue(showIntersectionSizesSelector); /* * Constants @@ -294,16 +299,18 @@ export const SizeBar: FC = ({ row, size, selected }) => { /> )} - 0 ? dimensions.attribute.width : sizeWidth) + 5, - dimensions.body.rowHeight / 2, - )} - > - {size} - + {showText && ( + 0 ? dimensions.attribute.width : sizeWidth) + 5, + dimensions.body.rowHeight / 2, + )} + > + {size} + + )} ); }; diff --git a/packages/upset/src/components/ElementView/AddPlot.tsx b/packages/upset/src/components/ElementView/AddPlot.tsx index 7097aa71..0efb8778 100644 --- a/packages/upset/src/components/ElementView/AddPlot.tsx +++ b/packages/upset/src/components/ElementView/AddPlot.tsx @@ -14,7 +14,7 @@ import { import { FC, useContext, useState } from 'react'; import { useRecoilValue } from 'recoil'; -import { attributeAtom } from '../../atoms/attributeAtom'; +import { attributeAtom, dataAttributeSelector } from '../../atoms/attributeAtom'; import { itemsAtom } from '../../atoms/itemsAtoms'; import { ProvenanceContext } from '../Root'; import { HistogramPlot } from './HistogramPlot'; @@ -140,7 +140,7 @@ export const AddScatterplot: FC = ({ handleClose }) => { export const AddHistogram: FC = ({ handleClose }) => { const { actions } = useContext(ProvenanceContext); const items = useRecoilValue(itemsAtom); - const attributeColumns = useRecoilValue(attributeAtom); + const attributeColumns = useRecoilValue(dataAttributeSelector); const [attribute, setAttribute] = useState(attributeColumns[0]); const [bins, setBins] = useState(20); // Frequency plots are temporarily disabled, see comment further down diff --git a/packages/upset/src/components/ElementView/ElementSidebar.tsx b/packages/upset/src/components/ElementView/ElementSidebar.tsx index 4df141a9..e6ea3c11 100644 --- a/packages/upset/src/components/ElementView/ElementSidebar.tsx +++ b/packages/upset/src/components/ElementView/ElementSidebar.tsx @@ -1,16 +1,11 @@ -import OpenInFullIcon from '@mui/icons-material/OpenInFull'; -import CloseFullscreen from '@mui/icons-material/CloseFullscreen'; import DownloadIcon from '@mui/icons-material/Download'; -import CloseIcon from '@mui/icons-material/Close'; import { - Box, Divider, Drawer, IconButton, Tooltip, Typography, css, + IconButton, Tooltip, } from '@mui/material'; import { Item } from '@visdesignlab/upset2-core'; -import React, { - useCallback, useContext, useEffect, useState, -} from 'react'; import { useRecoilValue } from 'recoil'; +import { useMemo } from 'react'; import { columnsAtom } from '../../atoms/columnAtom'; import { selectedElementSelector, selectedItemsCounter, @@ -19,10 +14,10 @@ import { import { BookmarkChips } from './BookmarkChips'; import { ElementTable } from './ElementTable'; import { ElementVisualization } from './ElementVisualization'; -import { UpsetActions } from '../../provenance'; -import { ProvenanceContext } from '../Root'; import { QueryInterface } from './QueryInterface'; import { bookmarkSelector, currentIntersectionSelector } from '../../atoms/config/currentIntersectionAtom'; +import { Sidebar } from '../custom/Sidebar'; +import { UpsetHeading } from '../custom/theme/heading'; /** * Props for the ElementSidebar component @@ -34,15 +29,6 @@ type Props = { close: () => void } -/** - * The *exact* width at which we don't get a horizontal scrollbar in the table controls - */ -const initialDrawerWidth = 462; -/** - * The *exact* width at which the 'apply' button in the element query controls is forced onto a new line - */ -const minDrawerWidth = 368; - /** * Immediately downloads a csv containing items with the given columns * @param items Rows to download @@ -85,156 +71,43 @@ function downloadElementsAsCSV(items: Item[], columns: string[], name: string) { * @param close Function to close the sidebar */ export const ElementSidebar = ({ open, close }: Props) => { - const [fullWidth, setFullWidth] = useState(false); - const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth); const currentElementSelection = useRecoilValue(selectedElementSelector); const selectedItems = useRecoilValue(selectedItemsSelector); const itemCount = useRecoilValue(selectedItemsCounter); const columns = useRecoilValue(columnsAtom); - const [hideElementSidebar, setHideElementSidebar] = useState(!open); - const { actions }: {actions: UpsetActions} = useContext(ProvenanceContext); const bookmarked = useRecoilValue(bookmarkSelector); const currentIntersection = useRecoilValue(currentIntersectionSelector); - /** - * Effects - */ - - useEffect(() => { - setHideElementSidebar(!open); - }, [open]); - - /** - * Callbacks - */ - - const handleMouseMove = useCallback((e: MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - const newWidth = document.body.clientWidth - e.clientX; - - if (newWidth > minDrawerWidth) { - setDrawerWidth(newWidth); - } - }, []); - - const handleMouseUp = useCallback(() => { - document.removeEventListener('mouseup', handleMouseUp, true); - document.removeEventListener('mousemove', handleMouseMove, true); - }, [handleMouseMove]); - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - document.addEventListener('mouseup', handleMouseUp, true); - document.addEventListener('mousemove', handleMouseMove, true); - }, - [handleMouseUp, handleMouseMove], + /** Whether to show the 'Element Queries' section */ + const showQueries = useMemo( + () => bookmarked.length > 0 || currentIntersection || currentElementSelection, + [bookmarked.length, currentIntersection, currentElementSelection], ); return ( - - handleMouseDown(e)} - /> -
- { !fullWidth ? - { - setFullWidth(true); - }} - aria-label="Expand the sidebar in full screen" - > - - - : - { - if (fullWidth) { - setFullWidth(false); - } else { - setHideElementSidebar(true); - } - }} - aria-label="Reduce the sidebar to normal size" - > - - } - { - setHideElementSidebar(true); - actions.setElementSelection(currentElementSelection); - close(); - }} - aria-label="Close the sidebar" - > - - -
+
- + Element View - - +
- {(bookmarked.length > 0 || currentIntersection || currentElementSelection) && ( + {showQueries && ( <> - + Bookmarked Queries - - + )} - + Element Visualization - - + - + Element Queries - - + - + Query Result { currentElementSelection?.label ?? 'upset_elements', ); }} + // This needs to stay shorter than the h2 text or the divider spacing gets off + style={{ height: '1.2em' }} > - - + -
+ ); }; diff --git a/packages/upset/src/components/Header/AttributeButton.tsx b/packages/upset/src/components/Header/AttributeButton.tsx index cd694779..411aa434 100644 --- a/packages/upset/src/components/Header/AttributeButton.tsx +++ b/packages/upset/src/components/Header/AttributeButton.tsx @@ -190,6 +190,7 @@ export const AttributeButton: FC = ({ label, tooltip }) => { pointerEvents="default" dominantBaseline="middle" textAnchor="middle" + transform={translate(0, 1)} // Vertical centering correction > {label} diff --git a/packages/upset/src/components/Header/HiddenSets.tsx b/packages/upset/src/components/Header/HiddenSets.tsx index e8323ca3..73ec2c56 100644 --- a/packages/upset/src/components/Header/HiddenSets.tsx +++ b/packages/upset/src/components/Header/HiddenSets.tsx @@ -98,6 +98,7 @@ export const HiddenSets: FC = ({ hiddenSets, scale }) => { foregroundOpacity={0.4} label={sets[item.id].elementName} showLabel + hideSizeText /> ))} diff --git a/packages/upset/src/components/Header/MatrixHeader.tsx b/packages/upset/src/components/Header/MatrixHeader.tsx index 03d4dc2f..858004a5 100644 --- a/packages/upset/src/components/Header/MatrixHeader.tsx +++ b/packages/upset/src/components/Header/MatrixHeader.tsx @@ -8,12 +8,17 @@ import { useScale } from '../../hooks/useScale'; import { SetHeader } from './SetHeader'; import { HiddenSets } from './HiddenSets'; import translate from '../../utils/transform'; +import { showHiddenSetsSelector } from '../../atoms/config/displayAtoms'; +/** + * Header for the plot: shows both visible and hidden sets at the top of the page + */ export const MatrixHeader = () => { const visibleSets = useRecoilValue(visibleSetSelector); const dimensions = useRecoilValue(dimensionsSelector); const maxSize = useRecoilValue(maxSetSizeSelector); const hiddenSets = useRecoilValue(hiddenSetSelector); + const showHiddenSets = useRecoilValue(showHiddenSetsSelector); const { set } = dimensions; @@ -22,31 +27,33 @@ export const MatrixHeader = () => { return ( <> - -
- - - -
-
+
+ + + +
+ + )} ); }; diff --git a/packages/upset/src/components/Header/SetHeader.tsx b/packages/upset/src/components/Header/SetHeader.tsx index 208fee55..00391f9e 100644 --- a/packages/upset/src/components/Header/SetHeader.tsx +++ b/packages/upset/src/components/Header/SetHeader.tsx @@ -31,7 +31,7 @@ type Props = { }; /** - * Renders the header component for the set. + * Renders a header for the set matrix column, showing each set and its size. * * @component * @param {Object} props - The component props. @@ -109,7 +109,7 @@ export const SetHeader: FC = ({ visibleSets, scale }) => { /** * Opens the context menu for a given setName. - * + * * @param e - The mouse event that triggered the context menu. * @param setName - The name of the set. */ diff --git a/packages/upset/src/components/Header/SizeHeader.tsx b/packages/upset/src/components/Header/SizeHeader.tsx index 9a16c751..fc9b6171 100644 --- a/packages/upset/src/components/Header/SizeHeader.tsx +++ b/packages/upset/src/components/Header/SizeHeader.tsx @@ -272,6 +272,7 @@ export const SizeHeader: FC = () => { `} dominantBaseline="middle" textAnchor="middle" + transform={translate(0, 1)} // Vertical centering correction > Size diff --git a/packages/upset/src/components/ProvenanceVis.tsx b/packages/upset/src/components/ProvenanceVis.tsx index d7348172..23b11e3a 100644 --- a/packages/upset/src/components/ProvenanceVis.tsx +++ b/packages/upset/src/components/ProvenanceVis.tsx @@ -2,19 +2,18 @@ import { useContext, useState, useEffect, useMemo, } from 'react'; import { ProvVis } from '@trrack/vis-react'; -import { - Divider, Drawer, IconButton, Typography, css, -} from '@mui/material'; -import CloseIcon from '@mui/icons-material/Close'; import { ProvenanceContext } from './Root'; +import { UpsetHeading } from './custom/theme/heading'; +import { Sidebar } from './custom/Sidebar'; type Props = { open: boolean, close: () => void } -const initialDrawerWidth = 450; - +/** + * Sidebar containing the Trrack provenance visualization. + */ export const ProvenanceVis = ({ open, close }: Props) => { const { provenance } = useContext(ProvenanceContext); const [currentNodeId, setCurrentNodeId] = useState(provenance.current.id); @@ -38,50 +37,16 @@ export const ProvenanceVis = ({ open, close }: Props) => { }, [provenance.root.id, provenance.to, provenance.graph.backend.nodes, currentNodeId]); return ( - -
-
- - History - - - - -
- - {provVis} -
-
+ + History + + {provVis} + ); }; diff --git a/packages/upset/src/components/Root.tsx b/packages/upset/src/components/Root.tsx index 23a5d719..b1105223 100644 --- a/packages/upset/src/components/Root.tsx +++ b/packages/upset/src/components/Root.tsx @@ -21,12 +21,13 @@ import { import { Body } from './Body'; import { ElementSidebar } from './ElementView/ElementSidebar'; import { Header } from './Header/Header'; -import { Sidebar } from './Sidebar'; +import { SettingsSidebar } from './SettingsSidebar'; import { SvgBase } from './SvgBase'; import { ContextMenu } from './ContextMenu'; import { ProvenanceVis } from './ProvenanceVis'; import { AltTextSidebar } from './AltTextSidebar'; import { AltText } from '../types'; +import { footerHeightAtom } from '../atoms/dimensionsAtom'; export const ProvenanceContext = createContext<{ provenance: UpsetProvenance; @@ -35,6 +36,7 @@ export const ProvenanceContext = createContext<{ const baseStyle = css` padding: 0.25em; + padding-left: 0; `; type Props = { @@ -59,11 +61,12 @@ type Props = { open: boolean; close: () => void; }; + footerHeight?: number; generateAltText?: () => Promise; }; export const Root: FC = ({ - data, config, allowAttributeRemoval, hideSettings, canEditPlotInformation, extProvenance, provVis, elementSidebar, altTextSidebar, generateAltText, + data, config, allowAttributeRemoval, hideSettings, canEditPlotInformation, extProvenance, provVis, elementSidebar, altTextSidebar, footerHeight, generateAltText, }) => { // Get setter for recoil config atom const setState = useSetRecoilState(upsetConfigAtom); @@ -114,7 +117,7 @@ export const Root: FC = ({ useEffect(() => { setSets(data.sets); setItems(data.items); - setAttributeColumns(data.attributeColumns); + setAttributeColumns(['Degree', 'Deviation', ...data.attributeColumns]); setAllColumns(data.columns); setData(data); // if it is defined, pass through the provided value, else, default to true @@ -134,6 +137,11 @@ export const Root: FC = ({ }; }, []); + // Sets the footer height atom if provided as an argument + const setFooterHeight = useSetRecoilState(footerHeightAtom); + // Footer height needs to be doubled to work right... idk why that is! + useEffect(() => { if (footerHeight) setFooterHeight(2 * footerHeight); }, [footerHeight]); + if (Object.keys(sets).length === 0 || Object.keys(items).length === 0) return null; return ( @@ -147,7 +155,7 @@ export const Root: FC = ({ ${baseStyle}; `} > - + }

!old.includes(s)); + const removed = old.filter((s) => !current.includes(s)); + return { added, removed }; +} + +/** + * Props for the toggle switch + */ +type ToggleProps = { + /** A short title for the toggle switch's functionality; displays directly to the user */ + shortLabel: string; + /** A longer description of the functionality; used for the help circle text */ + longLabel: string; + /** Whether the toggle switch is currently checked */ + checked: boolean; + /** The function to call when the toggle changes */ + onChange: (ev: React.ChangeEvent | React.KeyboardEvent) => void; +} + +/** + * A toggle switch for a boolean setting + */ +const ToggleSwitch: FC = ({ + shortLabel, longLabel, checked, onChange, +}: ToggleProps) => ( + { + if (e.key === 'Enter') { + onChange(e); + } + }} + > + + } + labelPlacement="start" + /> + + +); + +/** + * Settings sidebar; appears to the left of the plot + */ +export const SettingsSidebar = () => { + const { actions }: {actions: UpsetActions} = useContext( + ProvenanceContext, + ); + + const visibleSets = useRecoilValue(visibleSetSelector); + const allSets = useRecoilValue(setsAtom); + const allAtts = useRecoilValue(attributeAtom); + const visibleAtts = useRecoilValue(visibleAttributesSelector); + + const showIntersectionSizes = useRecoilValue(showIntersectionSizesSelector); + const showSetSizes = useRecoilValue(showSetSizesSelector); + const showHiddenSets = useRecoilValue(showHiddenSetsSelector); + + const firstAggregateBy = useRecoilValue(firstAggregateSelector); + const firstOverlapDegree = useRecoilValue(firstOvelapDegreeSelector); + const secondAggregateBy = useRecoilValue(secondAggregateSelector); + const secondOverlapDegree = useRecoilValue(secondOverlapDegreeSelector); + + const maxVisible = useRecoilValue(maxVisibleSelector); + const minVisible = useRecoilValue(minVisibleSelector); + const hideEmpty = useRecoilValue(hideEmptySelector); + const hideNoSet = useRecoilValue(hideNoSetSelector); + const dimensions = useRecoilValue(dimensionsSelector); + const footerHeight = useRecoilValue(footerHeightAtom); + + const [secondaryAccordionOpen, setSecondaryAccordionOpen] = useState( + secondAggregateBy !== 'None', + ); + const [collapsed, setCollapsed] = useState(false); + + const [degreeFilters, setDegreeFilters] = useState([minVisible, maxVisible]); + // Tracking the previous state of the filters to avoid unnecessary updates + const [prevFilters, setPrevFilters] = useState([minVisible, maxVisible]); + + useEffect(() => { + if (firstAggregateBy === 'None') { + setSecondaryAccordionOpen(false); + } + }, [firstAggregateBy]); + + /** + * Handles a change in the visible sets multiselect by adding or removing the sets that changed + */ + const handleSetChange = useCallback((event: SelectChangeEvent) => { + const newSets = ([] as string[]).concat(...[event.target.value]); + const { added, removed } = findChange(visibleSets, newSets); + added.forEach((s) => actions.addVisibleSet(s)); + removed.forEach((s) => actions.removeVisibleSet(s)); + }, [visibleSets, actions]); + + /** + * Handles a change in the visible attributes multiselect + * by adding or removing the attributes that changed + */ + const handleAttChange = useCallback((event: SelectChangeEvent) => { + const newAtts = typeof event.target.value === 'string' ? [event.target.value] : event.target.value; + // Ensures that the order is always Degree, Deviation, then the rest; + // this keeps the plot consistent & prevents graphical bugs + newAtts.sort((a, b) => { + if (a === 'Degree') return -1; + if (b === 'Degree') return 1; + if (a === 'Deviation') return -1; + if (b === 'Deviation') return 1; + return 0; + }); + // This simply sets the config visibleAtts to all newAtts, so it removes atts as well + actions.addMultipleAttributes(newAtts); + }, [visibleAtts, actions]); + + return ( + + + {collapsed ? + setCollapsed(false)} + > + + + : + + setCollapsed(true)} style={{ marginRight: BUTTON_PAD_LEFT }}> + + + Settings + } + + }> + General + + + + actions.setIntersectionSizeLabels(!showIntersectionSizes)} + /> + actions.setSetSizeLabels(!showSetSizes)} + /> + actions.setShowHiddenSets(!showHiddenSets)} + /> + + + + + }> + Sets and Attributes + + + + Sets + + + + + Attributes + + + + + + + }> + Aggregation + + + + { + const newAggBy: AggregateBy = ev.target.value as AggregateBy; + actions.firstAggregateBy(newAggBy); + }} + > + {aggregateByList.map((agg) => ( + + { + if (e.key === 'Enter') { + actions.firstAggregateBy(agg); + } + }} + > + } + /> + {agg !== 'None' && } + + {agg === 'Overlaps' && firstAggregateBy === agg && ( + { + let val = parseInt(ev.target.value, 10); + if (!val) return; // Blocks users from clearing the input + if (val < 2) val = 2; + if (val > visibleSets.length) val = visibleSets.length; + if (val === firstOverlapDegree) return; // Don't dispatch action if overlap hasn't changed + actions.firstOverlapBy(val); + }} + /> + )} + + ))} + + + + + {firstAggregateBy !== 'None' && + { + setSecondaryAccordionOpen(!secondaryAccordionOpen); + }} + disableGutters + style={ACCORDION_CSS} + > + }> + Second Aggregation + + + + { + const newAggBy: AggregateBy = ev.target.value as AggregateBy; + actions.secondAggregateBy(newAggBy); + }} + > + {aggregateByList + .filter((agg) => agg !== firstAggregateBy) + .map((agg) => ( + + { + if (e.key === 'Enter') { + actions.secondAggregateBy(agg); + } + }} + > + } + /> + {agg !== 'None' && } + + {agg === 'Overlaps' && secondAggregateBy === agg && ( + { + let val = parseInt(ev.target.value, 10); + if (!val) return; // Block users from clearing the input + if (val < 2) val = 2; + if (val > visibleSets.length) val = visibleSets.length; + if (val === secondOverlapDegree) return; // Don't dispatch an action if overlap hasn't changed + actions.secondOverlapBy(val); + }} + /> + )} + + ))} + + + + } + + }> + Filter Intersections + + + + actions.setHideEmpty(!hideEmpty)} + /> + actions.setHideNoSet(!hideNoSet)} + /> + + + + + Filter by Degree + + + + + { + if (typeof newVal === 'number') { // if the sliders are set to the same value + setDegreeFilters([newVal, newVal]); + } else { + setDegreeFilters(newVal); + } + }} + onChangeCommitted={() => { + // Prevents unncessary Trrack state changes + if (prevFilters[0] !== degreeFilters[0]) { + actions.setMinVisible(degreeFilters[0]); + } + if (prevFilters[1] !== degreeFilters[1]) { + actions.setMaxVisible(degreeFilters[1]); + } + setPrevFilters(degreeFilters); + }} + /> + + + + + + + + ); +}; diff --git a/packages/upset/src/components/Sidebar.tsx b/packages/upset/src/components/Sidebar.tsx deleted file mode 100644 index dec7eecb..00000000 --- a/packages/upset/src/components/Sidebar.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import { css } from '@emotion/react'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { - Accordion, - AccordionDetails, - AccordionSummary, - Box, - FormControl, - FormControlLabel, - FormGroup, - FormLabel, - Radio, - RadioGroup, - Slider, - Switch, - TextField, - Typography, -} from '@mui/material'; -import { - AggregateBy, aggregateByList, -} from '@visdesignlab/upset2-core'; -import { - Fragment, useContext, useEffect, useState, -} from 'react'; -import { useRecoilValue } from 'recoil'; - -import { - firstAggregateSelector, - firstOvelapDegreeSelector, - secondAggregateSelector, - secondOverlapDegreeSelector, -} from '../atoms/config/aggregateAtoms'; -import { - hideEmptySelector, hideNoSetSelector, maxVisibleSelector, minVisibleSelector, -} from '../atoms/config/filterAtoms'; -import { visibleSetSelector } from '../atoms/config/visibleSetsAtoms'; -import { ProvenanceContext } from './Root'; -import { HelpCircle, defaultMargin } from './custom/HelpCircle'; -import { helpText } from '../utils/helpText'; -import { dimensionsSelector } from '../atoms/dimensionsAtom'; - -const itemDivCSS = css` - display: flex; - justify-content: space-between; - align-items: center; -`; - -const sidebarHeaderCSS = css` - font-size: 0.95rem; -`; - -/** @jsxImportSource @emotion/react */ -export const Sidebar = () => { - const { actions } = useContext( - ProvenanceContext, - ); - - const visibleSets = useRecoilValue(visibleSetSelector); - const firstAggregateBy = useRecoilValue(firstAggregateSelector); - const firstOverlapDegree = useRecoilValue(firstOvelapDegreeSelector); - const secondAggregateBy = useRecoilValue(secondAggregateSelector); - const secondOverlapDegree = useRecoilValue(secondOverlapDegreeSelector); - const maxVisible = useRecoilValue(maxVisibleSelector); - const minVisible = useRecoilValue(minVisibleSelector); - const hideEmpty = useRecoilValue(hideEmptySelector); - const hideNoSet = useRecoilValue(hideNoSetSelector); - const dimensions = useRecoilValue(dimensionsSelector); - - const [secondaryAccordionOpen, setSecondaryAccordionOpen] = useState( - secondAggregateBy !== 'None', - ); - - const [degreeFilters, setDegreeFilters] = useState([minVisible, maxVisible]); - // Tracking the previous state of the filters to avoid unnecessary updates - const [prevFilters, setPrevFilters] = useState([minVisible, maxVisible]); - - useEffect(() => { - if (firstAggregateBy === 'None') { - setSecondaryAccordionOpen(false); - } - }, [firstAggregateBy]); - - return ( -
- - Settings - - - }> - Aggregation - - - - { - const newAggBy: AggregateBy = ev.target.value as AggregateBy; - actions.firstAggregateBy(newAggBy); - }} - > - {aggregateByList.map((agg) => ( - - { - if (e.key === 'Enter') { - actions.firstAggregateBy(agg); - } - }} - > - } - /> - {agg !== 'None' && } - - {agg === 'Overlaps' && firstAggregateBy === agg && ( - { - let val = parseInt(ev.target.value, 10); - if (!val) return; // Blocks users from clearing the input - if (val < 2) val = 2; - if (val > visibleSets.length) val = visibleSets.length; - if (val === firstOverlapDegree) return; // Don't dispatch action if overlap hasn't changed - actions.firstOverlapBy(val); - }} - /> - )} - - ))} - - - - - { - setSecondaryAccordionOpen(!secondaryAccordionOpen); - }} - disableGutters - disabled={firstAggregateBy === 'None'} - > - }> - Second Aggregation - - - - { - const newAggBy: AggregateBy = ev.target.value as AggregateBy; - actions.secondAggregateBy(newAggBy); - }} - > - {aggregateByList - .filter((agg) => agg !== firstAggregateBy) - .map((agg) => ( - - { - if (e.key === 'Enter') { - actions.secondAggregateBy(agg); - } - }} - > - } - /> - {agg !== 'None' && } - - {agg === 'Overlaps' && secondAggregateBy === agg && ( - { - let val = parseInt(ev.target.value, 10); - if (!val) return; // Block users from clearing the input - if (val < 2) val = 2; - if (val > visibleSets.length) val = visibleSets.length; - if (val === secondOverlapDegree) return; // Don't dispatch an action if overlap hasn't changed - actions.secondOverlapBy(val); - }} - /> - )} - - ))} - - - - - - }> - Filter Intersections - - - - { - if (e.key === 'Enter') { - actions.setHideEmpty(!hideEmpty); - } - }} - > - { - actions.setHideEmpty(ev.target.checked); - }} - /> - } - labelPlacement="start" - /> - - - { - if (e.key === 'Enter') { - actions.setHideNoSet(!hideNoSet); - } - }} - > - { - actions.setHideNoSet(ev.target.checked); - }} - /> - } - labelPlacement="start" - /> - - - - - - - Filter by Degree - - - - - { - if (typeof newVal === 'number') { // if the sliders are set to the same value - setDegreeFilters([newVal, newVal]); - } else { - setDegreeFilters(newVal); - } - }} - onChangeCommitted={() => { - // Prevents unncessary Trrack state changes - if (prevFilters[0] !== degreeFilters[0]) { - actions.setMinVisible(degreeFilters[0]); - } - if (prevFilters[1] !== degreeFilters[1]) { - actions.setMaxVisible(degreeFilters[1]); - } - setPrevFilters(degreeFilters); - }} - /> - - - - -
- ); -}; diff --git a/packages/upset/src/components/Upset.tsx b/packages/upset/src/components/Upset.tsx index e783ee78..6cbf8913 100644 --- a/packages/upset/src/components/Upset.tsx +++ b/packages/upset/src/components/Upset.tsx @@ -25,6 +25,7 @@ const defaultVisibleSets = 6; * @param {SidebarProps} [provVis] - The provenance visualization sidebar options. * @param {SidebarProps} [elementSidebar] - The element sidebar options. This sidebar is used for element queries, element selection datatable, and supplimental plot generation. * @param {SidebarProps} [altTextSidebar] - The alternative text sidebar options. This sidebar is used to display the generated text descriptions for an Upset 2.0 plot, given that the `generateAltText` function is provided. + * @param {number} [footerHeight] - Height of the footer overlayed on the upset plot, in px, if one exists. Used to prevent the bottom of the sidebars from overlapping with the footer. * @param {() => Promise} [generateAltText] - The function to generate alternative text. * @returns {JSX.Element} The rendered Upset component. */ @@ -41,6 +42,7 @@ export const Upset: FC = ({ provVis, elementSidebar, altTextSidebar, + footerHeight, generateAltText, }) => { // If the provided data is not already processed by UpSet core, process it @@ -115,6 +117,7 @@ export const Upset: FC = ({ provVis={provVis} elementSidebar={elementSidebar} altTextSidebar={altTextSidebar} + footerHeight={footerHeight} generateAltText={generateAltText} /> diff --git a/packages/upset/src/components/custom/SetSizeBar.tsx b/packages/upset/src/components/custom/SetSizeBar.tsx index 053d922e..18d9d67f 100644 --- a/packages/upset/src/components/custom/SetSizeBar.tsx +++ b/packages/upset/src/components/custom/SetSizeBar.tsx @@ -1,5 +1,5 @@ import { ScaleLinear } from 'd3-scale'; -import { FC } from 'react'; +import { FC, useMemo } from 'react'; import { useRecoilValue } from 'recoil'; import { dimensionsSelector } from '../../atoms/dimensionsAtom'; @@ -7,6 +7,7 @@ import translate from '../../utils/transform'; import Group from './Group'; import { columnHoverAtom, columnSelectAtom } from '../../atoms/highlightAtom'; import { hoverHighlight } from '../../utils/styles'; +import { showSetSizesSelector } from '../../atoms/config/displayAtoms'; type Props = { scale: ScaleLinear; @@ -17,6 +18,8 @@ type Props = { foregroundOpacity?: number; tx?: number; ty?: number; + /** Whether this element should always hide size text regardless of the global setting */ + hideSizeText?: boolean; }; export const SetSizeBar: FC = ({ @@ -28,10 +31,14 @@ export const SetSizeBar: FC = ({ ty = 0, foregroundOpacity = 1, showLabel = false, + hideSizeText = false, }) => { const dimensions = useRecoilValue(dimensionsSelector); const columnHover = useRecoilValue(columnHoverAtom); const columnSelect = useRecoilValue(columnSelectAtom); + const showSize = useRecoilValue(showSetSizesSelector); + + const barSize = useMemo(() => scale(size), [size, scale]); return ( = ({ fill="#f0f0f0" fillOpacity={1.0} /> + {(showSize && !hideSizeText && size > 0) && ( + +

+ {size} +

+
+ )} + {(showSize && !hideSizeText && size > 0) && ( + +

+ {size} +

+
+ )} {showLabel && ( void; + /** Tab index for the close button */ + closeButtonTabIndex?: number; + /** Aria-label for the sidebar */ + label: string; +} + +/** Dimension for the square button wrappers */ +const BUTTON_DIMS = { height: '40px', width: '40px' }; + +/** + * A collapsible, right-sidebar for the plot + */ +export const Sidebar: FC> = ({ + open, close, closeButtonTabIndex, children, label, +}) => { + /** Chosen so we don't get a horizontal scrollbar in the element view table */ + const INITIAL_DRAWER_WIDTH = 462; + /** Chosen so we don't get a new line for the "Apply" button in the element query controls */ + const MIN_DRAWER_WIDTH = 368; + + const [fullWidth, setFullWidth] = useState(false); + const [drawerWidth, setDrawerWidth] = useState(INITIAL_DRAWER_WIDTH); + const footerHeight = useRecoilValue(footerHeightAtom); + + /** + * Callbacks + */ + + /** + * Only fires when the user drags the side of the sidebar; resizes the sidebar + */ + const handleMouseMove = useCallback((e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const newWidth = document.body.clientWidth - e.clientX; + + if (newWidth > MIN_DRAWER_WIDTH) { + setDrawerWidth(newWidth); + } + }, [document.body.clientWidth]); + + /** + * Unattaches itself and handleMouseMove from document when the user stops dragging the sidebar + */ + const handleMouseUp = useCallback(() => { + document.removeEventListener('mouseup', handleMouseUp, true); + document.removeEventListener('mousemove', handleMouseMove, true); + }, [handleMouseMove, document]); + + /** + * Enables dragging when the user clicks the side of the drawer + */ + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + document.addEventListener('mouseup', handleMouseUp, true); + document.addEventListener('mousemove', handleMouseMove, true); + }, + [handleMouseUp, handleMouseMove, document], + ); + + return ( + + handleMouseDown(e)} + /> +
+ { !fullWidth ? + { + setFullWidth(true); + }} + aria-label="Expand the sidebar in full screen" + > + {/* CRAZY, I know. 1 font size px changes the icon SVG dimensions (square) by .75px. So this is + EXACTLY the font size needed to get this icon SVG to be the same dimensions as the close button: 14x14 */} + + + : + { + if (fullWidth) { + setFullWidth(false); + } + }} + aria-label="Reduce the sidebar to normal size" + > + + } + { + close(); + }} + tabIndex={closeButtonTabIndex} + aria-label="Close the sidebar" + > + + +
+ {children} + +
+ ); +}; diff --git a/packages/upset/src/components/custom/theme/heading.tsx b/packages/upset/src/components/custom/theme/heading.tsx new file mode 100644 index 00000000..16116dcc --- /dev/null +++ b/packages/upset/src/components/custom/theme/heading.tsx @@ -0,0 +1,60 @@ +import { Typography, Divider } from '@mui/material'; +import { CSSProperties, FC, PropsWithChildren } from 'react'; + +/** + * The level of the heading + */ +export type HeadingLevel= 'h1' | 'h2' | 'h3' | 'h4'; + +/** + * Props for UpsetHeading + * @private Style props can be added here as needed + */ +type Props = { + /** The heading level: only supports up to h4 */ + level: HeadingLevel; + /** Left padding for the typography */ + paddingLeft?: string; + /** CSS for the wrapper div */ + style?: CSSProperties; +} + +/** + * Maps heading levels to styles for the corresponding typography elements + */ +const LEVELS: {[level in HeadingLevel]: { fontSize: string; }} = { + h1: { fontSize: '1.6em' }, + h2: { fontSize: '1.4em' }, + h3: { fontSize: '1.2em' }, + h4: { fontSize: '1.0em' }, +}; + +/** + * A heading for use in the UI; keeps styles consistent + */ +export const UpsetHeading: FC> = ({ + level, paddingLeft, style, children, +}) => { + const IS_MAJOR = level === 'h1' || level === 'h2'; + + return ( +
+ + {children} + + {IS_MAJOR && } +
+ ); +}; diff --git a/packages/upset/src/provenance/index.ts b/packages/upset/src/provenance/index.ts index d1654006..d02c2a7e 100644 --- a/packages/upset/src/provenance/index.ts +++ b/packages/upset/src/provenance/index.ts @@ -363,6 +363,39 @@ const setUseUserAltTextAction = register( }, ); +/** + * Sets whether the intersection size labels should be shown + */ +const setIntersectionSizeLabelsAction = register( + 'set-intersection-size-labels', + (state, show) => { + state.intersectionSizeLabels = show; + return state; + }, +); + +/** + * Sets whether the set size labels should be shown + */ +const setSetSizeLabelsAction = register( + 'set-set-size-labels', + (state, show) => { + state.setSizeLabels = show; + return state; + }, +); + +/** + * Sets whether the hidden sets should be shown + */ +const setShowHiddenSetsAction = register( + 'set-show-hidden-sets', + (state, show) => { + state.showHiddenSets = show; + return state; + }, +); + export function initializeProvenanceTracking( // eslint-disable-next-line default-param-last config: Partial = {}, @@ -375,7 +408,7 @@ export function initializeProvenanceTracking( ); if (setter) { - provenance.currentChange(() => setter(provenance.getState())); + provenance.currentChange(() => setter(convertConfig(provenance.getState()))); } provenance.done(); @@ -456,6 +489,30 @@ export function getActions(provenance: UpsetProvenance) { useUserAlt ? 'Enabled user alt text' : 'Disabled user alt text', setUseUserAltTextAction(useUserAlt), ), + /** + * Sets whether set intersection size labels should be shown + * @param show Whether to show intersection size labels + */ + setIntersectionSizeLabels: (show: boolean) => provenance.apply( + show ? 'Show intersection size labels' : 'Hide intersection size labels', + setIntersectionSizeLabelsAction(show), + ), + /** + * Sets whether set size labels should be shown + * @param show Whether to show set size labels + */ + setSetSizeLabels: (show: boolean) => provenance.apply( + show ? 'Show set size labels' : 'Hide set size labels', + setSetSizeLabelsAction(show), + ), + /** + * Sets whether hidden sets should be shown + * @param show Whether to show hidden sets + */ + setShowHiddenSets: (show: boolean) => provenance.apply( + show ? 'Show hidden sets' : 'Hide hidden sets', + setShowHiddenSetsAction(show), + ), }; } diff --git a/packages/upset/src/types.ts b/packages/upset/src/types.ts index 0424ffa3..30f17c92 100644 --- a/packages/upset/src/types.ts +++ b/packages/upset/src/types.ts @@ -173,6 +173,12 @@ export interface UpsetProps { */ altTextSidebar?: SidebarProps; + /** + * Height of the footer overlayed on the upset plot, in px, if one exists. + * Used to prevent the bottom of the sidebars from overlapping with the footer. + */ + footerHeight?: number; + /** * Async function which should return a generated AltText object. */ diff --git a/packages/upset/src/utils/helpText.ts b/packages/upset/src/utils/helpText.ts index 496c5927..7b33a657 100644 --- a/packages/upset/src/utils/helpText.ts +++ b/packages/upset/src/utils/helpText.ts @@ -1,4 +1,6 @@ -// Help text tooltip content for the sidebar +/** + * Help text tooltip content for the sidebar. + */ export const helpText = { sorting: { Degree: 'Sort all intersections by the number of overlaps', @@ -15,4 +17,9 @@ export const helpText = { HideNoSet: 'Hide the row that contains the items that are in no set.', Degree: 'Show only intersections that have a degree higher than the "Min Degree" and lower than the "Max Degree" value.', }, + general: { + IntersectionSizeLabels: 'Show numbers denoting the size of the intersections next to the size bars', + SetSizeLabels: 'Show numbers denoting the size of the sets in their size bars at the top of the visualization', + HiddenSets: 'Show the hidden sets & their sizes at the top of the visualization', + }, };