From ab2e1b3d1fd5b3d850ec4c899cf73fd7aeda675e Mon Sep 17 00:00:00 2001 From: Jay Hodgson Date: Thu, 14 Sep 2023 15:35:55 -0700 Subject: [PATCH] progress? --- .../components/TimelinePlot/TimelinePhase.tsx | 154 ++++++++++-------- .../TimelinePlot/TimelinePlot.stories.tsx | 3 +- .../components/TimelinePlot/TimelinePlot.tsx | 94 +++++++---- .../row_renderers/ObservationCard.tsx | 40 +++-- 4 files changed, 177 insertions(+), 114 deletions(-) diff --git a/packages/synapse-react-client/src/components/TimelinePlot/TimelinePhase.tsx b/packages/synapse-react-client/src/components/TimelinePlot/TimelinePhase.tsx index 6f8a4bc83d..ad4e44f7bc 100644 --- a/packages/synapse-react-client/src/components/TimelinePlot/TimelinePhase.tsx +++ b/packages/synapse-react-client/src/components/TimelinePlot/TimelinePhase.tsx @@ -1,28 +1,38 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useRef, useState } from 'react' import Plotly, { Layout } from 'plotly.js-basic-dist' import createPlotlyComponent from 'react-plotly.js/factory' -import { ObservationEvent } from './TimelinePlot' import dayjs, { ManipulateType } from 'dayjs' -import { Paper, Typography } from '@mui/material' +import { Dialog } from '@mui/material' +import { + ObservationCard, + ObservationCardSchema, +} from '../row_renderers/ObservationCard' +import { Row } from '@sage-bionetworks/synapse-types' +import pluralize from 'pluralize' const Plot = createPlotlyComponent(Plotly) const getTimelineData = ( start: dayjs.Dayjs, - observationEvents: ObservationEvent[], + rowData: Row[], + schema: ObservationCardSchema, + hoverEventRowId?: number, //if supplied, will highlight the hovered event ) => { - const data = observationEvents.map(event => { - const timepoint = start.add(event.time!, event.timeUnit as ManipulateType) + const data = rowData.map(row => { + const time = parseInt(row.values[schema.time]!) + const timeUnit = row.values[schema.timeUnits] + const timepoint = start.add(time, timeUnit as ManipulateType) const utcFormattedTimepoint = timepoint.format() + const isHoveredOver = row.rowId == hoverEventRowId return { x: [utcFormattedTimepoint, utcFormattedTimepoint, utcFormattedTimepoint], y: [0, 0.5, 1], mode: 'lines', line: { - color: 'gray', + color: isHoveredOver ? 'black' : 'gray', width: 2, }, // Add event into in the customdata - customdata: [event.id, event.id, event.id], + customdata: [row.rowId, row.rowId, row.rowId], // but tell Plotly that we do not want it to show a hover tooltip (we're going to handle this) hoverinfo: 'none', } @@ -35,9 +45,33 @@ const getLayout = ( timeMax: number, timeUnits: string, color: string, - observationEvents: ObservationEvent[], + annotateTime?: number, + annotateTimeUnits?: ManipulateType, ): Partial => { const end = start.add(timeMax, timeUnits as ManipulateType) + const annotations: Partial[] = [ + { + x: start.format(), + y: -0.1, + text: ' ', + showarrow: false, + }, + { + x: end.format(), + y: -0.1, + text: ' ', + showarrow: false, + }, + ] + if (annotateTime && annotateTimeUnits) { + const x = start.add(annotateTime, annotateTimeUnits) + annotations.push({ + x: x.format(), + y: -0.1, + text: `${annotateTime} ${pluralize(annotateTimeUnits, annotateTime)}`, + showarrow: false, + }) + } return { hovermode: 'closest', showlegend: false, @@ -54,20 +88,7 @@ const getLayout = ( showline: false, showticklabels: false, }, - - // event annotations - // annotations: observationEvents.map(event => { - // const x = start.add(event.time!, event.timeUnit as ManipulateType) - // const annotation: Partial = { - // x: x.format(), - // y: -0.4, - // text: `${event.time} ${pluralize(event.timeUnit!, event.time!)}`, - // showarrow: false, - // textangle: '270', - // } - // return annotation - // }), - + annotations: annotations, // Each phase has a shape shapes: [ { @@ -91,72 +112,71 @@ type TimelinePhaseProps = { color: string timeMax: number // how long is this phase? timeUnits: string // in what time units is the timeMax measured? (days? weeks?) - observationEvents: ObservationEvent[] + rowData: Row[] + schema: ObservationCardSchema } const TimelinePhase = ({ color, timeMax, timeUnits, - observationEvents, + rowData, + schema, }: TimelinePhaseProps) => { + const [clickEvent, setClickEvent] = useState() const [hoverEvent, setHoverEvent] = useState() const start = dayjs() // hide the hover UI if we detect that the user moves the mouse outside of this component boundary const componentRef = useRef(null) - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (componentRef.current) { - const componentRect = componentRef.current.getBoundingClientRect() - const mouseX = e.clientX - const mouseY = e.clientY - - // Check if the mouse is outside the component boundaries - if ( - mouseX < componentRect.left || - mouseX > componentRect.right || - mouseY < componentRect.top || - mouseY > componentRect.bottom - ) { - setHoverEvent(undefined) - } - } - } + const rowId = clickEvent?.points[0].customdata as number + const selectedRow = rowData.filter(row => { + return row.rowId === rowId + })[0] + const hoverEventRowId = hoverEvent?.points[0].customdata as number + const hoverRow = rowData.filter(row => { + return row.rowId === hoverEventRowId + })[0] + const annotateTime = hoverRow + ? parseInt(hoverRow.values[schema.time]!) + : undefined + const annotateTimeUnits = hoverRow + ? (hoverRow.values[schema.timeUnits] as ManipulateType) + : undefined - // Add the event listener to the window - window.addEventListener('mousemove', handleMouseMove) - - // Clean up the event listener on component unmount - return () => { - window.removeEventListener('mousemove', handleMouseMove) - } - }, []) - - const id = hoverEvent?.points[0].customdata as string return (
{ + setClickEvent(eventData) + }} onHover={eventData => { setHoverEvent(eventData) }} - /> - { + setHoverEvent(undefined) }} - > - - {id} - - + /> + {selectedRow && ( + setClickEvent(undefined)} open={!!selectedRow}> + + + )}
) } diff --git a/packages/synapse-react-client/src/components/TimelinePlot/TimelinePlot.stories.tsx b/packages/synapse-react-client/src/components/TimelinePlot/TimelinePlot.stories.tsx index c9c3ce23e3..8116f1b955 100644 --- a/packages/synapse-react-client/src/components/TimelinePlot/TimelinePlot.stories.tsx +++ b/packages/synapse-react-client/src/components/TimelinePlot/TimelinePlot.stories.tsx @@ -10,8 +10,9 @@ type Story = StoryObj export const Demo: Story = { args: { + observationsSql: + 'SELECT observationId as "id", observationPhase as "phase", observationSubmitterName as "submitterName", synapseId as "submitterUserId", observationTime as "time", observationTimeUnits as "timeUnits", observationText as "text", observationType as "tag" FROM syn51735464', species: 'Mus musculus', resourceId: '9971e47e-976a-4631-8edd-5cae04304b01', - eventsTableId: 'syn51735464', }, } diff --git a/packages/synapse-react-client/src/components/TimelinePlot/TimelinePlot.tsx b/packages/synapse-react-client/src/components/TimelinePlot/TimelinePlot.tsx index 10449342ca..406e7bc764 100644 --- a/packages/synapse-react-client/src/components/TimelinePlot/TimelinePlot.tsx +++ b/packages/synapse-react-client/src/components/TimelinePlot/TimelinePlot.tsx @@ -10,33 +10,52 @@ import hardcodedPhasesQueryResponseData, { import TimelinePhase from './TimelinePhase' import getColorPalette from '../ColorGradient/ColorGradient' import { Box } from '@mui/system' +import { ColumnSingleValueFilterOperator } from '@sage-bionetworks/synapse-types' +import { ObservationCardSchema } from '../row_renderers/ObservationCard' +import { parseEntityIdFromSqlStatement } from '../../utils/functions' -const OBSERVATION_PHASE_COLUMN_NAME = 'observationphase' -const OBSERVATION_ID_COLUMN_NAME = 'observationid' -const OBSERVATION_TIME_COLUMN_NAME = 'observationtime' -const OBSERVATION_TIME_UNITS_COLUMN_NAME = 'observationtimeunits' +const OBSERVATION_PHASE_COLUMN_NAME = 'phase' +const OBSERVATION_TIME_COLUMN_NAME = 'time' +const OBSERVATION_TIME_UNITS_COLUMN_NAME = 'timeunits' +const OBSERVATION_SUBMITTER_NAME_COLUMN_NAME = 'submittername' +const OBSERVATION_TEXT_COLUMN_NAME = 'text' +const OBSERVATION_TYPE_COLUMN_NAME = 'tag' +const OBSERVATION_SUBMITTER_USER_ID_COLUMN_NAME = 'submitteruserid' type TimelinePlotProps = { species: string resourceId: string - eventsTableId: string -} -export type ObservationEvent = { - id: string | null - time: number | null - timeUnit: string | null + observationsSql: string } const TimelinePlot = ({ + observationsSql, species, resourceId, - eventsTableId, }: TimelinePlotProps) => { // Fetch the table data + const eventsTableId = parseEntityIdFromSqlStatement(observationsSql) const eventTableQuery = useGetFullTableQueryResults({ entityId: eventsTableId, query: { - sql: `SELECT * FROM ${eventsTableId} WHERE resourceId = '${resourceId}' and species = '${species}'`, + sql: `${observationsSql} WHERE observationTime IS NOT NULL`, + additionalFilters: [ + { + columnName: 'resourceId', + concreteType: + 'org.sagebionetworks.repo.model.table.ColumnSingleValueQueryFilter', + values: [resourceId], + operator: ColumnSingleValueFilterOperator.EQUAL, + }, + { + columnName: 'species', + concreteType: + 'org.sagebionetworks.repo.model.table.ColumnSingleValueQueryFilter', + values: [species], + operator: ColumnSingleValueFilterOperator.EQUAL, + }, + ], }, + partMask: BUNDLE_MASK_QUERY_RESULTS, concreteType: 'org.sagebionetworks.repo.model.table.QueryBundleRequest', }) @@ -46,10 +65,6 @@ const TimelinePlot = ({ if (isLoading) { return <> } - const observationIdIndex = - eventsData?.queryResult?.queryResults.headers.findIndex( - header => header.name.toLowerCase() === OBSERVATION_ID_COLUMN_NAME, - )! const observationPhaseIndex = eventsData?.queryResult?.queryResults.headers.findIndex( header => header.name.toLowerCase() === OBSERVATION_PHASE_COLUMN_NAME, @@ -63,6 +78,24 @@ const TimelinePlot = ({ header => header.name.toLowerCase() === OBSERVATION_TIME_UNITS_COLUMN_NAME, )! + const observationSubmitterNameIndex = + eventsData?.queryResult?.queryResults.headers.findIndex( + header => + header.name.toLowerCase() === OBSERVATION_SUBMITTER_NAME_COLUMN_NAME, + )! + const observationTextIndex = + eventsData?.queryResult?.queryResults.headers.findIndex( + header => header.name.toLowerCase() === OBSERVATION_TEXT_COLUMN_NAME, + )! + const observationTypeIndex = + eventsData?.queryResult?.queryResults.headers.findIndex( + header => header.name.toLowerCase() === OBSERVATION_TYPE_COLUMN_NAME, + )! + const submitterUserIdIndex = + eventsData?.queryResult?.queryResults.headers.findIndex( + header => + header.name.toLowerCase() === OBSERVATION_SUBMITTER_USER_ID_COLUMN_NAME, + )! // filter the phases query response data to the specific species const phasesForTargetSpecies = @@ -87,28 +120,29 @@ const TimelinePlot = ({ phaseRow.values[phaseObservationIndex] ) }) - // transform into ObservationEvents and create a new TimelinePhase - const observationEvents = phaseEventRows?.map(row => { - const observationEvent: ObservationEvent = { - id: row.values[observationIdIndex], - time: parseInt(row.values[observationTimeIndex]!), - timeUnit: row.values[observationTimeUnitIndex], - } - return observationEvent - }) - return ( - observationEvents?.length && - observationEvents?.length > 0 && ( + const schema: ObservationCardSchema = { + submitterName: observationSubmitterNameIndex, + submitterUserId: submitterUserIdIndex, + tag: observationTypeIndex, + text: observationTextIndex, + time: observationTimeIndex, + timeUnits: observationTimeUnitIndex, + } + const isPhaseEvents = + phaseEventRows?.length && phaseEventRows?.length > 0 + if (isPhaseEvents) { + return ( ) - ) + } else return <> })} ) diff --git a/packages/synapse-react-client/src/components/row_renderers/ObservationCard.tsx b/packages/synapse-react-client/src/components/row_renderers/ObservationCard.tsx index 08267e6257..68ded780dd 100644 --- a/packages/synapse-react-client/src/components/row_renderers/ObservationCard.tsx +++ b/packages/synapse-react-client/src/components/row_renderers/ObservationCard.tsx @@ -6,18 +6,22 @@ import { Skeleton } from '@mui/material' import { SkeletonTable } from '../Skeleton/SkeletonTable' import { UserBadge } from '../UserCard/UserBadge' -type ObservationCardSchema = { - submitterName: string - submitterUserId: string - time: string - timeUnits: UnitType - text: string - tag: string +/** + * Column index values into the row values given provided in "data" + */ +export type ObservationCardSchema = { + submitterName: number + submitterUserId: number + time: number + timeUnits: number + text: number + tag: number } export type ObservationCardProps = { schema: ObservationCardSchema - data: Record + data: (string | null)[] + includePortalCardClass?: boolean } /** @@ -27,6 +31,7 @@ export type ObservationCardProps = { export const ObservationCard: React.FunctionComponent = ({ data, schema, + includePortalCardClass = true, }: ObservationCardProps) => { const submitterName = data[schema.submitterName] const submitterUserId = data[schema.submitterUserId] @@ -35,7 +40,11 @@ export const ObservationCard: React.FunctionComponent = ({ const text = data[schema.text] const tag = data[schema.tag] return ( -
+
{!submitterUserId &&
{submitterName}
} {submitterUserId && } @@ -43,15 +52,14 @@ export const ObservationCard: React.FunctionComponent = ({ {time && (
- - {`${time} ${timeUnits}`} - {Number(time) > 1 ? 's' : ''} - + {`${time} ${timeUnits}`} +
+ )} + {text && ( +
+
)} -
- -
{tag && {tag}}