diff --git a/src/js/components/Overview/Chart.tsx b/src/js/components/Overview/Chart.tsx index 82eb7c7a..a1561ebb 100644 --- a/src/js/components/Overview/Chart.tsx +++ b/src/js/components/Overview/Chart.tsx @@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { BarChart, PieChart } from 'bento-charts'; import { ChoroplethMap } from 'bento-charts/dist/maps'; -import { CHART_HEIGHT } from '@/constants/overviewConstants'; +import { CHART_HEIGHT, PIE_CHART_HEIGHT } from '@/constants/overviewConstants'; import { ChartData } from '@/types/data'; import { CHART_TYPE_BAR, CHART_TYPE_CHOROPLETH, CHART_TYPE_PIE, ChartConfig } from '@/types/chartConfig'; @@ -18,11 +18,10 @@ const Chart = memo(({ chartConfig, data, units, id }: ChartProps) => { switch (type) { case CHART_TYPE_BAR: - // bar charts can be rendered slightly larger as they do not clip return ( { return ( { @@ -44,12 +43,11 @@ const Chart = memo(({ chartConfig, data, units, id }: ChartProps) => { /> ); case CHART_TYPE_CHOROPLETH: { - // map charts can be rendered at full height as they do not clip const { category_prop: categoryProp, features, center, zoom, color_mode: colorMode } = chartConfig; return ( { +const ChartCard = memo(({ section, chart, onRemoveChart, width }: ChartCardProps) => { const { t } = useTranslation(NON_DEFAULT_TRANSLATION); const { t: td } = useTranslation(DEFAULT_TRANSLATION); @@ -38,12 +40,13 @@ const ChartCard = memo(({ section, chart, onRemoveChart }: ChartCardProps) => { // missing count label const missingCount = data.find((e) => e.x === 'missing')?.y ?? 0; - if (missingCount) + if (missingCount) { ed.push( {missingCount} {td('missing')} ); + } // controls (buttons) extraOptionsData.forEach((opt) => { @@ -54,13 +57,20 @@ const ChartCard = memo(({ section, chart, onRemoveChart }: ChartCardProps) => { ); }); + // We add a key to the chart which includes width to force a re-render if width changes. return ( -
+
{ed}}> {data.filter((e) => !(e.x === 'missing')).length !== 0 ? ( - + ) : ( - + )} @@ -75,6 +85,7 @@ export interface ChartCardProps { section: string; chart: ChartDataField; onRemoveChart: (arg: { section: string; id: string }) => void; + width: number; } export default ChartCard; diff --git a/src/js/components/Overview/Drawer/ChartTree.tsx b/src/js/components/Overview/Drawer/ChartTree.tsx index 79c50c5d..c1b78c3b 100644 --- a/src/js/components/Overview/Drawer/ChartTree.tsx +++ b/src/js/components/Overview/Drawer/ChartTree.tsx @@ -1,14 +1,14 @@ -import React, { useMemo } from 'react'; +import React, { ReactNode, useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import { Tree, TreeProps } from 'antd'; +import { InputNumber, Tree, TreeProps } from 'antd'; import { useTranslation } from 'react-i18next'; -import { rearrange, setDisplayedCharts } from '@/features/data/data.store'; +import { rearrange, setDisplayedCharts, setChartWidth } from '@/features/data/data.store'; import { NON_DEFAULT_TRANSLATION } from '@/constants/configConstants'; import { ChartDataField } from '@/types/data'; interface MappedChartItem { - title: string; + title: ReactNode; key: string; } @@ -17,7 +17,31 @@ const ChartTree = ({ charts, section }: ChartTreeProps) => { const { t } = useTranslation(NON_DEFAULT_TRANSLATION); const allCharts: MappedChartItem[] = useMemo( - () => charts.map(({ field: { title }, id }) => ({ title: t(title), key: id })), + () => + charts.map(({ field: { title }, id, width }) => ({ + title: ( +
+ {t(title)} + + Width:{' '} + { + if (v) { + dispatch(setChartWidth({ section, chart: id, width: v })); + } + }} + controls={true} + style={{ width: 50 }} + /> + +
+ ), + key: id, + })), [charts] ); @@ -38,7 +62,7 @@ const ChartTree = ({ charts, section }: ChartTreeProps) => { const onCheck = useMemo(() => { const fn: TreeProps['onCheck'] = (checkedKeysValue) => { - dispatch(setDisplayedCharts({ section, charts: checkedKeysValue })); + dispatch(setDisplayedCharts({ section, charts: checkedKeysValue as string[] })); }; return fn; }, [dispatch, section]); diff --git a/src/js/components/Overview/OverviewDisplayData.tsx b/src/js/components/Overview/OverviewDisplayData.tsx index 73b637f9..3b1f4daf 100644 --- a/src/js/components/Overview/OverviewDisplayData.tsx +++ b/src/js/components/Overview/OverviewDisplayData.tsx @@ -1,35 +1,45 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { CSSProperties, useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; -import { List } from 'antd'; -import ChartCard from './ChartCard'; import { disableChart } from '@/features/data/data.store'; import { ChartDataField } from '@/types/data'; +import ChartCard from './ChartCard'; + +const CHART_GUTTER = 16; const getColumnCount = (width: number): number => { - if (width < 990) { + if (width < 880) { return 1; } else if (width < 1420) { return 2; } else return 3; }; +// Keep these quantized rather than a function of width for two reasons: +// - makes design more predictable +// - we don't need to re-render children for all width changes, just for some const getFrameWidth = (width: number): number => { - if (width < 990) { - return 360; + if (width < 820) { + return 440; + } else if (width < 1060) { + return 780; } else if (width < 1420) { - return 910; - } else return 1325; + return 960; + } else { + return 1325; + } }; const OverviewDisplayData = ({ section, allCharts }: OverviewDisplayDataProps) => { const dispatch = useDispatch(); const { width } = useWindowSize(); + const columnCount = getColumnCount(width); + const frameWidth = getFrameWidth(width); - const [listStyle, listGrid] = useMemo( - () => [{ width: `${getFrameWidth(width)}px` }, { gutter: 0, column: getColumnCount(width) }], - [width] + const containerStyle = useMemo( + () => ({ width: frameWidth, display: 'flex', flexWrap: 'wrap', gap: CHART_GUTTER }), + [frameWidth] ); const displayedCharts = useMemo(() => allCharts.filter((e) => e.isDisplayed), [allCharts]); @@ -42,19 +52,23 @@ const OverviewDisplayData = ({ section, allCharts }: OverviewDisplayDataProps) = ); const renderItem = useCallback( - (chart: ChartDataField) => ( - - ), - [section, onRemoveChart] + (chart: ChartDataField) => { + const columnWidth = Math.min(chart.width, columnCount); + const pixelWidth = (columnWidth / columnCount) * (frameWidth - CHART_GUTTER * (columnCount - columnWidth)); + return ( + + ); + }, + [section, onRemoveChart, width] ); - return ; + return
{displayedCharts.map(renderItem)}
; }; const useWindowSize = () => { const [windowSize, setWindowSize] = useState({ - width: 0, - height: 0, + width: window.innerWidth, + height: window.innerHeight, }); useEffect(() => { diff --git a/src/js/components/Overview/PublicOverview.tsx b/src/js/components/Overview/PublicOverview.tsx index 7c2bc4ca..f90cb19a 100644 --- a/src/js/components/Overview/PublicOverview.tsx +++ b/src/js/components/Overview/PublicOverview.tsx @@ -3,6 +3,8 @@ import { Row, Col, FloatButton, Card, Skeleton } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import { convertSequenceAndDisplayData, saveValue } from '@/utils/localStorage'; +import type { Sections } from '@/types/data'; + import { LOCALSTORAGE_CHARTS_KEY } from '@/constants/overviewConstants'; import OverviewSection from './OverviewSection'; @@ -28,7 +30,7 @@ const PublicOverview = () => { useEffect(() => { // Save sections to localStorage when they change - saveValue(LOCALSTORAGE_CHARTS_KEY, convertSequenceAndDisplayData(sections)); + saveToLocalStorage(sections); }, [sections]); useEffect(() => { @@ -40,7 +42,11 @@ const PublicOverview = () => { const displayedSections = sections.filter(({ charts }) => charts.findIndex(({ isDisplayed }) => isDisplayed) !== -1); const onManageChartsOpen = useCallback(() => setDrawerVisible(true), []); - const onManageChartsClose = useCallback(() => setDrawerVisible(false), []); + const onManageChartsClose = useCallback(() => { + setDrawerVisible(false); + // When we close the drawer, save any changes to localStorage. This helps ensure width gets saved: + saveToLocalStorage(sections); + }, [sections]); return ( <> @@ -86,4 +92,8 @@ const PublicOverview = () => { ); }; +const saveToLocalStorage = (sections: Sections) => { + saveValue(LOCALSTORAGE_CHARTS_KEY, convertSequenceAndDisplayData(sections)); +}; + export default PublicOverview; diff --git a/src/js/components/Search/SearchResultsPane.tsx b/src/js/components/Search/SearchResultsPane.tsx index c642d1ee..6536a9f5 100644 --- a/src/js/components/Search/SearchResultsPane.tsx +++ b/src/js/components/Search/SearchResultsPane.tsx @@ -5,7 +5,7 @@ import { BiDna } from 'react-icons/bi'; import { PieChart } from 'bento-charts'; import CustomEmpty from '../Util/CustomEmpty'; import ExpSvg from '../Util/ExpSvg'; -import { CHART_HEIGHT, COUNTS_FILL } from '@/constants/overviewConstants'; +import { COUNTS_FILL, PIE_CHART_HEIGHT } from '@/constants/overviewConstants'; import { useTranslationDefault } from '@/hooks'; import { ChartData } from '@/types/data'; @@ -53,7 +53,7 @@ const SearchResultsPane = ({ {t('Biosamples')} {!hasInsufficientData && biosampleChartData.length ? ( - + ) : ( )} @@ -61,7 +61,7 @@ const SearchResultsPane = ({ {t('Experiments')} {!hasInsufficientData && experimentChartData.length ? ( - + ) : ( )} diff --git a/src/js/constants/overviewConstants.ts b/src/js/constants/overviewConstants.ts index 9c656d08..467a4e94 100644 --- a/src/js/constants/overviewConstants.ts +++ b/src/js/constants/overviewConstants.ts @@ -2,4 +2,6 @@ export const COUNTS_FILL = '#75787a'; export const LOCALSTORAGE_CHARTS_KEY = 'charts'; -export const CHART_HEIGHT = 300; +export const CHART_HEIGHT = 350; +export const PIE_CHART_HEIGHT = 300; // rendered slightly smaller since labels can clip +export const DEFAULT_CHART_WIDTH = 1; diff --git a/src/js/features/data/data.store.ts b/src/js/features/data/data.store.ts index 39f3a215..34c697c8 100644 --- a/src/js/features/data/data.store.ts +++ b/src/js/features/data/data.store.ts @@ -34,7 +34,7 @@ const data = createSlice({ const { section, id } = payload; state.sections.find((e) => e.sectionTitle === section)!.charts.find((e) => e.id === id)!.isDisplayed = false; }, - setDisplayedCharts: (state, { payload }) => { + setDisplayedCharts: (state, { payload }: PayloadAction<{ section: string; charts: string[] }>) => { const { section, charts } = payload; state.sections .find((e) => e.sectionTitle === section)! @@ -42,6 +42,11 @@ const data = createSlice({ arr[ind].isDisplayed = charts.includes(val.id); }); }, + setChartWidth: (state, { payload }: PayloadAction<{ section: string; chart: string; width: number }>) => { + const { section, chart, width } = payload; + const chartObj = state.sections.find((e) => e.sectionTitle === section)!.charts.find((c) => c.id === chart)!; + chartObj.width = width; + }, }, extraReducers: (builder) => { builder @@ -59,6 +64,6 @@ const data = createSlice({ }, }); -export const { rearrange, disableChart, setDisplayedCharts } = data.actions; +export const { rearrange, disableChart, setDisplayedCharts, setChartWidth } = data.actions; export { makeGetDataRequestThunk }; export default data.reducer; diff --git a/src/js/features/data/makeGetDataRequest.thunk.ts b/src/js/features/data/makeGetDataRequest.thunk.ts index 5be3351d..8768fcc9 100644 --- a/src/js/features/data/makeGetDataRequest.thunk.ts +++ b/src/js/features/data/makeGetDataRequest.thunk.ts @@ -4,7 +4,7 @@ import { MAX_CHARTS, publicOverviewUrl } from '@/constants/configConstants'; import { verifyData, saveValue, getValue, convertSequenceAndDisplayData } from '@/utils/localStorage'; -import { LOCALSTORAGE_CHARTS_KEY } from '@/constants/overviewConstants'; +import { DEFAULT_CHART_WIDTH, LOCALSTORAGE_CHARTS_KEY } from '@/constants/overviewConstants'; import { serializeChartData } from '@/utils/chart'; import { ChartConfig } from '@/types/chartConfig'; import { ChartDataField, LocalStorageData, Sections } from '@/types/data'; @@ -33,9 +33,11 @@ export const makeGetDataRequestThunk = createAsyncThunk< return { id: field.id, chartConfig: chart, - isDisplayed: i < MAX_CHARTS, field, data: serializeChartData(field.data), + // Initial display state + isDisplayed: i < MAX_CHARTS, + width: chart.width ?? DEFAULT_CHART_WIDTH, // initial configured width; users can change it from here }; }; @@ -50,9 +52,10 @@ export const makeGetDataRequestThunk = createAsyncThunk< verifyData(val, convertedData) ); sectionData.forEach(({ sectionTitle, charts }, i, arr) => { - arr[i].charts = localValue[sectionTitle].map(({ id, isDisplayed }) => ({ + arr[i].charts = localValue[sectionTitle].map(({ id, isDisplayed, width }) => ({ ...charts.find((c) => c.id === id)!, isDisplayed, + width, })); }); diff --git a/src/js/types/chartConfig.ts b/src/js/types/chartConfig.ts index 5a2fc346..ff325372 100644 --- a/src/js/types/chartConfig.ts +++ b/src/js/types/chartConfig.ts @@ -10,21 +10,27 @@ ChartConfig: represents what is stored in the configuration file for describing the field metadata (description / mapping information / units / etc.) By using a sum type here, we can optionally mandate configuration information for certain types of charts. */ -export type ChartConfig = - | { - chart_type: typeof CHART_TYPE_PIE; - field: string; - } - | { - chart_type: typeof CHART_TYPE_BAR; - field: string; - } - | { - chart_type: typeof CHART_TYPE_CHOROPLETH; - field: string; - category_prop: ChoroplethMapProps['categoryProp']; - color_mode: ChoroplethMapProps['colorMode']; - features: ChoroplethMapProps['features']; - center: ChoroplethMapProps['center']; - zoom: ChoroplethMapProps['zoom']; - }; + +interface BaseChartConfig { + field: string; + width?: number; +} + +interface ChartConfigPie extends BaseChartConfig { + chart_type: typeof CHART_TYPE_PIE; +} + +interface ChartConfigBar extends BaseChartConfig { + chart_type: typeof CHART_TYPE_BAR; +} + +interface ChartConfigChoropleth extends BaseChartConfig { + chart_type: typeof CHART_TYPE_CHOROPLETH; + category_prop: ChoroplethMapProps['categoryProp']; + color_mode: ChoroplethMapProps['colorMode']; + features: ChoroplethMapProps['features']; + center: ChoroplethMapProps['center']; + zoom: ChoroplethMapProps['zoom']; +} + +export type ChartConfig = ChartConfigPie | ChartConfigBar | ChartConfigChoropleth; diff --git a/src/js/types/data.ts b/src/js/types/data.ts index 6044b181..b7687e55 100644 --- a/src/js/types/data.ts +++ b/src/js/types/data.ts @@ -17,10 +17,12 @@ This represents a chart's "state", since it also has the isDisplayed property - export interface ChartDataField { id: string; // taken from field definition data: ChartData[]; - isDisplayed: boolean; // Field definition without data (we have mapped data in the data prop above instead): field: Omit; chartConfig: ChartConfig; + // display options: + isDisplayed: boolean; // whether the chart is currently displayed (state data) + width: number; // current width (state data); initial data taken from chart config } export interface ChartData { @@ -29,5 +31,5 @@ export interface ChartData { } export type LocalStorageData = { - [key in string]: { id: string; isDisplayed: boolean }[]; + [key in string]: { id: string; isDisplayed: boolean; width: number }[]; }; diff --git a/src/js/utils/localStorage.ts b/src/js/utils/localStorage.ts index 61f6072d..d1710590 100644 --- a/src/js/utils/localStorage.ts +++ b/src/js/utils/localStorage.ts @@ -7,8 +7,8 @@ export const verifyData = (nObj: any, oObj: LocalStorageData) => { if (nCharts.length !== oCharts.length) return false; const nChartsMap: { [key in string]: boolean } = {}; - for (const { id, isDisplayed } of nCharts) { - if (id && typeof isDisplayed === 'boolean') nChartsMap[id] = true; + for (const { id, isDisplayed, width } of nCharts) { + if (id && typeof isDisplayed === 'boolean' && typeof width === 'number') nChartsMap[id] = true; else return false; } return oCharts.every((e) => nChartsMap[e.id]); @@ -33,12 +33,12 @@ export const getValue = (key: string, defaultVal: T, verifyFunc: (arg: any) = if (serializedState === null) { return defaultVal; } - const unserializedState = JSON.parse(serializedState); - if (!verifyFunc(unserializedState)) { + const deserializedState = JSON.parse(serializedState); + if (!verifyFunc(deserializedState)) { return defaultVal; } - return unserializedState as T; + return deserializedState as T; } catch (err) { console.log(err); console.log('Error retrieving state from localStorage'); @@ -49,7 +49,7 @@ export const getValue = (key: string, defaultVal: T, verifyFunc: (arg: any) = export const convertSequenceAndDisplayData = (sections: Sections) => { const temp: LocalStorageData = {}; sections.forEach(({ sectionTitle, charts }) => { - temp[sectionTitle] = charts.map(({ id, isDisplayed }) => ({ id, isDisplayed })); + temp[sectionTitle] = charts.map(({ id, isDisplayed, width }) => ({ id, isDisplayed, width })); }); return temp; };