diff --git a/web/package.json b/web/package.json index 63470d04d4..eae9c21e3b 100644 --- a/web/package.json +++ b/web/package.json @@ -69,12 +69,14 @@ "classnames": "2.2.6", "connected-next-router": "3.1.0", "core-js": "3.6.5", + "country-flag-icons": "1.2.10", "css.escape": "1.5.1", "fast-copy": "2.1.0", "history": "5.0.0", "immer": "7.0.8", "immutable": "3.8.2", "is-absolute-url": "3.0.3", + "iso-3166-1-alpha-2": "1.0.0", "lodash": "4.17.20", "luxon": "1.25.0", "marked": "1.1.1", diff --git a/web/src/components/Common/CountryFlag.tsx b/web/src/components/Common/CountryFlag.tsx new file mode 100644 index 0000000000..b88718a9c8 --- /dev/null +++ b/web/src/components/Common/CountryFlag.tsx @@ -0,0 +1,45 @@ +import React, { SVGProps } from 'react' + +import styled from 'styled-components' +import iso3311a2 from 'iso-3166-1-alpha-2' +import Flags from 'country-flag-icons/react/3x2' + +export const missingCountryCodes: Record = { + 'Bonaire': 'BQ', + 'Curacao': 'CW', + 'North Macedonia': 'MK', + 'Russia': 'RU', + 'Sint Maarten': 'SX', + 'South Korea': 'KR', + 'USA': 'US', +} + +export const FlagWrapper = styled.div<{ $countryCode?: string }>` + height: calc(1em + 2px); + width: calc(1.5em + 2px); + border: 1px solid #ced4da; + display: flex; + > * { + width: 100%; + height: 100%; + } +` + +export interface CountryFlagProps extends SVGProps { + country: string + withFallback?: boolean +} + +export function CountryFlag({ country, withFallback = false }: CountryFlagProps) { + const countryCode = missingCountryCodes[country] ?? iso3311a2.getCode(country) ?? '?' + const Flag = Flags[countryCode] + + const fallback = withFallback ? : null + return Flag ? ( + + + + ) : ( + fallback + ) +} diff --git a/web/src/components/CountryDistribution/CountryDistributionPage.tsx b/web/src/components/CountryDistribution/CountryDistributionPage.tsx index 7ac5ccc29b..543e108d1e 100644 --- a/web/src/components/CountryDistribution/CountryDistributionPage.tsx +++ b/web/src/components/CountryDistribution/CountryDistributionPage.tsx @@ -21,6 +21,7 @@ import { import { CountryDistributionPlotCard } from './CountryDistributionPlotCard' import { CountryDistributionDatum } from './CountryDistributionPlot' +import { CountryFlag } from '../Common/CountryFlag' export interface ClusterState { [key: string]: { enabled: boolean } @@ -83,6 +84,7 @@ export function CountryDistributionPage() { }, [clustersState, countriesState]) const regionsTitle = useMemo(() => (currentRegion === 'World' ? 'Countries' : 'Regions'), [currentRegion]) + const iconComponent = useMemo(() => (currentRegion === 'World' ? CountryFlag : undefined), [currentRegion]) const { withCountriesFiltered } = /* prettier-ignore */ @@ -96,10 +98,15 @@ export function CountryDistributionPage() { () => withClustersFiltered.map(({ country, distribution }) => ( - + )), - [enabledClusters, withClustersFiltered], + [enabledClusters, withClustersFiltered, iconComponent], ) const handleClusterCheckedChange = useCallback( @@ -187,6 +194,7 @@ export function CountryDistributionPage() { regionsTitle={regionsTitle} enabledFilters={enabledFilters} clustersCollapsedByDefault={false} + Icon={iconComponent} onClusterFilterChange={handleClusterCheckedChange} onClusterFilterSelectAll={handleClusterSelectAll} onClusterFilterDeselectAll={handleClusterDeselectAll} diff --git a/web/src/components/CountryDistribution/CountryDistributionPlotCard.tsx b/web/src/components/CountryDistribution/CountryDistributionPlotCard.tsx index 381c4d16e6..1d88fc0351 100644 --- a/web/src/components/CountryDistribution/CountryDistributionPlotCard.tsx +++ b/web/src/components/CountryDistribution/CountryDistributionPlotCard.tsx @@ -1,32 +1,43 @@ /* eslint-disable camelcase */ import React from 'react' - import { Card, CardBody, CardHeader, Col, Row } from 'reactstrap' -import { ColoredCircle } from 'src/components/Common/ColoredCircle' +import styled from 'styled-components' + import { PlotCardTitle } from 'src/components/Common/PlotCardTitle' -import { getCountryColor } from 'src/io/getCountryColor' +import { CountryFlagProps } from 'src/components/Common/CountryFlag' import { CountryDistributionDatum, CountryDistributionPlot, } from 'src/components/CountryDistribution/CountryDistributionPlot' +const FlagAlignment = styled.span` + display: flex; + align-items: center; + > * + * { + margin-left: 0.5em; + } +` export interface CountryDistributionPlotCardProps { country: string distribution: CountryDistributionDatum[] cluster_names: string[] + Icon?: React.ComponentType } export function CountryDistributionPlotCard({ country, distribution, cluster_names, + Icon, }: CountryDistributionPlotCardProps) { return ( - - {country} + + {Icon && } + {country} + diff --git a/web/src/components/DistributionSidebar/CountryFilters.tsx b/web/src/components/DistributionSidebar/CountryFilters.tsx index cf73201f1b..8cb28a49d6 100644 --- a/web/src/components/DistributionSidebar/CountryFilters.tsx +++ b/web/src/components/DistributionSidebar/CountryFilters.tsx @@ -11,7 +11,7 @@ import { Label, Row, } from 'reactstrap' -import { ColoredCircle } from 'src/components/Common/ColoredCircle' +import { CountryFlagProps } from 'src/components/Common/CountryFlag' import styled from 'styled-components' import type { CountryState } from 'src/components/CountryDistribution/CountryDistributionPage' @@ -31,32 +31,52 @@ export const Form = styled(FormBase)` flex-wrap: wrap; ` +const FlagAlignment = styled.span` + display: inline-flex; + align-items: center; + margin-left: 0.25em; + > * + * { + margin-left: 0.5em; + } +` + export interface CountryFilterCheckboxProps { country: string enabled: boolean withIcons?: boolean + Icon?: React.ComponentType onFilterChange(country: string): void } -export function CountryFilterCheckbox({ country, enabled, withIcons, onFilterChange }: CountryFilterCheckboxProps) { +export function CountryFilterCheckbox({ + country, + enabled, + withIcons, + Icon, + onFilterChange, +}: CountryFilterCheckboxProps) { const onChange = useCallback(() => onFilterChange(country), [country, onFilterChange]) - return ( ) @@ -67,6 +87,7 @@ export interface CountryFiltersProps { regionsTitle: string collapsed: boolean withIcons?: boolean + Icon?: React.ComponentType onFilterChange(country: string): void onFilterSelectAll(): void onFilterDeselectAll(): void @@ -78,6 +99,7 @@ export function CountryFilters({ regionsTitle, collapsed, withIcons, + Icon, onFilterSelectAll, onFilterDeselectAll, onFilterChange, @@ -111,6 +133,7 @@ export function CountryFilters({ country={country} enabled={enabled} withIcons={withIcons} + Icon={Icon} onFilterChange={onFilterChange} /> ))} diff --git a/web/src/components/DistributionSidebar/DistributionSidebar.tsx b/web/src/components/DistributionSidebar/DistributionSidebar.tsx index 16e2f196df..fe5ddac52d 100644 --- a/web/src/components/DistributionSidebar/DistributionSidebar.tsx +++ b/web/src/components/DistributionSidebar/DistributionSidebar.tsx @@ -1,6 +1,5 @@ import { get } from 'lodash' import React, { useState, useMemo } from 'react' - import { Col, Row } from 'reactstrap' import type { ClusterState, CountryState } from 'src/components/CountryDistribution/CountryDistributionPage' @@ -8,6 +7,8 @@ import { getClusterNames } from 'src/io/getClusters' import { ClusterFilters } from './ClusterFilters' import { CountryFilters } from './CountryFilters' +import { CountryFlagProps } from '../Common/CountryFlag' + const clusterNames = getClusterNames() export function sortClusters(clusters?: ClusterState): ClusterState | undefined { @@ -32,6 +33,7 @@ export interface DistributionSidebarProps { clustersCollapsedByDefault?: boolean coutriesCollapsedByDefault?: boolean enabledFilters: string[] + Icon?: React.ComponentType onClusterFilterChange(cluster: string): void onClusterFilterSelectAll(): void onClusterFilterDeselectAll(): void @@ -47,6 +49,7 @@ export function DistributionSidebar({ clustersCollapsedByDefault = true, coutriesCollapsedByDefault = true, enabledFilters, + Icon, onClusterFilterChange, onClusterFilterSelectAll, onClusterFilterDeselectAll, @@ -78,6 +81,7 @@ export function DistributionSidebar({ key="country-filters" regionsTitle={regionsTitle} withIcons + Icon={Icon} countries={countries} onFilterChange={onCountryFilterChange} onFilterSelectAll={onCountryFilterSelectAll} @@ -110,6 +114,7 @@ export function DistributionSidebar({ onCountryFilterDeselectAll, onCountryFilterSelectAll, regionsTitle, + Icon, ], ) diff --git a/web/src/types/country-flag-icons/react/3x2.d.ts b/web/src/types/country-flag-icons/react/3x2.d.ts new file mode 100644 index 0000000000..992f54871d --- /dev/null +++ b/web/src/types/country-flag-icons/react/3x2.d.ts @@ -0,0 +1,5 @@ +import type { FC, SVGProps } from 'react' + +declare const Flags: Record>> + +export default Flags diff --git a/web/src/types/iso-3166-1-alpha-2.d.ts b/web/src/types/iso-3166-1-alpha-2.d.ts new file mode 100644 index 0000000000..9dc4345d29 --- /dev/null +++ b/web/src/types/iso-3166-1-alpha-2.d.ts @@ -0,0 +1,15 @@ +export declare class Iso31661a2 { + getCountry(code: string): string | undefined + + getCode(country: string): string | undefined + + getCountries(): string[] + + getCodes(): string[] + + getData(): Record +} + +const iso31661a2: Iso31661a2 + +export default iso31661a2 diff --git a/web/yarn.lock b/web/yarn.lock index 5d8ac863c5..d964445bfa 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -4294,6 +4294,11 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" +country-flag-icons@1.2.10: + version "1.2.10" + resolved "https://registry.yarnpkg.com/country-flag-icons/-/country-flag-icons-1.2.10.tgz#c60fdf25883abacd28fbbf3842b920890f944591" + integrity sha512-nG+kGe4wVU9M+EsLUhP4buSuNdBH0leTm0Fv6RToXxO9BbbxUKV9VUq+9AcztnW7nEnweK7WYdtJsfyNLmQugQ== + create-ecdh@^4.0.0: version "4.0.4" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e" @@ -7333,6 +7338,13 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= +iso-3166-1-alpha-2@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/iso-3166-1-alpha-2/-/iso-3166-1-alpha-2-1.0.0.tgz#bc9e0bb94e584df5468a932997a28552e26f97ac" + integrity sha1-vJ4LuU5YTfVGipMpl6KFUuJvl6w= + dependencies: + mout "^0.11.0" + isobject@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" @@ -8796,6 +8808,11 @@ moment@2.27.0: resolved "https://registry.yarnpkg.com/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d" integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ== +mout@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/mout/-/mout-0.11.1.tgz#ba3611df5f0e5b1ffbfd01166b8f02d1f5fa2b99" + integrity sha1-ujYR318OWx/7/QEWa48C0fX6K5k= + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"