Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add defining mutations #380

Draft
wants to merge 12 commits into
base: master
Choose a base branch
from
1,165 changes: 1,165 additions & 0 deletions web/data/definingMutations/BQ.1.json

Large diffs are not rendered by default.

1,804 changes: 1,804 additions & 0 deletions web/data/definingMutations/XBC.1.json

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions web/data/definingMutationsIndex.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"clusters": [
{
"lineage": "BQ.1",
"nextstrainClade": "22E"
},
{
"lineage": "XBC.1",
"nextstrainClade": "recombinant"
}
]
}
42 changes: 42 additions & 0 deletions web/src/components/Common/ButtonTransparent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Button, ButtonProps } from 'reactstrap'
import styled from 'styled-components'

export interface ButtonTransparentProps extends ButtonProps {
height?: string
width?: string
fontSize?: string
}

export const ButtonTransparent = styled(Button)<ButtonTransparentProps>`
width: ${(props) => props.width ?? props.height};
height: ${(props) => props.height};
line-height: ${(props) => props.height};
font-size: ${(props) => props.fontSize};
padding: 0;
margin: 4px 0;
background-color: transparent;
background-image: none;
color: ${(props) => props.theme.bodyColor};
border: none;
border-radius: 0;
box-shadow: none;
border-image: none;
text-decoration: none;
-webkit-tap-highlight-color: #ccc;

&.show > .btn-secondary.dropdown-toggle,
&.active,
&:active,
&:hover,
&:focus,
&:focus-within {
background-color: transparent;
background-image: none;
color: ${(props) => props.theme.bodyColor};
border: none;
border-radius: 0;
box-shadow: none;
border-image: none;
text-decoration: none;
}
`
93 changes: 52 additions & 41 deletions web/src/components/Common/MutationBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,32 +286,31 @@ export interface VariantLinkBadgeProps {
}

export function VariantLinkBadge({ name, href, prefix }: VariantLinkBadgeProps) {
const { mutationObj, mutationStr } = useMemo(() => variantToObjectAndString(name), [name])
const { mutationStr } = useMemo(() => variantToObjectAndString(name), [name])
const url = useMemo(() => href ?? formatVariantUrl(mutationStr), [href, mutationStr])

if (!mutationObj) {
return <span className="text-danger">{`VariantLinkBadge: Invalid mutation: ${JSON.stringify(name)}`}</span>
}

if (!url) {
return (
<span className="text-danger">
{
// prettier-ignore
`VariantLinkBadge: Variant not recognized: ${JSON.stringify(name)}.` +
`Known variants: ${clusterNames.join(", ")}`
}
</span>
)
}

return (
<LinkUnstyled href={url} icon={null}>
<MutationBadge prefix={prefix} mutation={mutationObj} colors={AMINOACID_COLORS} />
<VariantBadge prefix={prefix} name={name} />
</LinkUnstyled>
)
}

export interface VariantBadgeProps {
name: string | Mutation
prefix?: string
}

export function VariantBadge({ name, prefix }: VariantBadgeProps) {
const mutationObj = useMemo(() => {
const { mutationObj } = variantToObjectAndString(name)
if (!mutationObj) {
return { parent: mutationObj }
}
return mutationObj
}, [name])
return <MutationBadge prefix={prefix} mutation={mutationObj} colors={AMINOACID_COLORS} />
}

