diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 20672bad0..943f06520 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -109,7 +109,6 @@ "angular": "1.6.7", "angular-animate": "1.6.7", "angular-local-storage": "^0.7.1", - "angular-nvd3": "^1.0.7", "angular-sanitize": "1.6.7", "angular-simple-logger": "^0.1.7", "angular-translate": "^2.18.1", @@ -158,7 +157,6 @@ "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", "npm": "^9.6.3", - "nvd3": "^1.8.6", "phonegap-plugin-barcodescanner": "git+https://github.com/phonegap/phonegap-plugin-barcodescanner#v8.1.0", "prop-types": "^15.8.1", "react": "^18.2.*", diff --git a/package.serve.json b/package.serve.json index f16c8bd66..a4e3194f3 100644 --- a/package.serve.json +++ b/package.serve.json @@ -55,7 +55,6 @@ "angular": "1.6.7", "angular-animate": "1.6.7", "angular-local-storage": "^0.7.1", - "angular-nvd3": "^1.0.7", "angular-sanitize": "1.6.7", "angular-simple-logger": "^0.1.7", "angular-translate": "^2.18.1", @@ -83,7 +82,6 @@ "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", "npm": "^9.6.3", - "nvd3": "^1.8.6", "prop-types": "^15.8.1", "react": "^18.2.*", "react-chartjs-2": "^5.2.0", diff --git a/www/css/style.css b/www/css/style.css index 5e923f5bd..8910b2258 100644 --- a/www/css/style.css +++ b/www/css/style.css @@ -18,9 +18,6 @@ max-height: 50px; } -/* nvd3 styles */ -@import 'nvd3/build/nv.d3.css'; - .fill-container { display: block; position: relative; @@ -746,15 +743,6 @@ timestamp-badge[light-bg] { padding: 5% 10%; } -svg { - display: block; -} -#chart, #chart svg { - margin-right: 10px; -} -.nvd3, nv-noData { - font-weight: 300 !important; -} .metric-datepicker { /*height: 33px;*/ display: flex; /* establish flex container */ diff --git a/www/i18n/en.json b/www/i18n/en.json index 2e164eacb..fe0df617a 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -103,9 +103,11 @@ "less-than": " less than ", "less": " less ", "week-before": "vs. week before", + "this-week": "this week", "pick-a-date": "Pick a date", "trips": "trips", "hours": "hours", + "minutes": "minutes", "custom": "Custom" }, @@ -142,42 +144,43 @@ "no-travel-hint": "To see more, change the filters above or go record some travel!" }, - "user-gender": "Gender", - "gender-male": "Male", - "gender-female": "Female", - "user-height": "Height", - "user-weight": "Weight", - "user-age": "Age", - "main-metrics":{ "dashboard": "Dashboard", "summary": "My Summary", "chart": "Chart", "change-data": "Change dates:", - "distance": "My Distance", - "trips": "My Trips", - "duration": "My Duration", + "distance": "Distance", + "trips": "Trips", + "duration": "Duration", "fav-mode": "My Favorite Mode", "speed": "My Speed", "footprint": "My Footprint", + "estimated-emissions": "Estimated CO₂ emissions", "how-it-compares": "Ballpark comparisons", - "optimal": "Optimal (perfect mode choice for all my trips):", - "average": "Average for group:", - "avoided": "CO₂ avoided (vs. all 'taxi'):", + "optimal": "Optimal (perfect mode choice for all my trips)", + "average": "Group Avg.", + "worst-case": "Worse Case", "label-to-squish": "Label trips to collapse the range into a single number", + "range-uncertain-footnote": "²Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.", "lastweek": "My last week value:", - "us-2030-goal": "US 2030 Goal Estimate:", - "us-2050-goal": "US 2050 Goal Estimate:", - "calories": "My Calories", - "calibrate": "Calibrate", + "us-2030-goal": "2030 Guideline¹", + "us-2050-goal": "2050 Guideline¹", + "us-goals-footnote": "¹Guidelines based on US decarbonization goals, scaled to per-capita travel-related emissions.", + "past-week" : "Past Week", + "prev-week" : "Prev. Week", "no-summary-data": "No summary data", "mean-speed": "My Average Speed", - "equals-cookies_one": "Equals at least {{count}} homemade chocolate chip cookie", - "equals-cookies_other": "Equals at least {{count}} homemade chocolate chip cookies", - "equals-icecream_one": "Equals at least {{count}} half cup vanilla ice cream", - "equals-icecream_other": "Equals at least {{count}} half cups vanilla ice cream", - "equals-bananas_one": "Equals at least {{count}} banana", - "equals-bananas_other": "Equals at least {{count}} bananas" + "user-totals": "My Totals", + "group-totals": "Group Totals", + "active-minutes": "Active Minutes", + "weekly-active-minutes": "Weekly minutes of active travel", + "daily-active-minutes": "Daily minutes of active travel", + "active-minutes-table": "Table of active minutes metrics", + "weekly-goal": "Weekly Goal³", + "weekly-goal-footnote": "³Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.", + "labeled": "Labeled", + "unlabeled": "Unlabeled²", + "footprint-label": "Footprint (kg CO₂)" }, "main-inf-scroll" : { diff --git a/www/index.js b/www/index.js index 1ed4e3c27..17a5326d7 100644 --- a/www/index.js +++ b/www/index.js @@ -39,7 +39,6 @@ import './js/survey/enketo/infinite_scroll_filters.js'; import './js/survey/enketo/enketo-trip-button.js'; import './js/survey/enketo/enketo-demographics.js'; import './js/survey/enketo/enketo-add-note-button.js'; -import './js/metrics.js'; import './js/control/general-settings.js'; import './js/control/emailService.js'; import './js/control/uploadService.js'; diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index 7571a564c..5f47f00b1 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -26,7 +26,8 @@ const AppTheme = { level4: '#e0f0ff', // lch(94% 50 250) level5: '#d6ebff', // lch(92% 50 250) }, - success: '#38872e', // lch(50% 55 135) + success: '#00a665', // lch(60% 55 155) + warn: '#f8cf53', //lch(85% 65 85) danger: '#f23934' // lch(55% 85 35) }, roundness: 5, diff --git a/www/js/components/BarChart.tsx b/www/js/components/BarChart.tsx index 6da3d2a2b..1e957923b 100644 --- a/www/js/components/BarChart.tsx +++ b/www/js/components/BarChart.tsx @@ -1,201 +1,27 @@ +import React from "react"; +import Chart, { Props as ChartProps } from "./Chart"; +import { useTheme } from "react-native-paper"; +import { getGradient } from "./charting"; -import React, { useRef, useState } from 'react'; -import { array, string, bool } from 'prop-types'; -import { angularize } from '../angular-react-helper'; -import { View } from 'react-native'; -import { useTheme } from 'react-native-paper'; -import { Chart, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, TimeScale } from 'chart.js'; -import { Bar } from 'react-chartjs-2'; -import Annotation, { AnnotationOptions } from 'chartjs-plugin-annotation'; - -Chart.register( - CategoryScale, - LinearScale, - TimeScale, - BarElement, - Title, - Tooltip, - Legend, - Annotation, -); - -const BarChart = ({ chartData, axisTitle, lineAnnotations=null, isHorizontal=false }) => { +type Props = Omit & { + meter?: {high: number, middle: number, dash_key: string}, +} +const BarChart = ({ meter, ...rest }: Props) => { const { colors } = useTheme(); - const [ numVisibleDatasets, setNumVisibleDatasets ] = useState(1); - - const barChartRef = useRef(null); - - const defaultPalette = [ - '#c95465', // red oklch(60% 0.15 14) - '#4a71b1', // blue oklch(55% 0.11 260) - '#d2824e', // orange oklch(68% 0.12 52) - '#856b5d', // brown oklch(55% 0.04 50) - '#59894f', // green oklch(58% 0.1 140) - '#e0cc55', // yellow oklch(84% 0.14 100) - '#b273ac', // purple oklch(64% 0.11 330) - '#f09da6', // pink oklch(78% 0.1 12) - '#b3aca8', // grey oklch(75% 0.01 55) - '#80afad', // teal oklch(72% 0.05 192) - ] - - const indexAxis = isHorizontal ? 'y' : 'x'; - function getChartHeight() { - /* when horizontal charts have more data, they should get taller - so they don't look squished */ - if (isHorizontal) { - // 'ideal' chart height is based on the number of datasets and number of unique index values - const uniqueIndexVals = []; - chartData.forEach(e => e.records.forEach(r => { - if (!uniqueIndexVals.includes(r[indexAxis])) uniqueIndexVals.push(r[indexAxis]); - })); - const numIndexVals = uniqueIndexVals.length; - const idealChartHeight = numVisibleDatasets * numIndexVals * 8; - - /* each index val should be at least 20px tall for visibility, - and the graph itself should be at least 250px tall */ - const minChartHeight = Math.max(numIndexVals * 20, 250); - - // return whichever is greater - return { height: Math.max(idealChartHeight, minChartHeight) }; + if (meter) { + rest.getColorForChartEl = (chart, dataset, ctx, colorFor) => { + const darkenDegree = colorFor == 'border' ? 0.25 : 0; + const alpha = colorFor == 'border' ? 1 : 0; + return getGradient(chart, meter, dataset, ctx, alpha, darkenDegree); } - // vertical charts will just match the parent container - return { height: '100%' }; + rest.borderWidth = 3; } return ( - - ({ - label: d.label, - data: d.records, - // cycle through the default palette, repeat if necessary - backgroundColor: defaultPalette[i % defaultPalette.length], - })) - }} - options={{ - indexAxis: indexAxis, - responsive: true, - maintainAspectRatio: false, - resizeDelay: 1, - scales: { - ...(isHorizontal ? { - y: { - offset: true, - type: 'time', - adapters: { - date: { zone: 'utc' }, - }, - time: { - unit: 'day', - tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 - }, - beforeUpdate: (axis) => { - setNumVisibleDatasets(axis.chart.getVisibleDatasetCount()) - }, - reverse: true, - }, - x: { - title: { display: true, text: axisTitle }, - }, - } : { - x: { - offset: true, - type: 'time', - adapters: { - date: { zone: 'utc' }, - }, - time: { - unit: 'day', - tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 - }, - }, - y: { - title: { display: true, text: axisTitle }, - }, - }), - }, - plugins: { - ...(lineAnnotations?.length > 0 && { - annotation: { - annotations: lineAnnotations.map((a, i) => ({ - type: 'line', - label: { - display: true, - padding: { x: 3, y: 1 }, - borderRadius: 0, - backgroundColor: 'rgba(0,0,0,.7)', - color: 'rgba(255,255,255,1)', - font: { size: 10 }, - position: 'start', - content: a.label, - }, - ...(isHorizontal ? { xMin: a.value, xMax: a.value } - : { yMin: a.value, yMax: a.value }), - borderColor: colors.onBackground, - borderWidth: 2, - borderDash: [3, 3], - } satisfies AnnotationOptions)), - } - }), - } - }} /> - - ) + + ); } -BarChart.propTypes = { - chartData: array, - axisTitle: string, - lineAnnotations: array, - isHorizontal: bool, -}; - -angularize(BarChart, 'BarChart', 'emission.main.barchart'); export default BarChart; - -// const sampleAnnotations = [ -// { value: 35, label: 'Target1' }, -// { value: 65, label: 'Target2' }, -// ]; - -// const sampleChartData = [ -// { -// label: 'Primary', -// records: [ -// { x: moment('2023-06-20'), y: 20 }, -// { x: moment('2023-06-21'), y: 30 }, -// { x: moment('2023-06-23'), y: 80 }, -// { x: moment('2023-06-24'), y: 40 }, -// ], -// }, -// { -// label: 'Secondary', -// records: [ -// { x: moment('2023-06-21'), y: 10 }, -// { x: moment('2023-06-22'), y: 50 }, -// { x: moment('2023-06-23'), y: 30 }, -// { x: moment('2023-06-25'), y: 40 }, -// ], -// }, -// { -// label: 'Tertiary', -// records: [ -// { x: moment('2023-06-20'), y: 30 }, -// { x: moment('2023-06-22'), y: 40 }, -// { x: moment('2023-06-24'), y: 10 }, -// { x: moment('2023-06-25'), y: 60 }, -// ], -// }, -// { -// label: 'Quaternary', -// records: [ -// { x: moment('2023-06-22'), y: 10 }, -// { x: moment('2023-06-23'), y: 20 }, -// { x: moment('2023-06-24'), y: 30 }, -// { x: moment('2023-06-25'), y: 40 }, -// ], -// }, -// ]; diff --git a/www/js/components/Carousel.tsx b/www/js/components/Carousel.tsx new file mode 100644 index 000000000..28a31ff6a --- /dev/null +++ b/www/js/components/Carousel.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { ScrollView, View } from 'react-native'; + +type Props = { + children: React.ReactNode, + cardWidth: number, + cardMargin: number, +} +const Carousel = ({ children, cardWidth, cardMargin }: Props) => { + const numCards = React.Children.count(children); + return ( + + {React.Children.map(children, (child, i) => ( + + {child} + + ))} + + ) +}; + +export const s = { + carouselScroll: (cardMargin) => ({ + // @ts-ignore, RN doesn't recognize `scrollSnapType`, but it does work on RN Web + scrollSnapType: 'x mandatory', + paddingVertical: 10, + }), + carouselCard: (cardWidth, cardMargin, isFirst, isLast) => ({ + marginLeft: isFirst ? cardMargin : cardMargin/2, + marginRight: isLast ? cardMargin : cardMargin/2, + width: cardWidth, + scrollSnapAlign: 'center', + scrollSnapStop: 'always', + }), +}; + +export default Carousel; diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx new file mode 100644 index 000000000..3969eb588 --- /dev/null +++ b/www/js/components/Chart.tsx @@ -0,0 +1,207 @@ + +import React, { useEffect, useRef, useState, useMemo } from 'react'; +import { View } from 'react-native'; +import { useTheme } from 'react-native-paper'; +import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, TimeScale, ChartData, ChartType, ScriptableContext, PointElement, LineElement } from 'chart.js'; +import { Chart as ChartJSChart } from 'react-chartjs-2'; +import Annotation, { AnnotationOptions, LabelPosition } from 'chartjs-plugin-annotation'; +import { dedupColors, getChartHeight, darkenOrLighten } from './charting'; + +ChartJS.register( + CategoryScale, + LinearScale, + TimeScale, + BarElement, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Annotation, +); + +type XYPair = { x: number|string, y: number|string }; +type ChartDataset = { + label: string, + data: XYPair[], +}; + +export type Props = { + records: { label: string, x: number|string, y: number|string }[], + axisTitle: string, + type: 'bar'|'line', + getColorForLabel?: (label: string) => string, + getColorForChartEl?: (chart, currDataset: ChartDataset, ctx: ScriptableContext<'bar'|'line'>, colorFor: 'background'|'border') => string|CanvasGradient|null, + borderWidth?: number, + lineAnnotations?: { value: number, label?: string, color?:string, position?: LabelPosition }[], + isHorizontal?: boolean, + timeAxis?: boolean, + stacked?: boolean, +} +const Chart = ({ records, axisTitle, type, getColorForLabel, getColorForChartEl, borderWidth, lineAnnotations, isHorizontal, timeAxis, stacked }: Props) => { + + const { colors } = useTheme(); + const [ numVisibleDatasets, setNumVisibleDatasets ] = useState(1); + + const indexAxis = isHorizontal ? 'y' : 'x'; + const chartRef = useRef>(null); + const [chartDatasets, setChartDatasets] = useState([]); + + const chartData = useMemo>(() => { + let labelColorMap; // object mapping labels to colors + if (getColorForLabel) { + const colorEntries = chartDatasets.map(d => [d.label, getColorForLabel(d.label)] ); + labelColorMap = dedupColors(colorEntries); + } + return { + datasets: chartDatasets.map((e, i) => ({ + ...e, + backgroundColor: (barCtx) => ( + labelColorMap?.[e.label] || getColorForChartEl(chartRef.current, e, barCtx, 'background') + ), + borderColor: (barCtx) => ( + darkenOrLighten(labelColorMap?.[e.label], -.5) || getColorForChartEl(chartRef.current, e, barCtx, 'border') + ), + borderWidth: borderWidth || 2, + borderRadius: 3, + })), + }; + }, [chartDatasets, getColorForLabel]); + + // group records by label (this is the format that Chart.js expects) + useEffect(() => { + const d = records?.reduce((acc, record) => { + const existing = acc.find(e => e.label == record.label); + if (!existing) { + acc.push({ + label: record.label, + data: [{ + x: record.x, + y: record.y, + }], + }); + } else { + existing.data.push({ + x: record.x, + y: record.y, + }); + } + return acc; + }, [] as ChartDataset[]); + setChartDatasets(d); + }, [records]); + + const annotationsAtTop = isHorizontal && lineAnnotations?.some(a => (!a.position || a.position == 'start')); + + return ( + + { + setNumVisibleDatasets(axis.chart.getVisibleDatasetCount()) + }, + ticks: timeAxis ? {} : { + callback: (value, i) => { + const label = chartDatasets[0].data[i].y; + if (typeof label == 'string' && label.includes('\n')) + return label.split('\n'); + return label; + }, + font: { size: 11 }, // default is 12, we want a tad smaller + }, + reverse: true, + stacked, + }, + x: { + title: { display: true, text: axisTitle }, + stacked, + }, + } : { + x: { + offset: true, + type: timeAxis ? 'time' : 'category', + adapters: timeAxis ? { + date: { zone: 'utc' }, + } : {}, + time: timeAxis ? { + unit: 'day', + tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 + } : {}, + ticks: timeAxis ? {} : { + callback: (value, i) => { + console.log("testing vertical", chartData, i); + const label = chartDatasets[0].data[i].x; + if (typeof label == 'string' && label.includes('\n')) + return label.split('\n'); + return label; + }, + }, + stacked, + }, + y: { + title: { display: true, text: axisTitle }, + stacked, + }, + }), + }, + plugins: { + ...(lineAnnotations?.length > 0 && { + annotation: { + clip: false, + annotations: lineAnnotations.map((a, i) => ({ + type: 'line', + label: { + display: true, + padding: { x: 3, y: 1 }, + borderRadius: 0, + backgroundColor: 'rgba(0,0,0,.7)', + color: 'rgba(255,255,255,1)', + font: { size: 10 }, + position: a.position || 'start', + content: a.label, + yAdjust: annotationsAtTop ? -12 : 0, + }, + ...(isHorizontal ? { xMin: a.value, xMax: a.value } + : { yMin: a.value, yMax: a.value }), + borderColor: a.color || colors.onBackground, + borderWidth: 3, + borderDash: [3, 3], + } satisfies AnnotationOptions)), + } + }), + } + }} + // if there are annotations at the top of the chart, it overlaps with the legend + // so we need to increase the spacing between the legend and the chart + // https://stackoverflow.com/a/73498454 + plugins={annotationsAtTop && [{ + id: "increase-legend-spacing", + beforeInit(chart) { + const originalFit = (chart.legend as any).fit; + (chart.legend as any).fit = function fit() { + originalFit.bind(chart.legend)(); + this.height += 12; + }; + } + }]} /> + + ) +} +export default Chart; diff --git a/www/js/components/LineChart.tsx b/www/js/components/LineChart.tsx new file mode 100644 index 000000000..66d21aac2 --- /dev/null +++ b/www/js/components/LineChart.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import Chart, { Props as ChartProps } from "./Chart"; + +type Props = Omit & { } +const LineChart = ({ ...rest }: Props) => { + return ( + + ); +} + +export default LineChart; diff --git a/www/js/components/charting.ts b/www/js/components/charting.ts new file mode 100644 index 000000000..f0da14619 --- /dev/null +++ b/www/js/components/charting.ts @@ -0,0 +1,161 @@ +import color from 'color'; +import { getBaseModeByKey } from '../diary/diaryHelper'; +import { readableLabelToKey } from '../survey/multilabel/confirmHelper'; + +export const defaultPalette = [ + '#c95465', // red oklch(60% 0.15 14) + '#4a71b1', // blue oklch(55% 0.11 260) + '#d2824e', // orange oklch(68% 0.12 52) + '#856b5d', // brown oklch(55% 0.04 50) + '#59894f', // green oklch(58% 0.1 140) + '#e0cc55', // yellow oklch(84% 0.14 100) + '#b273ac', // purple oklch(64% 0.11 330) + '#f09da6', // pink oklch(78% 0.1 12) + '#b3aca8', // grey oklch(75% 0.01 55) + '#80afad', // teal oklch(72% 0.05 192) +]; + +export function getChartHeight(chartDatasets, numVisibleDatasets, indexAxis, isHorizontal, stacked) { + /* when horizontal charts have more data, they should get taller + so they don't look squished */ + if (isHorizontal) { + // 'ideal' chart height is based on the number of datasets and number of unique index values + const uniqueIndexVals = []; + chartDatasets.forEach(e => e.data.forEach(r => { + if (!uniqueIndexVals.includes(r[indexAxis])) uniqueIndexVals.push(r[indexAxis]); + })); + const numIndexVals = uniqueIndexVals.length; + const heightPerIndexVal = stacked ? 36 : numVisibleDatasets * 8; + const idealChartHeight = heightPerIndexVal * numIndexVals; + + /* each index val should be at least 20px tall for visibility, + and the graph itself should be at least 250px tall */ + const minChartHeight = Math.max(numIndexVals * 20, 250); + + // return whichever is greater + return { height: Math.max(idealChartHeight, minChartHeight) }; + } + // vertical charts should just fill the available space in the parent container + return { flex: 1 }; +} + +function getBarHeight(stacks) { + let totalHeight = 0; + console.log("ctx stacks", stacks.x); + for(let val in stacks.x) { + if(!val.startsWith('_')){ + totalHeight += stacks.x[val]; + console.log("ctx added ", val ); + } + } + return totalHeight; +} + +//fill pattern creation +//https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns +function createDiagonalPattern(color = 'black') { + let shape = document.createElement('canvas') + shape.width = 10 + shape.height = 10 + let c = shape.getContext('2d') + c.strokeStyle = color + c.lineWidth = 2 + c.beginPath() + c.moveTo(2, 0) + c.lineTo(10, 8) + c.stroke() + c.beginPath() + c.moveTo(0, 8) + c.lineTo(2, 10) + c.stroke() + return c.createPattern(shape, 'repeat') +} + +export function getMeteredBackgroundColor(meter, currDataset, barCtx, colors, darken=0) { + if (!barCtx || !currDataset) return; + let bar_height = getBarHeight(barCtx.parsed._stacks); + console.debug("bar height for", barCtx.raw.y, " is ", bar_height, "which in chart is", currDataset); + let meteredColor; + if (bar_height > meter.high) meteredColor = colors.danger; + else if (bar_height > meter.middle) meteredColor = colors.warn; + else meteredColor = colors.success; + if (darken) { + return color(meteredColor).darken(darken).hex(); + } + //if "unlabeled", etc -> stripes + if (currDataset.label == meter.dash_key) { + return createDiagonalPattern(meteredColor); + } + //if :labeled", etc -> solid + return meteredColor; +} + +const meterColors = { + below: '#00cc95', // green oklch(75% 0.3 165) + // https://www.joshwcomeau.com/gradient-generator?colors=fcab00|ba0000&angle=90&colorMode=lab&precision=3&easingCurve=0.25|0.75|0.75|0.25 + between: ['#fcab00', '#ef8215', '#db5e0c', '#ce3d03', '#b70100'], // yellow-orange-red + above: '#440000', // dark red +} + +export function getGradient(chart, meter, currDataset, barCtx, alpha = null, darken = 0) { + const { ctx, chartArea, scales } = chart; + if (!chartArea) return null; + let gradient: CanvasGradient; + const total = getBarHeight(barCtx.parsed._stacks); + alpha = alpha || (currDataset.label == meter.dash_key ? 0.2 : 1); + if (total < meter.middle) { + const adjColor = darken||alpha ? color(meterColors.below).darken(darken).alpha(alpha).rgb().string() : meterColors.below; + return adjColor; + } + const scaleMaxX = scales.x._range.max; + gradient = ctx.createLinearGradient(chartArea.left, 0, chartArea.right, 0); + meterColors.between.forEach((clr, i) => { + const clrPosition = ((i + 1) / meterColors.between.length) * (meter.high - meter.middle) + meter.middle; + const adjColor = darken || alpha ? color(clr).darken(darken).alpha(alpha).rgb().string() : clr; + gradient.addColorStop(Math.min(clrPosition, scaleMaxX) / scaleMaxX, adjColor); + }); + if (scaleMaxX > meter.high + 20) { + const adjColor = darken||alpha ? color(meterColors.above).darken(0.2).alpha(alpha).rgb().string() : meterColors.above; + gradient.addColorStop((meter.high+20) / scaleMaxX, adjColor); + } + return gradient; +} + +/** + * @param baseColor a color string + * @param change a number between -1 and 1, indicating the amount to darken or lighten the color + * @returns an adjusted color, either darkened or lightened, depending on the sign of change + */ +export function darkenOrLighten(baseColor: string, change: number) { + if (!baseColor) return baseColor; + let colorObj = color(baseColor); + if(change < 0) { + // darkening appears more drastic than lightening, so we will be less aggressive (scale change by .5) + return colorObj.darken(Math.abs(change * .5)).hex(); + } else { + return colorObj.lighten(Math.abs(change)).hex(); + } +} + +/** + * @param colors an array of colors, each of which is an array of [key, color string] + * @returns an object mapping keys to colors, with duplicates darkened/lightened to be distinguishable + */ +export const dedupColors = (colors: string[][]) => { + const dedupedColors = {}; + const maxAdjustment = 0.7; // more than this is too drastic and the colors approach black/white + for (const [key, clr] of colors) { + if (!clr) continue; // skip empty colors + const duplicates = colors.filter(([k, c]) => c == clr); + if (duplicates.length > 1) { + // there are duplicates; calculate an evenly-spaced adjustment for each one + duplicates.forEach(([k, c], i) => { + const change = -maxAdjustment + (maxAdjustment*2 / (duplicates.length - 1)) * i; + dedupedColors[k] = darkenOrLighten(clr, change); + }); + } else if (!dedupedColors[key]) { + dedupedColors[key] = clr; // not a dupe, & not already deduped, so use the color as-is + } + } + return dedupedColors; +} diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts index 3ddb287a2..58af79551 100644 --- a/www/js/config/useImperialConfig.ts +++ b/www/js/config/useImperialConfig.ts @@ -1,33 +1,40 @@ import React, { useEffect, useState } from "react"; import useAppConfig from "../useAppConfig"; +import i18next from "i18next"; const KM_TO_MILES = 0.621371; -/* formatting distances for display: - - if distance >= 100, round to the nearest integer - e.g. "105 mi", "167 km" - - if 1 <= distance < 100, round to 3 significant digits - e.g. "7.02 mi", "11.3 km" - - if distance < 1, round to 2 significant digits - e.g. "0.47 mi", "0.75 km" */ -const formatDistance = (dist: number) => { - if (dist < 1) - return dist.toPrecision(2); - if (dist < 100) - return dist.toPrecision(3); - return Math.round(dist).toString(); +const MPS_TO_KMPH = 3.6; + +// it might make sense to move this to a more general location in the codebase +/* formatting units for display: + - if value >= 100, round to the nearest integer + e.g. "105 mi", "119 kmph" + - if 1 <= value < 100, round to 3 significant digits + e.g. "7.02 km", "11.3 mph" + - if value < 1, round to 2 decimal places + e.g. "0.07 mi", "0.75 km" */ +export const formatForDisplay = (value: number): string => { + let opts: Intl.NumberFormatOptions = {}; + if (value >= 100) + opts.maximumFractionDigits = 0; + else if (value >= 1) + opts.maximumSignificantDigits = 3; + else + opts.maximumFractionDigits = 2; + return Intl.NumberFormat(i18next.language, opts).format(value); } -const getFormattedDistanceInKm = (distInMeters: string) => - formatDistance(Number.parseFloat(distInMeters) / 1000); - -const getFormattedDistanceInMiles = (distInMeters: string) => - formatDistance((Number.parseFloat(distInMeters) / 1000) * KM_TO_MILES); - -const getKmph = (metersPerSec) => - (metersPerSec * 3.6).toFixed(2); +const convertDistance = (distMeters: number, imperial: boolean): number => { + if (imperial) + return (distMeters / 1000) * KM_TO_MILES; + return distMeters / 1000; +} -const getMph = (metersPerSecond) => - (KM_TO_MILES * Number.parseFloat(getKmph(metersPerSecond))).toFixed(2); +const convertSpeed = (speedMetersPerSec: number, imperial: boolean): number => { + if (imperial) + return speedMetersPerSec * MPS_TO_KMPH * KM_TO_MILES; + return speedMetersPerSec * MPS_TO_KMPH; +} export function useImperialConfig() { const { appConfig, loading } = useAppConfig(); @@ -41,7 +48,9 @@ export function useImperialConfig() { return { distanceSuffix: useImperial ? "mi" : "km", speedSuffix: useImperial ? "mph" : "kmph", - getFormattedDistance: useImperial ? getFormattedDistanceInMiles : getFormattedDistanceInKm, - getFormattedSpeed: useImperial ? getMph : getKmph, + getFormattedDistance: useImperial ? (d) => formatForDisplay(convertDistance(d, true)) + : (d) => formatForDisplay(convertDistance(d, false)), + getFormattedSpeed: useImperial ? (s) => formatForDisplay(convertSpeed(s, true)) + : (s) => formatForDisplay(convertSpeed(s, false)), } } diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 746d2014d..c836d9db2 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -3,17 +3,18 @@ import moment from "moment"; import { DateTime } from "luxon"; - -export const modeColors = { - pink: '#d43678', // oklch(59% 0.2 0) // e-car - red: '#b9003d', // oklch(50% 0.37 15) // car - orange: '#b25200', // oklch(55% 0.37 50) // air, hsr - green: '#007e46', // oklch(52% 0.37 155) // bike, e-biek, moped - blue: '#0068a5', // oklch(50% 0.37 245) // walk - periwinkle: '#5e45cd', // oklch(50% 0.2 285) // light rail, train, tram, subway - magenta: '#8e35a1', // oklch(50% 0.18 320) // bus - grey: '#484848', // oklch(40% 0 0) // unprocessed / unknown - taupe: '#7d5857', // oklch(50% 0.05 15) // ferry, trolleybus, user-defined modes +import { LabelOptions, readableLabelToKey } from "../survey/multilabel/confirmHelper"; + +const modeColors = { + pink: '#c32e85', // oklch(56% 0.2 350) // e-car + red: '#c21725', // oklch(52% 0.2 25) // car + orange: '#bf5900', // oklch(58% 0.16 50) // air, hsr + green: '#008148', // oklch(53% 0.14 155) // bike, e-bike, moped + blue: '#0074b7', // oklch(54% 0.14 245) // walk + periwinkle: '#6356bf', // oklch(52% 0.16 285) // light rail, train, tram, subway + magenta: '#9240a4', // oklch(52% 0.17 320) // bus + grey: '#555555', // oklch(45% 0 0) // unprocessed / unknown + taupe: '#7d585a', // oklch(50% 0.05 15) // ferry, trolleybus, user-defined modes } type BaseMode = { @@ -71,6 +72,16 @@ export function getBaseModeOfLabeledTrip(trip, labelOptions) { return getBaseModeByKey(modeOption?.baseMode || "OTHER"); } +export function getBaseModeByValue(value, labelOptions: LabelOptions) { + const modeOption = labelOptions?.MODE?.find(opt => opt.value == value); + return getBaseModeByKey(modeOption?.baseMode || "OTHER"); +} + +export function getBaseModeByText(text, labelOptions: LabelOptions) { + const modeOption = labelOptions?.MODE?.find(opt => opt.text == text); + return getBaseModeByKey(modeOption?.baseMode || "OTHER"); +} + /** * @param beginFmtTime An ISO 8601 formatted timestamp (with timezone) * @param endTs An ISO 8601 formatted timestamp (with timezone) diff --git a/www/js/main.js b/www/js/main.js index 02b4b80c2..6e68d16b3 100644 --- a/www/js/main.js +++ b/www/js/main.js @@ -2,12 +2,16 @@ import angular from 'angular'; +import MetricsTab from './metrics/MetricsTab'; + angular.module('emission.main', ['emission.main.diary', 'emission.main.control', - 'emission.main.metrics', + 'emission.main.metrics.factory', + 'emission.main.metrics.mappings', 'emission.config.dynamic', 'emission.services', - 'emission.services.upload']) + 'emission.services.upload', + MetricsTab.module]) .config(function($stateProvider, $ionicConfigProvider, $urlRouterProvider) { $stateProvider @@ -23,8 +27,7 @@ angular.module('emission.main', ['emission.main.diary', url: '/metrics', views: { 'main-metrics': { - templateUrl: 'templates/main-metrics.html', - controller: 'MetricsCtrl' + template: ``, } } }) diff --git a/www/js/metrics-factory.js b/www/js/metrics-factory.js index 04bd7989d..ce813fbaa 100644 --- a/www/js/metrics-factory.js +++ b/www/js/metrics-factory.js @@ -1,6 +1,8 @@ 'use strict'; import angular from 'angular'; +import { getBaseModeByValue } from './diary/diaryHelper' +import { labelOptions } from './survey/multilabel/confirmHelper'; angular.module('emission.main.metrics.factory', ['emission.main.metrics.mappings', @@ -38,13 +40,12 @@ angular.module('emission.main.metrics.factory', if (mode == 'ON_FOOT') { mode = 'WALKING'; } + if (mode in footprint) { result += footprint[mode] * mtokm(userMetrics[i].values); - } - else if (mode == 'IN_VEHICLE') { + } else if (mode == 'IN_VEHICLE') { result += ((footprint['CAR'] + footprint['BUS'] + footprint["LIGHT_RAIL"] + footprint['TRAIN'] + footprint['TRAM'] + footprint['SUBWAY']) / 6) * mtokm(userMetrics[i].values); - } - else { + } else { console.warn('WARNING FootprintHelper.getFootprintFromMetrics() was requested for an unknown mode: ' + mode + " metrics JSON: " + JSON.stringify(userMetrics)); result += defaultIfMissing * mtokm(userMetrics[i].values); } diff --git a/www/js/metrics.js b/www/js/metrics.js deleted file mode 100644 index ef1b26891..000000000 --- a/www/js/metrics.js +++ /dev/null @@ -1,1449 +0,0 @@ -'use strict'; - -import angular from 'angular'; -import 'nvd3'; -import BarChart from './components/BarChart'; - -angular.module('emission.main.metrics',['emission.services', - 'ionic-datepicker', - 'emission.config.imperial', - 'emission.main.metrics.factory', - 'emission.main.metrics.mappings', - 'emission.stats.clientstats', - 'emission.plugin.kvstore', - 'emission.plugin.logger', - BarChart.module]) - -.controller('MetricsCtrl', function($scope, $ionicActionSheet, $ionicLoading, - ClientStats, CommHelper, $window, $ionicPopup, - ionicDatePicker, $ionicPlatform, - FootprintHelper, CalorieCal, ImperialConfig, $ionicModal, $timeout, KVStore, CarbonDatasetHelper, - $rootScope, $location, $state, ReferHelper, Logger) { - var lastTwoWeeksQuery = true; - $scope.defaultTwoWeekUserCall = true; - - var DURATION = "duration"; - var MEAN_SPEED = "mean_speed"; - var COUNT = "count"; - var DISTANCE = "distance"; - - var METRIC_LIST = [DURATION, MEAN_SPEED, COUNT, DISTANCE]; - var COMPUTATIONAL_METRIC_LIST = [DURATION, MEAN_SPEED, DISTANCE]; - - /* - * BEGIN: Data structures to parse and store the data in different formats. - * So that we don't have to keep re-creating them over and over as we used - * to, slowing down the processing. - */ - - /* - These are metric maps, with the key as the metric, and the value as a - list of ModeStatTimeSummary objects for the metric. - i.e. {count: [ - {fmt_time: "2021-12-03T00:00:00+00:00", - label_drove_alone: 4 label_walk: 1 - local_dt: {year: 2021, month: 12, day: 3, hour: 0, minute: 0, …} - nUsers: 1 - ts: 1638489600},....], - duration: [...] - distance: [...] - mean_speed: [...]} - */ - $scope.userCurrentResults = {}; - $scope.userTwoWeeksAgo = {}; - $scope.aggCurrentResults = {}; - - /* - These are metric mode maps, with a nested map of maps. The outer key is - the metric, and the inner key is the , with the key as the metric, and the - inner key is the mode. The innermost value is the list of - ModeStatTimeSummary objects for that mode. - list of ModeStatTimeSummary objects for the metric. - i.e. { - count: [ - {key: drove_alone, values: : [[1638489600, 4, "2021-12-03T00:00:00+00:00"], ...]}, - { key: walk, values: [[1638489600, 4, "2021-12-03T00:00:00+00:00"],...]}], - duration: [ { key: drove_alone, values: [...]}, {key: walk, values: [...]} ], - distance: [ { key: drove_alone, values: [...]}, {key: walk, values: [...]} ], - mean_speed: [ { key: drove_alone, values: [...]}, {key: walk, values: [...]} ] - } - */ - $scope.userCurrentModeMap = {}; - $scope.userTwoWeeksAgoModeMap = {}; - $scope.userCurrentModeMapFormatted = {}; - $scope.aggCurrentModeMap = {}; - $scope.aggCurrentModeMapFormatted = {}; - $scope.aggCurrentPerCapitaModeMap = {}; - - /* - These are summary mode maps, which have the same structure as the mode - maps, but with a value that is a single array instead of an array of arrays. - The single array is the summation of the values in the individual arrays of the non-summary mode maps. - i.e. { - count: [{key: "drove_alone", values: [10, "trips", "10 trips"], - {key: "walk", values: [5, "trips", "5 trips"]}], - - duration: [ { key: drove_alone, values: [...]}, {key: walk, values: [...]} ], - distance: [ { key: drove_alone, values: [...]}, {key: walk, values: [...]} ], - mean_speed: [ { key: drove_alone, values: [...]}, {key: walk, values: [...]} ] - } - */ - $scope.userCurrentSummaryModeMap = {}; - $scope.userTwoWeeksAgoSummaryModeMap = {}; - $scope.aggCurrentSummaryModeMap = {}; - $scope.aggCurrentSummaryPerCapitaModeMap = {}; - - /* - $scope.onCurrentTrip = function() { - window.cordova.plugins.BEMDataCollection.getState().then(function(result) { - Logger.log("Current trip state" + JSON.stringify(result)); - if(JSON.stringify(result) == "\"STATE_ONGOING_TRIP\""|| - JSON.stringify(result) == "\"local.state.ongoing_trip\"") { - $state.go("root.main.current"); - } - }); - }; - */ - - $ionicPlatform.ready(function() { - CarbonDatasetHelper.loadCarbonDatasetLocale().then(function(result) { - getData(); - }); - // $scope.onCurrentTrip(); - }); - - // If we want to share this function (see the pun?) between the control screen and the dashboard, we need to put it into a service/factory. - // But it is not clear to me why it needs to be in the profile screen... - var prepopulateMessage = { - message: 'Have fun, support research and get active. Your privacy is protected. \nDownload the emission app:', // not supported on some apps (Facebook, Instagram) - subject: 'Help Berkeley become more bikeable and walkable', // fi. for email - url: 'https://bic2cal.eecs.berkeley.edu/#download' - } - - $scope.share = function() { - window.plugins.socialsharing.shareWithOptions(prepopulateMessage, function(result) { - console.log("Share completed? " + result.completed); // On Android apps mostly return false even while it's true - console.log("Shared to app: " + result.app); // On Android result.app is currently empty. On iOS it's empty when sharing is cancelled (result.completed=false) - }, function(msg) { - console.log("Sharing failed with message: " + msg); - }); - } - - // TODO: Move this out into its own service - var FOOD_COMPARE_KEY = 'foodCompare'; - $scope.setCookie = function(){ - $scope.foodCompare = 'cookie'; - return KVStore.set(FOOD_COMPARE_KEY, 'cookie'); - } - $scope.setIceCream = function(){ - $scope.foodCompare = 'iceCream'; - return KVStore.set(FOOD_COMPARE_KEY, 'iceCream'); - } - $scope.setBanana = function(){ - $scope.foodCompare = 'banana'; - return KVStore.set(FOOD_COMPARE_KEY, 'banana'); - } - $scope.handleChosenFood = function(retVal) { - if (retVal == null){ - $scope.setCookie(); - } else { - var choosenFood = retVal; - if(choosenFood == 'cookie') - $scope.setCookie(); - else if (choosenFood == 'iceCream') - $scope.setIceCream(); - else - $scope.setBanana(); - } - } - $ionicModal.fromTemplateUrl('templates/metrics/metrics-control.html', { - scope: $scope, - animation: 'slide-in-up' - }).then(function(modal) { - $scope.modal = modal; - }); - $scope.openModal = function(){ - $scope.modal.show(); - } - $scope.closeModal = function(){ - $scope.modal.hide(); - } - $scope.uictrl = { - showRange: true, - showFilter: false, - showVis: true, - showResult: true, - current: true, - currentString: i18next.t('metrics.last-week'), - showChart: false, - showSummary: true, - showMe: true, - showAggr: false, - showContent: false, - showTrips: false, - showDuration: false, - showDistance: false, - showSpeed: false, - } - $scope.showChart = function() { - $scope.uictrl.showSummary = false; - $scope.uictrl.showChart = true; - $scope.showDistance(); - } - $scope.showDistance = function() { - $scope.uictrl.showTrips = false; - $scope.uictrl.showDuration = false; - $scope.uictrl.showSpeed = false; - $scope.uictrl.showDistance = true; - } - $scope.showTrips = function() { - $scope.uictrl.showDistance = false; - $scope.uictrl.showSpeed = false; - $scope.uictrl.showDuration = false; - $scope.uictrl.showTrips = true; - } - $scope.showDuration = function() { - $scope.uictrl.showSpeed = false; - $scope.uictrl.showDistance = false; - $scope.uictrl.showTrips = false; - $scope.uictrl.showDuration = true; - } - $scope.showSpeed = function() { - $scope.uictrl.showTrips = false; - $scope.uictrl.showDuration = false; - $scope.uictrl.showDistance = false; - $scope.uictrl.showSpeed = true; - } - $scope.showSummary = function() { - $scope.uictrl.showChart = false; - $scope.uictrl.showSummary = true; - } - $scope.chartButtonClass = function() { - return $scope.uictrl.showChart? "metric-chart-button-active hvcenter" : "metric-chart-button hvcenter"; - } - $scope.summaryButtonClass = function() { - return $scope.uictrl.showSummary? "metric-summary-button-active hvcenter" : "metric-summary-button hvcenter"; - } - $scope.distanceButtonClass = function() { - return $scope.uictrl.showDistance? "distance-button-active hvcenter" : "distance-button hvcenter"; - } - $scope.tripsButtonClass = function() { - return $scope.uictrl.showTrips? "trips-button-active hvcenter" : "trips-button hvcenter"; - } - $scope.durationButtonClass = function() { - return $scope.uictrl.showDuration? "duration-button-active hvcenter" : "duration-button hvcenter"; - } - $scope.speedButtonClass = function() { - return $scope.uictrl.showSpeed? "speed-button-active hvcenter" : "speed-button hvcenter"; - } - $scope.rangeButtonClass = function() { - return $scope.uictrl.showRange? "metric-range-button-active hvcenter" : "metric-range-button hvcenter"; - } - $scope.filterButtonClass = function() { - return $scope.uictrl.showFilter? "metric-filter-button-active hvcenter" : "metric-filter-button hvcenter"; - } - $scope.getButtonClass = function() { - return ($scope.uictrl.showFilter || $scope.uictrl.showRange)? "metric-get-button hvcenter" : "metric-get-button-inactive hvcenter"; - } - $scope.fullToggleLeftClass = function() { - return $scope.userData.gender == 1? "full-toggle-left-active hvcenter" : "full-toggle-left hvcenter"; - } - $scope.fullToggleRightClass = function() { - return $scope.userData.gender == 0? "full-toggle-right-active hvcenter" : "full-toggle-right hvcenter"; - } - $scope.fullToggleLeftClass1 = function() { - return $scope.showca2020? "full-toggle-left-active hvcenter" : "full-toggle-left hvcenter"; - } - $scope.fullToggleRightClass1 = function() { - return $scope.showca2035? "full-toggle-right-active hvcenter" : "full-toggle-right hvcenter"; - } - $scope.heightToggleLeftClass = function() { - return $scope.userData.heightUnit == 1? "unit-toggle-left-active hvcenter" : "unit-toggle-left hvcenter"; - } - $scope.heightToggleRightClass = function() { - return $scope.userData.heightUnit == 0? "unit-toggle-right-active hvcenter" : "unit-toggle-right hvcenter"; - } - $scope.weightToggleLeftClass = function() { - return $scope.userData.weightUnit == 1? "unit-toggle-left-active hvcenter" : "unit-toggle-left hvcenter"; - } - $scope.weightToggleRightClass = function() { - return $scope.userData.weightUnit == 0? "unit-toggle-right-active hvcenter" : "unit-toggle-right hvcenter"; - } - $scope.currentQueryForCalorie = function() { - return $scope.uictrl.current ? "user-calorie-percentage" : "user-calorie-no-percentage"; - } - $scope.currentQueryForCarbon = function() { - return $scope.uictrl.current ? "user-carbon-percentage" : "user-carbon-no-percentage"; - } - $scope.showRange = function() { - if ($scope.uictrl.showFilter) { - $scope.uictrl.showFilter = false; - $scope.uictrl.showRange = true; - } - } - $scope.showFilter = function() { - if ($scope.uictrl.showRange) { - $scope.uictrl.showRange = false; - $scope.uictrl.showFilter = true; - } - } - - $scope.setHeightUnit = function(heightUnit) { - // 1 for cm, 0 for ft - $scope.userData.heightUnit = heightUnit; - } - $scope.setWeightUnit = function(weightUnit) { - // 1 for kg, 0 for lb - $scope.userData.weightUnit = weightUnit; - } - $scope.setGender = function(gender) { - $scope.userData.gender = gender; - } - - $scope.storeUserData = function() { - var info = {'gender': $scope.userData.gender, - 'heightUnit': $scope.userData.heightUnit, - 'weightUnit': $scope.userData.weightUnit, - 'height': $scope.userData.height, - 'weight': $scope.userData.weight, - 'age': $scope.userData.age, - 'userDataSaved': true}; - CalorieCal.set(info).then(function() { - $scope.savedUserData = info; - }); - } - - $scope.loadUserData = function() { - if(angular.isDefined($scope.savedUserData)) { - // loaded or set - return Promise.resolve(); - } else { - return CalorieCal.get().then(function(userDataFromStorage) { - $scope.savedUserData = userDataFromStorage; - }); - } - } - - $scope.userDataSaved = function() { - // console.log("saved vals = "+JSON.stringify($scope.savedUserData)); - if (angular.isDefined($scope.savedUserData) && $scope.savedUserData != null) { - return $scope.savedUserData.userDataSaved == true; - } else { - return false; - }; - } - - $scope.options = { - chart: { - type: 'multiBarChart', - width: $window.screen.width - 30, - height: $window.screen.height - 220, - margin : { - top: 20, - right: 20, - bottom: 40, - left: 55 - }, - noData: i18next.t('metrics.chart-no-data'), - showControls: false, - showValues: true, - stacked: false, - x: function(d){ return d[0]; }, - y: function(d){ return d[1]; }, - /* - average: function(d) { - var vals = d.values.map(function(item){ - return item[1]; - }); - return d3.mean(vals); - }, - */ - - color: d3.scale.category10().range(), - // duration: 300, - useInteractiveGuideline: false, - // clipVoronoi: false, - - xAxis: { - axisLabelDistance: 3, - axisLabel: i18next.t('metrics.chart-xaxis-date'), - tickFormat: function(d) { - var day = new Date(d * 1000) - day.setDate(day.getDate()+1) // Had to add a day to match date with data - return d3.time.format('%y-%m-%d')(day) - }, - showMaxMin: false, - staggerLabels: true - }, - yAxis: { - axisLabel: i18next.t('metrics.trips-yaxis-number'), - axisLabelDistance: -10 - }, - callback: function(chart) { - chart.multibar.dispatch.on('elementClick', function(bar) { - var date = bar.data[2].slice(0,10); - $rootScope.barDetailDate = moment(date); - $rootScope.barDetail = true; - $state.go('root.main.diary'); - console.log($rootScope.barDetailDate); - }) - } - } - }; - - var moment2Localdate = function(momentObj) { - return { - year: momentObj.year(), - month: momentObj.month() + 1, - day: momentObj.date(), - }; - } - var moment2Timestamp = function(momentObj) { - return momentObj.unix(); - } - - $scope.data = []; - - var getData = function(){ - $scope.getMetricsHelper(); - $scope.loadUserData(); - KVStore.get(FOOD_COMPARE_KEY).then(function(retVal) { - $scope.handleChosenFood(retVal); - }); - } - - $scope.getMetricsHelper = function() { - $scope.uictrl.showContent = false; - setMetricsHelper(getMetrics); - } - - var setMetricsHelper = function(metricsToGet) { - if ($scope.uictrl.showRange) { - setMetrics('timestamp', metricsToGet); - } else if ($scope.uictrl.showFilter) { - setMetrics('local_date', metricsToGet); - } else { - console.log("Illegal time_type"); // Notice that you need to set query - } - if(angular.isDefined($scope.modal) && $scope.modal.isShown()){ - $scope.modal.hide(); - } - } - - var data = {} - var theMode = ""; - - var setMetrics = function(mode, callback) { - theMode = mode; - if (['local_date', 'timestamp'].indexOf(mode) == -1) { - console.log('Illegal time_type'); - return; - } - - if (mode === 'local_date') { // local_date filter - var tempFrom = $scope.selectCtrl.fromDateLocalDate; - tempFrom.weekday = $scope.selectCtrl.fromDateWeekdayValue; - var tempTo = $scope.selectCtrl.toDateLocalDate; - tempTo.weekday = $scope.selectCtrl.toDateWeekdayValue; - data = { - freq: $scope.selectCtrl.freq, - start_time: tempFrom, - end_time: tempTo, - metric: "" - }; - } else if (mode === 'timestamp') { // timestamp range - if(lastTwoWeeksQuery) { - var tempFrom = moment2Timestamp(moment().utc().startOf('day').subtract(14, 'days')); - var tempTo = moment2Timestamp(moment().utc().startOf('day').subtract(1, 'days')) - lastTwoWeeksQuery = false; // Only get last week's data once - $scope.defaultTwoWeekUserCall = true; - } else { - var tempFrom = moment2Timestamp($scope.selectCtrl.fromDateTimestamp); - var tempTo = moment2Timestamp($scope.selectCtrl.toDateTimestamp); - $scope.defaultTwoWeekUserCall = false; - } - data = { - freq: $scope.selectCtrl.pandaFreq, - start_time: tempFrom, - end_time: tempTo, - metric: "" - }; - } else { - console.log('Illegal mode'); - return; - } - console.log("Sending data "+JSON.stringify(data)); - callback() - }; - - var getUserMetricsFromServer = function() { - var clonedData = angular.copy(data); - delete clonedData.metric; - clonedData.metric_list = METRIC_LIST; - clonedData.is_return_aggregate = false; - var getMetricsResult = CommHelper.getMetrics(theMode, clonedData); - return getMetricsResult; - } - var getAggMetricsFromServer = function() { - var clonedData = angular.copy(data); - delete clonedData.metric; - clonedData.metric_list = METRIC_LIST; - clonedData.is_return_aggregate = true; - var getMetricsResult = CommHelper.getAggregateData( - "result/metrics/timestamp", clonedData) - return getMetricsResult; - } - - var isValidNumber = function(number) { - if (angular.isDefined(Number.isFinite)) { - return Number.isFinite(number); - } else { - return !isNaN(number); - } - } - - var getMetrics = function() { - $ionicLoading.show({ - template: i18next.t('loading') - }); - if(!$scope.defaultTwoWeekUserCall){ - $scope.uictrl.currentString = i18next.t('metrics.custom'); - $scope.uictrl.current = false; - } - //$scope.uictrl.showRange = false; - //$scope.uictrl.showFilter = false; - $scope.uictrl.showVis = true; - $scope.uictrl.showResult = true; - $scope.uictrl.hasAggr = false; - - $scope.caloriesData = {}; - $scope.carbonData = {}; - $scope.summaryData = {}; - - $scope.carbonData.optimalCarbon = "0 kg CO₂"; - - $scope.summaryData.userSummary = []; - $scope.chartDataUser = {}; - $scope.chartDataAggr = {}; - $scope.food = { - 'chocolateChip' : 78, //16g 1 cookie - 'vanillaIceCream' : 137, //1/2 cup - 'banana' : 105, //medium banana 118g - }; - // calculation is at - // https://github.com/e-mission/e-mission-docs/issues/696#issuecomment-1018181638 - $scope.carbon = { - 'phoneCharge' : 8.22 * 10**(-3), // 8.22 x 10^-6 metric tons CO2/smartphone charge - }; - - getUserMetricsFromServer().then(function(results) { - $ionicLoading.hide(); - console.log("user results ", results); - if(results.user_metrics.length == 1){ - console.log("$scope.defaultTwoWeekUserCall = "+$scope.defaultTwoWeekUserCall); - $scope.defaultTwoWeekUserCall = false; - // If there is no data from last week (ex. new user) - // Don't store the any other data as last we data - } - $scope.fillUserValues(results.user_metrics); - $scope.summaryData.defaultSummary = $scope.summaryData.userSummary; - $scope.defaultTwoWeekUserCall = false; //If there is data from last week store the data only first time - $scope.uictrl.showContent = true; - if (angular.isDefined($scope.chartDataUser)) { - $scope.$apply(function() { - if ($scope.uictrl.showMe) { - $scope.showCharts($scope.chartDataUser); - } - }) - } else { - $scope.$apply(function() { - $scope.showCharts([]); - console.log("did not find aggregate result in response data "+JSON.stringify(results[2])); - }); - } - }) - .catch(function(error) { - $ionicLoading.hide(); - Logger.displayError("Error loading user data", error); - }) - - getAggMetricsFromServer().then(function(results) { - console.log("aggregate results ", results); - $scope.fillAggregateValues(results.aggregate_metrics); - $scope.uictrl.hasAggr = true; - if (angular.isDefined($scope.chartDataAggr)) { //Only have to check one because - // Restore the $apply if/when we go away from $http - $scope.$apply(function() { - if (!$scope.uictrl.showMe) { - $scope.showCharts($scope.chartDataAggr); - } - }) - } else { - $scope.$apply(function() { - $scope.showCharts([]); - console.log("did not find aggregate result in response data "+JSON.stringify(results[2])); - }); - } - }) - .catch(function(error) { - $ionicLoading.hide(); - $scope.carbonData.aggrCarbon = i18next.t('metrics.carbon-data-unknown'); - $scope.caloriesData.aggrCalories = i18next.t('metrics.calorie-data-unknown'); - Logger.displayError("Error loading aggregate data, averages not available", - error); - }); - }; - - $scope.fillUserValues = function(user_metrics_arr) { - var seventhDayAgo = moment().utc().startOf('day').subtract(7, 'days'); - METRIC_LIST.forEach((m) => $scope.userCurrentResults[m] = []); - - METRIC_LIST.forEach((m) => $scope.userTwoWeeksAgo[m] = []); - - if($scope.defaultTwoWeekUserCall){ - for(var i in user_metrics_arr[0]) { - if(seventhDayAgo.isSameOrBefore(moment.unix(user_metrics_arr[0][i].ts).utc())){ - METRIC_LIST.forEach((m, idx) => $scope.userCurrentResults[m].push(user_metrics_arr[idx][i])); - } else { - METRIC_LIST.forEach((m, idx) => $scope.userTwoWeeksAgo[m].push(user_metrics_arr[idx][i])); - } - } - METRIC_LIST.forEach((m) => console.log("userTwoWeeksAgo."+m+" = "+$scope.userTwoWeeksAgo[m])); - } else { - METRIC_LIST.forEach((m, idx) => $scope.userCurrentResults[m] = user_metrics_arr[idx]); - } - - METRIC_LIST.forEach((m) => - $scope.userCurrentModeMap[m] = getDataFromMetrics($scope.userCurrentResults[m], metric2valUser)); - - COMPUTATIONAL_METRIC_LIST.forEach((m) => - $scope.userTwoWeeksAgoModeMap[m] = getDataFromMetrics($scope.userTwoWeeksAgo[m], metric2valUser)); - - METRIC_LIST.forEach((m) => - $scope.userCurrentModeMapFormatted[m] = formatModeMap($scope.userCurrentModeMap[m], m)); - - METRIC_LIST.forEach((m) => - $scope.userCurrentSummaryModeMap[m] = getSummaryDataRaw($scope.userCurrentModeMap[m], m)); - - COMPUTATIONAL_METRIC_LIST.forEach((m) => - $scope.userTwoWeeksAgoSummaryModeMap[m] = getSummaryDataRaw($scope.userTwoWeeksAgoModeMap[m], m)); - - METRIC_LIST.forEach((m) => - $scope.summaryData.userSummary[m] = getSummaryDataRaw($scope.userCurrentModeMap[m], m)); - - $scope.isCustomLabelResult = isCustomLabels($scope.userCurrentModeMap); - FootprintHelper.setUseCustomFootprint($scope.isCustomLabelResult); - CalorieCal.setUseCustomFootprint($scope.isCustomLabelResult); - - $scope.chartDataUser = $scope.userCurrentModeMapFormatted; - - // Fill in user calorie information - $scope.fillCalorieCardUserVals($scope.userCurrentSummaryModeMap.duration, - $scope.userCurrentSummaryModeMap.mean_speed, - $scope.userTwoWeeksAgoSummaryModeMap.duration, - $scope.userTwoWeeksAgoSummaryModeMap.mean_speed); - $scope.fillFootprintCardUserVals($scope.userCurrentModeMap.distance, - $scope.userCurrentSummaryModeMap.distance, - $scope.userTwoWeeksAgoModeMap.distance, - $scope.userTwoWeeksAgoSummaryModeMap.distance); - } - - $scope.fillAggregateValues = function(agg_metrics_arr) { - METRIC_LIST.forEach((m) => $scope.aggCurrentResults[m] = []); - if ($scope.defaultTwoWeekUserCall) { - METRIC_LIST.forEach((m, idx) => $scope.aggCurrentResults[m] = agg_metrics_arr[idx].slice(0,7)); - } else { - METRIC_LIST.forEach((m, idx) => $scope.aggCurrentResults[m] = agg_metrics_arr[idx]); - } - - METRIC_LIST.forEach((m) => - $scope.aggCurrentModeMap[m] = getDataFromMetrics($scope.aggCurrentResults[m], metric2valUser)); - - METRIC_LIST.forEach((m) => - $scope.aggCurrentModeMapFormatted[m] = formatModeMap($scope.aggCurrentModeMap[m], m)); - - COMPUTATIONAL_METRIC_LIST.forEach((m) => - $scope.aggCurrentPerCapitaModeMap[m] = getDataFromMetrics($scope.aggCurrentResults[m], metric2valAvg)); - - COMPUTATIONAL_METRIC_LIST.forEach((m) => - $scope.aggCurrentSummaryPerCapitaModeMap[m] = getSummaryDataRaw($scope.aggCurrentPerCapitaModeMap[m], m)); - - $scope.chartDataAggr = $scope.aggCurrentModeMapFormatted; - $scope.fillCalorieAggVals($scope.aggCurrentSummaryPerCapitaModeMap.duration, - $scope.aggCurrentSummaryPerCapitaModeMap.mean_speed); - $scope.fillFootprintAggVals($scope.aggCurrentSummaryPerCapitaModeMap.distance); - } - - /* - * We use the results to determine whether these results are from custom - * labels or from the automatically sensed labels. Automatically sensedV - * labels are in all caps, custom labels are prefixed by label, but have had - * the label_prefix stripped out before this. Results should have either all - * sensed labels or all custom labels. - */ - var isCustomLabels = function(modeMap) { - const isSensed = (mode) => mode == mode.toUpperCase(); - const isCustom = (mode) => mode == mode.toLowerCase(); - const metricSummaryChecksCustom = []; - const metricSummaryChecksSensed = []; - for (const metric in modeMap) { - const metricKeys = modeMap[metric].map((e) => e.key); - const isSensedKeys = metricKeys.map(isSensed); - const isCustomKeys = metricKeys.map(isCustom); - console.log("Checking metric keys", metricKeys, " sensed ", isSensedKeys, - " custom ", isCustomKeys); - const isAllCustomForMetric = isAllCustom(isSensedKeys, isCustomKeys); - metricSummaryChecksSensed.push(!isAllCustomForMetric); - metricSummaryChecksCustom.push(isAllCustomForMetric); - } - console.log("overall custom/not results for each metric = ", metricSummaryChecksCustom); - return isAllCustom(metricSummaryChecksSensed, metricSummaryChecksCustom); - } - - var isAllCustom = function(isSensedKeys, isCustomKeys) { - const allSensed = isSensedKeys.reduce((a, b) => a && b, true); - const anySensed = isSensedKeys.reduce((a, b) => a || b, false); - const allCustom = isCustomKeys.reduce((a, b) => a && b, true); - const anyCustom = isCustomKeys.reduce((a, b) => a || b, false); - if ((allSensed && !anyCustom)) { - return false; // sensed, not custom - } - if ((!anySensed && allCustom)) { - return true; // custom, not sensed; false implies that the other option is true - } - Logger.displayError("Mixed entries that combine sensed and custom labels", - "Please report to your program admin"); - return undefined; - } - - $scope.fillCalorieCardUserVals = function(userDurationSummary, userMeanSpeedSummary, - twoWeeksAgoDurationSummary, twoWeeksAgoMeanSpeedSummary) { - $scope.caloriesData.userCalories = {low: 0, high: 0}; - const highestMET = CalorieCal.getHighestMET(); - for (var i in userDurationSummary) { - var lowMET = $scope.getCorrectedMetFromUserData(userDurationSummary[i], userMeanSpeedSummary[i], 0); - var highMET = $scope.getCorrectedMetFromUserData(userDurationSummary[i], userMeanSpeedSummary[i], highestMET); - $scope.caloriesData.userCalories.low += - Math.round(CalorieCal.getuserCalories(userDurationSummary[i].values / 3600, lowMET)) //+ ' cal' - $scope.caloriesData.userCalories.high += - Math.round(CalorieCal.getuserCalories(userDurationSummary[i].values / 3600, highMET)) //+ ' cal' - } - - $scope.numberOfCookies = { - low: Math.floor($scope.caloriesData.userCalories.low/ - $scope.food.chocolateChip), - high: Math.floor($scope.caloriesData.userCalories.high/ - $scope.food.chocolateChip), - }; - $scope.numberOfIceCreams = { - low: Math.floor($scope.caloriesData.userCalories.low/ - $scope.food.vanillaIceCream), - high: Math.floor($scope.caloriesData.userCalories.high/ - $scope.food.vanillaIceCream), - }; - $scope.numberOfBananas = { - low: Math.floor($scope.caloriesData.userCalories.low/ - $scope.food.banana), - high: Math.floor($scope.caloriesData.userCalories.high/ - $scope.food.banana), - }; - - if($scope.defaultTwoWeekUserCall) { - if (twoWeeksAgoDurationSummary.length > 0) { - var twoWeeksAgoCalories = {low: 0, high: 0}; - for (var i in twoWeeksAgoDurationSummary) { - var lowMET = $scope.getCorrectedMetFromUserData(twoWeeksAgoDurationSummary[i], - twoWeeksAgoMeanSpeedSummary[i], 0) - var highMET = $scope.getCorrectedMetFromUserData(twoWeeksAgoDurationSummary[i], - twoWeeksAgoMeanSpeedSummary[i], highestMET) - twoWeeksAgoCalories.low += - Math.round(CalorieCal.getuserCalories(twoWeeksAgoDurationSummary[i].values / 3600, lowMET)); - twoWeeksAgoCalories.high += - Math.round(CalorieCal.getuserCalories(twoWeeksAgoDurationSummary[i].values / 3600, highMET)); - } - $scope.caloriesData.lastWeekUserCalories = { - low: twoWeeksAgoCalories.low, - high: twoWeeksAgoCalories.high - }; - console.log("Running calorieData with ", $scope.caloriesData); - // TODO: Refactor this so that we can filter out bad values ahead of time - // instead of having to work around it here - $scope.caloriesData.greaterLesserPct = { - low: ($scope.caloriesData.userCalories.low/$scope.caloriesData.lastWeekUserCalories.low) * 100 - 100, - high: ($scope.caloriesData.userCalories.high/$scope.caloriesData.lastWeekUserCalories.high) * 100 - 100, - } - } - } - } - - $scope.fillCalorieAggVals = function(aggDurationSummaryAvg, aggMeanSpeedSummaryAvg) { - $scope.caloriesData.aggrCalories = {low: 0, high: 0}; - const highestMET = CalorieCal.getHighestMET(); - for (var i in aggDurationSummaryAvg) { - var lowMET = CalorieCal.getMet(aggDurationSummaryAvg[i].key, aggMeanSpeedSummaryAvg[i].values, 0); - var highMET = CalorieCal.getMet(aggDurationSummaryAvg[i].key, aggMeanSpeedSummaryAvg[i].values, highestMET); - $scope.caloriesData.aggrCalories.low += - CalorieCal.getuserCalories(aggDurationSummaryAvg[i].values / 3600, lowMET); //+ ' cal' - $scope.caloriesData.aggrCalories.high += - CalorieCal.getuserCalories(aggDurationSummaryAvg[i].values / 3600, highMET); //+ ' cal' - } - } - - $scope.getCorrectedMetFromUserData = function(currDurationData, currSpeedData, defaultIfMissing) { - if ($scope.userDataSaved()) { - // this is safe because userDataSaved will never be set unless there - // is stored user data that we have loaded - var userDataFromStorage = $scope.savedUserData; - var met = CalorieCal.getMet(currDurationData.key, currSpeedData.values, defaultIfMissing); - var gender = userDataFromStorage.gender; - var heightUnit = userDataFromStorage.heightUnit; - var height = userDataFromStorage.height; - var weightUnit = userDataFromStorage.weightUnit; - var weight = userDataFromStorage.weight; - var age = userDataFromStorage.age; - return CalorieCal.getCorrectedMet(met, gender, age, height, heightUnit, weight, weightUnit); - } else { - return CalorieCal.getMet(currDurationData.key, currSpeedData.values, defaultIfMissing); - } - }; - - $scope.fillFootprintCardUserVals = function( - userDistance, userDistanceSummary, - twoWeeksAgoDistance, twoWeeksAgoDistanceSummary) { - if (userDistance) { - // var optimalDistance = getOptimalFootprintDistance(userDistance); - var worstDistance = getWorstFootprintDistance(userDistanceSummary); - - var date1 = $scope.selectCtrl.fromDateTimestamp; - var date2 = $scope.selectCtrl.toDateTimestamp; - var duration = moment.duration(date2.diff(date1)); - var days = duration.asDays(); - - /* - * 54 and 14 are the per-week CO2 estimates. - * https://github.com/e-mission/e-mission-docs/issues/688 - * Since users can choose a custom range which can be less or greater - * than 7 days, we calculate the per day value by dividing by 7 and - * then multiplying by the actual number of days. - */ - $scope.carbonData.us2030 = Math.round(54 / 7 * days); // kg/day - $scope.carbonData.us2050 = Math.round(14 / 7 * days); - - $scope.carbonData.userCarbon = { - low: FootprintHelper.getFootprintForMetrics(userDistanceSummary,0), - high: FootprintHelper.getFootprintForMetrics(userDistanceSummary, - FootprintHelper.getHighestFootprint()), - }; - // $scope.carbonData.optimalCarbon = FootprintHelper.getLowestFootprintForDistance(optimalDistance); - $scope.carbonData.worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); - $scope.carbonData.carbonAvoided = { - low: $scope.carbonData.worstCarbon - $scope.carbonData.userCarbon.high, - high: $scope.carbonData.worstCarbon - $scope.carbonData.userCarbon.low, - }; - } - - $scope.numberOfCharges = { - low: Math.floor($scope.carbonData.carbonAvoided.low/ - $scope.carbon.phoneCharge), - high: Math.floor($scope.carbonData.carbonAvoided.high/ - $scope.carbon.phoneCharge), - }; - - if ($scope.defaultTwoWeekUserCall) { - // This is a default call in which we retrieved the current week and - // the previous week of data - if (twoWeeksAgoDistance.length > 0) { - // and this user has been around long enough that they have two weeks - // of data, or they haven't turned off tracking for all of last week, - // or.... - $scope.carbonData.lastWeekUserCarbon = { - low: FootprintHelper.getFootprintForMetrics(twoWeeksAgoDistanceSummary,0), - high: FootprintHelper.getFootprintForMetrics(twoWeeksAgoDistanceSummary, - FootprintHelper.getHighestFootprint()), - }; - - console.log("Running calculation with " + $scope.carbonData.userCarbon + " and " + $scope.carbonData.lastWeekUserCarbon); - console.log("Running calculation with ", $scope.carbonData); - $scope.carbonData.greaterLesserPct = { - low: ($scope.carbonData.userCarbon.low/$scope.carbonData.lastWeekUserCarbon.low) * 100 - 100, - high: ($scope.carbonData.userCarbon.high/$scope.carbonData.lastWeekUserCarbon.high) * 100 - 100, - } - } - } - }; - - $scope.fillFootprintAggVals = function(aggDistance) { - if (aggDistance) { - var aggrCarbonData = aggDistance; - - // Issue 422: - // https://github.com/e-mission/e-mission-docs/issues/422 - for (var i in aggrCarbonData) { - if (isNaN(aggrCarbonData[i].values)) { - console.warn("WARNING fillFootprintAggVals(): value is NaN for mode " + aggrCarbonData[i].key + ", changing to 0"); - aggrCarbonData[i].values = 0; - } - } - - $scope.carbonData.aggrCarbon = { - low: FootprintHelper.getFootprintForMetrics(aggrCarbonData, 0), - high: FootprintHelper.getFootprintForMetrics(aggrCarbonData, - FootprintHelper.getHighestFootprint()), - }; - } - }; - - $scope.showCharts = function(agg_metrics) { - $scope.data = agg_metrics; - $scope.countOptions = angular.copy($scope.options) - $scope.countOptions.chart.yAxis.axisLabel = i18next.t('metrics.trips-yaxis-number'); - $scope.distanceOptions = angular.copy($scope.options) - $scope.distanceOptions.chart.yAxis.axisLabel = ImperialConfig.getDistanceSuffix; - $scope.durationOptions = angular.copy($scope.options) - $scope.durationOptions.chart.yAxis.axisLabel = i18next.t('metrics.hours'); - $scope.speedOptions = angular.copy($scope.options) - $scope.speedOptions.chart.yAxis.axisLabel = ImperialConfig.getSpeedSuffix; - }; - $scope.pandaFreqOptions = [ - {text: i18next.t('metrics.pandafreqoptions-daily'), value: 'D'}, - {text: i18next.t('metrics.pandafreqoptions-weekly'), value: 'W'}, - {text: i18next.t('metrics.pandafreqoptions-biweekly'), value: '2W'}, - {text: i18next.t('metrics.pandafreqoptions-monthly'), value: 'M'}, - {text: i18next.t('metrics.pandafreqoptions-yearly'), value: 'A'} - ]; - $scope.freqOptions = [ - {text: i18next.t('metrics.freqoptions-daily'), value:'DAILY'}, - {text: i18next.t('metrics.freqoptions-monthly'), value: 'MONTHLY'}, - {text: i18next.t('metrics.freqoptions-yearly'), value: 'YEARLY'} - ]; - - /* - * 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 - */ - - var metric2valUser = function(metric, field) { - return metric[field]; - } - - var metric2valAvg = function(metric, field) { - return metric[field]/metric.nUsers; - } - - var getDataFromMetrics = function(metrics, metric2val) { - console.log("Called getDataFromMetrics on ", metrics); - var mode_bins = {}; - metrics.forEach(function(metric) { - var on_foot_val = 0; - for (var 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()) { - // since we can have multiple possible ON_FOOT modes, we - // add all of them up here - // see https://github.com/e-mission/e-mission-docs/issues/422 - if (field === "WALKING" || field === "RUNNING" || field === "ON_FOOT") { - on_foot_val = on_foot_val + metric2val(metric, field); - field = "ON_FOOT"; - } - if (field in mode_bins == false) { - mode_bins[field] = [] - } - // since we can have multiple on_foot entries, let's hold - // off on handling them until we have considered all fields - if (field != "ON_FOOT") { - mode_bins[field].push([metric.ts, metric2val(metric, field), metric.fmt_time]); - } - } - // For modes from user labels, we assume that the field stars with - // the label_ prefix - if (field.startsWith("label_")) { - // "label_" is 6 characters - let actualMode = field.slice(6, field.length); - console.log("Mapped field "+field+" to mode "+actualMode); - if (actualMode in mode_bins == false) { - mode_bins[actualMode] = [] - } - mode_bins[actualMode].push([metric.ts, Math.round(metric2val(metric, field)), moment(metric.fmt_time).format()]); - } - } - // here's where we handle the ON_FOOT - if ("ON_FOOT" in mode_bins == true) { - // we must have received one of the on_foot modes, so we can - // boldly insert the value - mode_bins["ON_FOOT"].push([metric.ts, Math.round(on_foot_val), metric.fmt_time]); - } - }); - var rtn = []; - for (var mode in mode_bins) { - var val_arrays = rtn.push({key: mode, values: mode_bins[mode]}); - } - return rtn; - } - - var getSummaryDataRaw = function(modeMap, metric) { - console.log("Invoked getSummaryDataRaw on ", modeMap, "with", metric); - let summaryMap = angular.copy(modeMap); - for (var i = 0; i < modeMap.length; i++) { - var temp = 0; - for (var j = 0; j < modeMap[i].values.length; j++) { - temp += modeMap[i].values[j][1]; - } - if (metric === "mean_speed") { - summaryMap[i].values = Math.round(temp / modeMap[i].values.length); - } else { - summaryMap[i].values = Math.round(temp); - } - - } - return summaryMap; - } - - /*var sortNumber = function(a,b) { - return a - b; - }*/ - - /* - * This is _broken_ because what we see on the client is summary values, - * not individual trip values. So value > longTrip just means that the - * overall travel by that mode was long, not that each individual trip - * was long. - * - * As an obvious example, if I had 10 1k car trips, they would show up as - * daily travel of 10k by car, and be counted as a long trip, although each - * individual trip was actually 1k and short. - * - * Leaving this disabled until we come up with a principled solution. - * https://github.com/e-mission/e-mission-docs/issues/688#issuecomment-1000626564 - */ - - var getOptimalFootprintDistance = function(metrics){ - var data = getDataFromMetrics(metrics, metric2valUser); - var distance = 0; - var longTrip = 5000; - // total distance for long trips using motorized vehicles - for(var i = 0; i < data.length; i++) { - if(data[i].key == "CAR" || data[i].key == "BUS" || data[i].key == "TRAIN" || data[i].key == "AIR_OR_HSR") { - for(var j = 0; j < data[i].values.length; j++){ - if(data[i].values[j][1] >= longTrip){ - distance += data[i].values[j][1]; - } - } - } - } - return distance; - } - var getWorstFootprintDistance = function(modeMapSummary) { - var totalDistance = modeMapSummary.reduce((prevDistance, currModeSummary) => prevDistance + currModeSummary.values, 0); - return totalDistance; - } - - $scope.formatCount = function(value) { - const formatVal = Math.round(value); - const unit = i18next.t('metrics.trips'); - const stringRep = formatVal + " " + unit; - return [formatVal, unit, stringRep]; - } - - $scope.formatDistance = function(value) { - const formatVal = Number.parseFloat(ImperialConfig.getFormattedDistance(value)); - const unit = ImperialConfig.getDistanceSuffix; - const stringRep = formatVal + " " + unit; - return [formatVal, unit, stringRep]; - } - - $scope.formatDuration = function(value) { - const durM = moment.duration(value * 1000); - const formatVal = durM.asHours(); - const unit = i18next.t('metrics.hours'); - const stringRep = durM.humanize(); - return [formatVal, unit, stringRep]; - } - - $scope.formatMeanSpeed = function(value) { - const formatVal = Number.parseFloat(ImperialConfig.getFormattedSpeed(value)); - const unit = ImperialConfig.getSpeedSuffix; - const stringRep = formatVal + " " + unit; - return [formatVal, unit, stringRep]; - } - - $scope.formatterMap = { - count: $scope.formatCount, - distance: $scope.formatDistance, - duration: $scope.formatDuration, - mean_speed: $scope.formatMeanSpeed - } - - var formatModeMap = function(modeMapList, metric) { - const formatter = $scope.formatterMap[metric]; - let formattedModeList = []; - modeMapList.forEach((modeMap) => { - let currMode = modeMap["key"]; - let modeStatList = modeMap["values"]; - let formattedModeStatList = modeStatList.map((modeStat) => { - let [formatVal, unit, stringRep] = formatter(modeStat[1]); - // horizontal graphs: date on y axis and value on x axis - return { y: modeStat[0] * 1000, x: formatVal }; - }); - formattedModeList.push({label: currMode, records: formattedModeStatList}); - }); - return formattedModeList; - } - - $scope.changeFromWeekday = function() { - return $scope.changeWeekday(function(newVal) { - $scope.selectCtrl.fromDateWeekdayString = newVal; - }, - 'from'); - } - - $scope.changeToWeekday = function() { - return $scope.changeWeekday(function(newVal) { - $scope.selectCtrl.toDateWeekdayString = newVal; - }, - 'to'); - } - - // $scope.show fil - - $scope.changeWeekday = function(stringSetFunction, target) { - var weekdayOptions = [ - {text: i18next.t('weekdays-all'), value: null}, - {text: moment.weekdays(1), value: 0}, - {text: moment.weekdays(2), value: 1}, - {text: moment.weekdays(3), value: 2}, - {text: moment.weekdays(4), value: 3}, - {text: moment.weekdays(5), value: 4}, - {text: moment.weekdays(6), value: 5}, - {text: moment.weekdays(0), value: 6} - ]; - $ionicActionSheet.show({ - buttons: weekdayOptions, - titleText: i18next.t('weekdays-select'), - cancelText: i18next.t('metrics.cancel'), - buttonClicked: function(index, button) { - stringSetFunction(button.text); - if (target === 'from') { - $scope.selectCtrl.fromDateWeekdayValue = button.value; - } else if (target === 'to') { - $scope.selectCtrl.toDateWeekdayValue = button.value; - } else { - console.log("Illegal target"); - } - return true; - } - }); - }; - $scope.changeFreq = function() { - $ionicActionSheet.show({ - buttons: $scope.freqOptions, - titleText: i18next.t('metrics.select-frequency'), - cancelText: i18next.t('metrics.cancel'), - buttonClicked: function(index, button) { - $scope.selectCtrl.freqString = button.text; - $scope.selectCtrl.freq = button.value; - return true; - } - }); - }; - - $scope.changePandaFreq = function() { - $ionicActionSheet.show({ - buttons: $scope.pandaFreqOptions, - titleText: i18next.t('metrics.select-pandafrequency'), - cancelText: i18next.t('metrics.cancel'), - buttonClicked: function(index, button) { - $scope.selectCtrl.pandaFreqString = button.text; - $scope.selectCtrl.pandaFreq = button.value; - return true; - } - }); - }; - - $scope.toggle = function() { - if (!$scope.uictrl.showMe) { - $scope.uictrl.showMe = true; - $scope.showCharts($scope.chartDataUser); - - } else { - $scope.uictrl.showMe = false; - $scope.showCharts($scope.chartDataAggr); - } - } - var initSelect = function() { - var now = moment().utc(); - var weekAgoFromNow = moment().utc().subtract(7, 'd'); - $scope.selectCtrl.freq = 'DAILY'; - $scope.selectCtrl.freqString = i18next.t('metrics.freqoptions-daily'); - $scope.selectCtrl.pandaFreq = 'D'; - $scope.selectCtrl.pandaFreqString = i18next.t('metrics.pandafreqoptions-daily'); - // local_date saved as localdate - $scope.selectCtrl.fromDateLocalDate = moment2Localdate(weekAgoFromNow); - $scope.selectCtrl.toDateLocalDate = moment2Localdate(now); - // ts saved as moment - $scope.selectCtrl.fromDateTimestamp= weekAgoFromNow; - $scope.selectCtrl.toDateTimestamp = now; - - $scope.selectCtrl.fromDateWeekdayString = i18next.t('weekdays-all'); - $scope.selectCtrl.toDateWeekdayString = i18next.t('weekdays-all'); - - $scope.selectCtrl.fromDateWeekdayValue = null; - $scope.selectCtrl.toDateWeekdayValue = null; - - $scope.selectCtrl.region = null; - }; - - - $scope.selectCtrl = {} - initSelect(); - - $scope.doRefresh = function() { - getMetrics(); - } - - $scope.$on('$ionicView.enter',function(){ - $scope.startTime = moment().utc() - ClientStats.addEvent(ClientStats.getStatKeys().OPENED_APP).then( - function() { - console.log("Added "+ClientStats.getStatKeys().OPENED_APP+" event"); - }); - }); - - $scope.$on('$ionicView.leave',function() { - var timeOnPage = moment().utc() - $scope.startTime; - ClientStats.addReading(ClientStats.getStatKeys().METRICS_TIME, timeOnPage); - }); - - $ionicPlatform.on("pause", function() { - if ($state.$current == "root.main.metrics") { - var timeOnPage = moment().utc() - $scope.startTime; - ClientStats.addReading(ClientStats.getStatKeys().METRICS_TIME, timeOnPage); - } - }) - - $ionicPlatform.on("resume", function() { - if ($state.$current == "root.main.metrics") { - $scope.startTime = moment().utc() - } - }) - - $scope.linkToMaps = function() { - let start = $scope.suggestionData.startCoordinates[1] + ',' + $scope.suggestionData.startCoordinates[0]; - let destination = $scope.suggestionData.endCoordinates[1] + ',' + $scope.suggestionData.endCoordinates[0]; - var mode = $scope.suggestionData.mode - if(ionic.Platform.isIOS()){ - if (mode === 'bike') { - mode = 'b'; - } else if (mode === 'public') { - mode = 'r'; - } else if (mode === 'walk') { - mode = 'w'; - } - window.open('https://www.maps.apple.com/?saddr=' + start + '&daddr=' + destination + '&dirflg=' + mode, '_system'); - } else { - if (mode === 'bike') { - mode = 'b'; - } else if (mode === 'public') { - mode = 'r'; - } else if (mode === 'walk') { - mode = 'w'; - } - window.open('https://www.google.com/maps?saddr=' + start + '&daddr=' + destination +'&dirflg=' + mode, '_system'); - } - } - - $scope.linkToDiary = function(trip_id) { - console.log("Loading trip "+trip_id); - window.location.href = "#/root/main/diary/" + trip_id; - } - - $scope.hasUsername = function(obj) { - return (obj.hasOwnProperty('username')); - } - - $scope.modeIcon = function(key) { - var icons = {"BICYCLING":"ion-android-bicycle", - "ON_FOOT":" ion-android-walk", - "WALKING":" ion-android-walk", - "IN_VEHICLE":"ion-speedometer", - "CAR":"ion-android-car", - "BUS": "ion-android-bus", - "LIGHT_RAIL":"lightrail fas fa-subway", - "TRAIN": "ion-android-train", - "TRAM": "fas fa-tram", - "SUBWAY":"fas fa-subway", - "UNKNOWN": "ion-ios-help", - "AIR_OR_HSR": "ion-plane"} - return icons[key]; - } - - $scope.setCurDayFrom = function(val) { - if (val) { - $scope.selectCtrl.fromDateTimestamp = moment(val).startOf('day'); - if ($scope.selectCtrl.fromDateTimestamp > $scope.selectCtrl.toDateTimestamp) { - const copyToDateTimestamp = $scope.selectCtrl.toDateTimestamp.clone(); - $scope.selectCtrl.fromDateTimestamp = copyToDateTimestamp.startOf('day'); - } - $scope.datepickerObjFrom.inputMoment = $scope.selectCtrl.fromDateTimestamp; - $scope.datepickerObjFrom.inputDate = $scope.selectCtrl.fromDateTimestamp.toDate(); - } else { - $scope.datepickerObjFrom.inputMoment = $scope.selectCtrl.fromDateTimestamp; - $scope.datepickerObjFrom.inputDate = $scope.selectCtrl.fromDateTimestamp.toDate(); - } - - }; - $scope.setCurDayTo = function(val) { - if (val) { - $scope.selectCtrl.toDateTimestamp = moment(val).endOf('day'); - if ($scope.selectCtrl.toDateTimestamp < $scope.selectCtrl.fromDateTimestamp) { - const copyFromDateTimestamp = $scope.selectCtrl.fromDateTimestamp.clone(); - $scope.selectCtrl.toDateTimestamp = copyFromDateTimestamp.endOf('day'); - } - $scope.datepickerObjTo.inputMoment = $scope.selectCtrl.toDateTimestamp; - $scope.datepickerObjTo.inputDate = $scope.selectCtrl.toDateTimestamp.toDate(); - } else { - $scope.datepickerObjTo.inputMoment = $scope.selectCtrl.toDateTimestamp; - $scope.datepickerObjTo.inputDate = $scope.selectCtrl.toDateTimestamp.toDate(); - } - }; - - - $scope.data = {}; - - $scope.userData = { - gender: -1, - heightUnit: 1, - weightUnit: 1 - }; - $scope.caloriePopup = function() { - $ionicPopup.show({ - templateUrl: 'templates/caloriePopup.html', - title: '', - scope: $scope, - buttons: [ - { text: i18next.t('metrics.cancel') }, - { - text: ''+ i18next.t('metrics.confirm') +'', - type: 'button-positive', - onTap: function(e) { - if (!($scope.userData.gender != -1 && $scope.userData.age && $scope.userData.weight && $scope.userData.height)) { - e.preventDefault(); - } else { - $scope.storeUserData(); - // refresh - } - } - } - ] - }); - } - - $scope.datepickerObjBase = { - todayLabel: i18next.t('list-datepicker-today'), //Optional - closeLabel: i18next.t('list-datepicker-close'), //Optional - setLabel: i18next.t('list-datepicker-set'), //Optional - titleLabel: i18next.t('metrics.pick-a-date'), - mondayFirst: false, - weeksList: moment.weekdaysMin(), - monthsList: moment.monthsShort(), - templateType: 'popup', - from: new Date(2015, 1, 1), - to: new Date(), - showTodayButton: true, - closeOnSelect: false, - // add this instruction if you want to exclude a particular weekday, e.g. Saturday disableWeekdays: [6] - }; - - $scope.datepickerObjFrom = angular.copy($scope.datepickerObjBase); - angular.extend($scope.datepickerObjFrom, { - callback: $scope.setCurDayFrom, - inputDate: $scope.selectCtrl.fromDateTimestamp.toDate(), - inputMoment: $scope.selectCtrl.fromDateTimestamp, - }); - - $scope.datepickerObjTo = angular.copy($scope.datepickerObjBase); - angular.extend($scope.datepickerObjTo, { - callback: $scope.setCurDayTo, - inputDate: $scope.selectCtrl.toDateTimestamp.toDate(), - inputMoment: $scope.selectCtrl.toDateTimestamp, - }); - - $scope.pickFromDay = function() { - ionicDatePicker.openDatePicker($scope.datepickerObjFrom); - } - - $scope.pickToDay = function() { - ionicDatePicker.openDatePicker($scope.datepickerObjTo); - } - - $scope.extendFootprintCard = function() { - if($scope.expandedf){ - $scope.expandedf = false; - } else { - $scope.expandedf = true - } - } - $scope.checkFootprintCardExpanded = function() { - return ($scope.expandedf)? "icon ion-chevron-up" : "icon ion-chevron-down"; - } - $scope.extendCalorieCard = function() { - if($scope.expandedc){ - $scope.expandedc = false; - } else { - $scope.expandedc = true - } - } - $scope.checkCalorieCardExpanded = function() { - return ($scope.expandedc)? "icon ion-chevron-up" : "icon ion-chevron-down"; - } - - $scope.changeFootprintCardHeight = function() { - return ($scope.expandedf)? "expanded-footprint-card" : "small-footprint-card"; - } - - $scope.changeCalorieCardHeight = function() { - return ($scope.expandedc)? "expanded-calorie-card" : "small-calorie-card"; - } - - -}) -.directive('diffdisplay', function() { - return { - scope: { - change: "=" - }, - link: function(scope) { - if (isNaN(scope.change.low)) scope.change.low = 0; - if (isNaN(scope.change.high)) scope.change.high = 0; - console.log("In diffdisplay, after changes, scope = ", scope); - }, - templateUrl: "templates/metrics/arrow-greater-lesser.html" - } -}) -.directive('rangedisplay', function() { - return { - scope: { - range: "=" - }, - link: function(scope) { - // console.log("RANGE DISPLAY "+JSON.stringify(scope.range)); - var humanize = function(num) { - if (Math.abs(num) < 1) { - return num.toFixed(2); - } else { - return num.toFixed(0); - } - } - - if (Math.abs(scope.range.high - scope.range.low) < 1) { - scope.tinyDiff = true; - } - scope.lowFmt = humanize(scope.range.low); - scope.highFmt = humanize(scope.range.high); - }, - templateUrl: "templates/metrics/range-display.html" - } -}); diff --git a/www/js/metrics/ActiveMinutesTableCard.tsx b/www/js/metrics/ActiveMinutesTableCard.tsx new file mode 100644 index 000000000..ea360ce8e --- /dev/null +++ b/www/js/metrics/ActiveMinutesTableCard.tsx @@ -0,0 +1,99 @@ +import React, { useMemo, useState } from 'react'; +import { Card, DataTable, useTheme } from 'react-native-paper'; +import { MetricsData } from './metricsTypes'; +import { cardStyles } from './MetricsTab'; +import { formatDate, formatDateRangeOfDays, secondsToMinutes, segmentDaysByWeeks } from './metricsHelper'; +import { useTranslation } from 'react-i18next'; +import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; +import { labelKeyToRichMode } from '../survey/multilabel/confirmHelper'; + +type Props = { userMetrics: MetricsData } +const ActiveMinutesTableCard = ({ userMetrics }: Props) => { + + const { colors } = useTheme(); + const { t } = useTranslation(); + + const cumulativeTotals = useMemo(() => { + if (!userMetrics?.duration) return []; + const totals = {}; + ACTIVE_MODES.forEach(mode => { + const sum = userMetrics.duration.reduce((acc, day) => ( + acc + (day[`label_${mode}`] || 0) + ), 0); + totals[mode] = secondsToMinutes(sum); + }); + totals['period'] = formatDateRangeOfDays(userMetrics.duration); + return totals; + }, [userMetrics?.duration]); + + const recentWeeksActiveModesTotals = useMemo(() => { + if (!userMetrics?.duration) return []; + return segmentDaysByWeeks(userMetrics.duration).reverse().map(week => { + const totals = {}; + ACTIVE_MODES.forEach(mode => { + const sum = week.reduce((acc, day) => ( + acc + (day[`label_${mode}`] || 0) + ), 0); + totals[mode] = secondsToMinutes(sum); + }) + totals['period'] = formatDateRangeOfDays(week); + return totals; + }); + }, [userMetrics?.duration]); + + const dailyActiveModesTotals = useMemo(() => { + if (!userMetrics?.duration) return []; + return userMetrics.duration.map(day => { + const totals = {}; + ACTIVE_MODES.forEach(mode => { + const sum = day[`label_${mode}`] || 0; + totals[mode] = secondsToMinutes(sum); + }) + totals['period'] = formatDate(day); + return totals; + }).reverse(); + }, [userMetrics?.duration]); + + const allTotals = [cumulativeTotals, ...recentWeeksActiveModesTotals, ...dailyActiveModesTotals]; + + const itemsPerPage = 5; + const [page, setPage] = useState(0); + const from = page * itemsPerPage; + const to = Math.min((page + 1) * itemsPerPage, allTotals.length); + + return ( + + + + + + + {ACTIVE_MODES.map((mode, i) => + {labelKeyToRichMode(mode)} + )} + + {allTotals.slice(from, to).map((total, i) => + + {total['period']} + {ACTIVE_MODES.map((mode, j) => + {total[mode]} {t('metrics.minutes')} + )} + + )} + setPage(p)} + numberOfPages={Math.ceil(allTotals.length / 5)} numberOfItemsPerPage={5} + label={`${page * 5 + 1}-${page * 5 + 5} of ${allTotals.length}`} /> + + + + ) +} + +export default ActiveMinutesTableCard; diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx new file mode 100644 index 000000000..6012cb61a --- /dev/null +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -0,0 +1,168 @@ +import React, { useState, 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 { formatDateRangeOfDays, parseDataFromMetrics, generateSummaryFromData, calculatePercentChange, segmentDaysByWeeks, isCustomLabels } 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"; + +type Props = { userMetrics: MetricsData, aggMetrics: MetricsData } +const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { + const FootprintHelper = getAngularService("FootprintHelper"); + const { colors } = useTheme(); + const { t } = useTranslation(); + + const [emissionsChange, setEmissionsChange] = useState({}); + + const userCarbonRecords = useMemo(() => { + if(userMetrics?.distance?.length > 0) { + //separate data into weeks + const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2); + + //formatted data from last week, if exists (14 days ago -> 8 days ago) + let userLastWeekModeMap = {}; + let userLastWeekSummaryMap = {}; + if(lastWeekDistance && lastWeekDistance?.length == 7) { + userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); + userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); + } + + //formatted distance data from this week (7 days ago -> yesterday) + let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); + let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); + let worstDistance = userThisWeekSummaryMap.reduce((prevDistance, currModeSummary) => prevDistance + currModeSummary.values, 0); + + //setting up data to be displayed + let graphRecords = []; + + //set custon dataset, if the labels are custom + if(isCustomLabels(userThisWeekModeMap)){ + FootprintHelper.setUseCustomFootprint(); + } + + //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) + let userPrevWeek; + if(userLastWeekSummaryMap[0]) { + userPrevWeek = { + low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, FootprintHelper.getHighestFootprint()) + }; + 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: userPrevWeek.low, y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`}); + } + + //calculate low-high and format range for past week (7 days ago -> yesterday) + let userPastWeek = { + low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, FootprintHelper.getHighestFootprint()), + }; + 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)})`}); + if (userPrevWeek) { + let pctChange = calculatePercentChange(userPastWeek, userPrevWeek); + setEmissionsChange(pctChange); + } + + //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')}`}); + + return graphRecords; + } + }, [userMetrics?.distance]) + + const groupCarbonRecords = useMemo(() => { + if(aggMetrics?.distance?.length > 0) + { + //separate data into weeks + const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0]; + console.log("testing agg metrics" , aggMetrics, thisWeekDistance); + + 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 groupRecords = []; + + let aggCarbon = { + low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), + high: FootprintHelper.getFootprintForMetrics(aggCarbonData, FootprintHelper.getHighestFootprint()), + } + 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)})`}); + + return groupRecords; + } + }, [aggMetrics]) + + const chartData = useMemo(() => { + let tempChartData = []; + if(userCarbonRecords?.length) { + tempChartData = tempChartData.concat(userCarbonRecords); + } + if(groupCarbonRecords?.length) { + tempChartData = tempChartData.concat(groupCarbonRecords); + } + tempChartData = tempChartData.reverse(); + console.log("testing chart data", tempChartData); + return tempChartData; + }, [userCarbonRecords, groupCarbonRecords]); + + const cardSubtitleText = useMemo(() => { + const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2).reverse().flat(); + const recentEntriesRange = formatDateRangeOfDays(recentEntries); + return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`; + }, [aggMetrics?.distance]); + + //hardcoded here, could be read from config at later customization? + let carbonGoals = [ {label: t('main-metrics.us-2050-goal'), value: 14, color: color(colors.warn).darken(.65).saturate(.5).rgb().toString()}, + {label: t('main-metrics.us-2030-goal'), value: 54, color: color(colors.danger).saturate(.5).rgb().toString()} ]; + let meter = { dash_key: t('main-metrics.unlabeled'), high: 54, middle: 14 }; + + return ( + + } + style={cardStyles.title(colors)} /> + + { chartData?.length > 0 ? + + + + {t('main-metrics.us-goals-footnote')} + + + : + + + {t('metrics.chart-no-data')} + + } + + + ) +} + +export default CarbonFootprintCard; diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx new file mode 100644 index 000000000..223ae709f --- /dev/null +++ b/www/js/metrics/CarbonTextCard.tsx @@ -0,0 +1,151 @@ +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 { formatDateRangeOfDays, parseDataFromMetrics, generateSummaryFromData, calculatePercentChange, segmentDaysByWeeks } from './metricsHelper'; +import { getAngularService } from '../angular-react-helper'; + +type Props = { userMetrics: MetricsData, aggMetrics: MetricsData } +const CarbonTextCard = ({ 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 + const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2); + + //formatted data from last week, if exists (14 days ago -> 8 days ago) + let userLastWeekModeMap = {}; + let userLastWeekSummaryMap = {}; + if(lastWeekDistance && lastWeekDistance?.length == 7) { + userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); + userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); + } + + //formatted distance data from this week (7 days ago -> yesterday) + let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); + let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); + let worstDistance = userThisWeekSummaryMap.reduce((prevDistance, currModeSummary) => prevDistance + currModeSummary.values, 0); + + //setting up data to be displayed + let textList = []; + + //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) + if(userLastWeekSummaryMap[0]) { + let userPrevWeek = { + low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, FootprintHelper.getHighestFootprint()) + }; + const label = `${t('main-metrics.prev-week')} (${formatDateRangeOfDays(lastWeekDistance)})`; + if (userPrevWeek.low == userPrevWeek.high) + textList.push({label: label, value: Math.round(userPrevWeek.low)}); + else + textList.push({label: label + '²', value: `${Math.round(userPrevWeek.low)} - ${Math.round(userPrevWeek.high)}`}); + } + + //calculate low-high and format range for past week (7 days ago -> yesterday) + let userPastWeek = { + low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, FootprintHelper.getHighestFootprint()), + }; + const label = `${t('main-metrics.past-week')} (${formatDateRangeOfDays(thisWeekDistance)})`; + if (userPastWeek.low == userPastWeek.high) + textList.push({label: label, value: Math.round(userPastWeek.low)}); + else + textList.push({label: label + '²', value: `${Math.round(userPastWeek.low)} - ${Math.round(userPastWeek.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 + const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[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); + const label = t('main-metrics.average'); + if (aggCarbon.low == aggCarbon.high) + groupText.push({label: label, value: Math.round(aggCarbon.low)}); + else + groupText.push({label: label + '²', value: `${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]); + + const cardSubtitleText = useMemo(() => { + const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2).reverse().flat(); + const recentEntriesRange = formatDateRangeOfDays(recentEntries); + return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`; + }, [aggMetrics?.distance]); + + return ( + + + + { textEntries?.length > 0 && + Object.keys(textEntries).map((i) => + + {textEntries[i].label} + {textEntries[i].value + ' ' + "kg CO₂"} + + ) + } + + {t('main-metrics.range-uncertain-footnote')} + + + + ) +} + +export default CarbonTextCard; diff --git a/www/js/metrics/ChangeIndicator.tsx b/www/js/metrics/ChangeIndicator.tsx new file mode 100644 index 000000000..eafd3460e --- /dev/null +++ b/www/js/metrics/ChangeIndicator.tsx @@ -0,0 +1,79 @@ +import React, {useMemo} from 'react'; +import { View } from 'react-native'; +import { useTheme, Text } from "react-native-paper"; +import { useTranslation } from 'react-i18next'; +import colorLib from "color"; + +type Props = { + change: {low: number, high: number}, +} + +const ChangeIndicator = ({ change }) => { + const { colors } = useTheme(); + const { t } = useTranslation(); + + const changeSign = function(changeNum) { + if(changeNum > 0) { + return "+"; + } else { + return "-"; + } + }; + + const changeText = useMemo(() => { + if(change) { + let low = isFinite(change.low) ? Math.round(Math.abs(change.low)): '∞'; + let high = isFinite(change.high) ? Math.round(Math.abs(change.high)) : '∞'; + + if(Math.round(change.low) == Math.round(change.high)) + { + let text = changeSign(change.low) + low + "%"; + return text; + } else if(!(isFinite(change.low) || isFinite(change.high))) { + return ""; //if both are not finite, no information is really conveyed, so don't show + } + else { + let text = `${changeSign(change.low) + low}% / ${changeSign(change.high) + high}%`; + return text; + } + } + },[change]) + + return ( + (changeText != "") ? + 0 ? colors.danger : colors.success)}> + + {changeText + '\n'} + + + {`${t("metrics.this-week")}`} + + + : + <> + ) +} + +const styles: any = { + text: (colors) => ({ + color: colors.onPrimary, + fontWeight: '400', + textAlign: 'center' + }), + importantText: (colors) => ({ + color: colors.onPrimary, + fontWeight: '500', + textAlign: 'center', + fontSize: 16, + }), + view: (color) => ({ + backgroundColor: colorLib(color).alpha(0.85).rgb().toString(), + padding: 2, + borderStyle: 'solid', + borderColor: colorLib(color).darken(0.4).rgb().toString(), + borderWidth: 2.5, + borderRadius: 10, + }), +} + +export default ChangeIndicator; diff --git a/www/js/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx new file mode 100644 index 000000000..479a5f5b5 --- /dev/null +++ b/www/js/metrics/DailyActiveMinutesCard.tsx @@ -0,0 +1,64 @@ + +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 { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; +import LineChart from '../components/LineChart'; +import { getBaseModeByText } from '../diary/diaryHelper'; + +const ACTIVE_MODES = ['walk', 'bike'] as const; +type ActiveMode = typeof ACTIVE_MODES[number]; + +type Props = { userMetrics: MetricsData } +const DailyActiveMinutesCard = ({ userMetrics }: Props) => { + + const { colors } = useTheme(); + const { t } = useTranslation(); + + const dailyActiveMinutesRecords = useMemo(() => { + const records = []; + const recentDays = userMetrics?.duration?.slice(-14); + recentDays?.forEach(day => { + ACTIVE_MODES.forEach(mode => { + const activeSeconds = day[`label_${mode}`]; + records.push({ + label: labelKeyToRichMode(mode), + x: day.ts * 1000, // vertical chart, milliseconds on X axis + y: activeSeconds && activeSeconds / 60, // minutes on Y axis + }); + }); + }); + return records as {label: ActiveMode, x: string, y: number}[]; + }, [userMetrics?.duration]); + + return ( + + + + { dailyActiveMinutesRecords.length ? + getBaseModeByText(l, labelOptions).color} /> + : + + + {t('metrics.chart-no-data')} + + + } + + + ); +} + +export default DailyActiveMinutesCard; diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx new file mode 100644 index 000000000..7a0f8c8bc --- /dev/null +++ b/www/js/metrics/MetricsCard.tsx @@ -0,0 +1,133 @@ + +import React, { useMemo, useState } from 'react'; +import { View } from 'react-native'; +import { Card, Checkbox, Text, useTheme } from 'react-native-paper'; +import colorLib from "color"; +import BarChart from '../components/BarChart'; +import { DayOfMetricData } from './metricsTypes'; +import { formatDateRangeOfDays, getLabelsForDay, getUniqueLabelsForDays } from './metricsHelper'; +import ToggleSwitch from '../components/ToggleSwitch'; +import { cardStyles } from './MetricsTab'; +import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; +import { getBaseModeByKey, getBaseModeByText } from '../diary/diaryHelper'; +import { useTranslation } from 'react-i18next'; + +type Props = { + cardTitle: string, + userMetricsDays: DayOfMetricData[], + aggMetricsDays: DayOfMetricData[], + axisUnits: string, + unitFormatFn?: (val: number) => string|number, +} +const MetricsCard = ({cardTitle, userMetricsDays, aggMetricsDays, axisUnits, unitFormatFn}: Props) => { + + const { colors } = useTheme(); + const { t } = useTranslation(); + const [viewMode, setViewMode] = useState<'details'|'graph'>('details'); + const [populationMode, setPopulationMode] = useState<'user'|'aggregate'>('user'); + const [graphIsStacked, setGraphIsStacked] = useState(true); + const metricDataDays = useMemo(() => ( + populationMode == 'user' ? userMetricsDays : aggMetricsDays + ), [populationMode, userMetricsDays, aggMetricsDays]); + + // for each label on each day, create a record for the chart + const chartData = useMemo(() => { + if (!metricDataDays || viewMode != 'graph') return []; + const records: {label: string, x: string|number, y: string|number}[] = []; + metricDataDays.forEach(day => { + const labels = getLabelsForDay(day); + labels.forEach(label => { + const rawVal = day[`label_${label}`]; + records.push({ + label: labelKeyToRichMode(label), + x: unitFormatFn ? unitFormatFn(rawVal) : rawVal, + y: day.ts * 1000, // time (as milliseconds) will go on Y axis because it will be a horizontal chart + }); + }); + }); + // sort records (affects the order they appear in the chart legend) + records.sort((a, b) => { + if (a.label == 'Unlabeled') return 1; // sort Unlabeled to the end + if (b.label == 'Unlabeled') return -1; // sort Unlabeled to the end + return (a.y as number) - (b.y as number); // otherwise, just sort by time + }); + return records; + }, [metricDataDays, viewMode]); + + const cardSubtitleText = useMemo(() => { + const groupText = populationMode == 'user' ? t('main-metrics.user-totals') + : t('main-metrics.group-totals'); + return `${groupText} (${formatDateRangeOfDays(metricDataDays)})`; + }, [metricDataDays, populationMode]); + + // for each label, sum up cumulative values across all days + const metricSumValues = useMemo(() => { + if (!metricDataDays || viewMode != 'details') return []; + const uniqueLabels = getUniqueLabelsForDays(metricDataDays); + + // for each label, sum up cumulative values across all days + const vals = {}; + uniqueLabels.forEach(label => { + const sum = metricDataDays.reduce((acc, day) => ( + acc + (day[`label_${label}`] || 0) + ), 0); + vals[label] = unitFormatFn ? unitFormatFn(sum) : sum; + }); + return vals; + }, [metricDataDays, viewMode]); + + // Unlabelled data shows up as 'UNKNOWN' grey and mostly transparent + // All other modes are colored according to their base mode + const getColorForLabel = (label: string) => { + if (label == "Unlabeled") { + const unknownModeColor = getBaseModeByKey('UNKNOWN').color; + return colorLib(unknownModeColor).alpha(0.15).rgb().string(); + } + return getBaseModeByText(label, labelOptions).color; + } + + return ( + + + + setViewMode(v as any)} + buttons={[{ icon: 'abacus', value: 'details' }, { icon: 'chart-bar', value: 'graph' }]} /> + setPopulationMode(p as any)} + buttons={[{ icon: 'account', value: 'user' }, { icon: 'account-group', value: 'aggregate' }]} /> + + } + style={cardStyles.title(colors)} /> + + {viewMode=='details' && + + { Object.keys(metricSumValues).map((label, i) => + + {labelKeyToRichMode(label)} + {metricSumValues[label] + ' ' + axisUnits} + + )} + + } + {viewMode=='graph' && <> + + + Stack bars: + setGraphIsStacked(!graphIsStacked)} /> + + } + + + ) +} + +export default MetricsCard; diff --git a/www/js/metrics/MetricsDateSelect.tsx b/www/js/metrics/MetricsDateSelect.tsx new file mode 100644 index 000000000..c66218453 --- /dev/null +++ b/www/js/metrics/MetricsDateSelect.tsx @@ -0,0 +1,71 @@ +/* This button launches a modal to select a date range, which determines what time period + for which metrics should be displayed. + The button itself is a NavBarButton, which shows the currently selected date range, + a calendar icon, and launches the modal when clicked. + The modal is a DatePickerModal from react-native-paper-dates, which shows a calendar + and allows the user to select a date. +*/ + +import React, { useState, useCallback, useMemo } from "react"; +import { Text, StyleSheet } from "react-native"; +import { DatePickerModal } from "react-native-paper-dates"; +import { Divider, useTheme } from "react-native-paper"; +import i18next from "i18next"; +import { useTranslation } from "react-i18next"; +import NavBarButton from "../components/NavBarButton"; +import { DateTime } from "luxon"; + +type Props = { + dateRange: DateTime[], + setDateRange: (dateRange: [DateTime, DateTime]) => void, +} +const MetricsDateSelect = ({ dateRange, setDateRange }: Props) => { + + const { t } = useTranslation(); + const { colors } = useTheme(); + const [open, setOpen] = useState(false); + const todayDate = useMemo(() => new Date(), []); + const dateRangeAsJSDate = useMemo(() => + [ dateRange[0].toJSDate(), dateRange[1].toJSDate() ], + [dateRange]); + + const onDismiss = useCallback(() => { + setOpen(false); + }, [setOpen]); + + const onChoose = useCallback(({ startDate, endDate }) => { + setOpen(false); + setDateRange([ + DateTime.fromJSDate(startDate).startOf('day'), + DateTime.fromJSDate(endDate).startOf('day') + ]); + }, [setOpen, setDateRange]); + + return (<> + setOpen(true)}> + {dateRange[0] && (<> + {dateRange[0].toLocaleString()} + + )} + {dateRange[1]?.toLocaleString() || t('diary.today')} + + + ); +}; + +export const s = StyleSheet.create({ + divider: { + width: '3ch', + marginHorizontal: 'auto', + } +}); + +export default MetricsDateSelect; diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx new file mode 100644 index 000000000..25020b435 --- /dev/null +++ b/www/js/metrics/MetricsTab.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useState, useMemo } from "react"; +import { angularize, getAngularService } from "../angular-react-helper"; +import { View, ScrollView, useWindowDimensions } from "react-native"; +import { Appbar } from "react-native-paper"; +import NavBarButton from "../components/NavBarButton"; +import { useTranslation } from "react-i18next"; +import { DateTime } from "luxon"; +import { MetricsData } from "./metricsTypes"; +import MetricsCard from "./MetricsCard"; +import { formatForDisplay, useImperialConfig } from "../config/useImperialConfig"; +import MetricsDateSelect from "./MetricsDateSelect"; +import WeeklyActiveMinutesCard from "./WeeklyActiveMinutesCard"; +import { secondsToHours, secondsToMinutes } from "./metricsHelper"; +import CarbonFootprintCard from "./CarbonFootprintCard"; +import Carousel from "../components/Carousel"; +import DailyActiveMinutesCard from "./DailyActiveMinutesCard"; +import CarbonTextCard from "./CarbonTextCard"; +import ActiveMinutesTableCard from "./ActiveMinutesTableCard"; + +export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; + +async function fetchMetricsFromServer(type: 'user'|'aggregate', dateRange: DateTime[]) { + const CommHelper = getAngularService('CommHelper'); + const query = { + freq: 'D', + start_time: dateRange[0].toSeconds(), + end_time: dateRange[1].toSeconds(), + metric_list: METRIC_LIST, + is_return_aggregate: (type == 'aggregate'), + } + if (type == 'user') + return CommHelper.getMetrics('timestamp', query); + return CommHelper.getAggregateData("result/metrics/timestamp", query); +} + +function getLastTwoWeeksDtRange() { + const now = DateTime.now().startOf('day'); + const start = now.minus({ days: 15 }); + const end = now.minus({ days: 1 }); + return [start, end]; +} + +const MetricsTab = () => { + + const { t } = useTranslation(); + const { getFormattedSpeed, speedSuffix, + getFormattedDistance, distanceSuffix } = useImperialConfig(); + + const [dateRange, setDateRange] = useState(getLastTwoWeeksDtRange); + const [aggMetrics, setAggMetrics] = useState(null); + const [userMetrics, setUserMetrics] = useState(null); + + useEffect(() => { + loadMetricsForPopulation('user', dateRange); + loadMetricsForPopulation('aggregate', dateRange); + }, [dateRange]); + + async function loadMetricsForPopulation(population: 'user'|'aggregate', dateRange: DateTime[]) { + const serverResponse = await fetchMetricsFromServer(population, dateRange); + console.debug("Got metrics = ", serverResponse); + const metrics = {}; + const dataKey = (population == 'user') ? 'user_metrics' : 'aggregate_metrics'; + METRIC_LIST.forEach((metricName, i) => { + metrics[metricName] = serverResponse[dataKey][i]; + }); + if (population == 'user') { + setUserMetrics(metrics as MetricsData); + } else { + setAggMetrics(metrics as MetricsData); + } + } + + function refresh() { + setDateRange(getLastTwoWeeksDtRange()); + } + + const { width: windowWidth } = useWindowDimensions(); + const cardWidth = windowWidth * .88; + + return (<> + + + + + + + + + + + + + + + + + + + + {/* */} + + + ); +} + +export const cardMargin = 10; + +export const cardStyles: any = { + card: { + overflow: 'hidden', + minHeight: 300, + }, + title: (colors) => ({ + backgroundColor: colors.primary, + paddingHorizontal: 8, + minHeight: 52, + }), + titleText: (colors) => ({ + color: colors.onPrimary, + fontWeight: '500', + textAlign: 'center' + }), + subtitleText: { + fontSize: 13, + lineHeight: 13, + fontWeight: '400', + fontStyle: 'italic', + }, + content: { + padding: 8, + paddingBottom: 12, + flex: 1, + } +} + +angularize(MetricsTab, 'MetricsTab', 'emission.main.metricstab'); +export default MetricsTab; diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx new file mode 100644 index 000000000..99bf9d425 --- /dev/null +++ b/www/js/metrics/WeeklyActiveMinutesCard.tsx @@ -0,0 +1,78 @@ + +import React, { useMemo, useState } 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 { formatDateRangeOfDays, segmentDaysByWeeks } from './metricsHelper'; +import { useTranslation } from 'react-i18next'; +import BarChart from '../components/BarChart'; +import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; +import { getBaseModeByText } from '../diary/diaryHelper'; + +export const ACTIVE_MODES = ['walk', 'bike'] as const; +type ActiveMode = typeof ACTIVE_MODES[number]; + +type Props = { userMetrics: MetricsData } +const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => { + + const { colors } = useTheme(); + const { t } = useTranslation(); + + + const weeklyActiveMinutesRecords = useMemo(() => { + const records = []; + const [ recentWeek, prevWeek ] = segmentDaysByWeeks(userMetrics?.duration, 2); + ACTIVE_MODES.forEach(mode => { + const prevSum = prevWeek?.reduce((acc, day) => ( + acc + (day[`label_${mode}`] || 0) + ), 0); + if (prevSum) { + const xLabel = `Previous Week\n(${formatDateRangeOfDays(prevWeek)})`; // TODO: i18n + records.push({label: labelKeyToRichMode(mode), x: xLabel, y: prevSum / 60}); + } + const recentSum = recentWeek?.reduce((acc, day) => ( + acc + (day[`label_${mode}`] || 0) + ), 0); + if (recentSum) { + const xLabel = `Past Week\n(${formatDateRangeOfDays(recentWeek)})`; // TODO: i18n + records.push({label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60}); + } + }); + return records as {label: ActiveMode, x: string, y: number}[]; + }, [userMetrics?.duration]); + + return ( + + + + { weeklyActiveMinutesRecords.length ? + + getBaseModeByText(l, labelOptions).color} /> + + {t('main-metrics.weekly-goal-footnote')} + + + : + + + {t('metrics.chart-no-data')} + + + } + + + ) +} + +export default WeeklyActiveMinutesCard; diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts new file mode 100644 index 000000000..d1cd435d4 --- /dev/null +++ b/www/js/metrics/metricsHelper.ts @@ -0,0 +1,212 @@ +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[] = []; + metricDataDays.forEach(e => { + Object.keys(e).forEach(k => { + if (k.startsWith('label_')) { + const label = k.substring(6); // remove 'label_' prefix leaving just the mode label + if (!uniqueLabels.includes(label)) uniqueLabels.push(label); + } + }); + }); + return uniqueLabels; +} + +export const getLabelsForDay = (metricDataDay: DayOfMetricData) => ( + Object.keys(metricDataDay).reduce((acc, k) => { + if (k.startsWith('label_')) { + acc.push(k.substring(6)); // remove 'label_' prefix leaving just the mode label + } + return acc; + }, [] as string[]) +); + +export const secondsToMinutes = (seconds: number) => + formatForDisplay(seconds / 60); + +export const secondsToHours = (seconds: number) => + formatForDisplay(seconds / 3600); + +// segments metricsDays into weeks, with the most recent week first +export function segmentDaysByWeeks (days: DayOfMetricData[], nWeeks?: number) { + const weeks: DayOfMetricData[][] = []; + for (let i = days?.length - 1; i >= 0; i -= 7) { + weeks.push(days.slice(Math.max(i - 6, 0), i + 1)); + } + if (nWeeks) return weeks.slice(0, nWeeks); + return weeks; +}; + +export function formatDate(day: DayOfMetricData) { + const dt = DateTime.fromISO(day.fmt_time, { zone: 'utc' }); + return dt.toLocaleString({...DateTime.DATE_SHORT, year: undefined}); +} + +export function formatDateRangeOfDays(days: DayOfMetricData[]) { + if (!days?.length) return ''; + const firstDayDt = DateTime.fromISO(days[0].fmt_time, { zone: 'utc' }); + const lastDayDt = DateTime.fromISO(days[days.length - 1].fmt_time, { zone: 'utc' }); + const firstDay = firstDayDt.toLocaleString({...DateTime.DATE_SHORT, year: undefined}); + 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; +} + +/* +* We use the results to determine whether these results are from custom +* labels or from the automatically sensed labels. Automatically sensedV +* labels are in all caps, custom labels are prefixed by label, but have had +* the label_prefix stripped out before this. Results should have either all +* sensed labels or all custom labels. +*/ +export const isCustomLabels = function(modeMap) { + const isSensed = (mode) => mode == mode.toUpperCase(); + const isCustom = (mode) => mode == mode.toLowerCase(); + const metricSummaryChecksCustom = []; + const metricSummaryChecksSensed = []; + + const distanceKeys = modeMap.map((e) => e.key); + const isSensedKeys = distanceKeys.map(isSensed); + const isCustomKeys = distanceKeys.map(isCustom); + console.log("Checking metric keys", distanceKeys, " sensed ", isSensedKeys, + " custom ", isCustomKeys); + const isAllCustomForMetric = isAllCustom(isSensedKeys, isCustomKeys); + metricSummaryChecksSensed.push(!isAllCustomForMetric); + metricSummaryChecksCustom.push(isAllCustomForMetric); + + console.log("overall custom/not results for each metric = ", metricSummaryChecksCustom); + return isAllCustom(metricSummaryChecksSensed, metricSummaryChecksCustom); +} + +const isAllCustom = function(isSensedKeys, isCustomKeys) { + const allSensed = isSensedKeys.reduce((a, b) => a && b, true); + const anySensed = isSensedKeys.reduce((a, b) => a || b, false); + const allCustom = isCustomKeys.reduce((a, b) => a && b, true); + const anyCustom = isCustomKeys.reduce((a, b) => a || b, false); + if ((allSensed && !anyCustom)) { + return false; // sensed, not custom + } + if ((!anySensed && allCustom)) { + return true; // custom, not sensed; false implies that the other option is true + } + // Logger.displayError("Mixed entries that combine sensed and custom labels", + // "Please report to your program admin"); + return undefined; +} \ No newline at end of file diff --git a/www/js/metrics/metricsTypes.ts b/www/js/metrics/metricsTypes.ts new file mode 100644 index 000000000..d51c98b3a --- /dev/null +++ b/www/js/metrics/metricsTypes.ts @@ -0,0 +1,14 @@ +import { METRIC_LIST } from "./MetricsTab" + +type MetricName = typeof METRIC_LIST[number]; +type LabelProps = {[k in `label_${string}`]?: number}; // label_, where could be anything +export type DayOfMetricData = LabelProps & { + ts: number, + fmt_time: string, + nUsers: number, + local_dt: {[k: string]: any}, // TODO type datetime obj +} + +export type MetricsData = { + [key in MetricName]: DayOfMetricData[] +} diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index cb14c61ea..ec56295eb 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -55,7 +55,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { if (isOther) { /* Let's make the value for user entered inputs look consistent with our other values (i.e. lowercase, and with underscores instead of spaces) */ - chosenLabel = readableLabelToKey(chosenLabel); + chosenLabel = readableLabelToKey(chosenLabel); } const inputDataToStore = { "start_ts": trip.start_ts, diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index fdfea319f..6350745eb 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -13,21 +13,22 @@ type InputDetails = { key: string, } }; -type LabelOptions = { +export type LabelOptions = { [k in T]: { value: string, baseMode: string, met?: {range: any[], mets: number} met_equivalent?: string, kgCo2PerKm: number, + text?: string, }[] } & { translations: { [lang: string]: { [translationKey: string]: string } }}; let appConfig; -let labelOptions: LabelOptions<'MODE'|'PURPOSE'|'REPLACED_MODE'>; -let inputDetails: InputDetails<'MODE'|'PURPOSE'|'REPLACED_MODE'>; +export let labelOptions: LabelOptions<'MODE'|'PURPOSE'|'REPLACED_MODE'>; +export let inputDetails: InputDetails<'MODE'|'PURPOSE'|'REPLACED_MODE'>; export async function getLabelOptions(appConfigParam?) { if (appConfigParam) appConfig = appConfigParam; @@ -118,3 +119,6 @@ export const getFakeEntry = (otherValue) => ({ text: labelKeyToReadable(otherValue), value: otherValue, }); + +export const labelKeyToRichMode = (labelKey: string) => + labelOptions?.MODE?.find(m => m.value == labelKey)?.text || labelKeyToReadable(labelKey); diff --git a/www/templates/main-metrics.html b/www/templates/main-metrics.html deleted file mode 100644 index d3c8b7ce3..000000000 --- a/www/templates/main-metrics.html +++ /dev/null @@ -1,225 +0,0 @@ - - - - - - - - - - -
-
-
{{'main-metrics.summary'}}
-
{{'main-metrics.chart'}}
-
-
-
-
{{'main-metrics.change-data'}}
-
-
-
-
{{ selectCtrl.fromDateTimestamp.format('ll') }} ➡️ {{ selectCtrl.toDateTimestamp.format('ll') }}
-
-
-
-
- - - -
-
-
-
-
{{'main-metrics.distance'}}
-
{{'main-metrics.trips'}}
-
{{'main-metrics.duration'}}
-
{{'main-metrics.speed'}}
-
-
-
-
- - - - -
-
-
-
-
-
-
-
-

{{'main-metrics.footprint'}}

-
-
-
kg CO₂
-
{{ 'main-metrics.label-to-squish' | i18next }}
- -
-
-
-
{{'main-metrics.how-it-compares'}}
- -
{{'main-metrics.average'}} kg CO₂
-
{{'main-metrics.avoided'}} kg CO₂
-
{{'main-metrics.lastweek'}} kg CO₂
- -
{{'main-metrics.us-2030-goal'}} {{carbonData.us2030 | number}} kg CO₂
-
{{'main-metrics.us-2050-goal'}} {{carbonData.us2050 | number}} kg CO₂
-
-
-
- -
-
-
-
-
- -
-
- -
-
- -
-

{{'main-metrics.calories'}}

-
- -
-
-
kcal
-
{{'main-metrics.equals-cookies' | i18next:{count: numberOfCookies.low} }}
-
{{'main-metrics.equals-icecream' | i18next:{count: numberOfIceCreams.low} }}
-
{{'main-metrics.equals-bananas' | i18next:{count: numberOfBananas.low} }}
- -
-
-
-
{{'main-metrics.average'}} cal
-
{{'main-metrics.lastweek'}} cal
-
-
- -
-
- -
-
- -
-
-
-
- -
-
- - -
-

{{'main-metrics.distance'}}

-
{{'main-metrics.no-summary-data'}}
-
-
- -
- -
-
-
- {{ summaryData.defaultSummary.distance[dIndex + i].key }} -
-
- {{ formatDistance(summaryData.defaultSummary.distance[dIndex + i].values).slice(-1)[0] }} -
-
-
-
-
-
- -
-

{{'main-metrics.trips'}}

-
{{'main-metrics.no-summary-data'}}
-
-
- -
- -
-
-
- {{ summaryData.defaultSummary.count[dIndex + i].key }} -
-
- {{ formatCount(summaryData.defaultSummary.count[dIndex + i].values).slice(-1)[0] }} -
-
-
-
-
-
- -
-

{{'main-metrics.duration'}}

-
{{'main-metrics.no-summary-data'}}
-
-
- -
- -
-
-
- {{ summaryData.defaultSummary.duration[dIndex + i].key }} -
-
- {{ formatDuration(summaryData.defaultSummary.duration[dIndex + i].values).slice(-1)[0] }} -
-
-
-
-
-
- -
-

{{'main-metrics.mean-speed'}}

-
{{'main-metrics.no-summary-data'}}
-
-
- -
- -
-
-
- {{ summaryData.defaultSummary.mean_speed[dIndex + i].key }} -
-
- {{ formatMeanSpeed(summaryData.defaultSummary.mean_speed[dIndex + i].values).slice(-1)[0] }} -
-
-
-
-
-
-
-
-
-
-