diff --git a/package-lock.json b/package-lock.json index 195e0e28..412f1c7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,29 +1,31 @@ { "name": "bento_public", - "version": "0.14.1", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bento_public", - "version": "0.14.1", + "version": "0.15.0", "license": "LGPL-3.0-only", "dependencies": { "@ant-design/icons": "^5.1.4", "@reduxjs/toolkit": "^1.9.3", "antd": "^5.6.2", "axios": "^1.4.0", - "bento-charts": "^2.3.0", + "bento-charts": "^2.4.1", "css-loader": "^6.8.1", "dotenv": "^16.3.1", "i18next": "^23.2.2", "i18next-browser-languagedetector": "^7.0.2", "i18next-http-backend": "^2.2.1", + "leaflet": "^1.9.4", "less": "^4.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^13.0.0", "react-icons": "^4.9.0", + "react-leaflet": "^4.2.1", "react-redux": "^8.1.1", "react-router-dom": "^6.13.0", "recharts": "^2.7.1", @@ -528,6 +530,16 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@reduxjs/toolkit": { "version": "1.9.5", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.5.tgz", @@ -1652,13 +1664,26 @@ "dev": true }, "node_modules/bento-charts": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/bento-charts/-/bento-charts-2.3.0.tgz", - "integrity": "sha512-xohxzX5dhMhla/T/zMphHmKBlQRyzmNNf5bQilib34vTJ7e48lLCzOwlvFo43Nq3mGFILc5353lFybkokdDgcw==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/bento-charts/-/bento-charts-2.4.1.tgz", + "integrity": "sha512-qPWL3Zv0HcBgIo7/hRAUjvYVo9kHLZf65zjPW+4qfcjPYzmsy6wlzt5hdewAXsJKAxVPGrnKJa9ItXPquFiuuw==", + "dependencies": { + "d3-interpolate": "^3.0.1" + }, "peerDependencies": { + "leaflet": "^1.9.4", "react": ">=16.0.0", "react-dom": ">=14.0.0", + "react-leaflet": "^4.2.1", "recharts": "^2.4.3" + }, + "peerDependenciesMeta": { + "leaflet": { + "optional": true + }, + "react-leafet": { + "optional": true + } } }, "node_modules/big.js": { @@ -4814,6 +4839,11 @@ "shell-quote": "^1.7.3" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + }, "node_modules/less": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/less/-/less-4.1.3.tgz", @@ -6693,6 +6723,19 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", diff --git a/package.json b/package.json index 272df7e5..ffb60dfa 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bento_public", - "version": "0.14.1", + "version": "0.15.0", "description": "A publicly accessible portal for clinical datasets, where users are able to see high-level statistics of the data available through predefined variables of interest and search the data using limited variables at a time. This portal allows users to gain a generic understanding of the data available (secure and firewalled) without the need to access it directly. Initially, this portal facilitates the search in English language only, but the French language will be added at a later time.", "main": "index.js", "scripts": { @@ -19,17 +19,19 @@ "@reduxjs/toolkit": "^1.9.3", "antd": "^5.6.2", "axios": "^1.4.0", - "bento-charts": "^2.3.0", + "bento-charts": "^2.4.1", "css-loader": "^6.8.1", "dotenv": "^16.3.1", "i18next": "^23.2.2", "i18next-browser-languagedetector": "^7.0.2", "i18next-http-backend": "^2.2.1", + "leaflet": "^1.9.4", "less": "^4.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^13.0.0", "react-icons": "^4.9.0", + "react-leaflet": "^4.2.1", "react-redux": "^8.1.1", "react-router-dom": "^6.13.0", "recharts": "^2.7.1", diff --git a/src/js/components/Overview/Chart.tsx b/src/js/components/Overview/Chart.tsx index 206ce95d..82eb7c7a 100644 --- a/src/js/components/Overview/Chart.tsx +++ b/src/js/components/Overview/Chart.tsx @@ -1,56 +1,84 @@ -import React from 'react'; +import React, { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { chartTypes } from '@/constants/overviewConstants'; +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 { ChartData } from '@/types/data'; -import { useNavigate } from 'react-router-dom'; +import { CHART_TYPE_BAR, CHART_TYPE_CHOROPLETH, CHART_TYPE_PIE, ChartConfig } from '@/types/chartConfig'; -const Chart = ({ chartType, data, units, id }: ChartProps) => { +const Chart = memo(({ chartConfig, data, units, id }: ChartProps) => { const { t, i18n } = useTranslation(); const navigate = useNavigate(); const translateMap = ({ x, y }: { x: string; y: number }) => ({ x: t(x), y }); const removeMissing = ({ x }: { x: string }) => x !== 'missing'; - const renderChartSwitch = () => { - switch (chartType) { - case chartTypes.BAR: - // bar charts can be rendered slightly larger as they do not clip - return ( - { - navigate(`/${i18n.language}/search?${id}=${d.payload.x}`); - }} - /> - ); - case chartTypes.PIE: - return ( - { - navigate(`/${i18n.language}/search?${id}=${d.name}`); - }} - /> - ); - default: - return

chart type doesnt exists

; + const { chart_type: type } = chartConfig; + + switch (type) { + case CHART_TYPE_BAR: + // bar charts can be rendered slightly larger as they do not clip + return ( + { + navigate(`/${i18n.language}/search?${id}=${d.payload.x}`); + }} + /> + ); + case CHART_TYPE_PIE: + return ( + { + navigate(`/${i18n.language}/search?${id}=${d.name}`); + }} + /> + ); + 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 val = d.properties?.[categoryProp]; + if (val === undefined) return; + navigate(`/${i18n.language}/search?${id}=${val}`); + }} + renderPopupBody={(_f, d) => ( + <> + Count: {d} {units} + + )} + /> + ); } - }; + default: + return

chart type does not exist

; + } +}); - return <>{renderChartSwitch()}; -}; +Chart.displayName = 'Chart'; export interface ChartProps { - chartType: string; + chartConfig: ChartConfig; data: ChartData[]; units: string; id: string; diff --git a/src/js/components/Overview/Drawer/ChartTree.tsx b/src/js/components/Overview/Drawer/ChartTree.tsx index 67a5c1a2..79c50c5d 100644 --- a/src/js/components/Overview/Drawer/ChartTree.tsx +++ b/src/js/components/Overview/Drawer/ChartTree.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { Tree, TreeProps } from 'antd'; import { useTranslation } from 'react-i18next'; @@ -7,27 +7,41 @@ import { rearrange, setDisplayedCharts } from '@/features/data/data.store'; import { NON_DEFAULT_TRANSLATION } from '@/constants/configConstants'; import { ChartDataField } from '@/types/data'; +interface MappedChartItem { + title: string; + key: string; +} + const ChartTree = ({ charts, section }: ChartTreeProps) => { const dispatch = useDispatch(); const { t } = useTranslation(NON_DEFAULT_TRANSLATION); - const allCharts = charts.map(({ title, id }) => ({ title: t(title), key: id })); + const allCharts: MappedChartItem[] = useMemo( + () => charts.map(({ field: { title }, id }) => ({ title: t(title), key: id })), + [charts] + ); - const onChartDrop: TreeProps['onDrop'] = (info) => { - const originalLocation = parseInt(info.dragNode.pos.substring(2)); - const newLocation = info.dropPosition - 1; + const onChartDrop: TreeProps['onDrop'] = useMemo(() => { + const fn: TreeProps['onDrop'] = (event) => { + const originalLocation = parseInt(event.dragNode.pos.substring(2)); + const newLocation = event.dropPosition - 1; - const data = [...allCharts]; - const element = data.splice(originalLocation, 1)[0]; - data.splice(newLocation, 0, element); - dispatch(rearrange({ section, arrangement: data.map((e) => e.key) })); - }; + const data = [...(allCharts ?? [])]; + const element = data.splice(originalLocation, 1)[0]; + data.splice(newLocation, 0, element); + dispatch(rearrange({ section, arrangement: data.map((e) => e.key) })); + }; + return fn; + }, [dispatch, allCharts, section]); - const checkedKeys = charts.filter((e) => e.isDisplayed).map((e) => e.id); + const checkedKeys = useMemo(() => charts.filter((e) => e.isDisplayed).map((e) => e.id), [charts]); - const onCheck: TreeProps['onCheck'] = (checkedKeysValue) => { - dispatch(setDisplayedCharts({ section, charts: checkedKeysValue })); - }; + const onCheck = useMemo(() => { + const fn: TreeProps['onCheck'] = (checkedKeysValue) => { + dispatch(setDisplayedCharts({ section, charts: checkedKeysValue })); + }; + return fn; + }, [dispatch, section]); return ( { +const MakeChartCard = memo(({ section, chart, onRemoveChart }: MakeChartCardProps) => { const { t } = useTranslation(NON_DEFAULT_TRANSLATION); const { t: td } = useTranslation(DEFAULT_TRANSLATION); - const { title, data, chartType, config, id, description } = chart; + const { + data, + field: { id, description, title, config }, + chartConfig, + } = chart; const extraOptionsData = [ { @@ -54,7 +58,7 @@ const MakeChartCard = ({ section, chart, onRemoveChart }: MakeChartCardProps) =>
{ed}}> {data.filter((e) => !(e.x === 'missing')).length !== 0 ? ( - + ) : ( @@ -63,7 +67,9 @@ const MakeChartCard = ({ section, chart, onRemoveChart }: MakeChartCardProps) =>
); -}; +}); + +MakeChartCard.displayName = 'MakeChartCard'; export interface MakeChartCardProps { section: string; diff --git a/src/js/features/data/makeGetDataRequest.thunk.ts b/src/js/features/data/makeGetDataRequest.thunk.ts index c2a3d1ed..5be3351d 100644 --- a/src/js/features/data/makeGetDataRequest.thunk.ts +++ b/src/js/features/data/makeGetDataRequest.thunk.ts @@ -6,8 +6,9 @@ import { verifyData, saveValue, getValue, convertSequenceAndDisplayData } from ' import { LOCALSTORAGE_CHARTS_KEY } from '@/constants/overviewConstants'; import { serializeChartData } from '@/utils/chart'; -import { LocalStorageData, Sections } from '@/types/data'; -import { Chart, Counts, OverviewResponse } from '@/types/overviewResponse'; +import { ChartConfig } from '@/types/chartConfig'; +import { ChartDataField, LocalStorageData, Sections } from '@/types/data'; +import { Counts, OverviewResponse } from '@/types/overviewResponse'; import { printAPIError } from '@/utils/error.util'; export const makeGetDataRequestThunk = createAsyncThunk< @@ -19,14 +20,24 @@ export const makeGetDataRequestThunk = createAsyncThunk< .get(publicOverviewUrl) .then((res) => res.data.overview) .catch(printAPIError(rejectWithValue))) as OverviewResponse['overview']; + const sections = overviewResponse.layout; - const normalizeChart = (chart: Chart, i: number) => ({ - chartType: chart.chart_type, - isDisplayed: i < MAX_CHARTS, - name: chart.field, - ...overviewResponse.fields[chart.field], - data: serializeChartData(overviewResponse.fields[chart.field].data), - }); + + // Take chart configuration and create a combined state object with: + // the chart configuration + // + displayed boolean - whether this chart is shown + // + field definition (from config.field) + // + the fields' relevant data. + const normalizeChart = (chart: ChartConfig, i: number): ChartDataField => { + const field = overviewResponse.fields[chart.field]; + return { + id: field.id, + chartConfig: chart, + isDisplayed: i < MAX_CHARTS, + field, + data: serializeChartData(field.data), + }; + }; const sectionData: Sections = sections.map(({ section_title, charts }) => ({ sectionTitle: section_title, diff --git a/src/js/index.tsx b/src/js/index.tsx index 63b386d8..a346c0c6 100644 --- a/src/js/index.tsx +++ b/src/js/index.tsx @@ -7,6 +7,8 @@ import { Layout } from 'antd'; import { ChartConfigProvider } from 'bento-charts'; import { SUPPORTED_LNGS } from './constants/configConstants'; +import 'leaflet/dist/leaflet.css'; +import 'bento-charts/src/styles.css'; import './i18n'; import '../styles.css'; diff --git a/src/js/types/chartConfig.ts b/src/js/types/chartConfig.ts new file mode 100644 index 00000000..5a2fc346 --- /dev/null +++ b/src/js/types/chartConfig.ts @@ -0,0 +1,30 @@ +import { ChoroplethMapProps } from 'bento-charts/dist/maps'; + +// Use multiple literals here instead of an object for full immutability. +export const CHART_TYPE_PIE = 'pie'; +export const CHART_TYPE_BAR = 'bar'; +export const CHART_TYPE_CHOROPLETH = 'choropleth'; + +/* +ChartConfig: represents what is stored in the configuration file for describing a chart, without any attached data or +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']; + }; diff --git a/src/js/types/data.ts b/src/js/types/data.ts index 1578d59c..6044b181 100644 --- a/src/js/types/data.ts +++ b/src/js/types/data.ts @@ -1,3 +1,4 @@ +import { ChartConfig } from '@/types/chartConfig'; import { OverviewResponseDataField } from '@/types/overviewResponse'; export type Sections = Section[]; @@ -7,10 +8,19 @@ export type Section = { charts: ChartDataField[]; }; -export interface ChartDataField extends Omit { +/* +ChartDataField represents a compound object which holds information about a chart's configuration, the relevant data +(mapped to a chart-compatible format), and the field descriptor - the field from which this data was selected. + +This represents a chart's "state", since it also has the isDisplayed property - whether the chart is shown. + */ +export interface ChartDataField { + id: string; // taken from field definition data: ChartData[]; isDisplayed: boolean; - chartType: ChartType; + // Field definition without data (we have mapped data in the data prop above instead): + field: Omit; + chartConfig: ChartConfig; } export interface ChartData { @@ -18,8 +28,6 @@ export interface ChartData { y: number; } -export type ChartType = 'bar' | 'pie'; - export type LocalStorageData = { [key in string]: { id: string; isDisplayed: boolean }[]; }; diff --git a/src/js/types/overviewResponse.ts b/src/js/types/overviewResponse.ts index 52c78774..999a2c4d 100644 --- a/src/js/types/overviewResponse.ts +++ b/src/js/types/overviewResponse.ts @@ -1,3 +1,5 @@ +import { ChartConfig } from '@/types/chartConfig'; + export interface OverviewResponse { overview: Overview; } @@ -47,11 +49,6 @@ export interface Datum { } interface Layout { - charts: Chart[]; + charts: ChartConfig[]; section_title: string; } - -export interface Chart { - chart_type: 'bar' | 'pie'; - field: string; -}