export interface LineageLinkBadgeProps {
name: string
href?: string
Expand All @@ -320,38 +319,50 @@ export interface LineageLinkBadgeProps {
}

export function LineageLinkBadge({ name, href, prefix, report }: LineageLinkBadgeProps) {
const { t } = useTranslationSafe()

const url = useMemo(
// prettier-ignore
() => (href ?? (report ? `https://cov-lineages.org/global_report_${name}.html` : "")),
() => (href ?? (report ? `https://cov-lineages.org/global_report_${name}.html` : '')),
[href, report, name],
)

return (
<LinkUnstyled href={url}>
<LineageBadge name={name} prefix={prefix} />
</LinkUnstyled>
)
}

export interface LineageBadgeProps {
name: string
prefix?: string
}

export function LineageBadge({ name, prefix }: LineageBadgeProps) {
const { t } = useTranslationSafe()

const tooltip = useMemo(() => {
const text = t('Pango Lineage')
return `${text} '${name}'`
}, [name, t])

return (
<LinkUnstyled href={url}>
<MutationBadgeBox title={tooltip}>
<MutationWrapper>
{prefix && <PrefixText>{prefix}</PrefixText>}
<ColoredText
$color={colorHash(name, {
reverse: false,
prefix: '',
suffix: '',
lightness: 0.75,
hue: undefined,
saturation: undefined,
})}
>
{name}
</ColoredText>
</MutationWrapper>
</MutationBadgeBox>
</LinkUnstyled>
<MutationBadgeBox title={tooltip}>
<MutationWrapper>
{prefix && <PrefixText>{prefix}</PrefixText>}
<ColoredText
$color={colorHash(name, {
reverse: false,
prefix: '',
suffix: '',
lightness: 0.75,
hue: undefined,
saturation: undefined,
})}
>
{name}
</ColoredText>
</MutationWrapper>
</MutationBadgeBox>
)
}

Expand Down
9 changes: 1 addition & 8 deletions web/src/components/Common/NameTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { ReactNode, useMemo } from 'react'
import { Table as TableBase, Thead, Tbody, Tr, Th, Td } from 'react-super-responsive-table'
import styled from 'styled-components'

import { joinWithCommas } from 'src/helpers/join'
import { useTranslationSafe } from 'src/helpers/useTranslationSafe'
import { LinkExternal } from 'src/components/Link/LinkExternal'
import type { NameTableDatum, NameTableEntry } from 'src/io/getNameTable'
Expand Down Expand Up @@ -47,14 +48,6 @@ const Table = styled(TableBase)`
}
`

export function joinWithCommas(elems: ReactNode[]): ReactNode {
if (elems.length === 0) {
return ' '
}

return elems.reduce((prev, curr) => [prev, ', ', curr])
}

export interface NameTableEntryProps {
entry: NameTableEntry
}
Expand Down
96 changes: 96 additions & 0 deletions web/src/components/Common/SearchBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React, { ChangeEvent, useCallback, useMemo, HTMLProps } from 'react'
import styled from 'styled-components'
import { Form, Input as InputBase } from 'reactstrap'
import { MdSearch as IconSearchBase, MdClear as IconClearBase } from 'react-icons/md'
import { useTranslationSafe } from 'src/helpers/useTranslationSafe'
import { ButtonTransparent } from 'src/components/Common/ButtonTransparent'

const SearchForm = styled(Form)`
display: inline;
position: relative;
`

const IconSearchWrapper = styled.span`
display: inline;
position: absolute;
padding: 5px 7px;
`

const IconSearch = styled(IconSearchBase)`
* {
color: ${(props) => props.theme.gray500};
}
`

const ButtonClear = styled(ButtonTransparent)`
display: inline;
position: absolute;
right: 0;
padding: 0 7px;
`

const IconClear = styled(IconClearBase)`
* {
color: ${(props) => props.theme.gray500};
}
`

const Input = styled(InputBase)`
display: inline !important;
padding-left: 35px;
padding-right: 30px;
height: 2.2em;
`

export interface TableSearchBoxProps extends Omit<HTMLProps<HTMLFormElement>, 'as'> {
searchTitle?: string
searchTerm: string
onSearchTermChange(term: string): void
}

export function SearchBox({ searchTitle, searchTerm, onSearchTermChange, ...restProps }: TableSearchBoxProps) {
const { t } = useTranslationSafe()

const onChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onSearchTermChange(event.target.value)
},
[onSearchTermChange],
)

const onClear = useCallback(() => {
onSearchTermChange('')
}, [onSearchTermChange])

const buttonClear = useMemo(() => {
if (searchTerm.length === 0) {
return null
}
return (
<ButtonClear onClick={onClear} title={t('Clear')}>
<IconClear size={20} />
</ButtonClear>
)
}, [onClear, searchTerm.length, t])

return (
<SearchForm {...restProps}>
<IconSearchWrapper>
<IconSearch size={25} />
</IconSearchWrapper>
<Input
type="text"
title={searchTitle}
placeholder={searchTitle}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
data-gramm="false"
value={searchTerm}
onChange={onChange}
/>
{buttonClear}
</SearchForm>
)
}
8 changes: 8 additions & 0 deletions web/src/components/Common/parsePosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ export function parsePosition(raw: string | undefined | null) {

return num
}

export function parsePositionOrThrow(raw: string | undefined | null) {
const pos = parsePosition(raw)
if (!pos) {
throw new Error(`Unable to parse mutation posiiton: '${JSON.stringify(raw)}'`)
}
return pos
}
43 changes: 43 additions & 0 deletions web/src/components/DefiningMutations/DefMutLineageTitle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, { useMemo } from 'react'
import { DefiningMutationsDropdown } from 'src/components/DefiningMutations/DefiningMutationsDropdown'
import { useTranslationSafe } from 'src/helpers/useTranslationSafe'
import type { DefMutClusterDatum } from 'src/io/getDefiningMutationsClusters'

import styled from 'styled-components'

const VariantTitleWrapper = styled.header`
text-align: center;
min-height: 90px;
`

const ClusterNameTitle = styled.h1``

const ClusterNameSubtitle = styled.p`
margin-bottom: 0;
text-align: center;
`

export interface DefMutLineageTitleProps {
cluster: DefMutClusterDatum
}

export function DefMutLineageTitle({ cluster }: DefMutLineageTitleProps) {
const { t } = useTranslationSafe()

const subtitle = useMemo(() => {
return (
<ClusterNameSubtitle>
{t(`also known as clade `)}
{cluster.cluster?.display_name}
</ClusterNameSubtitle>
)
}, [cluster.cluster?.display_name, t])

return (
<VariantTitleWrapper>
<ClusterNameTitle>{`Defining mutations for ${cluster.lineage}`}</ClusterNameTitle>
{subtitle}
<DefiningMutationsDropdown cluster={cluster} />
</VariantTitleWrapper>
)
}