diff --git a/public/order.json b/public/order.json new file mode 100644 index 0000000..8ffbacf --- /dev/null +++ b/public/order.json @@ -0,0 +1,8 @@ +{ + "http://purl.obolibrary.org/obo/UBERON_0005844": [ + "http://purl.obolibrary.org/obo/UBERON_0002726", + "http://purl.obolibrary.org/obo/UBERON_0003038", + "http://purl.obolibrary.org/obo/UBERON_0002792", + "http://purl.obolibrary.org/obo/UBERON_0005843" + ] +} diff --git a/src/App.tsx b/src/App.tsx index aad9c70..ff423c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { fetchJSON, fetchKnowledgeStatements, fetchMajorNerves, + fetchOrderJson, } from './services/fetchService.ts'; import { getUniqueMajorNerves } from './services/filterValuesService.ts'; import { @@ -62,25 +63,25 @@ const App = () => { }, [LayoutComponent, dispatch]); useEffect(() => { - fetchJSON() - .then((data) => { - setHierarchicalNodes(getHierarchicalNodes(data)); - setOrgans(getOrgans(data)); - }) - .catch((error) => { - // TODO: We should give feedback to the user - console.error('Failed to fetch JSON data:', error); - }); + const fetchData = async () => { + try { + const [jsonData, orderData, majorNervesData] = await Promise.all([ + fetchJSON(), + fetchOrderJson(), + fetchMajorNerves(), + ]); - fetchMajorNerves() - .then((data) => { - setMajorNerves(getUniqueMajorNerves(data)); - }) - .catch((error) => { + setHierarchicalNodes(getHierarchicalNodes(jsonData, orderData)); + setOrgans(getOrgans(jsonData)); + setMajorNerves(getUniqueMajorNerves(majorNervesData)); + } catch (error) { // TODO: We should give feedback to the user - console.error('Failed to fetch major nerves data:', error); + console.error('Failed to fetch data:', error); setMajorNerves(undefined); - }); + } + }; + + fetchData(); }, []); useEffect(() => { diff --git a/src/components/Connections.tsx b/src/components/Connections.tsx index 7f7e1d7..a51daf7 100644 --- a/src/components/Connections.tsx +++ b/src/components/Connections.tsx @@ -4,9 +4,10 @@ import { ArrowRightIcon } from './icons'; import { vars } from '../theme/variables'; import { HierarchicalItem, - PhenotypeKsIdMap, + KsPerPhenotype, SummaryType, - KsMapType, + KsRecord, + Option, } from './common/Types'; import { useDataContext } from '../context/DataContext.ts'; import { @@ -22,6 +23,9 @@ import { import { getYAxis, getKnowledgeStatementMap, + filterYAxis, + getEmptyColumns, + filterConnectionsMap, } from '../services/heatmapService.ts'; import SummaryHeader from './connections/SummaryHeader'; import SummaryInstructions from './connections/SummaryInstructions.tsx'; @@ -50,60 +54,88 @@ const styles = { }; function Connections() { + const { + selectedConnectionSummary, + majorNerves, + hierarchicalNodes, + knowledgeStatements, + filters, + } = useDataContext(); + const [showConnectionDetails, setShowConnectionDetails] = useState(SummaryType.Instruction); const [connectionsMap, setConnectionsMap] = useState< - Map + Map + >(new Map()); + const [filteredConnectionsMap, setFilteredConnectionsMap] = useState< + Map >(new Map()); const [connectionPage, setConnectionPage] = useState(1); // represents the page number / index of the connections - if (x,y) has 4 connections, then connectionPage will be 1, 2, 3, 4 const [yAxis, setYAxis] = useState([]); + const [xAxis, setXAxis] = useState([]); + const [filteredYAxis, setFilteredYAxis] = useState([]); + const [filteredXAxis, setFilteredXAxis] = useState([]); const [selectedCell, setSelectedCell] = useState<{ x: number; y: number; } | null>(null); // useful for coordinates const [knowledgeStatementsMap, setKnowledgeStatementsMap] = - useState({}); - const [xAxis, setXAxis] = useState([]); + useState({}); + const [nerveFilters, setNerveFilters] = useState([]); + const [phenotypeFilters, setPhenotypeFilters] = useState([]); - const { - selectedConnectionSummary, - majorNerves, - hierarchicalNodes, - knowledgeStatements, - summaryFilters, - } = useDataContext(); + const summaryFilters = useMemo( + () => ({ + ...filters, + Nerve: nerveFilters, + Phenotype: phenotypeFilters, + }), + [filters, nerveFilters, phenotypeFilters], + ); useEffect(() => { // By default on the first render, show the instruction/summary if (selectedConnectionSummary) { setShowConnectionDetails(SummaryType.Summary); + } else { + setShowConnectionDetails(SummaryType.Instruction); } }, [selectedConnectionSummary]); // Values from the selected connection - In the SummaryType.summary const viasConnection = getAllViasFromConnections( - selectedConnectionSummary?.connections || ({} as KsMapType), + selectedConnectionSummary?.filteredKnowledgeStatements || ({} as KsRecord), ); const viasStatement = convertViaToString(Object.values(viasConnection)); const totalConnectionCount = Object.keys( - selectedConnectionSummary?.connections || ({} as KsMapType), + selectedConnectionSummary?.filteredKnowledgeStatements || ({} as KsRecord), ).length; - const nerves = getNerveFilters(viasConnection, majorNerves); + + const availableNerves = getNerveFilters(viasConnection, majorNerves); + const availablePhenotypes = useMemo( + () => + selectedConnectionSummary + ? getAllPhenotypes( + selectedConnectionSummary.filteredKnowledgeStatements, + ) + : [], + [selectedConnectionSummary], + ); useEffect(() => { // calculate the connectionsMap for the secondary heatmap if ( selectedConnectionSummary && - selectedConnectionSummary.hierarchy && + selectedConnectionSummary.hierarchicalNode && hierarchicalNodes ) { const destinations = getDestinations(selectedConnectionSummary); const connections = calculateSecondaryConnections( hierarchicalNodes, destinations, - knowledgeStatements, + selectedConnectionSummary.filteredKnowledgeStatements, summaryFilters, - selectedConnectionSummary.hierarchy, + selectedConnectionSummary.hierarchicalNode, ); setConnectionsMap(connections); } @@ -114,11 +146,6 @@ function Connections() { knowledgeStatements, ]); - const selectedPhenotypes = useMemo( - () => getAllPhenotypes(connectionsMap), - [connectionsMap], - ); - useEffect(() => { // set the xAxis for the heatmap if (selectedConnectionSummary) { @@ -133,8 +160,8 @@ function Connections() { // set the yAxis for the heatmap if (selectedConnectionSummary && hierarchicalNodes) { const hierarchyNode = { - [selectedConnectionSummary.hierarchy.id]: - selectedConnectionSummary.hierarchy, + [selectedConnectionSummary.hierarchicalNode.id]: + selectedConnectionSummary.hierarchicalNode, }; const yHierarchicalItem = getYAxis(hierarchicalNodes, hierarchyNode); setYAxis(yHierarchicalItem); @@ -144,7 +171,7 @@ function Connections() { const handleCellClick = (x: number, y: number, yId: string): void => { // when the heatmap cell is clicked setSelectedCell({ x, y }); - const row = connectionsMap.get(yId); + const row = filteredConnectionsMap.get(yId); if (row) { setConnectionPage(1); const ksIds = Object.values(row[x]).reduce((acc, phenotypeData) => { @@ -153,7 +180,9 @@ function Connections() { if ( selectedConnectionSummary && - Object.keys(selectedConnectionSummary.connections).length !== 0 + Object.keys(selectedConnectionSummary.filteredKnowledgeStatements) + .length !== 0 && + ksIds.length > 0 ) { setShowConnectionDetails(SummaryType.DetailedSummary); const ksMap = getKnowledgeStatementMap(ksIds, knowledgeStatements); @@ -162,10 +191,33 @@ function Connections() { } }; - const heatmapData = useMemo(() => { - return getSecondaryHeatmapData(yAxis, connectionsMap); - }, [yAxis, connectionsMap]); + useEffect(() => { + // Filter yAxis + const filteredYAxis = filterYAxis(yAxis, connectionsMap); + + // Determine columns with data + const columnsWithData = getEmptyColumns(filteredYAxis, connectionsMap); + + // Filter connections map + const filteredConnectionsMap = filterConnectionsMap( + filteredYAxis, + connectionsMap, + columnsWithData, + ); + // Filter xAxis + const filteredXAxis = xAxis.filter((_, index) => + columnsWithData.has(index), + ); + + setFilteredYAxis(filteredYAxis); + setFilteredXAxis(filteredXAxis); + setFilteredConnectionsMap(filteredConnectionsMap); + }, [yAxis, xAxis, connectionsMap]); + + const heatmapData = useMemo(() => { + return getSecondaryHeatmapData(filteredYAxis, filteredConnectionsMap); + }, [filteredYAxis, filteredConnectionsMap]); return ( @@ -255,13 +307,17 @@ function Connections() { - + )} diff --git a/src/components/ConnectivityGrid.tsx b/src/components/ConnectivityGrid.tsx index 8ebd336..174c90a 100644 --- a/src/components/ConnectivityGrid.tsx +++ b/src/components/ConnectivityGrid.tsx @@ -6,11 +6,13 @@ import { useDataContext } from '../context/DataContext.ts'; import { calculateConnections, getMinMaxConnections, - getHierarchyFromId, getXAxisOrgans, getYAxis, getHeatmapData, getKnowledgeStatementMap, + filterConnectionsMap, + getEmptyColumns, + filterYAxis, } from '../services/heatmapService.ts'; import FiltersDropdowns from './FiltersDropdowns.tsx'; import { HierarchicalItem } from './common/Types.ts'; @@ -32,15 +34,25 @@ function ConnectivityGrid() { organs, knowledgeStatements, filters, - setConnectionSummary, + setFilters, + setSelectedConnectionSummary, } = useDataContext(); - const [yAxis, setYAxis] = useState([]); const [xAxisOrgans, setXAxisOrgans] = useState([]); + const [filteredXOrgans, setFilteredXOrgans] = useState([]); + const [initialYAxis, setInitialYAxis] = useState([]); + + const [yAxis, setYAxis] = useState([]); + const [filteredYAxis, setFilteredYAxis] = useState([]); // Maps YaxisId -> KnowledgeStatementIds for each Organ const [connectionsMap, setConnectionsMap] = useState< Map[]> >(new Map()); + + const [filteredConnectionsMap, setFilteredConnectionsMap] = useState< + Map[]> + >(new Map()); + const [selectedCell, setSelectedCell] = useState<{ x: number; y: number; @@ -65,41 +77,72 @@ function ConnectivityGrid() { setXAxisOrgans(organList); }, [organs]); - const xAxis = useMemo(() => { - return xAxisOrgans.map((organ) => organ.name); - }, [xAxisOrgans]); - useEffect(() => { const yAxis = getYAxis(hierarchicalNodes); setYAxis(yAxis); + setInitialYAxis(yAxis); }, [hierarchicalNodes]); + useEffect(() => { + if (connectionsMap.size > 0 && yAxis.length > 0) { + // Apply filtering logic + const filteredYAxis = filterYAxis>(yAxis, connectionsMap); + const columnsWithData = getEmptyColumns(filteredYAxis, connectionsMap); + const filteredConnectionsMap = filterConnectionsMap( + filteredYAxis, + connectionsMap, + columnsWithData, + ); + const filteredOrgans = xAxisOrgans.filter((_, index) => + columnsWithData.has(index), + ); + + setFilteredYAxis(filteredYAxis); + setFilteredXOrgans(filteredOrgans); + setFilteredConnectionsMap(filteredConnectionsMap); + } + }, [yAxis, connectionsMap, xAxisOrgans]); + const { heatmapData, detailedHeatmapData } = useMemo(() => { - const heatmapdata = getHeatmapData(yAxis, connectionsMap); + const heatmapData = getHeatmapData(filteredYAxis, filteredConnectionsMap); return { - heatmapData: heatmapdata.heatmapMatrix, - detailedHeatmapData: heatmapdata.detailedHeatmap, + heatmapData: heatmapData.heatmapMatrix, + detailedHeatmapData: heatmapData.detailedHeatmap, }; - }, [yAxis, connectionsMap]); + }, [filteredYAxis, filteredConnectionsMap]); const handleClick = (x: number, y: number, yId: string): void => { // When the primary heatmap cell is clicked - this sets the react-context state for Connections in SummaryType.summary setSelectedCell({ x, y }); - const row = connectionsMap.get(yId); + const row = filteredConnectionsMap.get(yId); if (row) { - const endOrgan = xAxisOrgans[x]; - const origin = detailedHeatmapData[y]; - const hierarchy = getHierarchyFromId(origin.id, hierarchicalNodes); + const endOrgan = filteredXOrgans[x]; + const nodeData = detailedHeatmapData[y]; + const hierarchicalNode = hierarchicalNodes[nodeData.id]; const ksMap = getKnowledgeStatementMap(row[x], knowledgeStatements); - setConnectionSummary({ - origin: origin.label, - endOrgan: endOrgan, + setSelectedConnectionSummary({ connections: ksMap, - hierarchy: hierarchy, + endOrgan: endOrgan, + hierarchicalNode: hierarchicalNode, }); } }; + + const handleReset = () => { + setYAxis(initialYAxis); + setFilters({ + Origin: [], + EndOrgan: [], + Species: [], + Phenotype: [], + apiNATOMY: [], + Via: [], + }); + setSelectedCell(null); + setSelectedConnectionSummary(null); + }; + const isLoading = yAxis.length == 0; return isLoading ? ( @@ -122,10 +165,10 @@ function ConnectivityGrid() { organ.name)} xAxisLabel={'End organ'} yAxisLabel={'Connection Origin'} onCellClick={handleClick} @@ -150,14 +193,17 @@ function ConnectivityGrid() { fontWeight: 600, lineHeight: '1.25rem', color: primaryPurple600, - padding: 0, + borderRadius: '0.25rem', + border: `0.0625rem solid ${primaryPurple600}`, + padding: '0.5rem', '&:hover': { background: 'transparent', }, }} + onClick={handleReset} > - Reset grid + Reset All { - const { filters, setFilters, knowledgeStatements, organs } = useDataContext(); + const { + filters, + setFilters, + knowledgeStatements, + hierarchicalNodes, + organs, + } = useDataContext(); const originsOptions = useMemo( - () => getUniqueOrigins(knowledgeStatements), - [knowledgeStatements], + () => getUniqueOrigins(knowledgeStatements, hierarchicalNodes), + [knowledgeStatements, hierarchicalNodes], ); const speciesOptions = useMemo( () => getUniqueSpecies(knowledgeStatements), @@ -79,8 +85,8 @@ const FiltersDropdowns: React.FC = () => { [knowledgeStatements], ); const viasOptions = useMemo( - () => getUniqueVias(knowledgeStatements), - [knowledgeStatements], + () => getUniqueVias(knowledgeStatements, hierarchicalNodes), + [knowledgeStatements, hierarchicalNodes], ); const organsOptions = useMemo(() => getUniqueOrgans(organs), [organs]); @@ -94,14 +100,23 @@ const FiltersDropdowns: React.FC = () => { })); }; - const searchFunctions = { - Origin: (value: string) => searchOrigins(value, originsOptions), - EndOrgan: (value: string) => searchEndOrgans(value, organsOptions), - Species: (value: string) => searchSpecies(value, speciesOptions), - Phenotype: (value: string) => searchPhenotypes(value, phenotypesOptions), - apiNATOMY: (value: string) => searchApiNATOMY(value, apinatomiesOptions), - Via: (value: string) => searchVias(value, viasOptions), - }; + const searchFunctions = useMemo(() => { + return { + Origin: (value: string) => searchOrigins(value, originsOptions), + EndOrgan: (value: string) => searchEndOrgans(value, organsOptions), + Species: (value: string) => searchSpecies(value, speciesOptions), + Phenotype: (value: string) => searchPhenotypes(value, phenotypesOptions), + apiNATOMY: (value: string) => searchApiNATOMY(value, apinatomiesOptions), + Via: (value: string) => searchVias(value, viasOptions), + }; + }, [ + apinatomiesOptions, + organsOptions, + originsOptions, + phenotypesOptions, + speciesOptions, + viasOptions, + ]); return ( diff --git a/src/components/SummaryFiltersDropdown.tsx b/src/components/SummaryFiltersDropdown.tsx index 10be293..1264f58 100644 --- a/src/components/SummaryFiltersDropdown.tsx +++ b/src/components/SummaryFiltersDropdown.tsx @@ -1,5 +1,4 @@ import { useMemo } from 'react'; -import { SummaryFilters, useDataContext } from '../context/DataContext'; import CustomFilterDropdown from './common/CustomFilterDropdown'; import { Option } from './common/Types'; import { Box } from '@mui/material'; @@ -9,8 +8,10 @@ import { } from '../services/searchService'; import { OTHER_PHENOTYPE_LABEL } from '../settings'; +type FilterKey = 'Phenotype' | 'Nerve'; + interface FilterConfig { - id: keyof SummaryFilters; + id: FilterKey; placeholder: string; searchPlaceholder: string; } @@ -31,12 +32,18 @@ const filterConfig: FilterConfig[] = [ const SummaryFiltersDropdown = ({ nerves, phenotypes, + nerveFilters, + setNerveFilters, + phenotypeFilters, + setPhenotypeFilters, }: { nerves: { [key: string]: string }; phenotypes: string[]; + nerveFilters: Option[]; + setNerveFilters: React.Dispatch>; + phenotypeFilters: Option[]; + setPhenotypeFilters: React.Dispatch>; }) => { - const { summaryFilters, setSummaryFilters } = useDataContext(); - const convertNervesToOptions = (nerves: { [key: string]: string; }): Option[] => { @@ -47,6 +54,7 @@ const SummaryFiltersDropdown = ({ content: [], })); }; + const convertPhenotypesToOptions = (phenotypes: string[]): Option[] => { return phenotypes .map((phenotype) => ({ @@ -62,22 +70,31 @@ const SummaryFiltersDropdown = ({ () => convertPhenotypesToOptions(phenotypes), [phenotypes], ); + const nerveOptions = useMemo(() => convertNervesToOptions(nerves), [nerves]); - const handleSelect = ( - filterKey: keyof typeof summaryFilters, - selectedOptions: Option[], - ) => { - setSummaryFilters((prevFilters) => ({ - ...prevFilters, - [filterKey]: selectedOptions, - })); + const filterStateMap: { + [K in FilterKey]: { + filters: Option[]; + setFilters: React.Dispatch>; + searchFunction: (value: string) => Option[]; + }; + } = { + Phenotype: { + filters: phenotypeFilters, + setFilters: setPhenotypeFilters, + searchFunction: (value: string) => + searchPhenotypeFilter(value, phenotypeOptions), + }, + Nerve: { + filters: nerveFilters, + setFilters: setNerveFilters, + searchFunction: (value: string) => searchNerveFilter(value, nerveOptions), + }, }; - const searchFunctions = { - Phenotype: (value: string) => - searchPhenotypeFilter(value, phenotypeOptions), - Nerve: (value: string) => searchNerveFilter(value, nerveOptions), + const handleSelect = (filterKey: FilterKey, selectedOptions: Option[]) => { + filterStateMap[filterKey].setFilters(selectedOptions); }; return ( @@ -88,13 +105,11 @@ const SummaryFiltersDropdown = ({ id={filter.id} placeholder={filter.placeholder} searchPlaceholder={filter.searchPlaceholder} - selectedOptions={summaryFilters[filter.id]} + selectedOptions={filterStateMap[filter.id].filters} onSearch={(searchValue: string) => - searchFunctions[filter.id](searchValue) - } - onSelect={(options: Option[]) => - handleSelect(filter.id as keyof SummaryFilters, options) + filterStateMap[filter.id].searchFunction(searchValue) } + onSelect={(options: Option[]) => handleSelect(filter.id, options)} /> ))} diff --git a/src/components/SummaryPage.tsx b/src/components/SummaryPage.tsx index 72a3e4b..76b2f8d 100644 --- a/src/components/SummaryPage.tsx +++ b/src/components/SummaryPage.tsx @@ -1,56 +1,256 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import React, { useState, useEffect } from 'react'; import { Box, Divider, Stack, Tab, Tabs, Typography } from '@mui/material'; import { vars } from '../theme/variables.ts'; import { Detail } from './summaryPage/Detail.tsx'; -import { Section } from './summaryPage/Section.tsx'; -import { Notes } from './summaryPage/Notes.tsx'; +import { Section, SubSection } from './summaryPage/Section.tsx'; +// import { Notes } from './summaryPage/Notes.tsx'; import { TabPanel } from './summaryPage/TabPanel.tsx'; import InfoTab from './summaryPage/InfoTab.tsx'; import Loader from './common/Loader.tsx'; - -interface DataType { - [key: string]: { - [key: string]: string | number; - notes: string; - }; -} -type LabelsType = { - [key: string]: string; -}; +import { + SCKAN_DATABASE_SUMMARY_URL_LATEST, + SCKAN_DATABASE_SUMMARY_URL_PREVIOUS, + FILES, + DATABASE_FILES, +} from '../settings.ts'; const { primaryPurple600, gray500, white } = vars; -const databaseSummaryURL = - 'https://raw.githubusercontent.com/MetaCell/sckan-explorer/feature/ESCKAN-28/src/data/database_summary_data.json'; -const databaseSummaryLabelsURL = - 'https://raw.githubusercontent.com/MetaCell/sckan-explorer/feature/ESCKAN-28/src/data/database_summary_labels.json'; - const SummaryPage = () => { - const [data, setData] = useState(null); - const [labels, setLabels] = useState(null); + const [data, setData] = useState<{ [x: string]: null }>({}); + const [loaded, setLoaded] = useState(false); const [value, setValue] = useState(0); // @ts-expect-error Explanation: Handling Event properly const handleChange = (event: React.SyntheticEvent, newValue: number) => { setValue(newValue); }; + useEffect(() => { - fetch(databaseSummaryURL) - .then((response) => response.json()) - .then((jsonData) => { - setData(jsonData); - }) - .catch((error) => console.error('Error fetching data:', error)); - - fetch(databaseSummaryLabelsURL) - .then((response) => response.json()) - .then((jsonData) => { - setLabels(jsonData); - }) - .catch((error) => console.error('Error fetching labels:', error)); + const dataToPull = { + Latest: { + [FILES.POPULATION]: null, + [FILES.PHENOTYPE]: null, + [FILES.SPECIES]: null, + [FILES.CATEGORY]: null, + }, + Previous: { + [FILES.POPULATION]: null, + [FILES.PHENOTYPE]: null, + [FILES.SPECIES]: null, + [FILES.CATEGORY]: null, + }, + }; + + const results = { + [FILES.POPULATION]: null, + [FILES.PHENOTYPE]: null, + [FILES.SPECIES]: null, + [FILES.CATEGORY]: null, + }; + + for (const file in FILES) { + const request = new XMLHttpRequest(); + request.open( + 'GET', + SCKAN_DATABASE_SUMMARY_URL_LATEST + DATABASE_FILES[file], + false, + ); + request.send(null); + if (request.status === 200) { + dataToPull.Latest[file] = JSON.parse(request.responseText); + } + } + + for (const file in FILES) { + const request = new XMLHttpRequest(); + request.open( + 'GET', + SCKAN_DATABASE_SUMMARY_URL_PREVIOUS + DATABASE_FILES[file], + false, + ); + request.send(null); + if (request.status === 200) { + dataToPull.Previous[file] = JSON.parse(request.responseText); + } + } + + // @ts-expect-error Explanation: Handling the data properly + results[FILES.CATEGORY] = dataToPull.Latest[ + FILES.CATEGORY + ].results.bindings.map((item: any) => { + let filteredItem = null; + if (dataToPull.Previous[FILES.CATEGORY]) { + // @ts-expect-error Explanation: Handling the data properly + filteredItem = dataToPull.Previous[ + FILES.CATEGORY + ].results.bindings.filter( + (prevItem: any) => + prevItem.neuron_category.value === item.neuron_category.value, + ); + } + if (filteredItem.length) { + return { + label: item?.neuron_category?.value, + count: item?.population_count?.value, + change: + item.population_count.value - + filteredItem[0].population_count.value, + }; + } else { + return { + label: item.neuron_category.value, + count: item.population_count.value, + change: 0, + }; + } + }); + + // @ts-expect-error Explanation: Handling the data properly + results[FILES.PHENOTYPE] = dataToPull.Latest[ + FILES.PHENOTYPE + ].results.bindings.map((item: any) => { + let filteredItem = null; + if (dataToPull.Previous[FILES.PHENOTYPE]) { + // @ts-expect-error Explanation: Handling the data properly + filteredItem = dataToPull.Previous[ + FILES.PHENOTYPE + ].results.bindings.filter( + (prevItem: any) => + prevItem?.phenotype?.value === item?.phenotype?.value, + ); + } + if (filteredItem.length) { + return { + label: item?.phenotype?.value.split('/').at(-1), + count: item?.count?.value, + change: item.count.value - filteredItem[0].count.value, + }; + } else { + return { + label: item?.phenotype?.value.split('/').at(-1), + count: item?.count?.value, + change: 0, + }; + } + }); + + // @ts-expect-error Explanation: Handling the data properly + results[FILES.POPULATION] = dataToPull.Latest[ + FILES.POPULATION + ].results.bindings.map((item: any) => { + let filteredItem = null; + if (dataToPull.Previous[FILES.POPULATION]) { + // @ts-expect-error Explanation: Handling the data properly + filteredItem = dataToPull.Previous[ + FILES.POPULATION + ].results.bindings.filter( + (prevItem: any) => prevItem?.model?.value === item?.model?.value, + ); + } + if (filteredItem.length) { + return { + label: item?.model?.value, + category: item?.neuron_category?.value, + count: item?.count?.value, + change: item.count.value - filteredItem[0].count.value, + }; + } else { + return { + label: item?.model?.value, + category: item?.neuron_category?.value, + count: item?.count?.value, + change: 0, + }; + } + }); + + // @ts-expect-error Explanation: Handling the data properly + results[FILES.SPECIES] = dataToPull.Latest[ + FILES.SPECIES + ].results.bindings.map((item: any) => { + let filteredItem = null; + if (dataToPull.Previous[FILES.SPECIES]) { + // @ts-expect-error Explanation: Handling the data properly + filteredItem = dataToPull.Previous[ + FILES.SPECIES + ].results.bindings.filter( + (prevItem: any) => prevItem?.type?.value === item?.type?.value, + ); + } + if (filteredItem.length) { + return { + label: item?.type?.value, + category: item?.phenotype_label?.value, + count: item?.count?.value, + change: item.count.value - filteredItem[0].count.value, + }; + } else { + return { + label: item?.type?.value, + category: item?.phenotype_label?.value, + count: item?.count?.value, + change: 0, + }; + } + }); + + setLoaded(true); + setData(results); }, []); - if (!data || !labels) + const getDataPerSection = (section: any) => { + let total = 0; + const results = section.map((item: any) => { + total += Number(item.count); + return ( + + ); + }); + results.push( + , + ); + return results; + }; + + const getSubcategories = (section: any) => { + const categories = [ + ...new Set( + section.map((item: any) => { + return item?.category; + }), + ), + ]; + const results = categories.map((category: any) => { + const filteredItems = section.filter( + (item: any) => item.category === category, + ); + return ( + + {getDataPerSection(filteredItems)} + + + ); + }); + return results; + }; + + const getDataByFilter = (section: any, filter: string) => { + return section.filter((item: any) => item.label.includes(filter)); + }; + + if (!loaded) return ( @@ -77,7 +277,7 @@ const SummaryPage = () => { Database summary - Last updated on September 15, 2023 + Last updated on May 15, 2024 { - {Object.keys(data).map((sectionName) => ( -
- {Object.entries(data[sectionName]).map(([key, value], index) => { - if (key.endsWith('changes') || key === 'notes') { - return null; - } - - return ( - - ); - })} - {data[sectionName].notes && ( - - )} - -
- ))} +
+ {getDataPerSection( + getDataByFilter(data[FILES.PHENOTYPE], 'Location'), + )} + +
+
+ {getSubcategories(data[FILES.SPECIES])} +
+
+ {getSubcategories(data[FILES.POPULATION])} + +
diff --git a/src/components/common/CustomFilterDropdown.tsx b/src/components/common/CustomFilterDropdown.tsx index 6d41cbb..1947555 100644 --- a/src/components/common/CustomFilterDropdown.tsx +++ b/src/components/common/CustomFilterDropdown.tsx @@ -21,11 +21,7 @@ import ClearOutlinedIcon from '@mui/icons-material/ClearOutlined'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import { vars } from '../../theme/variables'; - -type OptionDetail = { - title: string; // What to display as the title/label for the property. - value: string; // The actual value/content for the property. -}; +import { Option } from './Types.ts'; const { gray100, @@ -39,13 +35,6 @@ const { gray50, } = vars; -export type Option = { - id: string; - label: string; - group: string; - content: OptionDetail[]; -}; - const transition = { transition: 'all ease-in-out .3s', }; @@ -389,6 +378,7 @@ export default function CustomEntitiesDropdown({ > - + {/* {option?.id} - + */} ))} @@ -533,7 +523,16 @@ export default function CustomEntitiesDropdown({
) : ( - <> "no result" + + No results found + )}
diff --git a/src/components/common/Heatmap.tsx b/src/components/common/Heatmap.tsx index 3eff2f6..1e11811 100644 --- a/src/components/common/Heatmap.tsx +++ b/src/components/common/Heatmap.tsx @@ -1,10 +1,10 @@ import React, { FC, useCallback, useMemo } from 'react'; -import { Box, Typography } from '@mui/material'; +import { Box, Button, ButtonGroup, Typography } from '@mui/material'; import { vars } from '../../theme/variables'; import CollapsibleList from './CollapsibleList'; import HeatMap from 'react-heatmap-grid'; import HeatmapTooltip, { HeatmapTooltipRow } from './HeatmapTooltip'; -import { HierarchicalItem, PhenotypeKsIdMap } from './Types.ts'; +import { HierarchicalItem, KsPerPhenotype } from './Types.ts'; import { getNormalizedValueForMinMax } from '../../services/summaryHeatmapService.ts'; import { generateYLabelsAndIds, @@ -24,12 +24,10 @@ interface HeatmapGridProps { yAxisLabel?: string; selectedCell?: { x: number; y: number } | null; heatmapData?: number[][]; - secondaryHeatmapData?: PhenotypeKsIdMap[][]; + secondaryHeatmapData?: KsPerPhenotype[][]; } -const prepareSecondaryHeatmapData = ( - data?: PhenotypeKsIdMap[][], -): number[][] => { +const prepareSecondaryHeatmapData = (data?: KsPerPhenotype[][]): number[][] => { if (!data) return []; return data.map((row) => row.map((cell) => { @@ -103,6 +101,44 @@ const HeatmapGrid: FC = ({ [yAxis, setYAxis], ); + const handleExpandAll = useCallback(() => { + const updateList = (list: HierarchicalItem[]): HierarchicalItem[] => { + return list?.map((listItem) => { + if (listItem.children) { + return { + ...listItem, + expanded: true, + children: updateList(listItem.children), + }; + } else if (listItem.expanded === false || listItem.expanded === true) { + return { ...listItem, expanded: true }; + } + return listItem; + }); + }; + const updatedList = updateList(yAxis); + setYAxis(updatedList); + }, [yAxis, setYAxis]); + + const handleCompressAll = useCallback(() => { + const updateList = (list: HierarchicalItem[]): HierarchicalItem[] => { + return list?.map((listItem) => { + if (listItem.children) { + return { + ...listItem, + expanded: false, + children: updateList(listItem.children), + }; + } else if (listItem.expanded === false || listItem.expanded === true) { + return { ...listItem, expanded: true }; + } + return listItem; + }); + }; + const updatedList = updateList(yAxis); + setYAxis(updatedList); + }, [yAxis, setYAxis]); + const handleCellClick = (x: number, y: number) => { const ids = yAxisData.ids; if (onCellClick) { @@ -173,6 +209,23 @@ const HeatmapGrid: FC = ({ return ( + + + + = ({ alignItems: 'center', fontSize: '0.875rem', fontWeight: '500', - marginLeft: '0.125rem', + marginLeft: '0.25rem', padding: '0.875rem 0', position: 'relative', borderRadius: '0.25rem', @@ -298,7 +351,7 @@ const HeatmapGrid: FC = ({ yLabels={yAxisData.labels} xLabelsLocation={'top'} xLabelsVisibility={xAxis?.map(() => true)} - xLabelWidth={160} + xLabelWidth={250} yLabelWidth={250} data={heatmapMatrixData} // squares @@ -320,7 +373,7 @@ const HeatmapGrid: FC = ({ min, max, ); - const safeNormalizedValue = Math.min( + let safeNormalizedValue = Math.min( Math.max(normalizedValue, 0), 1, ); @@ -350,6 +403,10 @@ const HeatmapGrid: FC = ({ ), }; } else { + safeNormalizedValue = + safeNormalizedValue < 0.076 && safeNormalizedValue > 0 + ? 0.076 + : safeNormalizedValue; return { ...commonStyles, borderWidth: isSelectedCell ? '0.125rem' : '0.0625rem', diff --git a/src/components/common/Types.ts b/src/components/common/Types.ts index 92b84b2..fde411e 100644 --- a/src/components/common/Types.ts +++ b/src/components/common/Types.ts @@ -13,9 +13,9 @@ export type Option = { export type LabelIdPair = { labels: string[]; ids: string[] }; -export type KsMapType = Record; +export type KsRecord = Record; -export type PhenotypeKsIdMap = { +export type KsPerPhenotype = { [phenotype: string]: { ksIds: string[]; }; diff --git a/src/components/connections/SummaryDetails.tsx b/src/components/connections/SummaryDetails.tsx index a24a196..d09dcaf 100644 --- a/src/components/connections/SummaryDetails.tsx +++ b/src/components/connections/SummaryDetails.tsx @@ -6,8 +6,9 @@ import PopulationDisplay from './PopulationDisplay.tsx'; import CommonAccordion from '../common/Accordion.tsx'; import CommonChip from '../common/CommonChip.tsx'; import { ArrowOutward } from '../icons/index.tsx'; -import { KsMapType } from '../common/Types.ts'; +import { KsRecord } from '../common/Types.ts'; import { getConnectionDetails } from '../../services/summaryHeatmapService.ts'; +import { generateCsvService } from '../../services/csvService.ts'; const { gray500, gray700, gray800 } = vars; @@ -43,7 +44,7 @@ const RowStack = ({ ); type SummaryDetailsProps = { - knowledgeStatementsMap: KsMapType; + knowledgeStatementsMap: KsRecord; connectionPage: number; }; @@ -80,7 +81,7 @@ const SummaryDetails = ({ icon: undefined, }, { - label: 'PhenoType', + label: 'Phenotype', value: connectionDetails?.phenotype || '-', icon: undefined, }, @@ -91,6 +92,16 @@ const SummaryDetails = ({ }, ]; + const generateCSV = () => { + const blob = generateCsvService(knowledgeStatementsMap); + const objUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', objUrl); + link.setAttribute('download', 'connections.csv'); + document.body.appendChild(link); + link.click(); + }; + return ( @@ -104,10 +115,16 @@ const SummaryDetails = ({ Details - - + @@ -115,9 +132,7 @@ const SummaryDetails = ({ Knowledge statement - {connectionDetails?.statement_preview || - connectionDetails?.knowledge_statement || - '-'} + {connectionDetails?.knowledge_statement || '-'} {phenotype && } { + if (row.includes('http')) { + window.open(row, '_blank'); + } + }} icon={ } diff --git a/src/components/connections/SummaryHeader.tsx b/src/components/connections/SummaryHeader.tsx index f10b9b3..33280a5 100644 --- a/src/components/connections/SummaryHeader.tsx +++ b/src/components/connections/SummaryHeader.tsx @@ -5,20 +5,21 @@ import { Divider, Typography, Stack, - Link, } from '@mui/material'; import { vars } from '../../theme/variables'; import IconButton from '@mui/material/IconButton'; -import { ArrowDown, ArrowRight, ArrowUp, HelpCircle } from '../icons'; +import { CloseArrows, ArrowRight, ArrowLeft, HelpCircle } from '../icons'; import Breadcrumbs from '@mui/material/Breadcrumbs'; -import { SummaryType, KsMapType } from '../common/Types'; +import { SummaryType, KsRecord } from '../common/Types'; +import { useDataContext } from '../../context/DataContext.ts'; +import { generateCsvService } from '../../services/csvService.ts'; -const { gray100, gray600A, gray500 } = vars; +const { gray100, gray600A, gray500, primaryPurple600 } = vars; type SummaryHeaderProps = { showDetails: SummaryType; setShowDetails: (showDetails: SummaryType) => void; - knowledgeStatementsMap: KsMapType; + knowledgeStatementsMap: KsRecord; connectionPage: number; setConnectionPage: (connectionPage: number) => void; totalConnectionCount: number; @@ -34,6 +35,8 @@ const SummaryHeader = ({ }: SummaryHeaderProps) => { const totalUniqueKS = Object.keys(knowledgeStatementsMap).length; + const { selectedConnectionSummary } = useDataContext(); + function getConnectionId() { return Object.keys(knowledgeStatementsMap)[connectionPage - 1] || ''; } @@ -51,6 +54,17 @@ const SummaryHeader = ({ } }; + const generateCSV = () => { + // @ts-expect-error - TS doesn't know that selectedConnectionSummary exists + const blob = generateCsvService(selectedConnectionSummary['connections']); + const objUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', objUrl); + link.setAttribute('download', 'connections.csv'); + document.body.appendChild(link); + link.click(); + }; + if (showDetails === SummaryType.Instruction) { return <>; } @@ -79,11 +93,13 @@ const SummaryHeader = ({ '& .MuiButtonBase-root': { width: '2rem', height: '2rem', + borderRadius: '0.25rem', + border: `0.0625rem solid ${primaryPurple600}`, }, }} > - - + setShowDetails(SummaryType.Summary)}> + - + + + + )} } aria-label="breadcrumb"> {showDetails === SummaryType.DetailedSummary ? ( - setShowDetails(SummaryType.Summary)} - > - Summary - + {connectionId} ) : ( Summary )} - {showDetails === SummaryType.DetailedSummary && ( - {connectionId} - )} @@ -144,7 +160,7 @@ const SummaryHeader = ({ color: gray600A, }} > - {totalConnectionCount} connections + {totalConnectionCount} populations - + )} diff --git a/src/components/connections/SummaryInstructions.tsx b/src/components/connections/SummaryInstructions.tsx index f82d1be..96fa757 100644 --- a/src/components/connections/SummaryInstructions.tsx +++ b/src/components/connections/SummaryInstructions.tsx @@ -25,10 +25,85 @@ const SummaryInstructions = () => { className="SummaryInstructions" sx={{ fontSize: '1rem', - textAlign: 'center', + textAlign: 'left', + marginLeft: '1rem', + paddingTop: '1rem', }} > - SummaryInstructions +

How to use the tool

+

+ 1. Click on a square in the connectivity grid to view details of + connections. +

+

+ 2. Details of the selected connection will be displayed in the right + panel. +

+

+ +

Filter the data

+

+ {' '} + - Use the filters to narrow down the data displayed in the grid. +

+

+ {' '} + - You can filter by data source, connection type, and more. +

+

+ +

View the data

+

+ {' '} + - Use the heatmap on the left and then summary heatmap to highlight + the data of your interest +

+

+ {' '} + - Once you click on a cluster in the summary map all the details + about the connections, like{' '} +

+

+ {' '} + - connection URI, species, sex, graph and triples will be displayed + in the right panel. +

+

+ +

Export the data

+

+ {' '} + - Use the export button to download the data displayed in the grid. +

+

+ {' '} + - Data can be exported in CSV format. +

+

diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index 8fde60d..baa8b2e 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -52,7 +52,7 @@ export const ArrowRight = () => ( > ( ); + +export const ArrowLeft = () => ( + + + + + +); + +export const CloseArrows = () => ( + + + + + +); + +export const PurplePlus = () => ( + + + + + +); + +export const PurpleMinus = () => ( + + + + + +); + export const ArrowOutward = () => ( ( +export const Detail = ({ keyName, value, labels, index }: DetailProps) => ( - {labels[keyName]} + {labels} {index === 0 && ( @@ -65,13 +50,12 @@ export const Detail = ({ {value} - {sectionData[`${keyName}_changes`] && ( + {/* {sectionData[`${keyName}_changes`] && ( +{sectionData[`${keyName}_changes`]} change (since last stats) - )} + )} */} ); diff --git a/src/components/summaryPage/Section.tsx b/src/components/summaryPage/Section.tsx index 0ce8db0..e693d73 100644 --- a/src/components/summaryPage/Section.tsx +++ b/src/components/summaryPage/Section.tsx @@ -13,3 +13,12 @@ export const Section = ({ title, children }: SectionProps) => ( {children} ); + +export const SubSection = ({ title, children }: SectionProps) => ( + + + {title} + + {children} + +); diff --git a/src/context/DataContext.ts b/src/context/DataContext.ts index 35e1980..fbb3122 100644 --- a/src/context/DataContext.ts +++ b/src/context/DataContext.ts @@ -5,7 +5,7 @@ import { KnowledgeStatement, } from '../models/explorer'; import { Option, PhenotypeDetail } from '../components/common/Types.ts'; -import { KsMapType } from '../components/common/Types'; +import { KsRecord } from '../components/common/Types'; export interface Filters { Origin: Option[]; @@ -16,31 +16,28 @@ export interface Filters { Via: Option[]; } -export interface SummaryFilters { - Phenotype: Option[]; +export interface SummaryFilters extends Filters { Nerve: Option[]; } export interface ConnectionSummary { - connections: KsMapType; // displaying connection 1 of 5 - origin: string; + connections: KsRecord; + filteredKnowledgeStatements: KsRecord; + hierarchicalNode: HierarchicalNode; endOrgan: Organ; - hierarchy: HierarchicalNode; } export interface DataContext { filters: Filters; - summaryFilters: SummaryFilters; majorNerves: Set; organs: Record; hierarchicalNodes: Record; knowledgeStatements: Record; setFilters: React.Dispatch>; - setSummaryFilters: React.Dispatch>; selectedConnectionSummary: ConnectionSummary | null; - setConnectionSummary: React.Dispatch< - React.SetStateAction - >; + setSelectedConnectionSummary: ( + summary: Omit | null, + ) => void; phenotypesColorMap: Record; } @@ -53,18 +50,13 @@ export const DataContext = createContext({ apiNATOMY: [], Via: [], }, - summaryFilters: { - Phenotype: [], - Nerve: [], - }, majorNerves: new Set(), organs: {}, hierarchicalNodes: {}, knowledgeStatements: {}, setFilters: () => {}, - setSummaryFilters: () => {}, selectedConnectionSummary: null, - setConnectionSummary: () => {}, + setSelectedConnectionSummary: () => {}, phenotypesColorMap: {}, }); diff --git a/src/context/DataContextProvider.tsx b/src/context/DataContextProvider.tsx index 0b18906..c3661f6 100644 --- a/src/context/DataContextProvider.tsx +++ b/src/context/DataContextProvider.tsx @@ -1,10 +1,5 @@ -import { PropsWithChildren, useMemo, useState } from 'react'; -import { - DataContext, - Filters, - ConnectionSummary, - SummaryFilters, -} from './DataContext'; +import { PropsWithChildren, useEffect, useMemo, useState } from 'react'; +import { DataContext, Filters, ConnectionSummary } from './DataContext'; import { HierarchicalNode, KnowledgeStatement, @@ -13,6 +8,7 @@ import { import { PhenotypeDetail } from '../components/common/Types.ts'; import { generatePhenotypeColors } from '../services/summaryHeatmapService.ts'; import { OTHER_PHENOTYPE_LABEL } from '../settings.ts'; +import { filterKnowledgeStatements } from '../services/heatmapService.ts'; export const DataContextProvider = ({ hierarchicalNodes, @@ -34,10 +30,6 @@ export const DataContextProvider = ({ apiNATOMY: [], Via: [], }); - const [summaryFilters, setSummaryFilters] = useState({ - Phenotype: [], - Nerve: [], - }); const [selectedConnectionSummary, setSelectedConnectionSummary] = useState(null); @@ -61,17 +53,54 @@ export const DataContextProvider = ({ return colorMap; }, [phenotypes]); + const updateSelectedConnectionSummary = ( + summary: + | Omit + | ConnectionSummary + | null, + filters: Filters, + hierarchicalNodes: Record, + ) => { + if (summary) { + const filteredKnowledgeStatements = filterKnowledgeStatements( + summary.connections, + hierarchicalNodes, + filters, + ); + return { + ...summary, + filteredKnowledgeStatements, + }; + } + return null; + }; + + const handleSetSelectedConnectionSummary = ( + summary: Omit | null, + ) => { + const updatedSummary = updateSelectedConnectionSummary( + summary, + filters, + hierarchicalNodes, + ); + setSelectedConnectionSummary(updatedSummary); + }; + + useEffect(() => { + setSelectedConnectionSummary((prevSummary) => + updateSelectedConnectionSummary(prevSummary, filters, hierarchicalNodes), + ); + }, [filters, hierarchicalNodes]); + const dataContextValue = { filters, - summaryFilters, - setSummaryFilters, organs, majorNerves, hierarchicalNodes, knowledgeStatements, setFilters, selectedConnectionSummary, - setConnectionSummary: setSelectedConnectionSummary, + setSelectedConnectionSummary: handleSetSelectedConnectionSummary, phenotypesColorMap, }; diff --git a/src/layout-manager/layout.ts b/src/layout-manager/layout.ts index 9b1796d..2621f86 100644 --- a/src/layout-manager/layout.ts +++ b/src/layout-manager/layout.ts @@ -13,7 +13,7 @@ export default { children: [ { type: 'row', - weight: 62, + weight: 55, children: [ { type: 'tabset', @@ -26,7 +26,7 @@ export default { }, { type: 'row', - weight: 38, + weight: 45, children: [ { type: 'tabset', diff --git a/src/models/json.ts b/src/models/json.ts index 639ae83..d275a34 100644 --- a/src/models/json.ts +++ b/src/models/json.ts @@ -39,6 +39,10 @@ export interface JsonData { results: Result; } +export interface OrderJson { + [nodeId: string]: string[]; +} + interface NerveData { type: string; value: string; diff --git a/src/services/csvService.ts b/src/services/csvService.ts new file mode 100644 index 0000000..06855d6 --- /dev/null +++ b/src/services/csvService.ts @@ -0,0 +1,152 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +import { KnowledgeStatement } from '../models/explorer'; + +type csvData = { + // declar a type where we have knowledge statements objects linked to a key, similar to a Map + [key: string]: KnowledgeStatement; +}; + +export const generateCsvService = (data: csvData) => { + const properties = [ + 'id', + 'statement_preview', + 'provenances', + 'phenotype', + 'laterality', + 'projection', + 'circuit_type', + 'sex', + 'species', + 'apinatomy', + 'journey', + 'origins', + 'vias', + 'destinations', + ]; + const keys = Object.keys(data); + const rows = [properties]; + keys.forEach((key) => { + const ks = data[key]; + const row = properties.map((property) => { + if (property === 'origins') { + const node = [] as string[]; + ks[property].forEach((origin) => { + node.push( + '[ URIs: ' + + origin['ontology_uri'] + + '; Label: ' + + origin['name'] + + ' ]', + ); + }); + const toReturn = node + .join(' & ') + .replaceAll('\n', '. ') + .replaceAll('\r', '') + .replaceAll('\t', ' ') + .replaceAll(',', ';'); + return toReturn; + } else if (property === 'vias') { + const node = []; + ks[property].forEach((via) => { + node.push( + '[ (' + + via['anatomical_entities'] + .map( + (e) => 'URI: ' + e['ontology_uri'] + '; Label: ' + e['name'], + ) + .join(' & ') + + '); Type: ' + + via['type'] + + '; From: ' + + via['from_entities'].map((e) => e['ontology_uri']).join('; ') + + ' ]', + ); + }); + const toReturn = node + .join(' & ') + .replaceAll('\n', '. ') + .replaceAll('\r', '') + .replaceAll('\t', ' ') + .replaceAll(',', ';'); + return toReturn; + } else if (property === 'destinations') { + const node = []; + ks[property].forEach((dest) => { + node.push( + '[ (' + + dest['anatomical_entities'] + .map( + (e) => 'URI: ' + e['ontology_uri'] + '; Label: ' + e['name'], + ) + .join(' & ') + + '); Type: ' + + dest['type'] + + '; From: ' + + dest['from_entities'].map((e) => e['ontology_uri']).join('; ') + + ' ]', + ); + }); + const toReturn = node + .join(' & ') + .replaceAll('\n', '. ') + .replaceAll('\r', '') + .replaceAll('\t', ' ') + .replaceAll(',', ';'); + return toReturn; + } else if (property === 'sex') { + if (ks[property].name && ks[property].ontology_uri) { + return ( + '[ URI: ' + + ks[property].ontology_uri + + '; Label: ' + + ks[property].name + + ' ]' + ); + } else { + return ''; + } + } else if (property === 'species') { + if (ks[property].length) { + return ks[property] + .map((e) => '[ URI: ' + e.id + '; Label: ' + e.name + ' ]') + .join(' & '); + } else { + return ''; + } + } else if (Array.isArray(ks[property])) { + // @ts-expect-error - TS doesn't know that ks[property] exists + const toReturn = ks[property] + .map((v) => '[ ' + v + ' ]') + .join(' & ') + .replaceAll('\n', '. ') + .replaceAll('\r', '') + .replaceAll('\t', ' ') + .replaceAll(',', ';'); + return toReturn; + } else { + // @ts-expect-error - TS doesn't know that ks[property] exists + const toReturn = ks[property] + .replaceAll('\n', '. ') + .replaceAll('\r', '') + .replaceAll('\t', ' ') + .replaceAll(',', ';'); + return toReturn; + } + }); + rows.push(row); + }); + + let csvData = ''; + rows.forEach((e) => { + const toReturn = e + .map(String) + .map((v) => v.replaceAll('"', '""')) + .map((v) => `"${v}"`) + .join(','); + csvData += toReturn + '\n'; + }); + const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8,' }); + return blob; +}; diff --git a/src/services/fetchService.ts b/src/services/fetchService.ts index 03a3f90..031c5ad 100644 --- a/src/services/fetchService.ts +++ b/src/services/fetchService.ts @@ -2,36 +2,43 @@ import { COMPOSER_API_URL, SCKAN_JSON_URL, SCKAN_MAJOR_NERVES_JSON_URL, + SCKAN_ORDER_JSON_URL, } from '../settings.ts'; import { KnowledgeStatement } from '../models/explorer.ts'; import { mapApiResponseToKnowledgeStatements } from './mappers.ts'; +import { JsonData, NerveResponse, OrderJson } from '../models/json.ts'; const KNOWLEDGE_STATEMENTS_BATCH_SIZE = 100; -export const fetchJSON = async () => { +const fetchData = async (url: string): Promise => { try { - const response = await fetch(SCKAN_JSON_URL); + const response = await fetch(url); if (!response.ok) { throw new Error(`${response.statusText}`); } return await response.json(); } catch (error) { - throw new Error(`Error fetching json data: ${error}`); + throw new Error(`Error fetching data from ${url}: ${error}`); } }; -export const fetchMajorNerves = async () => { +export const fetchJSON = async (): Promise => { + return await fetchData(SCKAN_JSON_URL); +}; + +export const fetchOrderJson = async (): Promise => { try { - const response = await fetch(SCKAN_MAJOR_NERVES_JSON_URL); - if (!response.ok) { - throw new Error(`${response.statusText}`); - } - return await response.json(); + return await fetchData(SCKAN_ORDER_JSON_URL); } catch (error) { - throw new Error(`Error fetching major nerves data: ${error}`); + console.warn('Failed to fetch order JSON:', error); + return {}; } }; +export const fetchMajorNerves = async (): Promise => { + return await fetchData(SCKAN_MAJOR_NERVES_JSON_URL); +}; + export const fetchKnowledgeStatements = async (neuronIds: string[]) => { let results = [] as KnowledgeStatement[]; diff --git a/src/services/filterValuesService.ts b/src/services/filterValuesService.ts index 02640de..46690ed 100644 --- a/src/services/filterValuesService.ts +++ b/src/services/filterValuesService.ts @@ -1,6 +1,7 @@ import { AnatomicalEntity, BaseEntity, + HierarchicalNode, KnowledgeStatement, Organ, } from '../models/explorer'; @@ -41,16 +42,21 @@ const getUniqueEntities = (entities: BaseEntity[]): Option[] => { export const getUniqueOrigins = ( knowledgeStatements: Record, + hierarchicalNodes: Record, ): Option[] => { let origins: AnatomicalEntity[] = []; Object.values(knowledgeStatements).forEach((ks) => { origins = origins.concat(ks.origins); }); - return getUniqueEntities(origins); + + const nonLeafNames = getNonLeafNames(hierarchicalNodes); + + return getUniqueEntities([...origins, ...nonLeafNames]); }; export const getUniqueVias = ( knowledgeStatements: Record, + hierarchicalNodes: Record, ): Option[] => { let vias: AnatomicalEntity[] = []; Object.values(knowledgeStatements).forEach((ks) => { @@ -59,7 +65,10 @@ export const getUniqueVias = ( ); vias = vias.concat(anatomical_entities); }); - return getUniqueEntities(vias); + + const nonLeafNames = getNonLeafNames(hierarchicalNodes); + + return getUniqueEntities([...vias, ...nonLeafNames]); }; export const getUniqueSpecies = ( @@ -123,3 +132,14 @@ export const getUniqueMajorNerves = (jsonData: NerveResponse) => { return nerves; }; + +const getNonLeafNames = ( + hierarchicalNodes: Record, +): BaseEntity[] => { + return Object.values(hierarchicalNodes) + .filter((node) => node.children && node.children.size > 0) + .map((node) => ({ + id: node.id, + name: node.name, + })); +}; diff --git a/src/services/heatmapService.ts b/src/services/heatmapService.ts index b60b529..aee371b 100644 --- a/src/services/heatmapService.ts +++ b/src/services/heatmapService.ts @@ -8,7 +8,7 @@ import { HierarchicalItem, HeatmapMatrixInformation, Option, - KsMapType, + KsRecord, LabelIdPair, } from '../components/common/Types.ts'; import { Filters } from '../context/DataContext.ts'; @@ -57,6 +57,7 @@ export function calculateConnections( const knowledgeStatements = filterKnowledgeStatements( allKnowledgeStatements, + hierarchicalNodes, filters, ); const organs = filterOrgans(allOrgans, filters.EndOrgan); @@ -207,13 +208,27 @@ export function filterOrgans( export function filterKnowledgeStatements( knowledgeStatements: Record, + hierarchicalNodes: Record, filters: Filters, ): Record { const phenotypeIds = filters.Phenotype.map((option) => option.id); - const apiNATOMYIds = filters.apiNATOMY.map((option) => option.id); - const speciesIds = filters.Species.flatMap((option) => option.id); - const viaIds = filters.Via.flatMap((option) => option.id); - const originIds = filters.Origin.flatMap((option) => option.id); + const apiNATOMYIds = + (filters as Filters).apiNATOMY?.map((option) => option.id) || []; + const speciesIds = filters.Species?.flatMap((option) => option.id) || []; + + const viaIds = + filters.Via?.flatMap((option) => + isLeaf(option.id, hierarchicalNodes) + ? option.id + : getLeafDescendants(option.id, hierarchicalNodes), + ) || []; + + const originIds = + filters.Origin?.flatMap((option) => + isLeaf(option.id, hierarchicalNodes) + ? option.id + : getLeafDescendants(option.id, hierarchicalNodes), + ) || []; return Object.entries(knowledgeStatements).reduce( (filtered, [id, ks]) => { @@ -223,15 +238,15 @@ export function filterKnowledgeStatements( !apiNATOMYIds.length || apiNATOMYIds.includes(ks.apinatomy); const speciesMatch = !speciesIds.length || - ks.species.some((species) => speciesIds.includes(species.id)); + ks.species?.some((species) => speciesIds.includes(species.id)); const viaMatch = !viaIds.length || ks.vias - .flatMap((via) => via.anatomical_entities) + ?.flatMap((via) => via.anatomical_entities) .some((via) => viaIds.includes(via.id)); const originMatch = !originIds.length || - ks.origins.some((origin) => originIds.includes(origin.id)); + ks.origins?.some((origin) => originIds.includes(origin.id)); if ( phenotypeMatch && @@ -248,18 +263,41 @@ export function filterKnowledgeStatements( ); } -export function getHierarchyFromId( - id: string, +const isLeaf = ( + nodeId: string, hierarchicalNodes: Record, -): HierarchicalNode { - return hierarchicalNodes[id]; -} +): boolean => { + return ( + !hierarchicalNodes[nodeId]?.children || + hierarchicalNodes[nodeId].children.size === 0 + ); +}; + +const getLeafDescendants = ( + nodeId: string, + hierarchicalNodes: Record, +): string[] => { + const descendants: string[] = []; + + const getDescendants = (currentId: string) => { + const node = hierarchicalNodes[currentId]; + if (node.children && node.children.size > 0) { + node.children.forEach((childId) => getDescendants(childId)); + } else { + const descendantID = currentId.split('#').pop() || currentId; + descendants.push(descendantID); + } + }; + + getDescendants(nodeId); + return descendants; +}; export function getKnowledgeStatementMap( ksIds: string[], knowledgeStatements: Record, -): KsMapType { - const ksMap: KsMapType = {}; +): KsRecord { + const ksMap: KsRecord = {}; ksIds.forEach((id: string) => { const ks = knowledgeStatements[id]; if (ks) { @@ -314,3 +352,73 @@ export const generateYLabelsAndIds = ( }); return { labels, ids }; }; + +type ConnectionsMap = Map; + +export const filterYAxis = ( + items: HierarchicalItem[], + connectionsMap: ConnectionsMap, +): HierarchicalItem[] => { + return items + .map((item) => { + const row = connectionsMap.get(item.id); + const hasConnections = + row && row.some((connections) => Object.keys(connections).length > 0); + + if (item.children) { + const filteredChildren = filterYAxis(item.children, connectionsMap); + return filteredChildren.length > 0 || hasConnections + ? { ...item, children: filteredChildren } + : null; + } + + return hasConnections ? item : null; + }) + .filter((item): item is HierarchicalItem => item !== null); +}; + +// Determine columns with data +export const getEmptyColumns = ( + filteredYAxis: HierarchicalItem[], + connectionsMap: ConnectionsMap, +): Set => { + const columnsWithData = new Set(); + filteredYAxis.forEach((item) => { + const row = connectionsMap.get(item.id); + if (row) { + row.forEach((connections, index) => { + if (Object.keys(connections).length > 0) { + columnsWithData.add(index); + } + }); + } + }); + return columnsWithData; +}; + +// Recursive function to filter connections map +export const filterConnectionsMap = ( + items: HierarchicalItem[], + map: ConnectionsMap, + columnsWithData: Set, +): ConnectionsMap => { + const filteredMap = new Map(); + items.forEach((item) => { + const row = map.get(item.id); + if (row) { + const filteredRow = row.filter((_, index) => columnsWithData.has(index)); + filteredMap.set(item.id, filteredRow); + } + if (item.children) { + const childMap = filterConnectionsMap( + item.children, + map, + columnsWithData, + ); + childMap.forEach((value, key) => { + filteredMap.set(key, value); + }); + } + }); + return filteredMap; +}; diff --git a/src/services/hierarchyService.ts b/src/services/hierarchyService.ts index bfdcfe7..02f9589 100644 --- a/src/services/hierarchyService.ts +++ b/src/services/hierarchyService.ts @@ -1,8 +1,10 @@ import { BaseEntity, HierarchicalNode, Organ } from '../models/explorer.ts'; -import { Binding, JsonData } from '../models/json.ts'; -import { OTHER_X_AXIS_ID, OTHER_X_AXIS_LABEL } from '../settings.ts'; - -const PATH_DELIMITER = '#'; +import { Binding, JsonData, OrderJson } from '../models/json.ts'; +import { + HIERARCHY_ID_PATH_DELIMITER, + OTHER_X_AXIS_ID, + OTHER_X_AXIS_LABEL, +} from '../settings.ts'; interface RootNode { name: string; @@ -19,7 +21,7 @@ const CNS = { const PNS = { name: 'Peripheral nervous system', - id: 'http://purl.obolibrary.org/obo/UBERON_0000010 ', + id: 'http://purl.obolibrary.org/obo/UBERON_0000010', isAncestor: (a_l1_name: string) => a_l1_name !== 'brain' && a_l1_name !== '', } as RootNode; @@ -31,7 +33,10 @@ const UNK = { export const ROOTS = [CNS, PNS, UNK]; -export const getHierarchicalNodes = (jsonData: JsonData) => { +export const getHierarchicalNodes = ( + jsonData: JsonData, + orderJson: OrderJson, +) => { const { results } = jsonData; // Initialize root nodes @@ -60,7 +65,7 @@ export const getHierarchicalNodes = (jsonData: JsonData) => { const levelName = entry[`A_L${level}`]?.value; if (levelId && levelName) { - currentPath += `${PATH_DELIMITER}${levelId}`; // Append current level ID to path to create a unique path identifier + currentPath += `${HIERARCHY_ID_PATH_DELIMITER}${levelId}`; // Append current level ID to path to create a unique path identifier // Get or create the hierarchical node if (!hierarchicalNodes[currentPath]) { @@ -83,7 +88,8 @@ export const getHierarchicalNodes = (jsonData: JsonData) => { // Process the leaf node given by A_ID column if (entry.A_ID && entry.A) { - const leafNodeId = currentPath + `${PATH_DELIMITER}${entry.A_ID.value}`; + const leafNodeId = + currentPath + `${HIERARCHY_ID_PATH_DELIMITER}${entry.A_ID.value}`; const leafNodeName = entry.A.value; // Get or create the leaf node @@ -146,15 +152,39 @@ export const getHierarchicalNodes = (jsonData: JsonData) => { Object.values(hierarchicalNodes).forEach((node) => { if (node.children) { node.children = new Set( - Array.from(node.children).sort((a, b) => { - const nodeA = hierarchicalNodes[a]; - const nodeB = hierarchicalNodes[b]; + Array.from(node.children).sort((nodeAPath, nodeBPath) => { + const nodeA = hierarchicalNodes[nodeAPath]; + const nodeB = hierarchicalNodes[nodeBPath]; // First, compare based on whether they have children if (nodeA.children.size > 0 && nodeB.children.size === 0) return -1; if (nodeA.children.size === 0 && nodeB.children.size > 0) return 1; - // If both have children or both don't have children, use natural sort + // Check if the current node's id exists in orderJson + const order = orderJson[getNodeIdFromPath(node.id)]; + if (order) { + const idA = getNodeIdFromPath(nodeAPath); + const idB = getNodeIdFromPath(nodeBPath); + const indexA = order.indexOf(idA); + const indexB = order.indexOf(idB); + + // Both nodes are in the order array + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; + } + + // Only nodeA is in the order array + if (indexA !== -1) { + return -1; + } + + // Only nodeB is in the order array + if (indexB !== -1) { + return 1; + } + } + + // Fallback to natural sort return naturalSort(nodeA.name, nodeB.name); }), ); @@ -221,3 +251,8 @@ export const getOrgans = (jsonData: JsonData): Record => { const naturalSort = (a: string, b: string) => { return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); }; + +const getNodeIdFromPath = (fullPath: string): string => { + const parts = fullPath.split(HIERARCHY_ID_PATH_DELIMITER); + return parts[parts.length - 1]; +}; diff --git a/src/services/summaryHeatmapService.ts b/src/services/summaryHeatmapService.ts index 125f732..9354bcf 100644 --- a/src/services/summaryHeatmapService.ts +++ b/src/services/summaryHeatmapService.ts @@ -1,8 +1,8 @@ import chroma from 'chroma-js'; import { HierarchicalItem, - PhenotypeKsIdMap, - KsMapType, + KsPerPhenotype, + KsRecord, } from '../components/common/Types.ts'; import { ConnectionSummary, SummaryFilters } from '../context/DataContext.ts'; import { @@ -29,7 +29,7 @@ export function convertViaToString(via: string[]): string { return via[0]; } -export function getAllViasFromConnections(connections: KsMapType): { +export function getAllViasFromConnections(connections: KsRecord): { [key: string]: string; } { const vias: { [key: string]: string } = {}; @@ -46,21 +46,15 @@ export function getAllViasFromConnections(connections: KsMapType): { return vias; } -export function getAllPhenotypes( - connections: Map, -): string[] { +export function getAllPhenotypes(connections: KsRecord): string[] { const phenotypeNames: Set = new Set(); - connections.forEach((phenotypeKsIdMaps) => { - phenotypeKsIdMaps.forEach((phenotypeKsIdMap) => { - Object.keys(phenotypeKsIdMap).forEach((phenotype) => { - if (phenotype) { - phenotypeNames.add(phenotype); - } else { - phenotypeNames.add(OTHER_PHENOTYPE_LABEL); - } - }); - }); + Object.values(connections).forEach((ks) => { + if (ks.phenotype) { + phenotypeNames.add(ks.phenotype); + } else { + phenotypeNames.add(OTHER_PHENOTYPE_LABEL); + } }); return Array.from(phenotypeNames).sort(); @@ -113,9 +107,9 @@ export function summaryFilterKnowledgeStatements( // else just store the data for that level export function getSecondaryHeatmapData( yAxis: HierarchicalItem[], - connections: Map, + connections: Map, ) { - const newData: PhenotypeKsIdMap[][] = []; + const newData: KsPerPhenotype[][] = []; function addDataForItem(item: HierarchicalItem) { const itemData = connections.get(item.id); @@ -158,8 +152,9 @@ export function calculateSecondaryConnections( allKnowledgeStatements: Record, summaryFilters: SummaryFilters, hierarchyNode: HierarchicalNode, -): Map { +): Map { // Apply filters to organs and knowledge statements + const knowledgeStatements = summaryFilterKnowledgeStatements( allKnowledgeStatements, summaryFilters, @@ -174,16 +169,16 @@ export function calculateSecondaryConnections( ); // Memoization map to store computed results for nodes - const memo = new Map(); + const memo = new Map(); // Function to compute node connections with memoization - function computeNodeConnections(nodeId: string): PhenotypeKsIdMap[] { + function computeNodeConnections(nodeId: string): KsPerPhenotype[] { if (memo.has(nodeId)) { return memo.get(nodeId)!; } const node = hierarchicalNodes[nodeId]; - const result: PhenotypeKsIdMap[] = Object.values(endorgans).map(() => ({})); + const result: KsPerPhenotype[] = Object.values(endorgans).map(() => ({})); if (node.children && node.children.size > 0) { node.children.forEach((childId) => { @@ -268,7 +263,7 @@ export const getDestinations = ( }; export const getConnectionDetails = ( - uniqueKS: KsMapType, + uniqueKS: KsRecord, connectionPage: number, ): KnowledgeStatement => { return uniqueKS !== undefined diff --git a/src/settings.ts b/src/settings.ts index 2c0b2f4..8e3660d 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,9 +1,31 @@ export const SCKAN_JSON_URL = 'https://raw.githubusercontent.com/smtifahim/SCKAN-Apps/master/sckan-explorer/json/a-b-via-c-2.json'; + +export const SCKAN_ORDER_JSON_URL = + 'https://raw.githubusercontent.com/MetaCell/sckan-explorer/feature/order/public/order.json'; export const SCKAN_MAJOR_NERVES_JSON_URL = 'https://raw.githubusercontent.com/smtifahim/SCKAN-Apps/master/sckan-explorer/json/major-nerves.json'; export const COMPOSER_API_URL = import.meta.env.VITE_COMPOSER_API_URL; +export const SCKAN_DATABASE_SUMMARY_URL_LATEST = + 'https://raw.githubusercontent.com/smtifahim/SCKAN-Apps/master/sckan-explorer/json/sckan-stats/sckan-version-2024-03-04/'; + +export const SCKAN_DATABASE_SUMMARY_URL_PREVIOUS = + 'https://raw.githubusercontent.com/smtifahim/SCKAN-Apps/master/sckan-explorer/json/sckan-stats/sckan-version-2024-03-04/'; + +export const FILES = { + POPULATION: 'POPULATION', + PHENOTYPE: 'PHENOTYPE', + SPECIES: 'SPECIES', + CATEGORY: 'CATEGORY', +}; + +export const DATABASE_FILES = { + [FILES.POPULATION]: 'stats-model-population-count.json', + [FILES.PHENOTYPE]: 'stats-phenotype-count.json', + [FILES.SPECIES]: 'stats-phenotype-value-count.json', + [FILES.CATEGORY]: 'stats-population-category-count.json', +}; export const OTHER_X_AXIS_ID = 'OTHER_X'; export const OTHER_X_AXIS_LABEL = 'Other'; @@ -17,3 +39,5 @@ export const FIXED_FOUR_PHENOTYPE_COLORS_ARRAY = [ 'rgba(220, 104, 3, 1)', 'rgba(234, 170, 8, 1)', ]; + +export const HIERARCHY_ID_PATH_DELIMITER = '#';