diff --git a/i18n/en.pot b/i18n/en.pot index 212d0126..ffe2ff52 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-03-07T10:47:32.346Z\n" -"PO-Revision-Date: 2025-03-07T10:47:32.346Z\n" +"POT-Creation-Date: 2025-03-21T10:46:10.062Z\n" +"PO-Revision-Date: 2025-03-21T10:46:10.062Z\n" msgid "About this app" msgstr "About this app" @@ -111,6 +111,9 @@ msgstr "Home" msgid "Explore data" msgstr "Explore data" +msgid "Station data" +msgstr "Station data" + msgid "Import data" msgstr "Import data" @@ -505,6 +508,19 @@ msgstr "{{name}}: Monthly temperatures {{period}}" msgid "Normal temperature" msgstr "Normal temperature" +msgid "Resample" +msgstr "Resample" + +msgid "Weekly" +msgstr "Weekly" + +msgid "" +"Change from 16-days to weekly or monthly periods using linear " +"interpolation. Weekly periods are recommended when importing to DHIS2." +msgstr "" +"Change from 16-days to weekly or monthly periods using linear " +"interpolation. Weekly periods are recommended when importing to DHIS2." + msgid "{{name}}: {{band}} vegetation index 2024" msgstr "{{name}}: {{band}} vegetation index 2024" @@ -707,6 +723,9 @@ msgstr "Heat stress upper category" msgid "Heat stress lower category" msgstr "Heat stress lower category" +msgid "AirQo token" +msgstr "AirQo token" + msgid "Calculate from data" msgstr "Calculate from data" @@ -785,6 +804,21 @@ msgstr "End value" msgid "Color" msgstr "Color" +msgid "Route set up" +msgstr "Route set up" + +msgid "Your route will be created with the following information ..." +msgstr "Your route will be created with the following information ..." + +msgid "Token" +msgstr "Token" + +msgid "Cancel" +msgstr "Cancel" + +msgid "Save" +msgstr "Save" + msgid "How to configure this app" msgstr "How to configure this app" @@ -900,6 +934,21 @@ msgstr "" msgid "Time zone" msgstr "Time zone" +msgid "Missing route" +msgstr "Missing route" + +msgid "AirQo token not set" +msgstr "AirQo token not set" + +msgid "Create route" +msgstr "Create route" + +msgid "Air Quality PM2.5" +msgstr "Air Quality PM2.5" + +msgid "AirQo" +msgstr "AirQo" + msgid "Approximately 31 km (0.25°)" msgstr "Approximately 31 km (0.25°)" @@ -1129,6 +1178,64 @@ msgstr "Extreme heat stress" msgid "Extreme
heat stress" msgstr "Extreme
heat stress" +msgid "Air Quality Index (AQI" +msgstr "Air Quality Index (AQI" + +msgid "Good" +msgstr "Good" + +msgid "" +"Air quality is considered satisfactory, and air pollution poses little or " +"no risk" +msgstr "" +"Air quality is considered satisfactory, and air pollution poses little or " +"no risk" + +msgid "Moderate" +msgstr "Moderate" + +msgid "" +"Active children and adults, and people with respiratory disease, such as " +"asthma, should limit prolonged outdoor exertion." +msgstr "" +"Active children and adults, and people with respiratory disease, such as " +"asthma, should limit prolonged outdoor exertion." + +msgid "Unhealthy for Sensitive Groups" +msgstr "Unhealthy for Sensitive Groups" + +msgid "" +"Members of sensitive groups may experience health effects. Active children " +"and adults, and people with respiratory disease, such as asthma, should " +"limit prolonged outdoor exertion." +msgstr "" +"Members of sensitive groups may experience health effects. Active children " +"and adults, and people with respiratory disease, such as asthma, should " +"limit prolonged outdoor exertion." + +msgid "Unhealthy" +msgstr "Unhealthy" + +msgid "" +"Everyone may begin to experience health effects; members of sensitive " +"groups may experience more serious health effects." +msgstr "" +"Everyone may begin to experience health effects; members of sensitive " +"groups may experience more serious health effects." + +msgid "Very unhealthy" +msgstr "Very unhealthy" + +msgid "" +"Health warnings of emergency conditions. The entire population is more " +"likely to be affected." +msgstr "" +"Health warnings of emergency conditions. The entire population is more " +"likely to be affected." + +msgid "Hazardous" +msgstr "Hazardous" + msgid "ERA5-Land / Copernicus Climate Change Service / Google Earth Engine" msgstr "ERA5-Land / Copernicus Climate Change Service / Google Earth Engine" @@ -1140,6 +1247,3 @@ msgstr "NASA LP DAAC at the USGS EROS Center / Google Earth Engine" msgid "No data found for the selected period" msgstr "No data found for the selected period" - -msgid "Weekly" -msgstr "Weekly" diff --git a/src/components/Root.jsx b/src/components/Root.jsx index 61ba6a31..42eae527 100644 --- a/src/components/Root.jsx +++ b/src/components/Root.jsx @@ -10,6 +10,7 @@ import styles from './styles/Root.module.css' export const appPages = [ { path: '/', name: i18n.t('Home') }, { path: '/explore', name: i18n.t('Explore data') }, + { path: '/station', name: i18n.t('Station data') }, { path: '/import', name: i18n.t('Import data') }, { path: '/setup', name: i18n.t('Setup guide') }, { path: '/settings', name: i18n.t('Settings') }, diff --git a/src/components/Routes.jsx b/src/components/Routes.jsx index 1f895e09..3b160df6 100644 --- a/src/components/Routes.jsx +++ b/src/components/Routes.jsx @@ -23,6 +23,7 @@ import ImportPage from './import/ImportPage.jsx' import Root from './Root.jsx' import SettingsPage from './settings/SettingsPage.jsx' import SetupPage from './setup/SetupPage.jsx' +import StationPage from './station/StationPage.jsx' const monthlyPath = 'monthly/:startTime/:endTime/:referencePeriodId' const dailyPath = 'daily/:startTime/:endTime' @@ -160,6 +161,10 @@ const Routes = () => { }, ], }, + { + path: 'station', + element: , + }, { path: 'import', element: , diff --git a/src/components/settings/ChartSettings.jsx b/src/components/settings/ChartSettings.jsx index 4692581d..80aa15c1 100644 --- a/src/components/settings/ChartSettings.jsx +++ b/src/components/settings/ChartSettings.jsx @@ -33,7 +33,6 @@ const ChartSettings = () => { label={i18n.t('Max temperature in °C')} value={tempMax} onChange={changeSetting} - inputWidth="100px" /> { + const { settings, changeSetting } = useAppSettings() + + if (!settings) { + return null + } + + const { airqoToken } = settings + + return ( + <> +

Data settings

+ + changeSetting( + 'airqoToken', + value !== '' ? value : undefined + ) + } + /> + + ) +} + +export default ChartSettings diff --git a/src/components/settings/SettingsPage.jsx b/src/components/settings/SettingsPage.jsx index f334886a..ace16633 100644 --- a/src/components/settings/SettingsPage.jsx +++ b/src/components/settings/SettingsPage.jsx @@ -1,6 +1,7 @@ import i18n from '@dhis2/d2-i18n' import useAppSettings from '../../hooks/useAppSettings.js' import ChartSettings from './ChartSettings.jsx' +import DataSettings from './DataSettings.jsx' import StartPageSelect from './StartPageSelect.jsx' import styles from './styles/SettingsPage.module.css' import TimeZoneSelect from './TimeZoneSelect.jsx' @@ -25,6 +26,7 @@ const SettingsPage = () => { + ) } diff --git a/src/components/setup/RouteSetup.jsx b/src/components/setup/RouteSetup.jsx new file mode 100644 index 00000000..887599ac --- /dev/null +++ b/src/components/setup/RouteSetup.jsx @@ -0,0 +1,94 @@ +import { useDataMutation } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import { + Modal, + ModalTitle, + ModalContent, + ModalActions, + ButtonStrip, + Button, + InputField, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useState } from 'react' + +const mutation = { + resource: 'routes', + type: 'create', + data: ({ data }) => data, +} + +const RouteSetup = ({ + routeInfo, + isRouteSetupModalOpen, + setIsRouteSetupModalOpen, +}) => { + const [token, setToken] = useState('') + if (routeInfo.auth.type == 'api-query-params') { + routeInfo.auth.queryParams = { + token: token, + } + } else if (routeInfo.auth.type == 'api-headers') { + routeInfo.auth.headers = { + 'X-API-KEY': token, + } + } + const [saveRoute] = useDataMutation(mutation, { + variables: { + data: routeInfo, + }, + onComplete: () => { + setIsRouteSetupModalOpen(!isRouteSetupModalOpen) + }, + }) + + return ( + <> + + {i18n.t('Route set up')} + +

+ {i18n.t( + 'Your route will be created with the following information ...' + )} +

+
+
{JSON.stringify(routeInfo, null, 2)}
+
+ setToken(value)} + /> +
+ + + + + + +
+ + ) +} + +RouteSetup.propTypes = { + isRouteSetupModalOpen: PropTypes.bool.isRequired, + routeInfo: PropTypes.object.isRequired, + setIsRouteSetupModalOpen: PropTypes.func.isRequired, +} + +export default RouteSetup diff --git a/src/components/station/StationPage.jsx b/src/components/station/StationPage.jsx new file mode 100644 index 00000000..81896776 --- /dev/null +++ b/src/components/station/StationPage.jsx @@ -0,0 +1,8 @@ +import AirQoPlugin from './airqo/AirQoPlugin.jsx' +// import styles from './styles/StationPage.module.css' + +const StationPage = () => { + return +} + +export default StationPage diff --git a/src/components/station/airqo/AirQoPlugin.jsx b/src/components/station/airqo/AirQoPlugin.jsx new file mode 100644 index 00000000..4d7f769f --- /dev/null +++ b/src/components/station/airqo/AirQoPlugin.jsx @@ -0,0 +1,118 @@ +import { useDataEngine } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import { CircularLoader, NoticeBox, Button } from '@dhis2/ui' +import PropTypes from 'prop-types' +import { useState, useEffect } from 'react' +import legend from '../../../data/pm2.5-legend.js' +import useGetRoute from '../../../hooks/useGetRoute.js' +import Chart from '../../explore/Chart.jsx' +import RouteSetup from '../../setup/RouteSetup.jsx' +import getChartConfig from '../charts/AirQualityGauge.js' +import styles from './styles/AirQoPlugin.module.css' + +const AirQoPlugin = ({ siteId }) => { + const engine = useDataEngine() + const [data, setData] = useState() + const { route } = useGetRoute() + + const [isRouteSetupModalOpen, setIsRouteSetupModalOpen] = useState(false) + + const routeInfo = { + name: 'AirQo', + code: 'airqo', + disabled: false, + url: 'https://api.airqo.net/api/v2/devices/measurements/sites/**', + auth: { + type: 'api-query-params', + }, + } + + useEffect(() => { + if (route && route.id && !data) { + const resource = `routes/${route.id}/run/${siteId}/recent` + + engine + .query({ + routes: { resource }, + }) + .then((response) => { + setData(response?.routes?.measurements?.[0]) + }) + .catch((error) => { + console.error('Error fetching airqo data:', error) + }) + } + }, [route, data]) + + if (!route || !route.id) { + return ( +
+ + {i18n.t('AirQo token not set')} + + +
+ + {isRouteSetupModalOpen && ( + + )} +
+
+ ) + } + + if (!data) { + return + } + + const { + siteDetails: { name, city }, + aqi_category: category, + pm2_5: { value: pm2_5 }, + pm10: { value: pm10 }, + time, + } = data + + const description = legend.items.find( + (item) => item.category === category + )?.description + + const formattedTime = new Date(time).toLocaleTimeString([], { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + + return data ? ( +
+ + {description && ( +
{description}
+ )} +
+ ) : null +} + +AirQoPlugin.propTypes = { + siteId: PropTypes.string.isRequired, +} + +export default AirQoPlugin diff --git a/src/components/station/airqo/DeviceSelect.jsx b/src/components/station/airqo/DeviceSelect.jsx new file mode 100644 index 00000000..19d291dc --- /dev/null +++ b/src/components/station/airqo/DeviceSelect.jsx @@ -0,0 +1,6 @@ +const DeviceSelect = () => { + console.log('DeviceSelect') + return null +} + +export default DeviceSelect diff --git a/src/components/station/airqo/styles/AirQoPlugin.module.css b/src/components/station/airqo/styles/AirQoPlugin.module.css new file mode 100644 index 00000000..674c45a8 --- /dev/null +++ b/src/components/station/airqo/styles/AirQoPlugin.module.css @@ -0,0 +1,25 @@ +.container { + margin-top: 20px; + border: 1px solid #ccc; + width: 400px; + height: 400px; +} + +.container :global(.airquality-label) span span { + display: block; + font-weight: normal; + font-size: 14px; + line-height: 28px; + text-align: center; +} + +.description { + padding: 10px 20px; + font-size: 16px; + line-height: 20px; + color: #333; +} + +.margin { + margin-top: 20px; +} \ No newline at end of file diff --git a/src/components/station/charts/AirQualityGauge.js b/src/components/station/charts/AirQualityGauge.js new file mode 100644 index 00000000..14193020 --- /dev/null +++ b/src/components/station/charts/AirQualityGauge.js @@ -0,0 +1,101 @@ +import i18n from "@dhis2/d2-i18n"; +import legend from "../../../data/pm2.5-legend"; +import { roundOneDecimal } from "../../../utils/calc"; + +const thickness = 50; + +const getChartConfig = (name, value, category, time) => ({ + title: { + text: i18n.t("Air Quality PM2.5"), + useHTML: true, + }, + subtitle: { + text: `${name}
${time}`, + style: { + fontSize: "14px", + lineHeight: "20px", + }, + }, + credits: { + href: "https://airqo.net", + text: i18n.t("AirQo"), + style: { + fontSize: "12px", + }, + }, + chart: { + type: "gauge", + plotBackgroundColor: null, + plotBackgroundImage: null, + plotBorderWidth: 0, + plotShadow: false, + height: "80%", + }, + pane: { + startAngle: -90, + endAngle: 90, + background: null, + center: ["50%", "75%"], + size: "130%", + }, + yAxis: { + min: 0, + max: 350, + tickPosition: "inside", + tickColor: "#FFFFFF", + tickLength: thickness, + tickWidth: 1, + minorTickInterval: null, + labels: { + distance: 20, + style: { + fontSize: "14px", + }, + }, + + tickPositions: [9, 35.5, 55.5, 125.5, 225.5], + lineWidth: 0, + plotBands: legend.items.map((item) => ({ + ...item, + thickness, + borderRadius: "50%", + })), + }, + tooltip: { + useHTML: true, + }, + series: [ + { + name: "PM2.5", + data: [value], + tooltip: { + valueSuffix: " μg/m3", + }, + dataLabels: { + useHTML: true, + format: `${category}${roundOneDecimal( + value + )} μg/m3`, + borderWidth: 0, + className: "airquality-label", + color: "#333333", + style: { + fontSize: "16px", + }, + }, + dial: { + radius: "80%", + backgroundColor: "#333", + baseWidth: 12, + baseLength: "0%", + rearLength: "0%", + }, + pivot: { + backgroundColor: "#333", + radius: 6, + }, + }, + ], +}); + +export default getChartConfig; diff --git a/src/components/station/styles/StationPage.module.css b/src/components/station/styles/StationPage.module.css new file mode 100644 index 00000000..e69de29b diff --git a/src/data/pm2.5-legend.js b/src/data/pm2.5-legend.js new file mode 100644 index 00000000..b5e5cfdd --- /dev/null +++ b/src/data/pm2.5-legend.js @@ -0,0 +1,70 @@ +import i18n from "@dhis2/d2-i18n"; + +// https://www.iqair.com/newsroom/what-is-aqi +// https://aqicn.org/scale/ +export default { + name: i18n.t("Air Quality Index (AQI"), + description: i18n.t(""), + items: [ + { + category: "Good", + name: i18n.t("Good"), + description: i18n.t( + "Air quality is considered satisfactory, and air pollution poses little or no risk" + ), + color: "#abd162", + from: 0, + to: 9, + }, + { + category: "Moderate", + name: i18n.t("Moderate"), + description: i18n.t( + "Active children and adults, and people with respiratory disease, such as asthma, should limit prolonged outdoor exertion." + ), + color: "#f8d461", + from: 9, + to: 35.5, + }, + { + category: "Unhealthy for Sensitive Groups", + name: i18n.t("Unhealthy for Sensitive Groups"), + description: i18n.t( + "Members of sensitive groups may experience health effects. Active children and adults, and people with respiratory disease, such as asthma, should limit prolonged outdoor exertion." + ), + color: "#fb9956", + from: 35.5, + to: 55.5, + }, + { + category: "Unhealthy", + name: i18n.t("Unhealthy"), + description: i18n.t( + "Everyone may begin to experience health effects; members of sensitive groups may experience more serious health effects." + ), + color: "#f6686a", + from: 55.5, + to: 125.5, + }, + { + category: "Very unhealthy", + name: i18n.t("Very unhealthy"), + description: i18n.t( + "Health warnings of emergency conditions. The entire population is more likely to be affected." + ), + color: "#a47db8", + from: 125.5, + to: 225.5, + }, + { + category: "Hazardous", + name: i18n.t("Hazardous"), + description: i18n.t( + "Health alert: everyone may experience more serious health effects" + ), + color: "#a07785", + from: 225.5, + to: 400, + }, + ], +}; diff --git a/src/hooks/useGetRoute.js b/src/hooks/useGetRoute.js new file mode 100644 index 00000000..c5c8072b --- /dev/null +++ b/src/hooks/useGetRoute.js @@ -0,0 +1,24 @@ +import { useDataQuery } from '@dhis2/app-runtime' + +const ROUTE_QUERY = { + routes: { + resource: 'routes', + params: { + paging: false, + filter: `code:eq:airqo`, + fields: '*', + }, + }, +} + +const useGetRoute = () => { + const { loading, error, data } = useDataQuery(ROUTE_QUERY) + + return { + route: data?.routes?.routes[0], + error, + loading, + } +} + +export default useGetRoute