diff --git a/components/CallToActionBox.js b/components/CallToActionBox.js index b51f5ec13..42099aaaf 100644 --- a/components/CallToActionBox.js +++ b/components/CallToActionBox.js @@ -4,24 +4,20 @@ import { FormattedMessage } from 'react-intl' const CallToActionBox = ({title, text}) => { return ( - - - + + + {title} - + {text} - - - - - - - + + + ) } diff --git a/components/country/Calendar.js b/components/country/Calendar.js new file mode 100644 index 000000000..a41050574 --- /dev/null +++ b/components/country/Calendar.js @@ -0,0 +1,185 @@ +import { ResponsiveCalendar } from '@nivo/calendar' +import CTABox from 'components/CallToActionBox' +import SpinLoader from 'components/vendor/SpinLoader' +import { Box, Flex, theme } from 'ooni-components' +import React, { useMemo, useState } from 'react' +import { FormattedMessage } from 'react-intl' +import dayjs from 'services/dayjs' +import { fetcherWithPreprocessing } from 'services/fetchers' +import { styled } from 'styled-components' +import useSWR from 'swr' +import { getRange } from 'utils' +import FormattedMarkdown from '../FormattedMarkdown' +import { useCountry } from './CountryContext' + +const swrOptions = { + revalidateOnFocus: false, + dedupingInterval: 10 * 60 * 1000, +} + +const prepareDataForCalendar = (data) => { + return data.map((r) => ({ + value: r.measurement_count, + day: r.measurement_start_day, + })) +} + +const CallToActionBox = () => { + const { countryName } = useCountry() + return ( + } + text={ + } /> + ) +} + +const StyledCalendar = styled.div` +height: 180px; +` +const { colors } = theme +const chartColors = [colors.blue2, colors.blue4, colors.blue5, colors.blue7] + +const findColor = number => { + if (number === 0) return colors.gray1 + if (number <= 50) return chartColors[0] + if (number <= 500) return chartColors[1] + if (number <= 5000) return chartColors[2] + return chartColors[3] +} + +const colorLegend = [ + {color: chartColors[0], range: '1-50'}, + {color: chartColors[1], range: '51-100'}, + {color: chartColors[2], range: '501-5000'}, + {color: chartColors[3], range: '>5000'}, +] + +const dateRange = (startDate, endDate) => { + if (!startDate || !endDate) return + const start = new Date(new Date(startDate.getFullYear(), 0, 0, 0).setUTCHours(0, 0, 0, 0)) + const end = new Date(new Date(endDate).setUTCHours(0, 0, 0, 0)) + const date = new Date(start.getTime()) + const dates = [] + + while (date <= end) { + dates.push(new Date(date).toISOString().split('T')[0]) + date.setUTCDate(date.getDate() + 1) + } + return dates +} + +const backfillData = data => { + const range = dateRange(new Date(data[0].day), new Date()) + return range.map((r) => (data.find((d) => d.day === r) || { value: 0, day: r})) +} + +const Calendar = React.memo(function Calendar({ startYear }) { + const { countryCode } = useCountry() + const today = new Date() + const currentYear = today.getFullYear() + const firstMeasurementYear = startYear ? new Date(startYear).getFullYear() : new Date(data[0].day).getFullYear() + + const [ selectedYear, setSelectedYear ] = useState(currentYear) + const since = `${selectedYear}-01-01` + const until = selectedYear === currentYear ? + dayjs.utc().add(1, 'day').format('YYYY-MM-DD') : + `${selectedYear + 1}-01-01` + + const yearsOptions = getRange(firstMeasurementYear, currentYear) + + const { data, error, isLoading } = useSWR( + [ + '/api/v1/aggregation', + { params: { + probe_cc: countryCode, + since, + until, + axis_x: 'measurement_start_day', + }, + resultKey: 'result', + preprocessFn: prepareDataForCalendar, + }, + ], + fetcherWithPreprocessing, + swrOptions + ) + + const calendarData = useMemo(() => { + if (data && data.length) { + return backfillData(data) + } else { + return [] + } + }, + [data] + ) + + return ( + + {isLoading && } + {!!calendarData.length && + + findColor(value)} + margin={{ top: 20, right: 0, bottom: 0, left: 20 }} + monthBorderColor="#ffffff" + dayBorderWidth={2} + dayBorderColor="#ffffff" + /> + + } + {!calendarData.length && !isLoading && } + {error && + + Error: {JSON.stringify(error)} + + } + + + {colorLegend.map(item => ( + + + {item.range} + + ))} + + + {yearsOptions.map(year => ( + setSelectedYear(year)} + > + {year} + + ))} + + + + ) +}) + +export default Calendar \ No newline at end of file diff --git a/components/country/Overview.js b/components/country/Overview.js index adb973b40..499d886ba 100644 --- a/components/country/Overview.js +++ b/components/country/Overview.js @@ -1,70 +1,12 @@ -import React from 'react' -import { defineMessages, FormattedMessage, useIntl } from 'react-intl' -import styled from 'styled-components' +import BlockText from 'components/BlockText' +import Calendar from 'components/country/Calendar' import { Box, Heading, Link, Text } from 'ooni-components' -import SectionHeader from './SectionHeader' -import { BoxWithTitle } from './boxes' -import TestsByGroup from './OverviewCharts' +import React from 'react' +import { FormattedMessage, useIntl } from 'react-intl' import FormattedMarkdown from '../FormattedMarkdown' import { useCountry } from './CountryContext' -import BlockText from 'components/BlockText' - -const NwInterferenceStatus = styled(Box)` - color: ${props => props.color || props.theme.colors.gray5}; - font-size: 18px; -` -NwInterferenceStatus.defaultProps = { - mb: 3 -} - -const messages = defineMessages({ - middleboxNoData: { - id: 'Country.Overview.NwInterference.Middleboxes.NoData', - defaultMessage: '' - }, - middleboxBlocked: { - id: 'Country.Overview.NwInterference.Middleboxes.Blocked', - defaultMessage: '' - }, - middleboxNormal: { - id: 'Country.Overview.NwInterference.Middleboxes.Normal', - defaultMessage: '' - }, - imNoData: { - id: 'Country.Overview.NwInterference.IM.NoData', - defaultMessage: '' - }, - imBlocked: { - id: 'Country.Overview.NwInterference.IM.Blocked', - defaultMessage: '' - }, - imNormal: { - id: 'Country.Overview.NwInterference.IM.Normal', - defaultMessage: '' - }, - websitesNoData: { - id: 'Country.Overview.NwInterference.Websites.NoData', - defaultMessage: '' - }, - websitesBlocked: { - id: 'Country.Overview.NwInterference.Websites.Blocked', - defaultMessage: '' - }, - websitesNormal: { - id: 'Country.Overview.NwInterference.Websites.Normal', - defaultMessage: '' - } -}) - -const getStatus = (count, formattedMessageId)=> { - if (count === null) { - return messages[`${formattedMessageId}NoData`] - } else if (count > 0) { - return messages[`${formattedMessageId}Blocked`] - } else { - return messages[`${formattedMessageId}Normal`] - } -} +import SectionHeader from './SectionHeader' +import { BoxWithTitle } from './boxes' const ooniBlogBaseURL = 'https://ooni.org' @@ -76,29 +18,8 @@ const FeaturedArticle = ({link, title}) => ( ) -// const SummaryText = styled(Box)` -// border: 1px solid ${props => props.theme.colors.gray4}; -// border-left: 12px solid ${props => props.theme.colors.blue5}; -// font-size: 22px; -// font-style: italic; -// line-height: 1.5; -// ` - -// SummaryText.defaultProps = { -// p: 3, -// } - -const LOW_DATA_THRESHOLD = 10 - const Overview = ({ countryName, - testCoverage, - networkCoverage, - fetchTestCoverageData, - middleboxCount, - imCount, - circumventionTools, - blockedWebsitesCount, networkCount, measurementCount, measuredSince, @@ -106,6 +27,7 @@ const Overview = ({ }) => { const intl = useIntl() const { countryCode } = useCountry() + return ( <> @@ -127,38 +49,15 @@ const Overview = ({ {/* */} - {/* - }> - - - - {intl.formatMessage(getStatus(middleboxCount, 'middlebox'))} - - - {intl.formatMessage(getStatus(circumventionTools, 'circumvention'))} - - - - {intl.formatMessage(getStatus(imCount, 'im'))} - - - - {intl.formatMessage(getStatus(blockedWebsitesCount, 'websites'))} - - - - */} - + + + }> { (featuredArticles.length === 0) diff --git a/components/country/OverviewCharts.js b/components/country/OverviewCharts.js deleted file mode 100644 index 557597d91..000000000 --- a/components/country/OverviewCharts.js +++ /dev/null @@ -1,279 +0,0 @@ -import React from 'react' -import styled from 'styled-components' -import { Flex, Box, theme } from 'ooni-components' -import { - VictoryChart, - VictoryBar, - VictoryStack, - VictoryAxis, - VictoryLine, - VictoryLabel, - VictoryVoronoiContainer -} from 'victory' -import { FormattedMessage, injectIntl } from 'react-intl' - -import Tooltip from './Tooltip' -import VictoryTheme from '../VictoryTheme' -import { testGroups } from '../test-info' -import FormattedMarkdown from '../FormattedMarkdown' -import { useCountry } from './CountryContext' -import CTABox from 'components/CallToActionBox' - -const Circle = styled.span` - height: 16px; - width: 16px; - border-radius: 50%; - background-color: ${props => props.color}; -` -const StyledTestGroupSelector = styled(Flex)` - cursor: pointer; - &${Box}:hover { - text-shadow: 1px 1px 1px black; - } -` - -const CallToActionBox = () => { - const { countryName } = useCountry() - return ( - } - text={ - } /> - ) -} - -const TestGroupSelector = ({ testGroup, active, onClick }) => ( - onClick(testGroup)}> - - {testGroups[testGroup].name} - -) - -class TestsByGroup extends React.Component { - constructor(props) { - super(props) - this.state = { - websites: true, - im: true, - performance: true, - middlebox: true, - circumvention: true - } - - this.onTestGroupClick = this.onTestGroupClick.bind(this) - } - - shouldComponentUpdate(nextProps, nextState) { - const testGroups = Object.keys(this.state) - for (let i = 0; i < testGroups.length; i++) { - if (this.state[testGroups[i]] !== nextState[testGroups[i]]) { - return true - } - } - return false - } - - componentDidUpdate() { - const activeTestGroups = Object.keys(this.state).filter((testGroup) => ( - this.state[testGroup] === true - )).join(',') - this.props.fetchTestCoverageData(activeTestGroups) - } - - onTestGroupClick(testGroup) { - // Toggle testGroup in the selection - this.setState((state) => ({ - [testGroup]: !state[testGroup] - })) - } - - - render() { - const { testCoverage, networkCoverage, intl } = this.props - - // Use react-intl's imperative API to render localized test names in chart tooltips - const testGroupNames = { - 'websites': intl.formatMessage({id: 'Tests.Groups.Websites.Name'}), - 'im': intl.formatMessage({id: 'Tests.Groups.Instant Messagging.Name'}), - 'middlebox': intl.formatMessage({id: 'Tests.Groups.Middlebox.Name'}), - 'performance': intl.formatMessage({id: 'Tests.Groups.Performance.Name'}), - 'circumvention': intl.formatMessage({id: 'Tests.Groups.Circumvention.Name'}) - } - - // Check if there is enough data to plot the charts - const testCoverageCount = testCoverage.reduce((count, item) => count + item.count, 0) - const networkCoverageCount = networkCoverage.reduce((count, item) => count + item.count, 0) - const notEnoughData = (testCoverageCount === 0 && networkCoverageCount === 0) - - const supportedTestGroups = ['websites', 'im', 'middlebox', 'performance', 'circumvention'] - - const renderEmptyChart = () => ( - - } - domain={{ y: [0, 1] }} - width={600} - height={200} - > - {}} /> - {}} /> - {}} /> - - - ) - - const renderCharts = () => { - let testCoverageMaxima, networkCoverageMaxima - const selectedTestGroups = Object.keys(this.state).filter(testGroup => this.state[testGroup]) - - const testCoverageByDay = testCoverage.reduce((prev, cur) => { - prev[cur.test_day] = prev[cur.test_day] || {} - if (selectedTestGroups.indexOf(cur.test_group) > -1) { - prev[cur.test_day][cur.test_group] = cur.count - const allTestCount = Object.values(prev[cur.test_day]).reduce((p,c) => p+c, 0) - if (typeof testCoverageMaxima === 'undefined' - || testCoverageMaxima < allTestCount) { - testCoverageMaxima = allTestCount - } - } - return prev - }, {}) - const testCoverageArray = Object.keys(testCoverageByDay) - .map(day => ({test_day: day, ...testCoverageByDay[day]})) - - networkCoverage.forEach((d) => { - if (typeof networkCoverageMaxima === 'undefined' - || networkCoverageMaxima < d.count) { - networkCoverageMaxima = d.count - } - }) - - const networkCoverageTick = (t) => Math.round(t * networkCoverageMaxima) - const ntIncrement = Math.round(networkCoverageMaxima/4) - const networkCoverageTickValues = [1,2,3,4].map(i => i * ntIncrement / networkCoverageMaxima) - - const testCoverageTick = (t) => Math.round(t * testCoverageMaxima) - const tsIncrement = Math.round(testCoverageMaxima/4) - const testCoverageTickValues = [1,2,3,4].map(i => i * tsIncrement / testCoverageMaxima) - - return ( - - } - domain={{ y: [0, 1] }} - width={600} - height={200} - > - - - - { - selectedTestGroups.map((testGroup, index) => { - let maybeLabels = {} - if (index === 0) { - maybeLabels['labels'] = (d) => { - let s = new Date(d.test_day).toLocaleDateString() - selectedTestGroups.forEach((name) => { - s += `\n${d[name]} ${testGroupNames[name]}` - }) - return s - } - maybeLabels['labelComponent'] = - } - return ( - d[testGroup] / testCoverageMaxima} - /> - ) - }) - } - - - d.count / networkCoverageMaxima} - scale={{x: 'time', y: 'linear'}} - labels={(d) => `${new Date(d.test_day).toLocaleDateString()}\n${d.count} Networks `} - labelComponent={} - style={{ - data: { - stroke: theme.colors.gray7, - } - }} - /> - - ) - } - - return ( - <> - {notEnoughData && } - - { - supportedTestGroups.map((testGroup, index) => ( - - )) - } - - {/* Bar chart */} - - - {notEnoughData ? renderEmptyChart() : renderCharts()} - - - - ) - } -} - -export default injectIntl(TestsByGroup)