diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx index f2a056422..212453b39 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -1,19 +1,14 @@ - import React, { useState, useMemo } from 'react'; import { View } from 'react-native'; import { Card, Text, useTheme} from 'react-native-paper'; import { MetricsData } from './metricsTypes'; -import { cardMargin, cardStyles } from './MetricsTab'; -import { filterToRecentWeeks, formatDateRangeOfDays } from './metricsHelper'; +import { cardStyles } from './MetricsTab'; +import { filterToRecentWeeks, formatDateRangeOfDays, parseDataFromMetrics, generateSummaryFromData, calculatePercentChange } from './metricsHelper'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; import { getAngularService } from '../angular-react-helper'; import ChangeIndicator from './ChangeIndicator'; import color from "color"; -import moment from 'moment'; - -//modes considered on foot for carbon calculation, expandable as needed -const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const; type Props = { userMetrics: MetricsData, aggMetrics: MetricsData } const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { @@ -22,120 +17,9 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { const { t } = useTranslation(); const [emissionsChange, setEmissionsChange] = useState({}); - const [userText, setUserText] = useState([]); - const [groupText, setGroupText] = useState([]); - - /* - * metric2val is a function that takes a metric entry and a field and returns - * the appropriate value. - * for regular data (user-specific), this will return the field value - * for avg data (aggregate), this will return the field value/nUsers - */ - const metricToValue = function(population:'user'|'aggreagte', metric, field) { - if(population == "user"){ - return metric[field]; - } - else{ - return metric[field]/metric.nUsers; - } - } - - //testing agains global list of what is "on foot" - //returns true | false - const isOnFoot = function(mode: string) { - for (let ped_mode in ON_FOOT_MODES) { - if (mode === ped_mode) { - return true; - } - } - return false; - } - - const parseDataFromMetrics = function(metrics, population) { - console.log("Called parseDataFromMetrics on ", metrics); - let mode_bins = {}; - metrics.forEach(function(metric) { - let onFootVal = 0; - - for (let field in metric) { - /*For modes inferred from sensor data, we check if the string is all upper case - by converting it to upper case and seeing if it is changed*/ - if(field == field.toUpperCase()) { - /*sum all possible on foot modes: see https://github.com/e-mission/e-mission-docs/issues/422 */ - if (isOnFoot(field)) { - onFootVal += metricToValue(population, metric, field); - field = 'ON_FOOT'; - } - if (!(field in mode_bins)) { - mode_bins[field] = []; - } - //for all except onFoot, add to bin - could discover mult onFoot modes - if (field != "ON_FOOT") { - mode_bins[field].push([metric.ts, metricToValue(population, metric, field), metric.fmt_time]); - } - } - //this section handles user lables, assuming 'label_' prefix - if(field.startsWith('label_')) { - let actualMode = field.slice(6, field.length); //remove prefix - console.log("Mapped field "+field+" to mode "+actualMode); - if (!(actualMode in mode_bins)) { - mode_bins[actualMode] = []; - } - mode_bins[actualMode].push([metric.ts, Math.round(metricToValue(population, metric, field)), moment(metric.fmt_time).format()]); - } - } - //handle the ON_FOOT modes once all have been summed - if ("ON_FOOT" in mode_bins) { - mode_bins["ON_FOOT"].push([metric.ts, Math.round(onFootVal), metric.fmt_time]); - } - }); - - let return_val = []; - for (let mode in mode_bins) { - return_val.push({key: mode, values: mode_bins[mode]}); - } - - return return_val; - } - - const generateSummaryFromData = function(modeMap, metric) { - console.log("Invoked getSummaryDataRaw on ", modeMap, "with", metric); - - let summaryMap = []; - - for (let i=0; i < modeMap.length; i++){ - let summary = {}; - summary['key'] = modeMap[i].key; - let sumVals = 0; - - for (let j = 0; j < modeMap[i].values.length; j++) - { - sumVals += modeMap[i].values[j][1]; //2nd item of array is value - } - if (metric === 'mean_speed'){ - //we care about avg speed, sum for other metrics - summary['values'] = Math.round(sumVals / modeMap[i].values.length); - } else { - summary['values'] = Math.round(sumVals); - } - - summaryMap.push(summary); - } - - return summaryMap; - } - - //from two weeks fo low and high values, calculates low and high change - const calculatePercentChange = function(pastWeekRange, previousWeekRange) { - let greaterLesserPct = { - low: (pastWeekRange.low/previousWeekRange.low) * 100 - 100, - high: (pastWeekRange.high/previousWeekRange.high) * 100 - 100, - } - return greaterLesserPct; - } - + const userCarbonRecords = useMemo(() => { - if(userMetrics) { + if(userMetrics?.distance?.length > 0) { //separate data into weeks let thisWeekDistance = filterToRecentWeeks(userMetrics?.distance)[0]; let lastWeekDistance = filterToRecentWeeks(userMetrics?.distance)[1]; @@ -155,7 +39,6 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { //setting up data to be displayed let graphRecords = []; - let textList = []; //calculate low-high and format range for past week let userPastWeek = { @@ -164,9 +47,7 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { }; graphRecords.push({label: t('main-metrics.unlabeled'), x: userPastWeek.high - userPastWeek.low, y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`}) graphRecords.push({label: t('main-metrics.labeled'), x: userPastWeek.low, y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`}); - textList.push({label: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - value: (userPastWeek.high - userPastWeek.low)==0 ? Math.round(userPastWeek.low) : Math.round(userPastWeek.low) + " - " + Math.round(userPastWeek.high)}); - + //calculate low-high and format range for prev week, if exists if(userLastWeekSummaryMap[0]) { let userPrevWeek = { @@ -175,8 +56,6 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { }; graphRecords.push({label: t('main-metrics.unlabeled'), x: userPrevWeek.high - userPrevWeek.low, y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`}) graphRecords.push({label: t('main-metrics.labeled'), x: userPastWeek.low, y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`}); - textList.push({label: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, - value: (userPrevWeek.high - userPrevWeek.low)==0 ? Math.round(userPrevWeek.low) : Math.round(userPrevWeek.low) + " - " + Math.round(userPrevWeek.high)}); let pctChange = calculatePercentChange(userPastWeek, userPrevWeek); setEmissionsChange(pctChange); @@ -187,15 +66,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { //calculate worst-case carbon footprint let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); graphRecords.push({label: t('main-metrics.labeled'), x: worstCarbon, y: `${t('main-metrics.worst-case')}`}); - textList.push({label:t('main-metrics.worst-case'), value: Math.round(worstCarbon)}); - setUserText(textList); return graphRecords; } }, [userMetrics?.distance]) const groupCarbonRecords = useMemo(() => { - if(aggMetrics) + if(aggMetrics?.distance?.length > 0) { //separate data into weeks let thisWeekDistance = filterToRecentWeeks(aggMetrics?.distance)[0]; @@ -216,7 +93,6 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { } let groupRecords = []; - let groupText = []; let aggCarbon = { low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), @@ -225,10 +101,7 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { console.log("testing group past week", aggCarbon); groupRecords.push({label: t('main-metrics.unlabeled'), x: aggCarbon.high - aggCarbon.low, y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`}); groupRecords.push({label: t('main-metrics.labeled'), x: aggCarbon.low, y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`}); - groupText.push({label: t('main-metrics.average'), - value: (aggCarbon.high - aggCarbon.low)==0 ? Math.round(aggCarbon.low) : Math.round(aggCarbon.low) + " - " + Math.round(aggCarbon.high)}); - setGroupText(groupText); return groupRecords; } }, [aggMetrics]) @@ -246,24 +119,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { return tempChartData; }, [userCarbonRecords, groupCarbonRecords]); - const textEntries = useMemo(() => { - let tempText = [] - if(userText?.length){ - tempText = tempText.concat(userText); - } - if(groupText?.length) { - tempText = tempText.concat(groupText); - } - return tempText; - }, [userText, groupText]); - //hardcoded here, could be read from config at later customization? let carbonGoals = [ {label: t('main-metrics.us-2030-goal'), value: 54, color: color(colors.danger).darken(.25).rgb().toString()}, {label: t('main-metrics.us-2050-goal'), value: 14, color: color(colors.warn).darken(.25).rgb().toString()}]; let meter = { dash_key: t('main-metrics.unlabeled'), high: 54, middle: 14 }; return ( - { {t('metrics.chart-no-data')} } - { textEntries?.length > 0 && - - { Object.keys(textEntries).map((i) => - - {textEntries[i].label} - {textEntries[i].value + ' ' + "kg Co2"} - - )} - - } ) diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx new file mode 100644 index 000000000..d2a6e620c --- /dev/null +++ b/www/js/metrics/CarbonTextCard.tsx @@ -0,0 +1,135 @@ +import React, { useMemo } from 'react'; +import { View } from 'react-native'; +import { Card, Text, useTheme} from 'react-native-paper'; +import { MetricsData } from './metricsTypes'; +import { cardStyles } from './MetricsTab'; +import { useTranslation } from 'react-i18next'; +import { filterToRecentWeeks, formatDateRangeOfDays, parseDataFromMetrics, generateSummaryFromData, calculatePercentChange } from './metricsHelper'; +import { getAngularService } from '../angular-react-helper'; + +type Props = { userMetrics: MetricsData, aggMetrics: MetricsData } +const DailyActiveMinutesCard = ({ userMetrics, aggMetrics }: Props) => { + + const { colors } = useTheme(); + const { t } = useTranslation(); + const FootprintHelper = getAngularService("FootprintHelper"); + + const userText = useMemo(() => { + if(userMetrics?.distance?.length > 0) { + //separate data into weeks + let thisWeekDistance = filterToRecentWeeks(userMetrics?.distance)[0]; + let lastWeekDistance = filterToRecentWeeks(userMetrics?.distance)[1]; + + //formatted distance data from this week + let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); + let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); + let worstDistance = userThisWeekSummaryMap.reduce((prevDistance, currModeSummary) => prevDistance + currModeSummary.values, 0); + + //formatted data from last week + let userLastWeekModeMap = {}; + let userLastWeekSummaryMap = {}; + if(lastWeekDistance) { + userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); + userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); + } + + //setting up data to be displayed + let textList = []; + + //calculate low-high and format range for past week + let userPastWeek = { + low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, FootprintHelper.getHighestFootprint()), + }; + textList.push({label: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + value: (userPastWeek.high - userPastWeek.low)==0 ? Math.round(userPastWeek.low) : Math.round(userPastWeek.low) + " - " + Math.round(userPastWeek.high)}); + + //calculate low-high and format range for prev week, if exists + if(userLastWeekSummaryMap[0]) { + let userPrevWeek = { + low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, FootprintHelper.getHighestFootprint()) + }; + textList.push({label: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, + value: (userPrevWeek.high - userPrevWeek.low)==0 ? Math.round(userPrevWeek.low) : Math.round(userPrevWeek.low) + " - " + Math.round(userPrevWeek.high)}); + } + + //calculate worst-case carbon footprint + let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); + textList.push({label:t('main-metrics.worst-case'), value: Math.round(worstCarbon)}); + + return textList; + } + }, [userMetrics]); + + const groupText = useMemo(() => { + if(aggMetrics?.distance?.length > 0) + { + //separate data into weeks + let thisWeekDistance = filterToRecentWeeks(aggMetrics?.distance)[0]; + + let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, "aggregate"); + let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, "distance"); + + // Issue 422: + // https://github.com/e-mission/e-mission-docs/issues/422 + let aggCarbonData = []; + for (var i in aggThisWeekSummary) { + aggCarbonData.push(aggThisWeekSummary[i]); + if (isNaN(aggCarbonData[i].values)) { + console.warn("WARNING in calculating groupCarbonRecords: value is NaN for mode " + aggCarbonData[i].key + ", changing to 0"); + aggCarbonData[i].values = 0; + } + } + + let groupText = []; + + let aggCarbon = { + low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), + high: FootprintHelper.getFootprintForMetrics(aggCarbonData, FootprintHelper.getHighestFootprint()), + } + console.log("testing group past week", aggCarbon); + groupText.push({label: t('main-metrics.average'), + value: (aggCarbon.high - aggCarbon.low)==0 ? Math.round(aggCarbon.low) : Math.round(aggCarbon.low) + " - " + Math.round(aggCarbon.high)}); + + return groupText; + } + }, [aggMetrics]); + + const textEntries = useMemo(() => { + let tempText = [] + if(userText?.length){ + tempText = tempText.concat(userText); + } + if(groupText?.length) { + tempText = tempText.concat(groupText); + } + return tempText; +}, [userText, groupText]); + + return ( + + + + { textEntries?.length > 0 && + + { Object.keys(textEntries).map((i) => + + {textEntries[i].label} + {textEntries[i].value + ' ' + "kg Co2"} + + )} + + } + + + ) +} + +export default DailyActiveMinutesCard; diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 9701bcc6f..01b1bddce 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -14,6 +14,7 @@ import { secondsToHours, secondsToMinutes } from "./metricsHelper"; import CarbonFootprintCard from "./CarbonFootprintCard"; import Carousel from "../components/Carousel"; import DailyActiveMinutesCard from "./DailyActiveMinutesCard"; +import CarbonTextCard from "./CarbonTextCard"; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; @@ -86,7 +87,10 @@ const MetricsTab = () => { refresh()} /> - + + + + diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 1c01b7fee..2703be19b 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -1,6 +1,7 @@ import { DateTime } from "luxon"; import { formatForDisplay } from "../config/useImperialConfig"; import { DayOfMetricData } from "./metricsTypes"; +import moment from 'moment'; export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { const uniqueLabels: string[] = []; @@ -47,3 +48,117 @@ export function formatDateRangeOfDays(days: DayOfMetricData[]) { const lastDay = lastDayDt.toLocaleString({...DateTime.DATE_SHORT, year: undefined}); return `${firstDay} - ${lastDay}`; } + +/* formatting data form carbon footprint calculations */ + +//modes considered on foot for carbon calculation, expandable as needed +const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const; + +/* +* metric2val is a function that takes a metric entry and a field and returns +* the appropriate value. +* for regular data (user-specific), this will return the field value +* for avg data (aggregate), this will return the field value/nUsers +*/ +const metricToValue = function(population:'user'|'aggreagte', metric, field) { + if(population == "user"){ + return metric[field]; + } + else{ + return metric[field]/metric.nUsers; + } +} + +//testing agains global list of what is "on foot" +//returns true | false +const isOnFoot = function(mode: string) { + for (let ped_mode in ON_FOOT_MODES) { + if (mode === ped_mode) { + return true; + } + } + return false; +} + +//from two weeks fo low and high values, calculates low and high change +export function calculatePercentChange(pastWeekRange, previousWeekRange) { + let greaterLesserPct = { + low: (pastWeekRange.low/previousWeekRange.low) * 100 - 100, + high: (pastWeekRange.high/previousWeekRange.high) * 100 - 100, + } + return greaterLesserPct; +} + +export function parseDataFromMetrics(metrics, population) { + console.log("Called parseDataFromMetrics on ", metrics); + let mode_bins = {}; + metrics.forEach(function(metric) { + let onFootVal = 0; + + for (let field in metric) { + /*For modes inferred from sensor data, we check if the string is all upper case + by converting it to upper case and seeing if it is changed*/ + if(field == field.toUpperCase()) { + /*sum all possible on foot modes: see https://github.com/e-mission/e-mission-docs/issues/422 */ + if (isOnFoot(field)) { + onFootVal += metricToValue(population, metric, field); + field = 'ON_FOOT'; + } + if (!(field in mode_bins)) { + mode_bins[field] = []; + } + //for all except onFoot, add to bin - could discover mult onFoot modes + if (field != "ON_FOOT") { + mode_bins[field].push([metric.ts, metricToValue(population, metric, field), metric.fmt_time]); + } + } + //this section handles user lables, assuming 'label_' prefix + if(field.startsWith('label_')) { + let actualMode = field.slice(6, field.length); //remove prefix + console.log("Mapped field "+field+" to mode "+actualMode); + if (!(actualMode in mode_bins)) { + mode_bins[actualMode] = []; + } + mode_bins[actualMode].push([metric.ts, Math.round(metricToValue(population, metric, field)), moment(metric.fmt_time).format()]); + } + } + //handle the ON_FOOT modes once all have been summed + if ("ON_FOOT" in mode_bins) { + mode_bins["ON_FOOT"].push([metric.ts, Math.round(onFootVal), metric.fmt_time]); + } + }); + + let return_val = []; + for (let mode in mode_bins) { + return_val.push({key: mode, values: mode_bins[mode]}); + } + + return return_val; +} + +export function generateSummaryFromData(modeMap, metric) { + console.log("Invoked getSummaryDataRaw on ", modeMap, "with", metric); + + let summaryMap = []; + + for (let i=0; i < modeMap.length; i++){ + let summary = {}; + summary['key'] = modeMap[i].key; + let sumVals = 0; + + for (let j = 0; j < modeMap[i].values.length; j++) + { + sumVals += modeMap[i].values[j][1]; //2nd item of array is value + } + if (metric === 'mean_speed'){ + //we care about avg speed, sum for other metrics + summary['values'] = Math.round(sumVals / modeMap[i].values.length); + } else { + summary['values'] = Math.round(sumVals); + } + + summaryMap.push(summary); + } + + return summaryMap; +} \ No newline at end of file