diff --git a/api/graphql/schema.py b/api/graphql/schema.py index f156cb402..2ee9643ef 100644 --- a/api/graphql/schema.py +++ b/api/graphql/schema.py @@ -1393,6 +1393,18 @@ async def analysis_runner( ) return GraphQLAnalysisRunner.from_internal(analysis_runners[0]) + @strawberry.field + async def analyses( + self, + info: Info[GraphQLContext, 'Query'], + id: GraphQLFilter[int], + ) -> list[GraphQLAnalysis]: + connection = info.context['connection'] + analyses = await AnalysisLayer(connection).query( + AnalysisFilter(id=id.to_internal_filter()) + ) + return [GraphQLAnalysis.from_internal(a) for a in analyses] + schema = strawberry.Schema( query=Query, mutation=None, extensions=[QueryDepthLimiter(max_depth=10)] diff --git a/models/models/web.py b/models/models/web.py index e273fa469..f8f7a1388 100644 --- a/models/models/web.py +++ b/models/models/web.py @@ -158,6 +158,9 @@ def get_entity_keys( """ Read through nested participants and full out the keys for the grid response """ + has_sequencing_groups = False + has_assays = False + hidden_participant_meta_keys: set[str] = set() hidden_sample_meta_keys = {'reads', 'vcfs', 'gvcf'} hidden_assay_meta_keys = { @@ -220,12 +223,14 @@ def update_d_from_meta(d: dict[str, bool], meta: dict[str, Any]): if not s.sequencing_groups: continue + has_sequencing_groups = True for sg in s.sequencing_groups or []: if sg.meta: update_d_from_meta(sg_meta_keys, sg.meta) if not sg.assays: continue + has_assays = True for a in sg.assays: if a.meta: update_d_from_meta(assay_meta_keys, a.meta) @@ -318,6 +323,11 @@ def update_d_from_meta(d: dict[str, bool], meta: dict[str, Any]): is_visible=True, filter_key='external_id', ), + Field( + key='type', + label='Type', + is_visible=True, + ), Field( key='sample_root_id', label='Root Sample ID', @@ -350,7 +360,7 @@ def update_d_from_meta(d: dict[str, bool], meta: dict[str, Any]): Field( key='type', label='Type', - is_visible=True, + is_visible=has_assays, filter_key='type', ) ] @@ -368,7 +378,7 @@ def update_d_from_meta(d: dict[str, bool], meta: dict[str, Any]): Field( key='id', label='Sequencing Group ID', - is_visible=True, + is_visible=has_sequencing_groups, filter_key='id', filter_types=[ ProjectParticipantGridFilterType.eq, @@ -378,19 +388,19 @@ def update_d_from_meta(d: dict[str, bool], meta: dict[str, Any]): Field( key='type', label='Type', - is_visible=True, + is_visible=has_sequencing_groups, filter_key='type', ), Field( key='technology', label='Technology', - is_visible=True, + is_visible=has_sequencing_groups, filter_key='technology', ), Field( key='platform', label='Platform', - is_visible=True, + is_visible=has_sequencing_groups, filter_key='platform', ), ] diff --git a/test/data/basic-trio.ped b/test/data/basic-trio.ped new file mode 100644 index 000000000..5d0a223ce --- /dev/null +++ b/test/data/basic-trio.ped @@ -0,0 +1,5 @@ +#Family Individual ID Paternal ID Maternal ID Sex Affected +Fam1 Father 1 2 +Fam1 Mother 2 1 +Fam1 Child1 Father Mother 2 1 +Fam1 Child2 Father Mother 1 2 diff --git a/test/data/generate_data.py b/test/data/generate_data.py index a4e63507c..188279706 100755 --- a/test/data/generate_data.py +++ b/test/data/generate_data.py @@ -56,7 +56,9 @@ ) -async def main(ped_path=default_ped_location, project='greek-myth'): +async def main( + ped_path=default_ped_location, project='greek-myth', sample_prefix='GRK' +): """Doing the generation for you""" papi = ProjectApi() @@ -131,7 +133,9 @@ def generate_random_number_within_distribution(): nsamples = generate_random_number_within_distribution() for _ in range(nsamples): sample = SampleUpsert( - external_ids={PRIMARY_EXTERNAL_ORG: f'GRK{sample_id_index}'}, + external_ids={ + PRIMARY_EXTERNAL_ORG: f'{sample_prefix}{sample_id_index}' + }, type=random.choice(sample_types), meta={ 'collection_date': datetime.datetime.now() @@ -207,6 +211,7 @@ def generate_random_number_within_distribution(): ) for s in sequencing_group_ids ] + ar_entries_inserted = len( await asyncio.gather( *[ @@ -264,5 +269,6 @@ def generate_random_number_within_distribution(): help='Path to the pedigree file', ) parser.add_argument('--project', type=str, default='greek-myth') + parser.add_argument('--sample-prefix', type=str, default='GRK') args = vars(parser.parse_args()) asyncio.new_event_loop().run_until_complete(main(**args)) diff --git a/test/test_web.py b/test/test_web.py index a5ade81b4..67345a465 100644 --- a/test/test_web.py +++ b/test/test_web.py @@ -1,5 +1,4 @@ import unittest -from test.testbase import DbIsolatedTest, run_as_sync from typing import Any from api.routes.web import ( @@ -43,6 +42,7 @@ sequencing_group_id_format, sequencing_group_id_transform_to_raw, ) +from test.testbase import DbIsolatedTest, run_as_sync default_assay_meta = { 'sequencing_type': 'genome', @@ -131,6 +131,9 @@ is_visible=False, filter_key='sample_root_id', ), + ProjectParticipantGridField( + key='type', label='Type', is_visible=True, filter_key=None, filter_types=None + ), ProjectParticipantGridField( key='created_date', label='Created date', diff --git a/web/package-lock.json b/web/package-lock.json index d0ce2ac81..cf2a6d130 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "metamist", - "version": "7.3.1", + "version": "7.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metamist", - "version": "7.3.1", + "version": "7.3.2", "dependencies": { "@apollo/client": "^3.7.3", "@monaco-editor/react": "^4.6.0", diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index b96c4975d..19fd3525a 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -4,6 +4,7 @@ import { Route, Routes as Switch } from 'react-router-dom' import SwaggerUI from 'swagger-ui-react' import ProjectsAdmin from './pages/admin/ProjectsAdmin' +import { AnalysisViewPage } from './pages/analysis/AnalysisView' import { BillingCostByAnalysis, BillingCostByCategory, @@ -14,10 +15,11 @@ import { BillingSeqrProp, } from './pages/billing' import DocumentationArticle from './pages/docs/Documentation' -import FamilyView from './pages/family/FamilyView' +import { FamilyPage } from './pages/family/FamilyView' import Details from './pages/insights/Details' import Summary from './pages/insights/Summary' import OurDnaDashboard from './pages/ourdna/OurDnaDashboard' +import { ParticipantPage } from './pages/participant/ParticipantViewContainer' import AnalysisRunnerSummary from './pages/project/AnalysisRunnerView/AnalysisRunnerSummary' import ProjectOverview from './pages/project/ProjectOverview' import SampleView from './pages/sample/SampleView' @@ -26,6 +28,7 @@ import ErrorBoundary from './shared/utilities/errorBoundary' const Routes: React.FunctionComponent = () => ( } /> + {/* } /> */} } /> ( } /> - + } /> @@ -103,7 +106,7 @@ const Routes: React.FunctionComponent = () => ( } /> @@ -112,10 +115,19 @@ const Routes: React.FunctionComponent = () => ( /> + + + } + /> + + - + } /> diff --git a/web/src/index.css b/web/src/index.css index ca596d3b1..398f5c5c7 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -37,6 +37,10 @@ --color-text-href: rgb(65, 131, 196); + --color-pedigree-person-border: rgb(50, 50, 50); + --color-pedigree-affected: rgb(76, 76, 76); + --color-pedigree-unaffected: rgb(240, 240, 240); + --ourdna-blue-transparent: rgba(113, 172, 225, 0.5); --ourdna-red-transparent: rgba(191, 0, 59, 0.5); --ourdna-yellow-transparent: rgba(232, 199, 29, 0.5); @@ -66,7 +70,7 @@ html[data-theme='dark-mode'] { --color-bg-disabled-card: #353535; --color-table-header: #383838; - --color-border-color: #3a3a3a; + --color-border-color: #565656; --color-border-default: #292a2b; --color-border-red: #921111; --color-divider: rgba(255, 255, 255, 0.2); @@ -85,6 +89,10 @@ html[data-theme='dark-mode'] { --color-text-href: rgb(188, 188, 251); + --color-pedigree-person-border: rgb(169, 169, 169); + --color-pedigree-affected: rgb(51, 51, 51); + --color-pedigree-unaffected: rgb(153, 153, 153); + --color-header-row: #383838; --color-exome-row: #887830; --color-genome-row: #805223; @@ -490,3 +498,7 @@ html[data-theme='dark-mode'] .ui.table { .ui.attached.inverted.menu { border: 1px solid #555 !important; } + +.dimmed.dimmable > .ui.modals.dimmer.visible { + display: flex !important; +} diff --git a/web/src/pages/analysis/AnalysisGrid.tsx b/web/src/pages/analysis/AnalysisGrid.tsx new file mode 100644 index 000000000..8473209a4 --- /dev/null +++ b/web/src/pages/analysis/AnalysisGrid.tsx @@ -0,0 +1,97 @@ +import * as React from 'react' + +import { Table as SUITable } from 'semantic-ui-react' +import { GraphQlAnalysis } from '../../__generated__/graphql' + +import Table from '../../shared/components/Table' +import AnalysisLink from '../../shared/components/links/AnalysisLink' + +export interface IAnalysisGridAnalysis extends Partial { + sgs?: string[] +} + +export const AnalysisGrid: React.FC<{ + analyses: IAnalysisGridAnalysis[] + participantBySgId: { [sgId: string]: { externalId: string } } + sgsById?: { [sgId: string]: { technology: string; platform: string } } + highlightedIndividual?: string | null + setAnalysisIdToView: (analysisId?: number | null) => void + showSequencingGroup?: boolean +}> = ({ + analyses, + participantBySgId, + sgsById, + highlightedIndividual, + setAnalysisIdToView, + showSequencingGroup, +}) => { + return ( + + + + ID + {showSequencingGroup && ( + Sequencing group + )} + Created + Type + Sequencing type + Sequencing technology + Output + + + + {analyses?.map((a) => { + const sgId = a.sgs?.length === 1 ? a.sgs[0] : null + const sg = sgId ? sgsById?.[sgId] : null + return ( + + !!highlightedIndividual && + participantBySgId[sg]?.externalId === highlightedIndividual + ) + ? 'var(--color-page-total-row)' + : 'var(--color-bg-card)', + }} + > + + { + e.preventDefault() + e.stopPropagation() + setAnalysisIdToView(a.id) + }} + /> + + {showSequencingGroup && ( + + {sg + ? sgId + : a.sgs?.map((sg) => ( +
  • + {sg}{' '} + {participantBySgId && sg in participantBySgId + ? `(${participantBySgId[sg]?.externalId})` + : ''} +
  • + ))} +
    + )} + {a.timestampCompleted} + {a.type} + {a.meta?.sequencing_type} + + {!!sg && `${sg?.technology} (${sg?.platform})`} + +
    + + ) + })} + +
    {a.output}
    + ) +} diff --git a/web/src/pages/analysis/AnalysisView.tsx b/web/src/pages/analysis/AnalysisView.tsx new file mode 100644 index 000000000..c4b4da443 --- /dev/null +++ b/web/src/pages/analysis/AnalysisView.tsx @@ -0,0 +1,268 @@ +import { useQuery } from '@apollo/client' +import React from 'react' +import { useParams } from 'react-router-dom' +import { Accordion, Button, Card, Message, Modal, Table as SUITable } from 'semantic-ui-react' +import { gql } from '../../__generated__' +import { KeyValueTable } from '../../shared/components/KeyValueTable' +import AnalysisLink from '../../shared/components/links/AnalysisLink' +import ProjectLink from '../../shared/components/links/ProjectLink' +import SequencingGroupLink from '../../shared/components/links/SequencingGroupLink' +import LoadingDucks from '../../shared/components/LoadingDucks/LoadingDucks' +import Table from '../../shared/components/Table' +import { getHailBatchURL } from '../../shared/utilities/hailBatch' + +interface IAnalysisViewProps { + analysisId: number +} + +const ANALYSIS_QUERY = gql(` +query Analyses($analysisId: Int!) { + analyses(id: {eq: $analysisId}) { + id + meta + output + status + timestampCompleted + type + project { + name + } + auditLogs { + id + arGuid + author + timestamp + meta + } + # cohorts { + # id + # } + sequencingGroups { + id + sample { + id + project { + name + } + } + + } + } +} +`) + +export const AnalysisViewPage: React.FC = () => { + const { analysisId } = useParams() + if (!analysisId) { + return Analysis ID not provided + } + + return ( + +

    Analysis

    + +
    + ) +} + +export const AnalysisView: React.FC = ({ analysisId }) => { + const [_sequencingGroupsIsOpen, setSequencingGroupsIsOpen] = React.useState(false) + + const { loading, error, data } = useQuery(ANALYSIS_QUERY, { + variables: { analysisId: analysisId }, + }) + + const analysis = data?.analyses[0] + + if (loading) { + return + } + + if (!!error) { + return {error} + } + + if (!analysis) { + return + } + const sortedAuditLogs = analysis.auditLogs.sort((a, b) => { + return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + }) + const attributeDict: Record = { + ID: , + Output: ( + + {analysis.output} + + ), + } + if (analysis.output?.includes('web')) { + // pattern is gs://cpg-{dataset}-{main,test}-web/{path} + // and the URL should become: https://{main,test}-web.populationgenomics.org.au/{dataset}/{path} + const match = analysis.output.match(/gs:\/\/cpg-(.+)-(main|test)-web\/(.+)/) + if (match) { + const [_, dataset, env, path] = match + const url = `https://${env}-web.populationgenomics.org.au/${dataset}/${path}` + attributeDict.Url = ( + + {url} + + ) + } + } + + attributeDict.Type = analysis.type + attributeDict.Status = analysis.status + attributeDict.Project = + + if (analysis.timestampCompleted) { + attributeDict.Completed = analysis.timestampCompleted + } + + const sequencingGroupsIsOpen = _sequencingGroupsIsOpen || analysis.sequencingGroups.length === 1 + const tapToOpen = + analysis.sequencingGroups.length > 1 ? ( + + {' '} + - tap to open + + ) : ( + '' + ) + + return ( + <> +

    Attributes

    + {/* */} + + + +

    History

    + + + + setSequencingGroupsIsOpen(!sequencingGroupsIsOpen)}> +

    + Sequencing Groups ({analysis.sequencingGroups.length}){tapToOpen} +

    +
    + + {sequencingGroupsIsOpen && ( + + + + {analysis.sequencingGroups.map((sg) => ( + + + + + ))} + +
    + + {sg?.sample?.project?.name}
    + )} +
    +
    + + ) +} + +interface IAuditLogHistoryProps { + auditLogs: { + id: number + arGuid?: string | null + author: string + timestamp: string + meta: Record + }[] +} + +const getBatchInformationFromLog = (meta: Record) => { + // { "path": "/api/v1/analysis/vcgs-clinical/", "ip": "169.254.169.126", "HAIL_ATTEMPT_ID": "Y5QQS3", "HAIL_BATCH_ID": "474353", "HAIL_JOB_ID": "33" + const batchId = meta.HAIL_BATCH_ID + const jobId = meta.HAIL_JOB_ID + + if (!batchId) { + return null + } + + return { + link: getHailBatchURL(batchId, jobId), + text: `batches/${batchId}/jobs/${jobId}`, + } +} +export const AuditLogHistory: React.FC = ({ auditLogs }) => { + return ( + + + + Timestamp + Author + Hail Batch + + + + {auditLogs.map((log) => { + const batchInformation = getBatchInformationFromLog(log.meta) + + return ( + + {log.timestamp} + {log.author} + + {batchInformation && ( + + {batchInformation.text} + + )} + + + ) + })} + +
    + ) +} + +interface AnalysisViewModalProps { + analysisId?: number | null + onClose: () => void + size?: 'mini' | 'tiny' | 'small' | 'large' | 'fullscreen' +} + +export const AnalysisViewModal: React.FC = ({ + analysisId, + onClose, + size, +}) => { + const isOpen = !!analysisId + return ( + + Analysis + + {!!analysisId && } + + + + + + ) +} diff --git a/web/src/pages/billing/components/BatchGrid.tsx b/web/src/pages/billing/components/BatchGrid.tsx index a00c35b6f..c514a903d 100644 --- a/web/src/pages/billing/components/BatchGrid.tsx +++ b/web/src/pages/billing/components/BatchGrid.tsx @@ -12,6 +12,7 @@ import { useQuery } from '@apollo/client' import { gql } from '../../../__generated__' import MuckTheDuck from '../../../shared/components/MuckTheDuck' import formatMoney from '../../../shared/utilities/formatMoney' +import { getHailBatchURL } from '../../../shared/utilities/hailBatch' import { AnalysisCostRecord, AnalysisCostRecordBatch } from '../../../sm-api' import { BatchJobsTable } from './BatchJobGrid' import { CostBySkuRow, SeqGrpDisplay } from './BillingByAnalysisComponents' @@ -27,10 +28,8 @@ interface IGenericCardData { }[] } -const hailBatchUrl = 'https://batch.hail.populationgenomics.org.au/batches' - const BatchUrlLink: React.FC<{ batch_id: string }> = ({ batch_id }) => ( - + BATCH ID: {batch_id} ) diff --git a/web/src/pages/family/FamilyView.tsx b/web/src/pages/family/FamilyView.tsx index 82caa7114..4798524a5 100644 --- a/web/src/pages/family/FamilyView.tsx +++ b/web/src/pages/family/FamilyView.tsx @@ -1,229 +1,358 @@ import * as React from 'react' import { useParams } from 'react-router-dom' -import { Accordion, AccordionTitleProps } from 'semantic-ui-react' - -import PersonRoundedIcon from '@mui/icons-material/PersonRounded' -import BloodtypeRoundedIcon from '@mui/icons-material/BloodtypeRounded' +import { Card, Table as SUITable } from 'semantic-ui-react' import { useQuery } from '@apollo/client' -import Pedigree from '../../shared/components/pedigree/Pedigree' import LoadingDucks from '../../shared/components/LoadingDucks/LoadingDucks' import { gql } from '../../__generated__/gql' -import FamilyViewTitle from './FamilyViewTitle' -import iconStyle from '../../shared/iconStyle' -import SeqPanel from '../../shared/components/SeqPanel' -import SampleInfo from '../../shared/components/SampleInfo' +import groupBy from 'lodash/groupBy' +import keyBy from 'lodash/keyBy' +import orderBy from 'lodash/orderBy' +import { GraphQlParticipant } from '../../__generated__/graphql' +import TangledTree, { PersonNode } from '../../shared/components/pedigree/TangledTree' +import Table from '../../shared/components/Table' +import { AnalysisGrid } from '../analysis/AnalysisGrid' +import { AnalysisViewModal } from '../analysis/AnalysisView' +import { ParticipantView } from '../participant/ParticipantView' const sampleFieldsToDisplay = ['active', 'type'] +const getSeqrUrl = (projectGuid: string, familyGuid: string) => + `https://seqr.populationgenomics.org.au/project/${projectGuid}/family_page/${familyGuid}` const GET_FAMILY_INFO = gql(` query FamilyInfo($family_id: Int!) { - family(familyId: $family_id) { - id - externalId - participants { + family(familyId: $family_id) { id - samples { - active - externalId - id + externalId + project { + name meta - type - sequencingGroups { + pedigree( + internalFamilyIds: [$family_id] + replaceWithFamilyExternalIds: true + replaceWithParticipantExternalIds: true + ) + } + familyParticipants { + affected + participant { + id + externalId + karyotype + reportedGender + phenotypes + meta + samples { id - platform - technology + externalId + meta type - assays { + sequencingGroups { + id + platform + technology + type + assays { id meta type + } + analyses(status: {eq: COMPLETED}) { + id + timestampCompleted + type + meta + output + } } + } } } - externalId - } - project { - families { - externalId - id - participants { - id - } - } - pedigree(internalFamilyIds: [$family_id]) - name } - } -}`) + }`) -const FamilyView: React.FunctionComponent> = () => { - const { familyID } = useParams() - const family_ID = familyID ? +familyID : -1 +interface IFamilyViewProps { + familyId: number +} - const [activeIndices, setActiveIndices] = React.useState([-1]) - const [mostRecent, setMostRecent] = React.useState('') +export const FamilyPage: React.FunctionComponent> = () => { + const { familyId } = useParams() + if (!familyId) return No family ID + + return +} + +export const FamilyView: React.FC = ({ familyId }) => { + const [highlightedIndividual, setHighlightedIndividual] = React.useState< + string | null | undefined + >() + + const [analysisIdToView, setAnalysisIdToView] = React.useState(null) + + if (!familyId || isNaN(familyId)) return Invalid family ID const { loading, error, data } = useQuery(GET_FAMILY_INFO, { - variables: { family_id: family_ID }, + variables: { family_id: familyId }, }) - const onPedigreeClick = React.useCallback( - (e: string) => { - if (!data) return - const indexToSet = Object.entries(data?.family.participants) - .map(([, value]) => value.externalId) - .findIndex((i) => i === e) - if (!activeIndices.includes(indexToSet)) { - setActiveIndices([...activeIndices, indexToSet]) - } - setMostRecent(e) - const element = document.getElementById(e) - if (element) { - const y = element.getBoundingClientRect().top + window.pageYOffset - 100 - window.scrollTo({ top: y, behavior: 'smooth' }) + if (loading) return + if (error) return <>Error! {error.message} + if (!data) return <>No data! + + const sgs = data?.family?.familyParticipants.flatMap((fp) => + fp.participant.samples.flatMap((s) => s.sequencingGroups) + ) + const sgsById = keyBy(sgs, (s) => s.id) + + const participantBySgId: { [sgId: string]: any } = data?.family?.familyParticipants.reduce( + (acc, fp) => { + for (const s of fp.participant.samples) { + for (const sg of s.sequencingGroups) { + acc[sg.id] = fp.participant as GraphQlParticipant + } } + return acc }, - [data, activeIndices] + {} as { [sgId: string]: GraphQlParticipant } ) - const handleTitleClick = (e: React.MouseEvent, itemProps: AccordionTitleProps) => { - setMostRecent('') - const index = itemProps.index ?? -1 - if (index === -1) return - if (activeIndices.indexOf(+index) > -1) { - setActiveIndices(activeIndices.filter((i) => i !== index)) - } else { - setActiveIndices([...activeIndices, +index]) + const aById: { + [id: number]: { + id: number + timestampCompleted?: any | null + type: string + sgs: string[] + meta?: any | null + output?: string | null + } + } = {} + + for (const fp of data?.family?.familyParticipants) { + for (const s of fp.participant.samples) { + for (const sg of s.sequencingGroups) { + for (const a of sg.analyses) { + if (a.id in aById) { + aById[a.id].sgs.push(sg.id) + } else { + aById[a.id] = { + ...a, + sgs: [sg.id], + } + } + } + } } } + const individualAnalysisByParticipantId = groupBy( + Object.values(aById).filter((a) => a.sgs.length == 1), + (a) => participantBySgId[a.sgs[0]]?.externalId + ) + const familyAnalysis = Object.values(aById).filter((a) => a.sgs.length > 1) + const analyses = orderBy(Object.values(aById), (a) => a.timestampCompleted) + const pedEntryByParticipantId = keyBy(data?.family?.project.pedigree, (pr) => pr.individual_id) - if (loading) return - if (error) return <>Error! {error.message} + return ( +
    +

    + {data?.family?.externalId} ({data?.family?.project?.name}) +

    +
    + + { + setHighlightedIndividual(e?.individual_id) + }} + nodeDiameter={60} + /> + + + + +
    +
    + {/* @ts-ignore: remove once families have external IDs*/} + - return data ? ( -
    - <> - family.participants.length - )} - externalId={data?.family.externalId} + {data?.family?.familyParticipants.flatMap((fp) => ( + - - ({ - key: item.id, - title: { - content: ( -

    - {item.externalId} -

    - ), - icon: ( - - ), - }, - content: { - content: ( -
    - ({ - key: s.id, - title: { - content: ( - <> -

    - {`${s.id}\t`} -

    - -

    - {s.externalId} -

    - - ), - icon: , - }, - content: { - content: ( - <> -
    - - sampleFieldsToDisplay.includes( - key - ) - ) - )} - /> - - -
    - - ), - }, - }))} - exclusive={false} - /> -
    - ), - }, - }))} + ))} +
    +
    +

    Family analyses

    + setAnalysisIdToView(aId)} /> - +
    + + setAnalysisIdToView(null)} + />
    - ) : ( - <> + ) +} + +const PedigreeTable: React.FC<{ + pedigree: any + highlightedIndividual?: string | null + setHighlightedIndividual?: (individualId?: string | null) => void +}> = ({ pedigree, highlightedIndividual, setHighlightedIndividual }) => { + return ( + + + + + Participant + Paternal ID + Maternal ID + Affected + Notes + + + + {pedigree?.map((pr: any) => { + const isHighlighted = highlightedIndividual == pr.individual_id + return ( + + + + + + + + + ) + })} + +
    + + + setHighlightedIndividual?.(e?.individual_id) + } + /> + + {pr.individual_id}{pr.paternal_id}{pr.maternal_id}{pr.affected}{pr.notes}
    + ) +} + +const getFamilyEidKeyForSeqrSeqType = (seqType: string) => `seqr-${seqType}` + +const SeqrUrls: React.FC<{ + project: { meta: any } + family: { externalIds: { [key: string]: string } } +}> = ({ project, family }) => { + // meta keys for seqr projectGuids follow the format: seqr-project-{sequencing_type} + // family.externalIds follow the format: seqr-{sequencing_type} + + const seqrProjectGuidToSequencingType: { [sequencingType: string]: string } = Object.keys( + project.meta + ) + .filter((k) => k.startsWith('seqr-project-')) + .reduce( + (acc, k) => ({ + ...acc, + [k.replace('seqr-project-', '')]: project.meta[k], + }), + {} + ) + + const sequencingTypeToSeqrUrl: { [sequencingType: string]: string } = Object.keys( + seqrProjectGuidToSequencingType + ) + .filter( + (sequencingType) => + family.externalIds && + getFamilyEidKeyForSeqrSeqType(sequencingType) in family.externalIds + ) + .reduce( + (acc, sequencingType) => ({ + ...acc, + [sequencingType]: getSeqrUrl( + seqrProjectGuidToSequencingType[sequencingType], + family.externalIds[getFamilyEidKeyForSeqrSeqType(sequencingType)] + ), + }), + {} as { [sequencingType: string]: string } + ) + + if (Object.keys(sequencingTypeToSeqrUrl).length === 0) { + return <> + } + return ( + + + + + + + + + {Object.entries(sequencingTypeToSeqrUrl).map(([seqType, url]) => ( + + + + + ))} + +
    + Sequencing Type + + URL +
    + {seqType} + + + {url} + +
    ) } diff --git a/web/src/pages/participant/ParticipantView.tsx b/web/src/pages/participant/ParticipantView.tsx new file mode 100644 index 000000000..4bcfce5cd --- /dev/null +++ b/web/src/pages/participant/ParticipantView.tsx @@ -0,0 +1,219 @@ +import * as React from 'react' + +import groupBy from 'lodash/groupBy' +import keyBy from 'lodash/keyBy' +import sortBy from 'lodash/sortBy' +import uniqBy from 'lodash/uniqBy' + +import { KeyValueTable } from '../../shared/components/KeyValueTable' +import AnalysisLink from '../../shared/components/links/AnalysisLink' +import { PedigreeEntry, PersonNode } from '../../shared/components/pedigree/TangledTree' +import Table from '../../shared/components/Table' +import { AnalysisGrid, IAnalysisGridAnalysis } from '../analysis/AnalysisGrid' +import { AnalysisViewModal } from '../analysis/AnalysisView' + +interface IParticipantViewParticipant { + id: number + externalId: string + karyotype?: string | null + meta?: any | null + phenotypes?: { [key: string]: any } + pedEntry?: PedigreeEntry + samples: { + id: string + type: string + meta: { [key: string]: any } + sequencingGroups: { + id: string + type: string + technology: string + platform: string + }[] + }[] +} + +interface IParticipantViewProps { + participant: IParticipantViewParticipant + analyses: IAnalysisGridAnalysis[] + individualToHiglight?: string | null + setHighlightedIndividual?: (individualId?: string | null) => void + showNonSingleSgAnalyses?: boolean +} + +export const ParticipantView: React.FC = ({ + participant, + individualToHiglight, + analyses, + setHighlightedIndividual, + showNonSingleSgAnalyses, +}) => { + const [analysisIdToView, setAnalysisIdToView] = React.useState() + const isHighlighted = individualToHiglight == participant.externalId + + const sgsById = keyBy( + // @ts-ignore + participant.samples.flatMap((s) => s.sequencingGroups), + (sg) => sg.id + ) + const analysesBySgId = groupBy( + analyses?.filter((a) => a?.sgs?.length === 1), + (a) => a?.sgs?.[0] + ) + const participantBySgId = Object.keys(sgsById).reduce( + (acc, sgId) => ({ + ...acc, + [sgId]: participant, + }), + {} + ) + const extraAnalyses = showNonSingleSgAnalyses + ? sortBy( + uniqBy( + analyses?.filter((a) => a?.sgs?.length !== 1), + (a) => a.id + ), + (a) => a.timestampCompleted + ) + : [] + const participantFields = { + ID: participant.id, + Karyotype: participant.karyotype, + ...participant.meta, + } + return ( +
    +

    + setHighlightedIndividual?.( + individualToHiglight == participant.externalId + ? null + : participant.externalId + ) + } + style={{ cursor: 'hand' }} + > + {participant.pedEntry && ( + + setHighlightedIndividual?.(e?.individual_id)} + /> + + )} + {participant.externalId} +

    + +
    + + +

    Samples

    + + + + + + + + + + {participant.samples.map((s) => ( + + + + + + ))} + +
    Sample IDTypeMeta
    {s.id}{s.type} + +
    + +

    Sequencing groups

    + + + + + + + + + + + + + + + + {participant.samples.flatMap((s) => + s.sequencingGroups.flatMap((sg) => { + const analyses = analysesBySgId[sg.id] || [] + const nAnalysis = analyses.length + 1 + return ( + + + + + + + + + + {analyses.map((a) => ( + + + + + + + ))} + + ) + }) + )} + +
    Sample IDSG IDTypeTechnologyPlatformAnalyses type
    {s.id}{sg.id}{sg.type}{sg.technology}{sg.platform} + + Analyses + +
    + { + e.preventDefault() + e.stopPropagation() + setAnalysisIdToView(a.id) + }} + /> + {a.type}{a.timestampCompleted.split('T')[0]}{a.output}
    + {extraAnalyses.length > 0 && ( + setAnalysisIdToView(aId)} + participantBySgId={participantBySgId} + /> + )} +
    + setAnalysisIdToView(undefined)} + /> +
    + ) +} diff --git a/web/src/pages/participant/ParticipantViewContainer.tsx b/web/src/pages/participant/ParticipantViewContainer.tsx new file mode 100644 index 000000000..f8c5ff6f8 --- /dev/null +++ b/web/src/pages/participant/ParticipantViewContainer.tsx @@ -0,0 +1,113 @@ +import { useQuery } from '@apollo/client' +import * as React from 'react' +import { gql } from '../../__generated__' + +import { useParams } from 'react-router-dom' +import { Button, Message, Modal } from 'semantic-ui-react' +import LoadingDucks from '../../shared/components/LoadingDucks/LoadingDucks' +import { IAnalysisGridAnalysis } from '../analysis/AnalysisGrid' +import { ParticipantView } from './ParticipantView' + +const GET_PARTICIPANT_VIEW_INFO = gql(` +query ParticipantViewInfo($participantId: Int!) { + participant(id: $participantId) { + id + externalId + karyotype + reportedGender + phenotypes + meta + samples { + id + externalId + meta + type + sequencingGroups { + id + platform + technology + type + assays { + id + meta + type + } + analyses(status: {eq: COMPLETED}) { + id + timestampCompleted + type + meta + output + sequencingGroups { + id + } + } + } + } + } +}`) + +interface IParticipantPageProps { + participantId?: number +} + +export const ParticipantPage: React.FC = (props) => { + const { participantId } = useParams() + const pid = props.participantId || parseInt(participantId || '') + if (!pid || isNaN(pid)) return No participant ID + + const { loading, error, data } = useQuery(GET_PARTICIPANT_VIEW_INFO, { + variables: { participantId: pid }, + }) + + if (loading) { + return + } + if (error || !data) { + return {error?.message || 'Participant was not found'} + } + + const participant = data?.participant + const analyses = participant.samples + .flatMap((s) => s.sequencingGroups) + .flatMap((sg) => sg.analyses) + .flatMap( + (an) => + ({ + ...an, + sgs: an.sequencingGroups.map((sg) => sg.id), + } as IAnalysisGridAnalysis) + ) + return +} + +interface IParticipantModalProps extends IParticipantPageProps { + isOpen?: boolean + onClose: () => void + size?: 'mini' | 'tiny' | 'small' | 'large' | 'fullscreen' +} + +export const ParticipantModal: React.FC = ({ + isOpen, + onClose, + size, + ...viewProps +}) => { + const _isOpen = isOpen !== undefined ? isOpen : !!viewProps.participantId + return ( + + Participant + + {!!viewProps.participantId && } + + + + + + ) +} diff --git a/web/src/pages/project/DictEditor.tsx b/web/src/pages/project/DictEditor.tsx index b852c7b76..c5425d427 100644 --- a/web/src/pages/project/DictEditor.tsx +++ b/web/src/pages/project/DictEditor.tsx @@ -11,7 +11,7 @@ interface DictEditorProps { input: DictEditorInput height?: string readonly?: boolean - onChange: (json: object) => void + onChange?: (json: object) => void } const getStringFromValue = (input: DictEditorInput) => { @@ -68,7 +68,7 @@ export const DictEditor: React.FunctionComponent = ({ const submit = () => { try { const newJson = parseString(textValue) - onChange(newJson) + onChange?.(newJson) } catch (e: any) { setError(e.message) } diff --git a/web/src/pages/project/ParticipantGridRow.tsx b/web/src/pages/project/ParticipantGridRow.tsx index a9e249409..b3ad17265 100644 --- a/web/src/pages/project/ParticipantGridRow.tsx +++ b/web/src/pages/project/ParticipantGridRow.tsx @@ -1,10 +1,12 @@ import * as React from 'react' import get from 'lodash/get' -import { TableCell, TableRow } from 'semantic-ui-react' +import { Button, Modal, TableCell, TableRow } from 'semantic-ui-react' import FamilyLink from '../../shared/components/links/FamilyLink' +import { getParticipantLink } from '../../shared/components/links/ParticipantLink' import SampleLink from '../../shared/components/links/SampleLink' import SequencingGroupLink from '../../shared/components/links/SequencingGroupLink' +import { SMModal } from '../../shared/components/Modal' import sanitiseValue from '../../shared/utilities/sanitiseValue' import { Assay, @@ -13,6 +15,8 @@ import { NestedSequencingGroup, ProjectParticipantGridField, } from '../../sm-api/api' +import FamilyView from '../family/FamilyView' +import { ParticipantModal } from '../participant/ParticipantViewContainer' import { firstColBorder, otherColBorder } from './ProjectGridHeaderGroup' const getBorderStyles = (idx: number) => { @@ -64,34 +68,59 @@ const FamilyCells: React.FC<{ backgroundColor?: string projectName: string participantRowSpan?: number -}> = ({ fields, participant, backgroundColor, projectName, participantRowSpan }) => ( - <> - {fields.map((field) => ( - - {field.key == 'external_id' - ? participant.families.map((f) => ( - - {f.external_id} - - )) - : participant.families - .map((fam) => sanitiseValue(get(fam, field.key))) - .join(', ')} - - ))} - -) +}> = ({ fields, participant, backgroundColor, projectName, participantRowSpan }) => { + const [showFamilyModal, setShowFamilyModal] = React.useState(false) + + const familyIdSingular = participant.families.length === 1 ? participant.families[0].id : null + + return ( + <> + {fields.map((field) => ( + + {field.key == 'external_id' + ? participant.families.map((f) => ( + { + e.preventDefault() + setShowFamilyModal(true) + }} + > + {f.external_id} + + )) + : participant.families + .map((fam) => sanitiseValue(get(fam, field.key))) + .join(', ')} + + ))} + {!!familyIdSingular && ( + setShowFamilyModal(false)} + > + Family + + + + + + + + )} + + ) +} const ParticipantCells: React.FC<{ fields: ProjectParticipantGridField[] @@ -99,24 +128,51 @@ const ParticipantCells: React.FC<{ backgroundColor?: string projectName: string participantRowSpan?: number -}> = ({ fields, participant, backgroundColor, projectName, participantRowSpan }) => ( - <> - {fields.map((field, i) => ( - = ({ fields, participant, backgroundColor, projectName, participantRowSpan }) => { + const [showParticipantModal, setShowParticipantModal] = React.useState(false) + + const defaultRenderer = (field: ProjectParticipantGridField) => + sanitiseValue(get(participant, field.key)) + const valuePreparers: Record any> = { + external_ids: (field: ProjectParticipantGridField) => + prepareExternalIds(participant.external_ids || {}), + id: (field: ProjectParticipantGridField) => ( + { + e.preventDefault() + setShowParticipantModal(true) }} - key={`${participant.id}participant.${field.key}`} - rowSpan={participantRowSpan} > - {field.key == 'external_ids' - ? prepareExternalIds(participant.external_ids || {}) - : sanitiseValue(get(participant, field.key))} - - ))} - -) + {defaultRenderer(field)} + + ), + } + + return ( + <> + {fields.map((field, i) => ( + + {(valuePreparers[field.key] || defaultRenderer)(field)} + + ))} + + setShowParticipantModal(false)} + /> + + ) +} export const ProjectGridParticipantRows: React.FC = ({ participant, @@ -201,12 +257,8 @@ export const ProjectGridParticipantRows: React.FC - {field.key === 'id' ? ( - + {field.key === 'id' && sg.id ? ( + {sanitiseValue(sg.id)} ) : ( diff --git a/web/src/pages/sample/SampleView.tsx b/web/src/pages/sample/SampleView.tsx index 0684d3979..8189b1565 100644 --- a/web/src/pages/sample/SampleView.tsx +++ b/web/src/pages/sample/SampleView.tsx @@ -1,14 +1,14 @@ import * as React from 'react' -import { useParams } from 'react-router-dom' import { useQuery } from '@apollo/client' +import { useParams } from 'react-router-dom' import LoadingDucks from '../../shared/components/LoadingDucks/LoadingDucks' import { gql } from '../../__generated__/gql' -import Pedigree from '../../shared/components/pedigree/Pedigree' -import SeqPanel from '../../shared/components/SeqPanel' import MuckError from '../../shared/components/MuckError' +import Pedigree from '../../shared/components/pedigree/Pedigree' import SampleInfo from '../../shared/components/SampleInfo' +import SeqPanel from '../../shared/components/SeqPanel' import { ThemeContext } from '../../shared/components/ThemeProvider' const GET_SAMPLE_INFO = gql(` @@ -84,7 +84,7 @@ const SampleView: React.FunctionComponent> = () => {
    {sample.participant?.families.map((family) => ( - + ))}
    diff --git a/web/src/shared/components/KeyValueTable.tsx b/web/src/shared/components/KeyValueTable.tsx new file mode 100644 index 000000000..b8137b557 --- /dev/null +++ b/web/src/shared/components/KeyValueTable.tsx @@ -0,0 +1,62 @@ +import * as React from 'react' + +interface IKeyValueTableProps { + obj: { [key: string]: any } + rightPadding?: string + tableClass?: React.FC<{}> +} + +// Define the default table like this, as it is not possible to set +// the default value to "table" in the IKeyValueTableProps interface +const DefaultTable: React.FC< + React.DetailedHTMLProps, HTMLTableElement> +> = (props) => + +function valueToDisplay(value: any) { + // if string, return + if (typeof value === 'string') { + if (value.startsWith('https://')) { + return ( + + {value} + + ) + } + return value + } + // if react element, return + if (React.isValidElement(value)) { + return value + } + // else json.stringify + return JSON.stringify(value) +} + +/** + * @name KeyValueTable + * @description A table that displays key-value pairs + * @param obj - object with key-value pairs + * @param rightPadding - padding for the right side of the key column + * @param tableClass - The class to use to present the table, by default, use the default html table + */ +export const KeyValueTable: React.FC = ({ + obj, + tableClass, + rightPadding = '20px', +}) => { + const TableClass = tableClass || DefaultTable + return ( + + + {Object.entries(obj || {}).map(([key, value]) => ( + + + + + ))} + + + ) +} diff --git a/web/src/shared/components/Modal.tsx b/web/src/shared/components/Modal.tsx new file mode 100644 index 000000000..9ea92cc7c --- /dev/null +++ b/web/src/shared/components/Modal.tsx @@ -0,0 +1,9 @@ +import { Modal, ModalProps } from 'semantic-ui-react' + +export const SMModal: React.FC = ({ title, children, style, ...props }) => { + return ( + + {children} + + ) +} diff --git a/web/src/shared/components/links/AnalysisLink.tsx b/web/src/shared/components/links/AnalysisLink.tsx new file mode 100644 index 000000000..1b96ab5db --- /dev/null +++ b/web/src/shared/components/links/AnalysisLink.tsx @@ -0,0 +1,16 @@ +import * as React from 'react' +import { Link } from 'react-router-dom' + +export interface AnalysisLinkProps { + id?: number | string + children?: React.ReactNode + onClick?: React.MouseEventHandler | undefined +} + +const AnalysisLink: React.FunctionComponent = ({ id, children, ...props }) => ( + + {children || id} + +) + +export default AnalysisLink diff --git a/web/src/shared/components/links/FamilyLink.tsx b/web/src/shared/components/links/FamilyLink.tsx index 7a9f24ac9..ecd312abc 100644 --- a/web/src/shared/components/links/FamilyLink.tsx +++ b/web/src/shared/components/links/FamilyLink.tsx @@ -2,8 +2,10 @@ import * as React from 'react' import { Link } from 'react-router-dom' import { LinkProps } from './LinkProps' -const FamilyLink: React.FunctionComponent = ({ id, children }) => ( - {children || id} +const FamilyLink: React.FunctionComponent = ({ id, children, ...props }) => ( + + {children || id} + ) export default FamilyLink diff --git a/web/src/shared/components/links/LinkProps.ts b/web/src/shared/components/links/LinkProps.ts index 546e4a833..139f9e8db 100644 --- a/web/src/shared/components/links/LinkProps.ts +++ b/web/src/shared/components/links/LinkProps.ts @@ -4,5 +4,6 @@ export interface LinkProps { id: string projectName: string sg_id?: string - children: React.ReactNode + children?: React.ReactNode + onClick?: React.MouseEventHandler | undefined } diff --git a/web/src/shared/components/links/ParticipantLink.tsx b/web/src/shared/components/links/ParticipantLink.tsx index 4928e34ab..95fa0f280 100644 --- a/web/src/shared/components/links/ParticipantLink.tsx +++ b/web/src/shared/components/links/ParticipantLink.tsx @@ -1,9 +1,24 @@ import * as React from 'react' import { Link } from 'react-router-dom' -import { LinkProps } from './LinkProps' -const ParticipantLink: React.FunctionComponent = ({ id, projectName, children }) => ( - {children || id} +interface ParticipantLinkProps { + id: string | number + children?: React.ReactNode + onClick?: React.MouseEventHandler | undefined +} + +export function getParticipantLink(id: string | number) { + return `/participant/${id}` +} + +const ParticipantLink: React.FunctionComponent = ({ + id, + children, + ...props +}) => ( + + {children || id} + ) export default ParticipantLink diff --git a/web/src/shared/components/links/ProjectLink.tsx b/web/src/shared/components/links/ProjectLink.tsx new file mode 100644 index 000000000..7142bb74c --- /dev/null +++ b/web/src/shared/components/links/ProjectLink.tsx @@ -0,0 +1,16 @@ +import * as React from 'react' +import { Link } from 'react-router-dom' + +export interface ProjectLinkProps { + name: string + children?: React.ReactNode + onClick?: React.MouseEventHandler | undefined +} + +const ProjectLink: React.FunctionComponent = ({ name, children, ...props }) => ( + + {children || name} + +) + +export default ProjectLink diff --git a/web/src/shared/components/links/SequencingGroupLink.tsx b/web/src/shared/components/links/SequencingGroupLink.tsx index f404cfac7..4a5dc992a 100644 --- a/web/src/shared/components/links/SequencingGroupLink.tsx +++ b/web/src/shared/components/links/SequencingGroupLink.tsx @@ -1,9 +1,16 @@ import * as React from 'react' import { Link } from 'react-router-dom' -import { LinkProps } from './LinkProps' -const SequencingGroupLink: React.FunctionComponent = ({ id, sg_id, children }) => ( - {children || sg_id} -) +export interface SequencingGroupLinkProps { + id: string + sampleId: string + children?: React.ReactNode + onClick?: React.MouseEventHandler | undefined +} +const SequencingGroupLink: React.FunctionComponent = ({ + id, + sampleId, + children, +}) => {children || id} export default SequencingGroupLink diff --git a/web/src/shared/components/pedigree/Pedigree.tsx b/web/src/shared/components/pedigree/Pedigree.tsx index c279de143..fa861a123 100644 --- a/web/src/shared/components/pedigree/Pedigree.tsx +++ b/web/src/shared/components/pedigree/Pedigree.tsx @@ -15,22 +15,22 @@ query PedigreeInfo($family_id: Int!) { } }`) -const Pedigree: React.FunctionComponent<{ - familyID: number - onClick?(e: string): void -}> = ({ familyID, onClick }) => { +interface IPedigreeProps { + familyId: number + onClick?: (e: string) => void +} + +const Pedigree: React.FC = ({ familyId, onClick }) => { const { loading, error, data } = useQuery(GET_PEDIGREE_INFO, { - variables: { family_id: familyID }, + variables: { family_id: familyId }, }) if (loading) return if (error) return <>Error! {error.message} - return data?.family.project.pedigree ? ( - - ) : ( - <> - ) + if (!data?.family?.project?.pedigree) return <> + + return } export default Pedigree diff --git a/web/src/shared/components/pedigree/README.md b/web/src/shared/components/pedigree/README.md new file mode 100644 index 000000000..6762480b7 --- /dev/null +++ b/web/src/shared/components/pedigree/README.md @@ -0,0 +1,139 @@ +# Tangled Tree component overview and mechanics + +This component tries to draw a single pedigree for a given list of individuals and their relationships. + +The input is a single pedigree file with columns for family id, individual id, paternal id, maternal id, sex and affected status. + +This input file needs to be arranged into a more structured data type to pass to the rendering function. This occurs in the formatData() function. + +## formatData() function + +This function takes in an unordered pedigree file and returns an array of levels, which map to levels of a drawn pedigree (i.e. individuals on the same horizontal axis such as sets of siblings, parents, cousins etc are on the same level). Each level is an array of entries containing an individual id and an array of parents if they exist. The entries contain some additional information that need to be initialised but this is the gist of it. + +### Example + +TRIO + +input: + +```json +[ + { + "family_id": "TRIO", + "individual_id": "Parent_1", + "paternal_id": null, + "maternal_id": null, + "sex": 1, + "affected": 2 + }, + { + "family_id": "TRIO", + "individual_id": "Parent_2", + "paternal_id": null, + "maternal_id": null, + "sex": 2, + "affected": 2 + }, + { + "family_id": "TRIO", + "individual_id": "Trio_Child", + "paternal_id": "Parent_1", + "maternal_id": "Parent_2", + "sex": 1, + "affected": 1 + } +] +``` + +Output: + +```[ + { + "nodes": [ + { + "x": 0, + "y": 0, + "parents": [], + "level": -1, + "bundles": [], + "links": [], + "parentsList": [], + "id": "Parent_1" + }, + { + "x": 0, + "y": 0, + "parents": [], + "level": -1, + "bundles": [], + "links": [], + "parentsList": [], + "id": "Parent_2" + } + ], + "bundles": [] + }, + { + "nodes": [ + { + "x": 0, + "y": 0, + "parents": [], + "level": -1, + "bundles": [], + "links": [], + "parentsList": [ + "Parent_1", + "Parent_2" + ], + "id": "Trio_Child" + } + ], + "bundles": [] + } +] +``` + +Note that the red is the 2 parent nodes, both in the same object (level 0) and the blue is the child node with a parentList array, this is level 1. + +Generating this output correctly can be trivial for easy families (trios, standard nuclear, 3 generations etc) but can be tricky when for more complicated cases. It is likely that some tweaking will be necessary when we come across these cases. I've outlined the logic below. The idea is to identify the longest possible spine of consecutive descendants across generations, and then add branches where possible/necessary. + +### Logic + +Identify all possible roots of the pedigree. These are individuals with no parents (ie no parental and maternal ids). + +Find the root(s) with the largest amount of generations of descendants. This is done by recursively checking if an individual has children. The longest root is chosen to build our pedigree on. In the event of multiple roots with the same longest length, the first is arbitrarily chosen. + +All other individuals other than the root are added to a Set of unseen from which they will be removed as they are encountered. + +Using a queue and starting with the root we chose, we pop the next individual or group of individuals off the list and add them all on the same level. Then we add all their children to the queue, these will go on the next level. Note this step does not include spouses, just simply starting with the root on level 0, all their children on level 1, childrens children on level 2 etc. + +After this 'spine' is completed, we try to add any remaining individuals to the pedigree in the appropriate level. First we check if the individual is a spouse of someone already added. If so, we add them to the same level. + +Next, we check if the individual is a parents of someone already added. They will be added in one level higher than the individual (-1). + +Finally if the individual is a child of someone already added, we add them at the level below (+1). + +We repeat through all individuals, resetting a counter each time someone is able to be added. When a pass through all unadded individuals completes without any further additions, the function is complete. + +Next, this formatted level data is passed to the constructTangleLayout() function which calculates 2d coordinates for each individual as well as the shapes of the branches connecting individuals. + +## constructTangleLayout() function + +This is likely the function that needs the most refactoring as it relies on deepcloning objects and recursive properties to work, which I don't think are necessary but haven't sat down to refactor it. This is inspired by [https://observablehq.com/@nitaku/tangled-tree-visualization-ii](https://observablehq.com/@nitaku/tangled-tree-visualization-ii). + +### Logic 2 + +Basically we give coordinates to each individual of each level, incrementing by some spacing variable across each level. Eg parents in level 0 would be at (0, 0) and (0, 50). Then individuals at level 1 might be at (100, 0), (100, 50), (100, 100) and (100, 150) etc. + +Now we try and move parents around on their levels so that they are placed together and not further left than their leftmost child. This is done in reverse through the levels so a parents is moved to be above their children, then the grandparents will be moved to be above the parents etc etc. + +Next we centre the parents above all the children and move around an individuals without children who tend to get in the way. + +Finally once the individuals are in their places, we create the series of lines between parents connecting to children. + +This is all then passed to a render function which simply loops over the list of connections and draws them, then loops over all individuals and draws a circle or square at the given coordinate. Fill is given for affected status. Here is where we can add other features like twins etc just by looking up some data if we want. + +Was thinking this might be a fun project to sit down with in person maybe in Melbourne, but keen to hear your thoughts. + +Like I mentioned in the PR, this component does work well for all my test sets, just aware that the code could use some clean up, particularly if we want to add features down the line for drawing more complicated pedigrees, or even merge this into seqr one day for better pedigree drawing. diff --git a/web/src/shared/components/pedigree/TangledTree.tsx b/web/src/shared/components/pedigree/TangledTree.tsx index 553684259..5b8d64eb6 100644 --- a/web/src/shared/components/pedigree/TangledTree.tsx +++ b/web/src/shared/components/pedigree/TangledTree.tsx @@ -1,29 +1,774 @@ /* Inspired by https://observablehq.com/@nitaku/tangled-tree-visualization-ii */ /* eslint-disable no-param-reassign */ +import _ from 'lodash' import * as React from 'react' -interface PedigreeEntry { +import { extent, max, mean, min, sum } from 'd3' +import MuckError from '../MuckError' + +const DECEASED_NODE_INSET_FACTOR = 2.5 + +export interface PedigreeEntry { affected: number family_id: string individual_id: string - maternal_id: string - paternal_id: string + maternal_id?: string | null + paternal_id?: string | null sex: number + deceased?: boolean +} + +interface ModifiedPedEntry extends PedigreeEntry { + children: string[] +} + +interface NodePosition { + x: number + y: number +} +interface Node extends NodePosition { + id: string + parents: Node[] + parentsList: string[] + level: number + bundle?: Node + bundles: Node[][] + i?: number + bundles_index?: { [key: string]: Node[] } + span?: number + height?: number + links: Link[] +} + +interface NodeList { + nodes: Node[] + bundles: Node[] +} + +interface Link { + source: Node + target: Node + xb: number + xs: number + xt: number + yb: number + ys: number + yt: number + x1: number + y1: number + x2: number + y2: number + bundle?: Node +} + +interface NodeParentsList { + id: string + parentsList?: string[] +} + +// layout +const defaultNodeDiameter = 40 + +const textColor = 'var(--color-text-primary)' +const colLines = 'var(--color-border-color)' + +const colAffected = 'var(--color-pedigree-affected)' +const colPersonBorder = 'var(--color-pedigree-person-border)' +const colUnaffected = 'var(--color-pedigree-unaffected)' + +interface ITangleLayoutOptions { + nodeDiameter: number + horizontalSpacing?: number + verticalSpacing?: number } + +function formObjectFromIdAndParents( + id: string, + parents: (string | null | undefined)[] +): NodeParentsList { + const obj: NodeParentsList = { id } + + const pl = parents.filter((i): i is string => !!i) + if (pl.length) { + obj.parentsList = pl + } + + return obj +} + +const constructTangleLayout = (levels: NodeList[], options: ITangleLayoutOptions) => { + const nodes_index: Record = {} + + const nodeDiameter = options.nodeDiameter ?? defaultNodeDiameter + const _horizontalSpacing = options.horizontalSpacing ?? nodeDiameter * 2.5 + const _verticalSpacing = options.verticalSpacing ?? Math.max(50, nodeDiameter * 1.7) + + // precompute level depth + levels.forEach((l, i) => + l.nodes.forEach((n) => { + n.level = i + nodes_index[n.id] = n + }) + ) + const nodes: Node[] = levels.map((l) => l.nodes).flat() + nodes.forEach((d) => { + d.parents = (d.parentsList === undefined ? [] : d.parentsList).map((p) => nodes_index[p]) + }) + + // precompute bundles + levels.forEach((l, i) => { + const index: Record = {} + l.nodes.forEach((n) => { + if (!n.parents || n.parents.length === 0) { + return + } + const id = n.parents + .map((d) => d.id) + .sort() + .join('-X-') + if (id in index) { + index[id].parents = index[id].parents.concat(n.parents) + } else { + index[id] = { + id, + parents: n.parents.slice(), + level: i, + span: i - (min(n.parents, (p) => p.level) ?? 0), + } + } + n.bundle = index[id] + }) + l.bundles = Object.keys(index).map((k) => index[k]) + l.bundles.forEach((b, j) => { + b.i = j + }) + }) + + const links: Link[] = [] + nodes.forEach((d) => { + if (d.parents) { + d.parents.forEach((p) => + links.push({ + source: d, + bundle: d.bundle, + target: p, + xt: p.x, + yt: p.y, + xb: d.bundle?.x ?? p.x, + yb: d.bundle?.y ?? p.y, + x1: d.bundle?.x ?? p.x, + y1: d.y - _verticalSpacing / 2, + x2: d.x, + y2: d.y - _verticalSpacing / 2, + xs: d.x, + ys: d.y, + }) + ) + } + }) + + const bundles = levels.map((l) => l.bundles).flat() + + // reverse pointer from parent to bundles + bundles.forEach((b) => { + if (b && b.parents) { + b.parents.forEach((p) => { + if (p.bundles_index === undefined) { + p.bundles_index = {} + } + if (!(b.id in p.bundles_index)) { + p.bundles_index[b.id] = [] + } + p.bundles_index[b.id].push(b) + }) + } + }) + + nodes.forEach((n) => { + if (n.bundles_index !== undefined) { + n.bundles = Object.values(n.bundles_index) + } else { + n.bundles_index = {} + n.bundles = [] + } + }) + + links.forEach((l) => { + if (!l.bundle) { + return + } + if (l.bundle.links === undefined) { + l.bundle.links = [] + } + l.bundle.links.push(l) + }) + + nodes.forEach((n) => { + n.height = Math.max(1, n.bundles.length) - 1 + }) + + let x_offset = 0 + let y_offset = 0 + levels.forEach((l) => { + x_offset = 0 + l.nodes.forEach((n) => { + n.x = x_offset + n.y = y_offset + + x_offset += _horizontalSpacing + }) + y_offset += _verticalSpacing + }) + + const rebalanceNodes = () => { + let moved = false + const movedBundles: string[] = [] + const movedChildren: string[] = [] + levels.forEach((level) => { + level.nodes.forEach((currentNode, movedIndex) => { + const oldLevel = [...level.nodes] + level.nodes.sort( + (a, b) => + a.x - b.x || + oldLevel.findIndex((c) => a.id === c.id) - + oldLevel.findIndex((c) => b.id === c.id) + ) + if (movedIndex < level.nodes.length - 1) { + // not the last node + const nextNode = level.nodes[movedIndex + 1] + if (Math.abs(currentNode.x - nextNode.x) < _horizontalSpacing) { + moved = true + const oldX = nextNode.x + nextNode.x = currentNode.x + _horizontalSpacing + const movedX = nextNode.x - oldX + nextNode.bundles.forEach((bundle) => { + bundle + .filter((b) => !movedBundles.includes(b.id)) + .forEach((b) => { + movedBundles.push(b.id) + b.links.forEach((l) => { + l.xs += movedX + l.xb += movedX + if (movedChildren.includes(l.source.id)) { + return + } + nodes_index[l.source.id].x += movedX + movedChildren.push(l.source.id) + l.x1 += movedX + l.x2 += movedX + // l.xt += movedX; + }) + }) + }) + } + } + }) + }) + return moved + } + + levels.reverse().forEach((l) => { + const seenBundles: string[] = [] + l.nodes.forEach((node) => { + if (!node.bundle || seenBundles.includes(node.bundle.id)) { + return + } + + const minXParent = min(node.parents.map((p) => p.x)) || 0 + if (minXParent < node.x) { + const amountToMove = node.x - minXParent + node.parents.forEach((p) => { + p.x += amountToMove + }) + const maxParent = node.parents.reduce((prev, current) => + prev.x > current.x ? prev : current + ) + // move rest of level accordingly + const currentLevel = maxParent.level + const movedIndex = nodes.findIndex((b) => b.id === maxParent.id) + nodes.forEach((n, i) => { + if (n.level === currentLevel && i > movedIndex) { + n.x += amountToMove + } + }) + } + seenBundles.push(node.bundle.id) + }) + }) + + levels.forEach((l) => { + l.bundles.forEach((b) => { + b.x = sum(b.parents, (d) => d.x) / b.parents.length + b.y = sum(b.parents, (d) => d.y) / b.parents.length + }) + }) + + links.forEach((l) => { + l.xt = l.target.x + l.yt = l.target.y + l.xb = l.bundle?.x ?? l.target.x + l.yb = l.bundle?.y ?? l.target.y + l.x1 = l.bundle?.x ?? l.target.x + l.y1 = l.source.y - _verticalSpacing / 2 + l.x2 = l.source.x + l.y2 = l.source.y - _verticalSpacing / 2 + l.xs = l.source.x + l.ys = l.source.y + }) + + // try centre parents + let moved = true + let numLoops = 0 + while (moved && numLoops < 1000) { + moved = false + /* eslint-disable @typescript-eslint/no-loop-func */ + levels.reverse().forEach((l) => { + l.bundles.forEach((b) => { + const avgX = mean(extent(b.links.map((li) => li.source.x))) || 0 + b.links.forEach((p) => { + if (p.x1 !== avgX) { + const oldxb = p.xb + p.xb = avgX + p.xt += avgX - oldxb + nodes_index[p.target.id].x = p.xt + p.x1 = avgX + moved = true + } + }) + }) + }) + /* eslint-enable @typescript-eslint/no-loop-func */ + moved = moved || rebalanceNodes() + links.forEach((l) => { + l.xt = l.target.x + l.yt = l.target.y + l.x2 = l.source.x + l.xs = l.source.x + l.ys = l.source.y + }) + if (!moved) { + break + } + numLoops += 1 + } + + if (numLoops === 1000) { + return { error: 'Infinite loop - could not generate pedigree' } + } + + const nodeHeight = nodeDiameter + 15 + + const minNodeCenterX = min(nodes, (n) => n.x) ?? 0 + const maxNodeCenterX = max(nodes, (n) => n.x) ?? 0 + const minNodeCenterY = min(nodes, (n) => n.y) ?? 0 + const maxNodeCenterY = max(nodes, (n) => n.y) ?? 0 + + const layout = { + width_dimensions: [minNodeCenterX - nodeDiameter / 2, maxNodeCenterX + nodeDiameter / 2], + height_dimensions: [ + minNodeCenterY - nodeDiameter / 2, + maxNodeCenterY + nodeHeight - nodeDiameter / 2, + ], + nodeDiameter: nodeDiameter, + nodeHeight: nodeHeight, + level_y_padding: _verticalSpacing, + } + + return { levels, nodes, nodes_index, links, bundles, layout } +} + +interface ITangleTreeChartProps { + data: NodeList[] + originalData: { [name: string]: PedigreeEntry } + onClick?: (e: PedigreeEntry) => void + onHighlight?: (entry?: PedigreeEntry | null) => void + highlightedIndividual?: string | null + + nodeDiameter?: number + nodeHorizontalSpacing?: number + nodeVerticalSpacing?: number + paddingX?: number + paddingY?: number +} + +const TangleTreeChart: React.FC = (props) => { + const { + data, + originalData, + onClick, + onHighlight, + highlightedIndividual, + nodeHorizontalSpacing, + nodeVerticalSpacing, + nodeDiameter = defaultNodeDiameter, + paddingX = 10, + paddingY = 10, + } = props + + const tangleLayout = constructTangleLayout(_.cloneDeep(data), { + nodeDiameter, + horizontalSpacing: nodeHorizontalSpacing, + verticalSpacing: nodeVerticalSpacing, + }) + + if ('error' in tangleLayout) { + return + } + + const minX = tangleLayout.layout.width_dimensions[0] - paddingX + const minY = tangleLayout.layout.height_dimensions[0] - paddingY + const width = tangleLayout.layout.width_dimensions[1] - minX + 2 * paddingX + const height = tangleLayout.layout.height_dimensions[1] - minY + 2 * paddingY + const viewBox = `${minX} ${minY} ${width} ${height}` + + return ( + + {tangleLayout.bundles.map((b) => { + const d = b.links + .map( + (l) => ` + M ${l.xt} ${l.yt} + L ${l.xb} ${l.yb} + L ${l.x1} ${l.y1} + L ${l.x2} ${l.y2} + L ${l.xs} ${l.ys}` + ) + .join('') + return ( + + {/* */} + + + ) + })} + + {tangleLayout.nodes.map((n) => ( + + ))} + + ) +} + +interface IPersonNodeProps { + node: NodePosition + entry: PedigreeEntry + isHighlighted?: boolean + + onClick?: (entry: PedigreeEntry) => void + onHighlight?: (entry?: PedigreeEntry | null) => void + + nodeSize?: number + showIndividualId?: boolean +} + +const getSVGPathForDeceasedLine = (node: NodePosition, nodeSize: number) => { + const insetFactor = DECEASED_NODE_INSET_FACTOR + const mX = node.x - nodeSize / insetFactor + const mY = node.y - nodeSize / insetFactor + const lX = node.x + nodeSize / insetFactor + const lY = node.y + nodeSize / insetFactor + return `M${mX} ${mY} L${lX} ${lY}` +} + +export const PersonNode: React.FC = ({ + node, + entry, + onClick, + onHighlight, + isHighlighted = false, + nodeSize = defaultNodeDiameter, + showIndividualId = true, +}) => { + const isDeceased = entry.deceased + + return ( + + { + onHighlight?.(entry) + }} + onMouseLeave={() => { + onHighlight?.(null) + }} + > + + onClick?.(entry)} + // on highlight + /> + {/* if deceased, show diagonal bar through node */} + {isDeceased && ( + + )} + {/* wrap text in g to get fill to work correctly */} + {showIndividualId && ( + + + {entry.individual_id} + + + )} + + + ) +} + +const calculateDepth = ( + person: ModifiedPedEntry, + data: { [name: string]: ModifiedPedEntry } +): number => { + if (!person.children.length) { + return 1 + } + + return max(person.children.map((i) => 1 + calculateDepth(data[i], data))) ?? 1 +} + +// const calculateAllDescendents = (person, data) => { +// if (!person.children.length) { +// return person.individual_id; +// } + +// return [ +// ...person.children, +// ...person.children.map((i) => calculateAllDescendents(data[i], data)), +// ].flat(); +// }; + +/* eslint-disable no-restricted-syntax */ +const findInHeirarchy = (id: string | null | undefined, heirarchy: Record) => { + for (const [index, level] of heirarchy.entries()) { + for (const person of level) { + if (person.id === id) { + return index + } + } + } + return -1 +} +/* eslint-enable no-restricted-syntax */ + +const formatData = (data: PedigreeEntry[]) => { + if (!data.length) { + return [] + } + + const possibleRoots = data + .filter((item) => !item.paternal_id && !item.maternal_id) + .map((i) => i.individual_id) + const dataWithChildren: ModifiedPedEntry[] = data.map((item) => ({ + ...item, + children: data + .filter( + (i) => i.paternal_id === item.individual_id || i.maternal_id === item.individual_id + ) + .map((j) => j.individual_id) + .sort(), + })) + const keyedData: { [name: string]: ModifiedPedEntry } = {} + dataWithChildren.forEach((d) => { + keyedData[d.individual_id] = d + }) + + let couples = Array.from( + new Set( + data + .map((item) => `${item.paternal_id}+${item.maternal_id}`) + .filter((item) => item !== 'null+null') + ) + ).map((i) => i.split('+')) + + const rootLengths: [string, number][] = possibleRoots.map((item) => [ + item, + calculateDepth(keyedData[item], keyedData), + ]) + const maxLength = max(rootLengths.map((i) => i[1])) + const bestRoots = rootLengths + .filter((i) => i[1] === maxLength) + .map((i) => i[0]) + .sort() + + const yetToSee = new Set(data.map((i) => i.individual_id)) + let queue = [[bestRoots[0]]] + + const toReturn: NodeParentsList[][] = [] + + /* eslint no-loop-func: 0 */ + + // create 1 lineage spine + while (queue.flat().length) { + const toAdd: NodeParentsList[] = [] + const toAddToQueue: string[] = [] + const nextList = queue.shift() ?? [] + nextList.forEach((next) => { + if (!yetToSee.has(next)) { + return + } + yetToSee.delete(next) + const obj: NodeParentsList = formObjectFromIdAndParents(next, [ + keyedData[next].paternal_id, + keyedData[next].maternal_id, + ]) + toAdd.push(obj) // add entry + toAddToQueue.push(...keyedData[next].children.filter((i) => yetToSee.has(i))) + }) + queue = [...queue, toAddToQueue] + toReturn.push(toAdd) + } + + // try add missing people + let missingPeople = [...yetToSee] + let updatedList = false + while (missingPeople.length) { + missingPeople = [...yetToSee] + for (let i = 0; i < missingPeople.length; i += 1) { + const next = missingPeople[i] + // try add spouses + const checkCouples = couples + .filter(([dad, mum]) => dad === next || mum === next) + .map(([dad, mum]) => (dad === next ? mum : dad)) + /* eslint-disable @typescript-eslint/no-loop-func */ + checkCouples.forEach((n) => { + const partnerLevel = findInHeirarchy(n, toReturn) + if (partnerLevel > -1) { + const partnerPosition = toReturn[partnerLevel].findIndex((j) => j.id === n) + // add spouse in next to partner + const obj: NodeParentsList = formObjectFromIdAndParents(next, [ + keyedData[next].paternal_id, + keyedData[next].maternal_id, + ]) + toReturn[partnerLevel].splice(partnerPosition + 1, 0, obj) + couples = couples.filter( + ([dad, mum]) => !([next, n].includes(dad) && [next, n].includes(mum)) + ) + updatedList = true + yetToSee.delete(next) + } + }) + /* eslint-enable @typescript-eslint/no-loop-func */ + if (updatedList) { + break + } + // try add person above children + const levels = keyedData[next].children + .map((n) => findInHeirarchy(n, toReturn)) + .filter((a) => a > -1) + if (levels.length) { + const nextObj: NodeParentsList = formObjectFromIdAndParents(next, [ + keyedData[next].paternal_id, + keyedData[next].maternal_id, + ]) + toReturn[min(levels) - 1] = [...toReturn[min(levels) - 1], nextObj] + yetToSee.delete(next) + updatedList = true + break + } + // try add child below parents + const parentLevel = max([ + findInHeirarchy(keyedData[next].maternal_id, toReturn), + findInHeirarchy(keyedData[next].paternal_id, toReturn), + ]) + if (parentLevel > -1) { + const nextObj: NodeParentsList = formObjectFromIdAndParents(next, [ + keyedData[next].paternal_id, + keyedData[next].maternal_id, + ]) + toReturn[parentLevel + 1] = [...toReturn[parentLevel + 1], nextObj] + yetToSee.delete(next) + updatedList = true + break + } + } + if (!updatedList) { + break + } + updatedList = false + } + + const nodeDefaults = { + x: 0, + y: 0, + parents: [], + level: -1, + bundles: [], + links: [], + parentsList: [], + } + + return toReturn.map((l) => ({ + nodes: l.map((m) => ({ ...nodeDefaults, ...m })), + bundles: [], + })) +} + interface RenderPedigreeProps { data: PedigreeEntry[] - click?(e: string): void -} - -const TangledTree: React.FunctionComponent = ({ data, click }) => ( - <> - Temporary Pedigree component -
    - {`Truncated Pedigree: ' ${JSON.stringify(data.slice(0, 10))}`} -
    - {JSON.stringify(click)} - -) + + onClick?: (e: PedigreeEntry) => void + onHighlight?: (e?: PedigreeEntry | null) => void + + highlightedIndividual?: string | null + + nodeDiameter?: number + nodeHorizontalSpacing?: number + nodeVerticalSpacing?: number +} + +const TangledTree: React.FunctionComponent = ({ data, onClick, ...props }) => { + if (!data?.length) { + return ( +

    + Empty pedigree +

    + ) + } + + const tree = formatData(data) + const keyedData = _.keyBy(data, (s) => s.individual_id) + + return ( + + ) +} export default TangledTree diff --git a/web/src/shared/components/pedigree/TangledTreeExamples.tsx b/web/src/shared/components/pedigree/TangledTreeExamples.tsx new file mode 100644 index 000000000..695cbebcd --- /dev/null +++ b/web/src/shared/components/pedigree/TangledTreeExamples.tsx @@ -0,0 +1,170 @@ +import * as React from 'react' + +import TangledTree, { PedigreeEntry } from './TangledTree' + +const basicTrio: PedigreeEntry[] = [ + { + family_id: 'TRIO', + individual_id: 'Parent_1', + paternal_id: null, + maternal_id: null, + sex: 1, + affected: 2, + }, + { + family_id: 'TRIO', + individual_id: 'Parent_2', + paternal_id: null, + maternal_id: null, + sex: 2, + affected: 2, + }, + { + family_id: 'TRIO', + individual_id: 'Trio_Child', + paternal_id: 'Parent_1', + maternal_id: 'Parent_2', + sex: 1, + affected: 1, + }, +] + +const fourGenTree: PedigreeEntry[] = [ + { + family_id: 'Gen1', + individual_id: 'Grandparent_1', + paternal_id: null, + maternal_id: null, + sex: 1, + affected: 2, + }, + { + family_id: 'Gen1', + individual_id: 'Grandparent_2', + paternal_id: null, + maternal_id: null, + sex: 2, + affected: 2, + }, + { + family_id: 'Gen1', + individual_id: 'Grandparent_3', + paternal_id: null, + maternal_id: null, + sex: 1, + affected: 2, + }, + { + family_id: 'Gen2', + individual_id: 'Parent_1', + paternal_id: 'Grandparent_1', + maternal_id: 'Grandparent_2', + sex: 1, + affected: 1, + }, + { + family_id: 'Gen2', + individual_id: 'Parent_2', + paternal_id: 'Grandparent_3', + sex: 2, + affected: 2, + }, + { + family_id: 'Gen3', + individual_id: 'Child_1', + paternal_id: 'Parent_1', + maternal_id: 'Parent_2', + sex: 1, + affected: 2, + }, + { + family_id: 'Gen3', + individual_id: 'Child_2', + paternal_id: 'Parent_1', + maternal_id: 'Parent_2', + sex: 2, + affected: 1, + }, + { + family_id: 'Gen3', + individual_id: 'Child_3', + paternal_id: 'Parent_1', + maternal_id: 'Parent_2', + sex: 1, + affected: 2, + }, + { + family_id: 'Gen3', + individual_id: 'Unrelated_Child1', + sex: 2, + affected: 2, + }, + { + family_id: 'Gen3', + individual_id: 'Unrelated_Child2', + sex: 1, + affected: 1, + }, + { + family_id: 'Gen4', + individual_id: 'Grandchild_1', + paternal_id: 'Child_1', + maternal_id: 'Unrelated_Child1', + sex: 1, + affected: 2, + }, + { + family_id: 'Gen4', + individual_id: 'Grandchild_2', + paternal_id: 'Child_1', + maternal_id: 'Unrelated_Child1', + sex: 2, + affected: 1, + }, + { + family_id: 'Gen4', + individual_id: 'Grandchild_3', + paternal_id: 'Child_1', + maternal_id: 'Unrelated_Child1', + sex: 1, + affected: 1, + }, + { + family_id: 'Gen4', + individual_id: 'Grandchild_4', + paternal_id: 'Unrelated_Child2', + maternal_id: 'Child_2', + sex: 2, + affected: 2, + }, +] + +export const TangledTreeExamples: React.FC = () => { + const [nodeDiameter, setNodeDiameter] = React.useState(40) + const [horizontalNodeSpacing, setHorizontalNodeSpacing] = React.useState( + undefined + ) + const [verticalNodeSpacing, setVerticalNodeSpacing] = React.useState( + undefined + ) + + return ( + <> +

    Node diameter {nodeDiameter}

    + setNodeDiameter(parseInt(e.target.value))} + /> +
    + +
    +
    +
    + +
    + + ) +} diff --git a/web/src/shared/utilities/hailBatch.ts b/web/src/shared/utilities/hailBatch.ts new file mode 100644 index 000000000..3fa3be240 --- /dev/null +++ b/web/src/shared/utilities/hailBatch.ts @@ -0,0 +1,7 @@ +export function getHailBatchURL(batchId: string, jobId?: string | null): string { + const base = `https://batch.hail.populationgenomics.org.au/batches/${batchId}` + if (!jobId) { + return base + } + return base + `/jobs/${jobId}` +}
    + {key} + {valueToDisplay(value)}