From ae4340c085b313517eab45e12b6d15ce2e736c3e Mon Sep 17 00:00:00 2001 From: Filippo Ledda Date: Mon, 27 Jul 2026 19:05:12 +0200 Subject: [PATCH 01/11] Precision ontology grid first working iteration --- src/App.jsx | 9 + src/Icons/index.jsx | 32 ++ src/components/CellCards/CellTile.jsx | 149 ++++++++ src/components/CellCards/CellTileGrid.jsx | 59 +++ .../CellCards/GridFilterSidebar.jsx | 321 ++++++++++++++++ src/components/CellCards/GridSearchBar.jsx | 54 +++ src/components/CellCards/OntologyGridPage.jsx | 278 ++++++++++++++ src/components/CellCards/config/gridConfig.ts | 115 ++++++ src/components/CellCards/model/types.ts | 86 +++++ .../CellCards/services/ontologyGridService.ts | 135 +++++++ src/components/Header/Search.jsx | 22 +- src/components/common/BreadcrumbBar.jsx | 89 +++++ src/parsers/neurdfParser.ts | 345 ++++++++++++++++++ 13 files changed, 1691 insertions(+), 3 deletions(-) create mode 100644 src/components/CellCards/CellTile.jsx create mode 100644 src/components/CellCards/CellTileGrid.jsx create mode 100644 src/components/CellCards/GridFilterSidebar.jsx create mode 100644 src/components/CellCards/GridSearchBar.jsx create mode 100644 src/components/CellCards/OntologyGridPage.jsx create mode 100644 src/components/CellCards/config/gridConfig.ts create mode 100644 src/components/CellCards/model/types.ts create mode 100644 src/components/CellCards/services/ontologyGridService.ts create mode 100644 src/components/common/BreadcrumbBar.jsx create mode 100644 src/parsers/neurdfParser.ts diff --git a/src/App.jsx b/src/App.jsx index 98a9270e..9ee71ce0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -21,6 +21,7 @@ import CurieEditor from "./components/CurieEditor"; import SearchResults from "./components/SearchResults"; import Organizations from "./components/organizations"; import SingleTermView from "./components/SingleTermView"; +import OntologyGridPage from "./components/CellCards/OntologyGridPage"; import { GlobalDataProvider } from "./contexts/DataContext"; import ResetPassword from "./components/Auth/ResetPassword"; import ForgotPassword from "./components/Auth/ForgotPassword"; @@ -184,6 +185,14 @@ function MainContent() { } /> + + + + } + /> } /> } /> } /> diff --git a/src/Icons/index.jsx b/src/Icons/index.jsx index efcb2e1f..f74f51bc 100644 --- a/src/Icons/index.jsx +++ b/src/Icons/index.jsx @@ -188,6 +188,38 @@ export const CheckboxSelected = () => ( /> ); + +export const CheckboxIndeterminate = () => ( + + + + + +); + +// Figma "v5/expand" — expand/collapse a facet value list (two rules + vertical arrows). +export const ExpandRowsIcon = () => ( + + + + + + + +); + export const ForwardIcon = () => ( { + if (!prop || !prop.values.length) return null; // auto-hide empty rows (tile auto-sizes) + return ( + + + {label} + + {render === "text" ? ( + + {prop.values.map((v) => v.label).join(", ")} + + ) : ( + + {prop.values.map((v) => ( + + ))} + + )} + + ); +}; + +PropertyRow.propTypes = { + label: PropTypes.string.isRequired, + prop: PropTypes.object, + render: PropTypes.oneOf(["text", "chip"]), +}; + +// A cell record as a grid tile: header (title/id/chips) → property rows → source footer. +// Objects inside the tile are not clickable; the whole tile navigates (handled by the grid). +const CellTile = ({ cell }) => { + const headerChips = TILE_HEADER_CHIPS.flatMap(({ localName, tone }) => { + const prop = cell.properties[localName]; + if (!prop) return []; + return prop.values.map((v) => ( + + )); + }); + + const rows = TILE_ROWS.map(({ localName, render }) => ( + + )).filter((row) => row.props.prop); + + return ( + + + + + {cell.label} + + + {cell.curie} + + + {headerChips.length > 0 && ( + + {headerChips} + + )} + + + {rows.length > 0 && ( + <> + + {rows} + + )} + + {cell.sources.length > 0 && ( + <> + + + + {labelFor("source")} + + + {cell.sources.map((src) => + linkFor(src.iri) ? ( + e.stopPropagation()} + sx={{ color: brand700, fontWeight: 500, display: "inline-flex", alignItems: "center", gap: 0.25 }} + > + + {src.label} + + + + ) : ( + + {src.label} + + ) + )} + + + + )} + + ); +}; + +CellTile.propTypes = { + cell: PropTypes.object.isRequired, +}; + +export default CellTile; diff --git a/src/components/CellCards/CellTileGrid.jsx b/src/components/CellCards/CellTileGrid.jsx new file mode 100644 index 00000000..33c494c6 --- /dev/null +++ b/src/components/CellCards/CellTileGrid.jsx @@ -0,0 +1,59 @@ +import PropTypes from "prop-types"; +import { Box, Typography } from "@mui/material"; +import CellTile from "./CellTile"; +import { vars } from "../../theme/variables"; + +const { gray500 } = vars; + +// Responsive tile grid. Whole-tile click navigates (tile interior is not interactive). +const CellTileGrid = ({ cells, onSelect }) => { + if (!cells.length) { + return ( + + + No cells match the current filters. + + + ); + } + + return ( + + {cells.map((cell) => ( + onSelect(cell)} + sx={{ cursor: "pointer", display: "flex" }} + > + + + ))} + + ); +}; + +CellTileGrid.propTypes = { + cells: PropTypes.array.isRequired, + onSelect: PropTypes.func.isRequired, +}; + +export default CellTileGrid; diff --git a/src/components/CellCards/GridFilterSidebar.jsx b/src/components/CellCards/GridFilterSidebar.jsx new file mode 100644 index 00000000..872bca38 --- /dev/null +++ b/src/components/CellCards/GridFilterSidebar.jsx @@ -0,0 +1,321 @@ +import PropTypes from "prop-types"; +import { useState, useCallback } from "react"; +import { + Box, + Typography, + IconButton, + Tooltip, + FormControl, + FormLabel, + FormGroup, + Button, + Switch, + Checkbox, + FormControlLabel, + Link, +} from "@mui/material"; +import { + CheckboxDefault, + CheckboxSelected, + CheckboxIndeterminate, + HelpOutlinedIcon, + ExpandRowsIcon, +} from "../../Icons"; +import { vars } from "../../theme/variables"; +import { linkFor } from "./config/gridConfig"; + +const { gray200, gray300, gray400, gray500, gray600, gray700, gray800, brand700, brand800 } = vars; + +// Values shown per facet while collapsed; above it the expand control appears. +const VISIBLE_LIMIT = 5; + +// Labels ellipsize instead of widening the pane — the sidebar never scrolls sideways. +const ellipsis = { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }; + +// "Clear filters" / "Show more" — text-only buttons (Text sm/Semibold). +const textButtonSx = { + minWidth: 0, + flexShrink: 0, + height: "1.25rem", + p: 0, + fontSize: "0.875rem", + fontWeight: 600, + lineHeight: "1.25rem", + whiteSpace: "nowrap", + color: brand700, + "&:hover": { color: brand800, background: "transparent" }, + "&.Mui-disabled": { color: gray400 }, +}; + +const iconButtonSx = { + p: 0, + flexShrink: 0, + color: gray600, + background: "transparent", + "&:hover": { background: "transparent", color: gray800 }, + "&.Mui-disabled": { color: gray300 }, +}; + +const checkboxSx = { p: 0, flexShrink: 0 }; + +const FacetCheckbox = ({ checked, onChange, label }) => ( + } + checkedIcon={} + checked={checked} + onChange={onChange} + sx={checkboxSx} + inputProps={{ "aria-label": label }} + /> +); + +FacetCheckbox.propTypes = { + checked: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + label: PropTypes.string.isRequired, +}; + +const FacetGroup = ({ facet, checked, expanded, onToggle, onClear, onToggleExpanded }) => { + const anyChecked = Object.values(checked || {}).some(Boolean); + const expandable = facet.values.length > VISIBLE_LIMIT; + const values = expanded ? facet.values : facet.values.slice(0, VISIBLE_LIMIT); + const toggleExpanded = () => onToggleExpanded(facet.localName); + + return ( + + + + + {facet.title} + + {facet.tooltip && ( + + + + + + )} + + {/* Expand control lives on the header so it stays put when the list grows. */} + + {expandable && ( + <> + + + + + + + + )} + + + + + {/* nowrap: FormGroup wraps by default, which would size rows to their + max-content width and defeat the per-label ellipsis. */} + + {values.map((v) => { + const href = linkFor(v.iri); + const labelSx = { + ...ellipsis, + flex: 1, + minWidth: 0, + color: gray700, + fontWeight: 500, + }; + return ( + + onToggle(facet.localName, v.key)} + label={v.label} + /> + {href ? ( + + {v.label} + + ) : ( + + {v.label} + + )} + {/* Count: right-aligned against the pane edge, natural width (no count column). */} + + {v.count} + + + ); + })} + + + {expandable && ( + + )} + + ); +}; + +FacetGroup.propTypes = { + facet: PropTypes.object.isRequired, + checked: PropTypes.object, + expanded: PropTypes.bool, + onToggle: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onToggleExpanded: PropTypes.func.isRequired, +}; + +// Left filter pane. Facets are data-driven; unchecked = all, checking filters down. +const GridFilterSidebar = ({ + facets, + checked, + onToggle, + onClear, + onClearAll, + displayedOnly, + onToggleDisplayedOnly, +}) => { + const [expanded, setExpanded] = useState({}); + + const onToggleExpanded = useCallback( + (localName) => setExpanded((prev) => ({ ...prev, [localName]: !prev[localName] })), + [] + ); + + const expandable = facets.filter((f) => f.values.length > VISIBLE_LIMIT); + const allExpanded = expandable.length > 0 && expandable.every((f) => expanded[f.localName]); + const toggleAll = () => + setExpanded( + allExpanded ? {} : Object.fromEntries(expandable.map((f) => [f.localName, true])) + ); + + const anySelection = Object.values(checked).some((sel) => + Object.values(sel || {}).some(Boolean) + ); + + return ( + + + + } + checkedIcon={} + indeterminateIcon={} + indeterminate={anySelection} + checked={false} + disabled={!anySelection} + onChange={onClearAll} + sx={checkboxSx} + inputProps={{ "aria-label": "Clear all filters" }} + /> + + + Filters + + } + label={ + + Displayed properties + + } + sx={{ m: 0, gap: 0.5, flexShrink: 0 }} + /> + + + + + + + + {/* Own scroll, independent of the results panel; never scrolls horizontally. */} + + {facets.map((facet) => ( + + ))} + + + ); +}; + +GridFilterSidebar.propTypes = { + facets: PropTypes.array.isRequired, + checked: PropTypes.object.isRequired, + onToggle: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onClearAll: PropTypes.func.isRequired, + displayedOnly: PropTypes.bool.isRequired, + onToggleDisplayedOnly: PropTypes.func.isRequired, +}; + +export default GridFilterSidebar; diff --git a/src/components/CellCards/GridSearchBar.jsx b/src/components/CellCards/GridSearchBar.jsx new file mode 100644 index 00000000..5ba24881 --- /dev/null +++ b/src/components/CellCards/GridSearchBar.jsx @@ -0,0 +1,54 @@ +import PropTypes from "prop-types"; +import { useState, useEffect } from "react"; +import { TextField, InputAdornment, IconButton } from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { SearchIcon } from "../../Icons"; + +// Free-text prefix filter over the ontology. Per design: does not filter until Enter. +const GridSearchBar = ({ value, onSubmit }) => { + const [text, setText] = useState(value || ""); + useEffect(() => setText(value || ""), [value]); + + const submit = (next) => onSubmit(next.trim()); + + return ( + setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submit(text); + }} + sx={{ width: "18rem" }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: text ? ( + + { + setText(""); + submit(""); + }} + > + + + + ) : null, + }} + /> + ); +}; + +GridSearchBar.propTypes = { + value: PropTypes.string, + onSubmit: PropTypes.func.isRequired, +}; + +export default GridSearchBar; diff --git a/src/components/CellCards/OntologyGridPage.jsx b/src/components/CellCards/OntologyGridPage.jsx new file mode 100644 index 00000000..40bfcafc --- /dev/null +++ b/src/components/CellCards/OntologyGridPage.jsx @@ -0,0 +1,278 @@ +import { useState, useEffect, useMemo, useCallback } from "react"; +import { useParams } from "react-router-dom"; +import { + Box, + Typography, + Chip, + CircularProgress, + Button, + Snackbar, + Stack, +} from "@mui/material"; +import HomeOutlinedIcon from "@mui/icons-material/HomeOutlined"; +import GridFilterSidebar from "./GridFilterSidebar"; +import GridSearchBar from "./GridSearchBar"; +import CellTileGrid from "./CellTileGrid"; +import BreadcrumbBar from "../common/BreadcrumbBar"; +import CustomSingleSelect from "../common/CustomSingleSelect"; +import CustomPagination from "../common/CustomPagination"; +import { loadOntology, getFacets } from "./services/ontologyGridService"; +import { vars } from "../../theme/variables"; + +const { gray200, gray500, gray600 } = vars; +const PAGE_SIZES = [12, 24, 48, 96]; + +// Serialised text for the free-text "Filter by word" (label + id + all values). +const cellText = (cell) => { + const parts = [cell.label, cell.curie]; + Object.values(cell.properties).forEach((p) => p.values.forEach((v) => parts.push(v.label))); + cell.sources.forEach((s) => parts.push(s.label)); + return parts.join(" ").toLowerCase(); +}; + +// The value keys a cell exposes for a given facet. +const cellFacetKeys = (cell, localName) => { + if (localName === "source") return cell.sources.map((s) => s.id); + return (cell.properties[localName]?.values || []).map((v) => v.id); +}; + +const OntologyGridPage = () => { + const { slug } = useParams(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + const [displayedOnly, setDisplayedOnly] = useState(true); + const [checked, setChecked] = useState({}); + const [word, setWord] = useState(""); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(24); + const [reloadKey, setReloadKey] = useState(0); + const [snack, setSnack] = useState(""); + + useEffect(() => { + let active = true; + setLoading(true); + setError(null); + // Reset all filter/paging state so it never leaks across ontologies. + setChecked({}); + setWord(""); + setDisplayedOnly(true); + setPage(1); + loadOntology(slug) + .then((res) => { + if (active) setData(res); + }) + .catch((err) => { + if (active) setError(err.message || "Failed to load ontology"); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [slug, reloadKey]); + + const cells = useMemo(() => data?.cells || [], [data]); + const facets = useMemo(() => getFacets(cells, displayedOnly), [cells, displayedOnly]); + // Only currently-visible facets constrain results — a facet hidden by the + // "Displayed properties" toggle must not silently filter (its checks are kept + // but inert until it is shown again). + const visibleFacets = useMemo(() => new Set(facets.map((f) => f.localName)), [facets]); + + const filteredCells = useMemo(() => { + const q = word.trim().toLowerCase(); + return cells.filter((cell) => { + for (const [localName, sel] of Object.entries(checked)) { + if (!visibleFacets.has(localName)) continue; // hidden facet → no constraint + const keys = Object.entries(sel) + .filter(([, on]) => on) + .map(([k]) => k); + if (!keys.length) continue; // no constraint for this facet + const cellKeys = cellFacetKeys(cell, localName); + if (!keys.some((k) => cellKeys.includes(k))) return false; // AND across facets + } + if (q && !cellText(cell).includes(q)) return false; + return true; + }); + }, [cells, checked, word, visibleFacets]); + + const total = filteredCells.length; + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const currentPage = Math.min(page, pageCount); + const pageCells = useMemo( + () => filteredCells.slice((currentPage - 1) * pageSize, currentPage * pageSize), + [filteredCells, currentPage, pageSize] + ); + + const onToggle = useCallback((localName, key) => { + setChecked((prev) => ({ + ...prev, + [localName]: { ...prev[localName], [key]: !prev[localName]?.[key] }, + })); + setPage(1); + }, []); + + const onClear = useCallback((localName) => { + setChecked((prev) => { + const next = { ...prev }; + delete next[localName]; + return next; + }); + setPage(1); + }, []); + + // Master checkbox in the filter header — drops every facet selection at once. + const onClearAll = useCallback(() => { + setChecked({}); + setPage(1); + }, []); + + const onWord = useCallback((value) => { + setWord(value); + setPage(1); + }, []); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + Could not load the ontology data. + + + {error} + + + + ); + } + + return ( + + {/* Breadcrumb bar — its own container (app-wide pattern) with copy-path */} + + + {/* Header: title + curation status -> description -> version -> tags below. + Title / description / version are read from the file's owl:Ontology node; + the curie is the real root class the grid is scoped to. */} + + + {data.meta.title} + {data.entry.curationStatus && ( + + )} + + {data.meta.description && ( + + {data.meta.description} + + )} + {data.meta.version && ( + + Version {data.meta.version} + + )} + + + Tags + + + + + + + + {/* Body: filter sidebar | results */} + + setDisplayedOnly((v) => !v)} + /> + + + + + Showing {pageCells.length} of {total} cells + + + + + + Show on page: + + { + setPageSize(Number(v)); + setPage(1); + }} + options={PAGE_SIZES} + /> + + + + + + setSnack("The single-cell Cell Card view is coming in the next round.")} /> + + + {total > pageSize && ( + + setPage(p)} + /> + + )} + + + + setSnack("")} + message={snack} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + /> + + ); +}; + +export default OntologyGridPage; diff --git a/src/components/CellCards/config/gridConfig.ts b/src/components/CellCards/config/gridConfig.ts new file mode 100644 index 00000000..ce0dca85 --- /dev/null +++ b/src/components/CellCards/config/gridConfig.ts @@ -0,0 +1,115 @@ +// Generic, data-driven display configuration for the CellCards grid. +// Kept outside the ontology (per design decision): predicate -> display label / tooltip, +// which predicates seed tiles vs facets, and the (currently hardcoded) ontology catalog. + +// --- ontology catalog (stands in for the not-yet-built search backend) ------ + +export interface OntologyEntry { + slug: string; // URL segment, e.g. "precision" + label: string; // friendly search-result name (the header title comes from the file, not this) + type: string; // shown as a result type chip + a header tag + curie: string; // ontology file identity, shown under the search result (submittedBy) + community: string; // owning community/organization, shown as the org breadcrumb crumb + tag + curationStatus?: string; // e.g. "Curated" — badge shown next to the title + description?: string; + // neurdf root: cells are (transitively) subClassOf this; also shown as the header's curie tag. + rootClass: string; +} + +export const ONTOLOGY_CATALOG: Record = { + precision: { + slug: "precision", + label: "Precision Cells", + type: "Cell ontology", + curie: "NPOprecisionCellType", + community: "Precision", + curationStatus: "Curated", + description: "HEAL-PRECISION neuron cell types (NPO).", + rootClass: "ilxtr:NeuronPrecision", + }, +}; + +// The single hardcoded search result until an ontology search backend exists. +export const HARDCODED_RESULTS: OntologyEntry[] = [ONTOLOGY_CATALOG.precision]; + +// --- data source ------------------------------------------------------------ + +// The reasoned neurdf JSON-LD (requested with Accept: application/ld+json). +// ACAO:* on the endpoint lets the browser fetch it directly; the body is served as +// text/plain, so the service JSON.parses the text rather than trusting content-type. +export const NEURDF_URL = + "https://uri.olympiangods.org/base/ontologies/dns/raw.githubusercontent.com/SciCrunch/NIF-Ontology/neurons/ttl/npo-merged-reasoned-neurdf.ttl"; + +// Optional same-origin dev fallback (served from /public) if the live endpoint is flaky. +export const NEURDF_FALLBACK_URL = "/data/npo-merged-neurdf.jsonld"; + +// --- predicate display metadata --------------------------------------------- + +// local name (family-stripped) -> human label. Any predicate not listed falls back +// to its local name, so nothing is silently dropped ("render everything"). +export const PREDICATE_LABELS: Record = { + neurondmBaseClass: "Cell class", + hasInstanceInTaxon: "Species", + hasSomaLocatedIn: "Soma location", + hasCircuitRolePhenotype: "Circuit role", + hasFunctionalPhenotype: "Physiology", + hasAxonPhenotype: "Axon type", + hasAdaptationPhenotype: "Adaptation", + hasThresholdPhenotype: "Threshold", + hasNeurotransmitterPhenotype: "Neurotransmitter", + hasNucleicAcidExpressionPhenotype: "Marker genes", + hasBiologicalSex: "Sex", + hasMorphologicalPhenotype: "Morphology", + source: "Source", +}; + +// Tooltip text sourced from pyontutils neuron_phenotype_edges.csv (displayDescription). +export const PREDICATE_TOOLTIPS: Record = { + hasInstanceInTaxon: "Species the cell type is observed in.", + hasSomaLocatedIn: "Anatomical location of the cell body (soma).", + hasFunctionalPhenotype: "Functional / physiological properties.", + hasAxonPhenotype: "Axon fiber type.", + hasNucleicAcidExpressionPhenotype: "Marker genes expressed by the cell type.", + hasNeurotransmitterPhenotype: "Neurotransmitters the cell type produces or releases.", + hasCircuitRolePhenotype: "Excitatory / inhibitory circuit role.", + source: "Source publication for the cell type.", +}; + +export const labelFor = (localName: string): string => + PREDICATE_LABELS[localName] || localName; + +// --- tile layout ------------------------------------------------------------ + +export type ChipTone = "class" | "subtype" | "species"; + +// Header chips (left→right), from the mockup: class + fiber/subtype + species. +export const TILE_HEADER_CHIPS: { localName: string; tone: ChipTone }[] = [ + { localName: "neurondmBaseClass", tone: "class" }, + { localName: "hasAxonPhenotype", tone: "subtype" }, + { localName: "hasInstanceInTaxon", tone: "species" }, +]; + +export type RowRender = "text" | "chip"; + +// Ordered property rows shown on a tile (auto-hidden when empty). +export const TILE_ROWS: { localName: string; render: RowRender }[] = [ + { localName: "hasSomaLocatedIn", render: "text" }, + { localName: "hasNucleicAcidExpressionPhenotype", render: "chip" }, + { localName: "hasFunctionalPhenotype", render: "chip" }, +]; + +// --- facets ----------------------------------------------------------------- + +// The properties actually shown on a tile: header chips + rows + the footer source. +// The "Displayed properties" toggle ON restricts facets to exactly these; OFF shows a +// facet for every property found on the terms. Derived from the tile config above so the +// two never drift apart. +export const DISPLAYED_PROPERTIES: string[] = [ + ...TILE_HEADER_CHIPS.map((c) => c.localName), + ...TILE_ROWS.map((r) => r.localName), + "source", +]; + +// External link target for a facet value / chip. All ref kinds resolve to their IRI; +// UBERON "internal view" mechanics are TBD (Tom), so link to the IRI for now. +export const linkFor = (iri?: string): string | undefined => iri || undefined; diff --git a/src/components/CellCards/model/types.ts b/src/components/CellCards/model/types.ts new file mode 100644 index 00000000..239f9028 --- /dev/null +++ b/src/components/CellCards/model/types.ts @@ -0,0 +1,86 @@ +// Data model for the CellCards ontology grid view. +// Kept generic so the parser works for any neurdf-lowered ontology, not just Precision. + +export interface JsonLdRef { + "@id": string; +} + +export type GraphNode = Record & { + "@id"?: string; + "@type"?: string | string[]; +}; + +export interface OntologyGraph { + "@context"?: Record; + "@graph": GraphNode[]; +} + +// Classifies where a referenced object lives, which drives how a facet value / chip links out. +export type RefKind = + | "interlex" + | "uberon" + | "chebi" + | "ncbigene" + | "ncbitaxon" + | "external" + | "literal"; + +// A resolved value: an object reference (or literal) with a human label + link target. +export interface ResolvedRef { + id: string; // the raw @id as it appears in the graph (curie or IRI), or the literal string + curie: string; // compact form for display fallback + label: string; // human label (resolved from the graph) or the literal / curie fallback + iri: string; // full IRI to link out to ("" for literals) + kind: RefKind; +} + +export type PredicateFamily = "eqv" | "ent"; + +// One phenotype/annotation predicate on a cell, normalised by local name across neurdf families. +export interface CellProperty { + localName: string; // e.g. "hasSomaLocatedIn" + family: PredicateFamily; // eqv (asserted) preferred over ent (entailed) + negated: boolean; // true when sourced from a neurdf.*.neg family + values: ResolvedRef[]; +} + +// A single cell record rendered as a grid tile. +export interface CellTerm { + id: string; // @id, e.g. "npokb:1067" + curie: string; // display id, e.g. "npokb:1067" + iri: string; // full IRI + label: string; // ilxtr:localLabel -> rdfs:label -> curie + // Positive phenotypes keyed by local name (eqv ∪ ent, deduped). + properties: Record; + // Negated phenotypes (neurdf.*.neg) keyed by local name — rendered with a "not" modifier. + negated: Record; + sources: ResolvedRef[]; // ilxtr:literatureCitation (a cell may have several) +} + +export interface OntologyMeta { + iri: string; + title: string; + description?: string; + version?: string; +} + +export interface ParsedOntology { + meta: OntologyMeta; + cells: CellTerm[]; +} + +// A filter facet built from the data values across all cells. +export interface FacetValue { + key: string; // ResolvedRef.id — the stable value identity + label: string; + iri: string; + kind: RefKind; + count: number; +} + +export interface Facet { + localName: string; + title: string; + tooltip?: string; + values: FacetValue[]; +} diff --git a/src/components/CellCards/services/ontologyGridService.ts b/src/components/CellCards/services/ontologyGridService.ts new file mode 100644 index 00000000..7e575ba3 --- /dev/null +++ b/src/components/CellCards/services/ontologyGridService.ts @@ -0,0 +1,135 @@ +// Service seam for the CellCards grid: fetches the neurdf JSON-LD once, parses it +// generically, and exposes cells + data-driven facets. Swap the fetch for a real +// API later without touching the components (they depend only on this contract). + +import { parseNeurdf, buildFacets } from "../../../parsers/neurdfParser"; +import { + NEURDF_URL, + NEURDF_FALLBACK_URL, + ONTOLOGY_CATALOG, + DISPLAYED_PROPERTIES, + PREDICATE_LABELS, + PREDICATE_TOOLTIPS, +} from "../config/gridConfig"; +import type { OntologyGraph, ParsedOntology, Facet, CellTerm } from "../model/types"; +import type { OntologyEntry } from "../config/gridConfig"; + +export interface LoadedOntology { + entry: OntologyEntry; + meta: ParsedOntology["meta"]; + cells: CellTerm[]; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// The endpoint is flaky on the ~16MB body — retry, then fall back to a served copy. +const fetchGraph = async (url: string): Promise => { + const res = await fetch(url, { + headers: { Accept: "application/ld+json" }, + credentials: "omit", + }); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); + const text = await res.text(); // body is served as text/plain — parse manually + if (text.trimStart().startsWith("<")) { + // A missing SPA path answers with index.html (HTTP 200), not JSON-LD. + throw new Error(`Expected JSON-LD but received HTML from ${url}`); + } + let data: OntologyGraph; + try { + data = JSON.parse(text) as OntologyGraph; + } catch { + throw new Error(`Malformed JSON-LD from ${url}`); + } + if (!data || !Array.isArray(data["@graph"]) || data["@graph"].length === 0) { + throw new Error("Empty or malformed neurdf graph"); + } + return data; +}; + +const LIVE_RETRIES = 3; + +const loadGraphWithRetry = async (): Promise => { + // Try the live endpoint (flaky on the large body), then the optional dev fallback. + let liveErr: unknown; + for (let i = 0; i < LIVE_RETRIES; i++) { + try { + return await fetchGraph(NEURDF_URL); + } catch (err) { + liveErr = err; + if (i < LIVE_RETRIES - 1) await sleep(600 * (i + 1)); + } + } + try { + return await fetchGraph(NEURDF_FALLBACK_URL); + } catch { + // The fallback is a dev-only convenience (gitignored, often absent). If it fails, + // surface the real live-endpoint error rather than the fallback's parse/HTML error. + } + throw liveErr instanceof Error ? liveErr : new Error("Failed to load ontology data"); +}; + +// Fetch + JSON.parse only once per session. +let graphPromise: Promise | null = null; +const getGraph = (): Promise => { + if (!graphPromise) { + graphPromise = loadGraphWithRetry().catch((err) => { + graphPromise = null; // allow retry on next mount after a failure + throw err; + }); + } + return graphPromise; +}; + +// Parsed result cache, keyed by ontology slug (parse depends on the root class). +const parsedCache = new Map(); + +export const loadOntology = async (slug: string): Promise => { + const entry = ONTOLOGY_CATALOG[slug]; + if (!entry) throw new Error(`Unknown ontology "${slug}"`); + const cached = parsedCache.get(slug); + if (cached) return cached; + + const graph = await getGraph(); + const parsed = parseNeurdf(graph, entry.rootClass); + const loaded: LoadedOntology = { + entry, + // Header identity is read straight from the file's owl:Ontology node + // (title/description/version) — no hardcoded overrides. + meta: parsed.meta, + cells: parsed.cells, + }; + parsedCache.set(slug, loaded); + return loaded; +}; + +// Which predicates are present across the cells (+ "source" from literatureCitation). +const presentNames = (cells: CellTerm[]): { found: Set; hasSource: boolean } => { + const found = new Set(); + let hasSource = false; + for (const cell of cells) { + Object.keys(cell.properties).forEach((k) => found.add(k)); + if (cell.sources.length) hasSource = true; + } + return { found, hasSource }; +}; + +const isPresent = (n: string, found: Set, hasSource: boolean): boolean => + found.has(n) || (n === "source" && hasSource); + +// Facet localnames in display order. displayedOnly=true keeps only the properties shown on +// the tiles; otherwise every property found on the terms, the tile ones first. +const facetNames = (cells: CellTerm[], displayedOnly: boolean): string[] => { + const { found, hasSource } = presentNames(cells); + const displayed = DISPLAYED_PROPERTIES.filter((n) => isPresent(n, found, hasSource)); + if (displayedOnly) return displayed; + const rest = [...found].filter((n) => !DISPLAYED_PROPERTIES.includes(n)).sort(); + return [...displayed, ...rest]; +}; + +// A facet needs at least this many distinct options to be worth showing — filtering on a +// single-option facet (e.g. Cell class = only "neuron") can't narrow anything. +const MIN_FACET_OPTIONS = 2; + +// Facets built from the data. displayedOnly=true → only the properties shown on the tiles. +export const getFacets = (cells: CellTerm[], displayedOnly: boolean): Facet[] => + buildFacets(cells, facetNames(cells, displayedOnly), PREDICATE_LABELS, PREDICATE_TOOLTIPS, MIN_FACET_OPTIONS); diff --git a/src/components/Header/Search.jsx b/src/components/Header/Search.jsx index f284b13e..7cd06549 100644 --- a/src/components/Header/Search.jsx +++ b/src/components/Header/Search.jsx @@ -20,8 +20,9 @@ import { GlobalDataContext } from "../../contexts/DataContext"; import { primeTermDataCache } from "../../hooks/useTermData"; import { SEARCH_TYPES } from "../../constants/types"; import { searchAll, elasticSearch } from "../../api/endpoints"; +import { HARDCODED_RESULTS } from "../CellCards/config/gridConfig"; import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined'; -import { useEffect, useState, useCallback, forwardRef, useContext } from 'react'; +import { useEffect, useState, useCallback, useMemo, forwardRef, useContext } from 'react'; import { CloseIcon, ForwardIcon, SearchIcon, TermsIcon } from '../../Icons'; import CorporateFareOutlinedIcon from '@mui/icons-material/CorporateFareOutlined'; @@ -100,6 +101,15 @@ const Search = () => { return user?.groupname || 'base'; }; + // Hardcoded CellCards ontologies surfaced in the "Ontologies" tab until a search + // backend exists. Shown by default; filtered by the query when one is typed. + const cellCardsOntologies = useMemo(() => { + const q = searchTerm.trim().toLowerCase(); + return HARDCODED_RESULTS + .filter((e) => !q || e.label.toLowerCase().includes(q) || e.curie.toLowerCase().includes(q)) + .map((e) => ({ label: e.label, name: e.label, submittedBy: e.curie, cellCardsSlug: e.slug })); + }, [searchTerm]); + const handleOpenList = () => setOpenList(true); const handleCloseList = () => setOpenList(false); const handleInputChange = (event) => setSearchTerm(event.target.value); @@ -108,6 +118,12 @@ const Search = () => { if (!newInputValue) return; handleCloseList(); + // CellCards ontology result → go straight to its grid view. + if (newInputValue.cellCardsSlug) { + updateStoredSearchTerm(newInputValue.label); + navigate(`/cellcards/${newInputValue.cellCardsSlug}`); + return; + } const groupName = getGroupName(); // We already know this term's label — prime the cache so the term page can // show the title immediately, without waiting on its .jsonld download. @@ -136,7 +152,7 @@ const Search = () => { event.preventDefault(); const query = searchTerm.trim().toLowerCase(); - const currentOptions = tabValue === 0 ? terms : tabValue === 1 ? organizations : ontologies; + const currentOptions = tabValue === 0 ? terms : tabValue === 1 ? organizations : [...(ontologies || []), ...cellCardsOntologies]; const exactMatch = (currentOptions || []).find( (option) => !option?.hidden && (option.label || option.name || '').toLowerCase() === query ); @@ -324,7 +340,7 @@ const Search = () => { ); }); - const displayOptions = (tabValue === 0 ? terms : tabValue === 1 ? organizations : ontologies); + const displayOptions = (tabValue === 0 ? terms : tabValue === 1 ? organizations : [...(ontologies || []), ...cellCardsOntologies]); const options = displayOptions?.length ? displayOptions : [{ hidden: true }]; return ( diff --git a/src/components/common/BreadcrumbBar.jsx b/src/components/common/BreadcrumbBar.jsx new file mode 100644 index 00000000..08b1213c --- /dev/null +++ b/src/components/common/BreadcrumbBar.jsx @@ -0,0 +1,89 @@ +import PropTypes from "prop-types"; +import { useState } from "react"; +import { Box, Link, Tooltip, Typography } from "@mui/material"; +import ContentCopyOutlinedIcon from "@mui/icons-material/ContentCopyOutlined"; +import CustomBreadcrumbs from "./CustomBreadcrumbs"; +import { vars } from "../../theme/variables"; + +const { gray200, gray300, gray600, gray700 } = vars; + +// App-wide breadcrumb container: the breadcrumb lives in its own top bar (never +// nested in a page's title block), with an optional "copy path" permalink control +// and optional right-aligned content (e.g. an active-ontology selector). +const BreadcrumbBar = ({ breadcrumbItems, copyPath, permalink, copyLabel, rightContent }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + const url = permalink || window.location.href; + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard unavailable — no-op */ + } + }; + + return ( + + + + {copyPath && ( + <> + + · + + + + {copyLabel} + + + + + )} + + {rightContent} + + ); +}; + +BreadcrumbBar.propTypes = { + breadcrumbItems: PropTypes.array.isRequired, + copyPath: PropTypes.bool, + permalink: PropTypes.string, + copyLabel: PropTypes.string, + rightContent: PropTypes.node, +}; + +BreadcrumbBar.defaultProps = { + copyPath: false, + permalink: undefined, + copyLabel: "Copy link", + rightContent: null, +}; + +export default BreadcrumbBar; diff --git a/src/parsers/neurdfParser.ts b/src/parsers/neurdfParser.ts new file mode 100644 index 00000000..0ea27e29 --- /dev/null +++ b/src/parsers/neurdfParser.ts @@ -0,0 +1,345 @@ +// Generic parser for the neurdf (reasoned-lowered) representation of an NPO-style ontology. +// Turns the raw JSON-LD @graph into a flat, label-resolved list of cell records + facets. +// Design goals: dependency-free, generic to any neurdf ontology (root class is a parameter), +// and driven entirely by the flat `neurdf.*` predicates — the OWL class expressions +// (owl:Restriction / blank nodes) are intentionally ignored, which is the point of neurdf. + +import type { + OntologyGraph, + GraphNode, + ResolvedRef, + RefKind, + CellTerm, + CellProperty, + OntologyMeta, + ParsedOntology, + Facet, + FacetValue, +} from "../components/CellCards/model/types"; + +// --- label + value helpers ------------------------------------------------- + +// Display-label sources, ordered most-human-readable → machine-id last (spec §2.2 / §8). +// Try the curated short label, then the standard label, then a title; only when a node +// carries none of these do we fall back to its CURIE (the machine id). ilxtr:genLabel is +// deliberately excluded — it is the verbose machine-generated string, never a title. +const LABEL_KEYS = ["ilxtr:localLabel", "rdfs:label", "dc:title", "dcterms:title"]; + +type LabelLike = + | string + | { "@value"?: string } + | Array; + +const firstString = (v: unknown): string | undefined => { + if (typeof v === "string") return v; + if (Array.isArray(v)) { + for (const item of v) { + const s = firstString(item); + if (s) return s; + } + return undefined; + } + if (v && typeof v === "object" && "@value" in (v as object)) { + const val = (v as { "@value"?: unknown })["@value"]; + return typeof val === "string" ? val : undefined; + } + return undefined; +}; + +const isBlankNode = (id: string): boolean => id.startsWith("_:"); +const isIri = (id: string): boolean => id.includes("://"); + +// Some cells carry a "TEMP:MISSING_" placeholder meaning the phenotype is *not specified*. +// It is not a real term, so it must never surface as a value / facet option / tile chip. +const isMissingSentinel = (id: string): boolean => /^TEMP:MISSING/i.test(id); + +// Compact an @id (curie or IRI) into { curie, kind } without needing the @context. +const classify = (id: string): { curie: string; kind: RefKind } => { + if (!isIri(id)) { + // already a curie like "npokb:1067", "NCBIGene:233222", "ilxtr:SensoryPhenotype" + const prefix = id.split(":")[0]; + return { curie: id, kind: kindForPrefix(prefix, id) }; + } + // full IRI — derive a short curie + kind from known bases + for (const [prefix, base] of Object.entries(IRI_BASES)) { + if (id.startsWith(base)) { + return { curie: `${prefix}:${id.slice(base.length)}`, kind: kindForPrefix(prefix, id) }; + } + } + const tail = id.split(/[/#]/).filter(Boolean).pop() || id; + return { curie: tail, kind: "external" }; +}; + +const IRI_BASES: Record = { + UBERON: "http://purl.obolibrary.org/obo/UBERON_", + CHEBI: "http://purl.obolibrary.org/obo/CHEBI_", + NCBITaxon: "http://purl.obolibrary.org/obo/NCBITaxon_", + NCBIGene: "http://www.ncbi.nlm.nih.gov/gene/", + ILX: "http://uri.interlex.org/base/ilx_", + ilxtr: "http://uri.interlex.org/tgbugs/uris/readable/", + npokb: "http://uri.interlex.org/npo/uris/neurons/", +}; + +const kindForPrefix = (prefix: string, id: string): RefKind => { + switch (prefix) { + case "UBERON": + return "uberon"; + case "CHEBI": + return "chebi"; + case "NCBIGene": + return "ncbigene"; + case "NCBITaxon": + return "ncbitaxon"; + case "ILX": + case "ilxtr": + case "npokb": + case "NIFEXT": + case "ilxcr": + return "interlex"; + default: + return id.includes("interlex.org") ? "interlex" : "external"; + } +}; + +// Full IRI for linking out (expand a curie via known bases; pass through IRIs). +const toIri = (id: string): string => { + if (isIri(id)) return id; + const [prefix, ...rest] = id.split(":"); + const base = IRI_BASES[prefix]; + return base ? base + rest.join(":") : ""; +}; + +// --- graph index + reference resolution ------------------------------------ + +type Index = Map; + +const indexGraph = (graph: GraphNode[]): Index => { + const idx: Index = new Map(); + for (const node of graph) { + const id = node["@id"]; + if (typeof id === "string") idx.set(id, node); + } + return idx; +}; + +const labelFor = (id: string, idx: Index): string => { + const node = idx.get(id); + if (node) { + for (const key of LABEL_KEYS) { + const s = firstString(node[key]); + if (s) return s; + } + } + return classify(id).curie; // fall back to a compact curie when no label exists +}; + +// Turn a predicate value (ref object, array, or literal) into ResolvedRefs. +const resolveValue = (raw: unknown, idx: Index): ResolvedRef[] => { + const out: ResolvedRef[] = []; + const push = (item: unknown) => { + if (item && typeof item === "object" && "@id" in (item as object)) { + const id = String((item as JsonLdRefish)["@id"]); + if (isBlankNode(id) || isMissingSentinel(id)) return; // skip restriction blanks + "not specified" sentinels + const { curie, kind } = classify(id); + out.push({ id, curie, label: labelFor(id, idx), iri: toIri(id), kind }); + } else if (item && typeof item === "object" && "@value" in (item as object)) { + // JSON-LD literal object, e.g. {"@value":"foo"} / {"@value":"foo","@language":"en"} + const v = (item as { "@value"?: unknown })["@value"]; + const s = v == null ? "" : String(v); + if (s) out.push({ id: s, curie: s, label: s, iri: "", kind: "literal" }); + } else if (typeof item === "string") { + if (isMissingSentinel(item)) return; + out.push({ id: item, curie: item, label: item, iri: "", kind: "literal" }); + } + }; + if (Array.isArray(raw)) raw.forEach(push); + else push(raw); + return out; +}; + +type JsonLdRefish = { "@id"?: unknown }; + +// --- neurdf predicate families --------------------------------------------- + +// "neurdf.eqv:hasSomaLocatedIn" -> { family:'eqv', negated:false, localName:'hasSomaLocatedIn' } +// "neurdf.eqv.neg:hasMorphologicalPhenotype" -> { family:'eqv', negated:true, ... } +const parseNeurdfKey = ( + key: string +): { family: "eqv" | "ent"; negated: boolean; localName: string } | null => { + if (!key.startsWith("neurdf.")) return null; + const [prefix, local] = key.split(":"); + if (!local) return null; + const fam = prefix.replace("neurdf.", ""); // "eqv" | "ent" | "eqv.neg" | "ent.neg" + return { + family: fam.startsWith("ent") ? "ent" : "eqv", + negated: fam.endsWith(".neg"), + localName: local, + }; +}; + +// A few literal-valued ilxtr predicates we surface directly (not neurdf refs). +const LITERAL_PREDICATES: Record = { + "ilxtr:neurondmBaseClass": "neurondmBaseClass", +}; + +const asType = (t: unknown): string[] => + Array.isArray(t) ? (t as string[]) : typeof t === "string" ? [t] : []; + +const subClassRefs = (node: GraphNode | undefined): string[] => { + if (!node) return []; + const s = node["rdfs:subClassOf"]; + const arr = Array.isArray(s) ? s : [s]; + return arr + .map((x) => (x && typeof x === "object" ? String((x as JsonLdRefish)["@id"] ?? "") : "")) + .filter(Boolean); +}; + +// Does `startId` reach `targetId` through the transitive rdfs:subClassOf chain? +const reaches = (startId: string, targetId: string, idx: Index): boolean => { + const seen = new Set(); + const stack = subClassRefs(idx.get(startId)); + while (stack.length) { + const cur = stack.pop() as string; + if (cur === targetId) return true; + if (seen.has(cur)) continue; + seen.add(cur); + stack.push(...subClassRefs(idx.get(cur))); + } + return false; +}; + +// --- public API ------------------------------------------------------------- + +const mergeValues = (existing: ResolvedRef[], incoming: ResolvedRef[]): ResolvedRef[] => { + const byId = new Map(existing.map((r) => [r.id, r])); + for (const r of incoming) if (!byId.has(r.id)) byId.set(r.id, r); + return [...byId.values()]; +}; + +const buildCell = (node: GraphNode, idx: Index): CellTerm => { + const id = String(node["@id"]); + const { curie } = classify(id); + const properties: Record = {}; + const negated: Record = {}; + let sources: ResolvedRef[] = []; + + for (const [key, raw] of Object.entries(node)) { + if (key === "ilxtr:literatureCitation") { + sources = resolveValue(raw, idx); // a cell may cite several publications + continue; + } + if (key in LITERAL_PREDICATES) { + const local = LITERAL_PREDICATES[key]; + const values = resolveValue(raw, idx); + if (values.length) properties[local] = { localName: local, family: "eqv", negated: false, values }; + continue; + } + const parsed = parseNeurdfKey(key); + if (!parsed) continue; // skip @id/@type/owl:*/rdfs:*/ilxtr:error/genLabel/etc. + const values = resolveValue(raw, idx); + if (!values.length) continue; + const bucket = parsed.negated ? negated : properties; + const prev = bucket[parsed.localName]; + if (!prev) { + bucket[parsed.localName] = { ...parsed, values }; + } else { + // prefer eqv over ent; always union the values + bucket[parsed.localName] = { + localName: parsed.localName, + family: prev.family === "eqv" ? "eqv" : parsed.family, + negated: parsed.negated, + values: mergeValues(prev.values, values), + }; + } + } + + return { + id, + curie, + iri: toIri(id), + label: labelFor(id, idx), + properties, + negated, + sources, + }; +}; + +export const parseOntologyMeta = (graph: GraphNode[]): OntologyMeta => { + const onto = graph.find((n) => asType(n["@type"]).includes("owl:Ontology")); + const iri = onto ? String(onto["@id"] ?? "") : ""; + const title = + (onto && + (firstString(onto["dc:title"]) || + firstString(onto["dcterms:title"]) || + firstString(onto["rdfs:label"]) || + firstString(onto["skos:prefLabel"]))) || + "Ontology"; + const description = + onto && + (firstString(onto["dc:description"]) || + firstString(onto["dcterms:description"]) || + firstString(onto["rdfs:comment"])); + const version = onto ? firstString(onto["owl:versionInfo"]) : undefined; + return { iri, title, description: description || undefined, version }; +}; + +// Parse the graph into cells that are (transitively) subClassOf `rootClass`. +export const parseNeurdf = (data: OntologyGraph, rootClass: string): ParsedOntology => { + const graph = data["@graph"] || []; + const idx = indexGraph(graph); + const neurons = graph.filter((n) => asType(n["@type"]).includes("neurdf:Neuron")); + const inScope = rootClass + ? neurons.filter((n) => reaches(String(n["@id"] ?? ""), rootClass, idx)) + : neurons; + const cells = inScope.map((n) => buildCell(n, idx)); + cells.sort((a, b) => a.label.localeCompare(b.label)); + return { meta: parseOntologyMeta(graph), cells }; +}; + +// Readable fallback title for a predicate with no configured label: drop the "has" prefix +// and "Phenotype" suffix, split camelCase into words (design: section titles come from a +// displayLabel, never a raw predicate name). e.g. hasExpressionPhenotype -> "Expression". +const humanizeLocalName = (s: string): string => { + const core = s.replace(/^has/, "").replace(/Phenotype$/, ""); + const words = core.replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim(); + return words ? words.charAt(0).toUpperCase() + words.slice(1) : s; +}; + +// Build facets (checkbox filter groups) from the values present across all cells. +// minOptions omits any facet offering fewer than that many distinct values — a facet with a +// single option can't narrow the results, so it is useless as a filter and dropped. +export const buildFacets = ( + cells: CellTerm[], + localNames: string[], + titles: Record, + tooltips: Record = {}, + minOptions = 1 +): Facet[] => { + const facets: Facet[] = []; + for (const localName of localNames) { + const counts = new Map(); + for (const cell of cells) { + const prop = + localName === "source" + ? cell.sources.length + ? { values: cell.sources } + : undefined + : cell.properties[localName]; + if (!prop) continue; + for (const v of prop.values) { + const existing = counts.get(v.id); + if (existing) existing.count += 1; + else counts.set(v.id, { key: v.id, label: v.label, iri: v.iri, kind: v.kind, count: 1 }); + } + } + if (counts.size >= minOptions) { + facets.push({ + localName, + title: titles[localName] || humanizeLocalName(localName), + tooltip: tooltips[localName], + values: [...counts.values()].sort((a, b) => a.label.localeCompare(b.label)), + }); + } + } + return facets; +}; From f08f544f26c8a678ce8c9ee2c0185bb803e2b8e0 Mon Sep 17 00:00:00 2001 From: Filippo Ledda Date: Tue, 28 Jul 2026 11:38:47 +0200 Subject: [PATCH 02/11] (cellcard) Grid page theme adjustments --- src/components/CellCards/CellTile.jsx | 39 +++--- src/components/CellCards/CellTileGrid.jsx | 27 ++--- .../CellCards/GridFilterSidebar.jsx | 33 ++++-- src/parsers/neurdfParser.ts | 6 +- src/theme/index.jsx | 111 +++++++++++++++++- src/theme/variables.js | 7 ++ 6 files changed, 173 insertions(+), 50 deletions(-) diff --git a/src/components/CellCards/CellTile.jsx b/src/components/CellCards/CellTile.jsx index 390e9ede..66c649e0 100644 --- a/src/components/CellCards/CellTile.jsx +++ b/src/components/CellCards/CellTile.jsx @@ -1,10 +1,10 @@ import PropTypes from "prop-types"; -import { Box, Typography, Chip, Divider, Link } from "@mui/material"; +import { Box, Card, CardContent, Typography, Chip, Divider, Link } from "@mui/material"; import { ArrowOutwardIcon } from "../../Icons"; import { vars } from "../../theme/variables"; import { TILE_HEADER_CHIPS, TILE_ROWS, labelFor, linkFor } from "./config/gridConfig"; -const { gray200, gray500, gray700, gray800, brand700 } = vars; +const { gray500, gray700, gray800, brand700 } = vars; const CHIP_COLOR = { class: "secondary", subtype: "success", species: "default" }; @@ -24,9 +24,9 @@ const PropertyRow = ({ label, prop, render }) => { {prop.values.map((v) => v.label).join(", ")} ) : ( - + {prop.values.map((v) => ( - + ))} )} @@ -47,7 +47,7 @@ const CellTile = ({ cell }) => { const prop = cell.properties[localName]; if (!prop) return []; return prop.values.map((v) => ( - + )); }); @@ -61,18 +61,18 @@ const CellTile = ({ cell }) => { )).filter((row) => row.props.prop); return ( - - + {cell.label} @@ -86,22 +86,23 @@ const CellTile = ({ cell }) => { {headerChips} )} - + {rows.length > 0 && ( <> - {rows} + + {rows} + )} {cell.sources.length > 0 && ( <> - { ) )} - + )} - + ); }; diff --git a/src/components/CellCards/CellTileGrid.jsx b/src/components/CellCards/CellTileGrid.jsx index 33c494c6..dbe69c3f 100644 --- a/src/components/CellCards/CellTileGrid.jsx +++ b/src/components/CellCards/CellTileGrid.jsx @@ -1,5 +1,5 @@ import PropTypes from "prop-types"; -import { Box, Typography } from "@mui/material"; +import { Box, Grid, Typography } from "@mui/material"; import CellTile from "./CellTile"; import { vars } from "../../theme/variables"; @@ -26,28 +26,23 @@ const CellTileGrid = ({ cells, onSelect }) => { } return ( - + {cells.map((cell) => ( - onSelect(cell)} sx={{ cursor: "pointer", display: "flex" }} > - + ))} - + ); }; diff --git a/src/components/CellCards/GridFilterSidebar.jsx b/src/components/CellCards/GridFilterSidebar.jsx index 872bca38..405ed984 100644 --- a/src/components/CellCards/GridFilterSidebar.jsx +++ b/src/components/CellCards/GridFilterSidebar.jsx @@ -58,6 +58,11 @@ const iconButtonSx = { const checkboxSx = { p: 0, flexShrink: 0 }; +// Master checkbox: clears every facet, so it is inert (and must look inert) +// while no filter is active — the icon SVGs carry their own colours, hence the +// explicit disabled treatment. +const masterCheckboxSx = { ...checkboxSx, "&.Mui-disabled": { opacity: 0.45 } }; + const FacetCheckbox = ({ checked, onChange, label }) => ( - - } - checkedIcon={} - indeterminateIcon={} - indeterminate={anySelection} - checked={false} - disabled={!anySelection} - onChange={onClearAll} - sx={checkboxSx} - inputProps={{ "aria-label": "Clear all filters" }} - /> + + {/* Wrapper: a disabled input fires no events, so the tooltip needs a host. */} + + } + indeterminateIcon={} + indeterminate={anySelection} + checked={false} + disabled={!anySelection} + onChange={onClearAll} + sx={masterCheckboxSx} + inputProps={{ "aria-label": "Clear all filters" }} + /> + a.label.localeCompare(b.label)), + // Most frequent value first, so the collapsed list shows the ones that + // actually narrow the results; labels break ties. + values: [...counts.values()].sort( + (a, b) => b.count - a.count || a.label.localeCompare(b.label) + ), }); } } diff --git a/src/theme/index.jsx b/src/theme/index.jsx index 4984e038..e330d325 100644 --- a/src/theme/index.jsx +++ b/src/theme/index.jsx @@ -35,10 +35,81 @@ const { error700, gray400, brand200, - errorInputBoxShadow + errorInputBoxShadow, + black, + brand400, + error400, + warning400, + success400, + blue200, + blue700 } = vars; const theme = createTheme({ + // Semantic colour tokens. `vars` stays the source of truth and feeds this; call sites should + // consume the palette (color="primary", sx={{ color: "text.secondary" }}) rather than importing + // `vars` directly. Only `main` is given where the design has no verified token at the tone MUI + // expects for light/dark, so MUI derives those via tonalOffset. contrastText is always computed. + palette: { + mode: "light", + primary: { + light: brand400, + main: brand600, + dark: brand700 + }, + // Blue accent, matching the shipped MuiChip.colorSecondary. + secondary: { + light: blue200, + main: blue700 + }, + info: { + light: blue200, + main: blue700 + }, + error: { + light: error400, + main: error500, + dark: error700 + }, + warning: { + light: warning400, + main: warning500, + dark: warning700 + }, + success: { + light: success400, + main: success500, + dark: success700 + }, + grey: { + 50: gray50, + 100: gray100, + 200: gray200, + 300: gray300, + 400: gray400, + 500: gray500, + 600: gray600, + 700: gray700, + 800: gray800, + 900: gray900 + }, + text: { + primary: gray900, + secondary: gray500, + disabled: gray400 + }, + background: { + default: white, + paper: white + }, + // Matches the MuiDivider override, which makes `variant="outlined"` borders correct by default. + divider: gray200, + common: { + black: black, + white: white + } + }, + typography: { allVariants: { fontFamily: primaryFont, @@ -128,6 +199,34 @@ const theme = createTheme({ } } }, + // Card is a Paper, and this theme defines no `palette`/`shape`, so an outlined Card would + // otherwise fall back to MUI defaults: palette.divider for the outline (which would not match + // the gray200 MuiDivider above) and shape.borderRadius = 4px. + MuiCard: { + styleOverrides: { + root: { + borderColor: gray200, + borderRadius: "0.75rem", + transition: "background-color 150ms ease-in-out", + // Hover state of the clickable CellCards tile (Figma "State=Hover"): fill only, + // border and radius unchanged. The click target itself lives on the grid item. + "&:hover": { + backgroundColor: gray50 + } + } + } + }, + MuiCardContent: { + styleOverrides: { + root: { + padding: "1rem", + // MUI pads the last CardContent to 24px; keep every section uniform instead. + "&:last-child": { + paddingBottom: "1rem" + } + } + } + }, MuiRichTreeView: { styleOverrides: { backgroundColor: "red", @@ -268,6 +367,8 @@ const theme = createTheme({ borderRadius: "0.375rem !important", fontWeight: 500, width: "fit-content", + maxWidth: "100%", + minWidth: 0, "&.rounded": { padding: "0.125rem 0.5rem 0.125rem 0.375rem", @@ -341,6 +442,14 @@ const theme = createTheme({ }, label: { padding: 0, + // Chip text is often an ontology term of unbounded length, so cap every label and + // ellipsize. 20ch resolves to 160px here, which is 23-29 characters depending on + // letter widths. Chips whose label can exceed that should pass a `title` so the + // full value stays readable on hover. + maxWidth: "20ch", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", }, outlined: { borderColor: gray300, diff --git a/src/theme/variables.js b/src/theme/variables.js index 40a108e5..b01c2b97 100644 --- a/src/theme/variables.js +++ b/src/theme/variables.js @@ -33,6 +33,13 @@ export const vars = { + // Blue accent. Only the three steps the design actually shipped — they lived as raw hex in + // MuiChip.colorSecondary before being tokenised here. Add further steps from the design, not + // by interpolation. + blue50: '#F0F9FF', + blue200: '#B9E6FE', + blue700: '#026AA2', + error25: '#FFFBFA', error50: '#FEF3F2', error100: '#FEE4E2', From 2acd53dcaa4b1258826186950a52db2bf16e5b3d Mon Sep 17 00:00:00 2001 From: Filippo Ledda Date: Tue, 28 Jul 2026 11:52:58 +0200 Subject: [PATCH 03/11] (cellcard) Add selectors to grid --- src/components/CellCards/CellTile.jsx | 58 +++++++++++++------ src/components/CellCards/CellTileGrid.jsx | 12 +++- src/components/CellCards/OntologyGridPage.jsx | 23 +++++++- src/theme/index.jsx | 23 ++++++-- 4 files changed, 86 insertions(+), 30 deletions(-) diff --git a/src/components/CellCards/CellTile.jsx b/src/components/CellCards/CellTile.jsx index 66c649e0..312bc302 100644 --- a/src/components/CellCards/CellTile.jsx +++ b/src/components/CellCards/CellTile.jsx @@ -1,6 +1,6 @@ import PropTypes from "prop-types"; -import { Box, Card, CardContent, Typography, Chip, Divider, Link } from "@mui/material"; -import { ArrowOutwardIcon } from "../../Icons"; +import { Box, Card, CardContent, Checkbox, Typography, Chip, Divider, Link } from "@mui/material"; +import { ArrowOutwardIcon, CheckboxDefault, CheckboxSelected } from "../../Icons"; import { vars } from "../../theme/variables"; import { TILE_HEADER_CHIPS, TILE_ROWS, labelFor, linkFor } from "./config/gridConfig"; @@ -40,9 +40,14 @@ PropertyRow.propTypes = { render: PropTypes.oneOf(["text", "chip"]), }; -// A cell record as a grid tile: header (title/id/chips) → property rows → source footer. -// Objects inside the tile are not clickable; the whole tile navigates (handled by the grid). -const CellTile = ({ cell }) => { +// The design pins the checkbox 20px in from the tile's top-right corner; CardContent already pads +// 16px, so the 32px hit area is pulled back 4px to line the 16px glyph up with it. +const CHECKBOX_SX = { p: 1, mt: -0.5, mr: -0.5 }; + +// A cell record as a grid tile: header (title/id/chips + select checkbox) → property rows → source +// footer. Apart from the checkbox and the source links, the tile interior is not clickable; the +// whole tile navigates (handled by the grid). +const CellTile = ({ cell, selected = false, onToggleSelect }) => { const headerChips = TILE_HEADER_CHIPS.flatMap(({ localName, tone }) => { const prop = cell.properties[localName]; if (!prop) return []; @@ -63,6 +68,8 @@ const CellTile = ({ cell }) => { return ( { height: "100%", }} > - - - - {cell.label} - - - {cell.curie} - - - {headerChips.length > 0 && ( - - {headerChips} + {/* Two columns, as in the design: the text block grows, the checkbox stays top-right. */} + + + + + {cell.label} + + + {cell.curie} + - )} + {headerChips.length > 0 && ( + + {headerChips} + + )} + + } + checkedIcon={} + checked={selected} + onChange={onToggleSelect} + onClick={(e) => e.stopPropagation()} // selecting must not navigate + inputProps={{ "aria-label": `Select ${cell.label}` }} + sx={CHECKBOX_SX} + /> {rows.length > 0 && ( @@ -145,6 +165,8 @@ const CellTile = ({ cell }) => { CellTile.propTypes = { cell: PropTypes.object.isRequired, + selected: PropTypes.bool, + onToggleSelect: PropTypes.func, }; export default CellTile; diff --git a/src/components/CellCards/CellTileGrid.jsx b/src/components/CellCards/CellTileGrid.jsx index dbe69c3f..129cdbe6 100644 --- a/src/components/CellCards/CellTileGrid.jsx +++ b/src/components/CellCards/CellTileGrid.jsx @@ -5,8 +5,8 @@ import { vars } from "../../theme/variables"; const { gray500 } = vars; -// Responsive tile grid. Whole-tile click navigates (tile interior is not interactive). -const CellTileGrid = ({ cells, onSelect }) => { +// Responsive tile grid. Whole-tile click navigates; the per-tile checkbox selects instead. +const CellTileGrid = ({ cells, onSelect, selectedIds, onToggleSelect }) => { if (!cells.length) { return ( { onClick={() => onSelect(cell)} sx={{ cursor: "pointer", display: "flex" }} > - + onToggleSelect(cell.id)} + /> ))} @@ -49,6 +53,8 @@ const CellTileGrid = ({ cells, onSelect }) => { CellTileGrid.propTypes = { cells: PropTypes.array.isRequired, onSelect: PropTypes.func.isRequired, + selectedIds: PropTypes.object.isRequired, + onToggleSelect: PropTypes.func.isRequired, }; export default CellTileGrid; diff --git a/src/components/CellCards/OntologyGridPage.jsx b/src/components/CellCards/OntologyGridPage.jsx index 40bfcafc..6eb4af28 100644 --- a/src/components/CellCards/OntologyGridPage.jsx +++ b/src/components/CellCards/OntologyGridPage.jsx @@ -42,7 +42,8 @@ const OntologyGridPage = () => { const [error, setError] = useState(null); const [data, setData] = useState(null); const [displayedOnly, setDisplayedOnly] = useState(true); - const [checked, setChecked] = useState({}); + const [checked, setChecked] = useState({}); // facet filter checks, keyed by facet localName + const [selectedIds, setSelectedIds] = useState({}); // tiles picked via their checkbox const [word, setWord] = useState(""); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(24); @@ -53,8 +54,9 @@ const OntologyGridPage = () => { let active = true; setLoading(true); setError(null); - // Reset all filter/paging state so it never leaks across ontologies. + // Reset all filter/paging/selection state so it never leaks across ontologies. setChecked({}); + setSelectedIds({}); setWord(""); setDisplayedOnly(true); setPage(1); @@ -128,6 +130,16 @@ const OntologyGridPage = () => { setPage(1); }, []); + // Tile selection. Kept across paging and filtering — a tile scrolled out of view stays picked. + const onToggleSelect = useCallback((id) => { + setSelectedIds((prev) => { + const next = { ...prev }; + if (next[id]) delete next[id]; + else next[id] = true; + return next; + }); + }, []); + const onWord = useCallback((value) => { setWord(value); setPage(1); @@ -248,7 +260,12 @@ const OntologyGridPage = () => { - setSnack("The single-cell Cell Card view is coming in the next round.")} /> + setSnack("The single-cell Cell Card view is coming in the next round.")} + selectedIds={selectedIds} + onToggleSelect={onToggleSelect} + /> {total > pageSize && ( diff --git a/src/theme/index.jsx b/src/theme/index.jsx index e330d325..dfb0fe0a 100644 --- a/src/theme/index.jsx +++ b/src/theme/index.jsx @@ -1,4 +1,4 @@ -import { createTheme } from "@mui/material/styles"; +import { alpha, createTheme } from "@mui/material/styles"; import { vars } from "./variables"; const { @@ -42,7 +42,8 @@ const { warning400, success400, blue200, - blue700 + blue700, + brand500 } = vars; const theme = createTheme({ @@ -199,19 +200,29 @@ const theme = createTheme({ } } }, - // Card is a Paper, and this theme defines no `palette`/`shape`, so an outlined Card would - // otherwise fall back to MUI defaults: palette.divider for the outline (which would not match - // the gray200 MuiDivider above) and shape.borderRadius = 4px. + // `borderColor` here predates the palette and now merely restates `palette.divider`; the + // radius still has to be pinned because `shape` is deliberately left per-component. MuiCard: { styleOverrides: { root: { borderColor: gray200, borderRadius: "0.75rem", - transition: "background-color 150ms ease-in-out", + transition: + "background-color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out", // Hover state of the clickable CellCards tile (Figma "State=Hover"): fill only, // border and radius unchanged. The click target itself lives on the grid item. "&:hover": { backgroundColor: gray50 + }, + // Selected tile (Figma "State=Focus", which the design reuses for selection): + // 2px brand border + the `ring-brand` focus ring, on a white fill that has to be + // restated so a selected tile does not pick up the gray hover fill. Listed after + // `:hover` so it wins at equal specificity. + "&.Mui-selected": { + backgroundColor: white, + borderColor: brand600, + borderWidth: "2px", + boxShadow: `0 0 0 4px ${alpha(brand500, 0.24)}` } } } From 1aaf5d33a6556e8622564d277a0b9b57834a7cce Mon Sep 17 00:00:00 2001 From: Filippo Ledda Date: Tue, 28 Jul 2026 14:43:28 +0200 Subject: [PATCH 04/11] (cellcard) Add grid browser --- package.json | 1 + src/App.jsx | 11 +- src/components/CellCards/CellTile.jsx | 6 +- .../CellCards/GridFilterSidebar.jsx | 4 +- .../CellCards/OntologyBrowsePage.jsx | 33 ++++ src/components/CellCards/OntologyGridPage.jsx | 114 ++----------- src/components/CellCards/OntologyHeader.jsx | 81 +++++++++ .../CellCards/OntologyHierarchyPanel.jsx | 85 ++++++++++ .../CellCards/OntologyHierarchyTree.jsx | 104 ++++++++++++ src/components/CellCards/OntologyPage.jsx | 79 +++++++++ .../CellCards/OntologyTermsTable.jsx | 156 ++++++++++++++++++ src/components/CellCards/config/gridConfig.ts | 76 ++++++++- src/components/CellCards/model/types.ts | 17 ++ .../CellCards/services/ontologyGridService.ts | 10 +- src/components/Header/Search.jsx | 8 +- src/components/common/CustomTabs.jsx | 11 +- src/parsers/neurdfParser.ts | 132 ++++++++++++++- src/theme/index.jsx | 65 +++++++- yarn.lock | 65 ++++++++ 19 files changed, 924 insertions(+), 134 deletions(-) create mode 100644 src/components/CellCards/OntologyBrowsePage.jsx create mode 100644 src/components/CellCards/OntologyHeader.jsx create mode 100644 src/components/CellCards/OntologyHierarchyPanel.jsx create mode 100644 src/components/CellCards/OntologyHierarchyTree.jsx create mode 100644 src/components/CellCards/OntologyPage.jsx create mode 100644 src/components/CellCards/OntologyTermsTable.jsx diff --git a/package.json b/package.json index a37f0c8c..beaca2dc 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@mui/icons-material": "^5.15.19", "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.15.15", + "@mui/x-data-grid": "^7.29.13", "@mui/x-tree-view": "^7.5.0", "body-parser": "^1.20.3", "cors": "^2.8.5", diff --git a/src/App.jsx b/src/App.jsx index 9ee71ce0..0d7a8fe0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -21,7 +21,9 @@ import CurieEditor from "./components/CurieEditor"; import SearchResults from "./components/SearchResults"; import Organizations from "./components/organizations"; import SingleTermView from "./components/SingleTermView"; +import OntologyPage from "./components/CellCards/OntologyPage"; import OntologyGridPage from "./components/CellCards/OntologyGridPage"; +import OntologyBrowsePage from "./components/CellCards/OntologyBrowsePage"; import { GlobalDataProvider } from "./contexts/DataContext"; import ResetPassword from "./components/Auth/ResetPassword"; import ForgotPassword from "./components/Auth/ForgotPassword"; @@ -186,13 +188,16 @@ function MainContent() { } /> - + } - /> + > + } /> + } /> + } /> } /> } /> diff --git a/src/components/CellCards/CellTile.jsx b/src/components/CellCards/CellTile.jsx index 312bc302..f4b753b7 100644 --- a/src/components/CellCards/CellTile.jsx +++ b/src/components/CellCards/CellTile.jsx @@ -2,7 +2,7 @@ import PropTypes from "prop-types"; import { Box, Card, CardContent, Checkbox, Typography, Chip, Divider, Link } from "@mui/material"; import { ArrowOutwardIcon, CheckboxDefault, CheckboxSelected } from "../../Icons"; import { vars } from "../../theme/variables"; -import { TILE_HEADER_CHIPS, TILE_ROWS, labelFor, linkFor } from "./config/gridConfig"; +import { TILE_HEADER_CHIPS, TILE_ROWS, labelFor, termLink } from "./config/gridConfig"; const { gray500, gray700, gray800, brand700 } = vars; @@ -134,10 +134,10 @@ const CellTile = ({ cell, selected = false, onToggleSelect }) => { {cell.sources.map((src) => - linkFor(src.iri) ? ( + termLink(src) ? ( {values.map((v) => { - const href = linkFor(v.iri); + const href = termLink(v); const labelSx = { ...ellipsis, flex: 1, diff --git a/src/components/CellCards/OntologyBrowsePage.jsx b/src/components/CellCards/OntologyBrowsePage.jsx new file mode 100644 index 00000000..dbfe81ac --- /dev/null +++ b/src/components/CellCards/OntologyBrowsePage.jsx @@ -0,0 +1,33 @@ +import { useOutletContext } from "react-router-dom"; +import { Box, Grid } from "@mui/material"; +import OntologyHierarchyPanel from "./OntologyHierarchyPanel"; +import OntologyTermsTable from "./OntologyTermsTable"; + +// The "Browse" tab: the ontology's subClassOf hierarchy next to the flat list of its terms. +// One third / two thirds, matching the design's 576 / 1152 split of a 1760px container. +const OntologyBrowsePage = () => { + const { data } = useOutletContext(); + + // Side by side the two panels each own the full height and scroll internally. Once they + // stack (below md) a full-height panel would push the other off-screen, so there they take + // a fixed slice of the viewport and the page itself scrolls. + const panelHeight = { xs: "32rem", md: 1 }; + + return ( + + + + + + + + + + + ); +}; + +export default OntologyBrowsePage; diff --git a/src/components/CellCards/OntologyGridPage.jsx b/src/components/CellCards/OntologyGridPage.jsx index 6eb4af28..38a24035 100644 --- a/src/components/CellCards/OntologyGridPage.jsx +++ b/src/components/CellCards/OntologyGridPage.jsx @@ -1,25 +1,15 @@ import { useState, useEffect, useMemo, useCallback } from "react"; -import { useParams } from "react-router-dom"; -import { - Box, - Typography, - Chip, - CircularProgress, - Button, - Snackbar, - Stack, -} from "@mui/material"; -import HomeOutlinedIcon from "@mui/icons-material/HomeOutlined"; +import { useOutletContext } from "react-router-dom"; +import { Box, Typography, Snackbar, Stack } from "@mui/material"; import GridFilterSidebar from "./GridFilterSidebar"; import GridSearchBar from "./GridSearchBar"; import CellTileGrid from "./CellTileGrid"; -import BreadcrumbBar from "../common/BreadcrumbBar"; import CustomSingleSelect from "../common/CustomSingleSelect"; import CustomPagination from "../common/CustomPagination"; -import { loadOntology, getFacets } from "./services/ontologyGridService"; +import { getFacets } from "./services/ontologyGridService"; import { vars } from "../../theme/variables"; -const { gray200, gray500, gray600 } = vars; +const { gray200, gray600 } = vars; const PAGE_SIZES = [12, 24, 48, 96]; // Serialised text for the free-text "Filter by word" (label + id + all values). @@ -36,44 +26,26 @@ const cellFacetKeys = (cell, localName) => { return (cell.properties[localName]?.values || []).map((v) => v.id); }; +// The "Grid View" tab. The ontology itself is loaded by the OntologyPage layout route and +// arrives through the outlet context, so switching tabs does not refetch it. const OntologyGridPage = () => { - const { slug } = useParams(); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [data, setData] = useState(null); + const { data } = useOutletContext(); const [displayedOnly, setDisplayedOnly] = useState(true); const [checked, setChecked] = useState({}); // facet filter checks, keyed by facet localName const [selectedIds, setSelectedIds] = useState({}); // tiles picked via their checkbox const [word, setWord] = useState(""); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(24); - const [reloadKey, setReloadKey] = useState(0); const [snack, setSnack] = useState(""); useEffect(() => { - let active = true; - setLoading(true); - setError(null); // Reset all filter/paging/selection state so it never leaks across ontologies. setChecked({}); setSelectedIds({}); setWord(""); setDisplayedOnly(true); setPage(1); - loadOntology(slug) - .then((res) => { - if (active) setData(res); - }) - .catch((err) => { - if (active) setError(err.message || "Failed to load ontology"); - }) - .finally(() => { - if (active) setLoading(false); - }); - return () => { - active = false; - }; - }, [slug, reloadKey]); + }, [data]); const cells = useMemo(() => data?.cells || [], [data]); const facets = useMemo(() => getFacets(cells, displayedOnly), [cells, displayedOnly]); @@ -145,74 +117,8 @@ const OntologyGridPage = () => { setPage(1); }, []); - if (loading) { - return ( - - - - ); - } - - if (error) { - return ( - - - Could not load the ontology data. - - - {error} - - - - ); - } - return ( - - {/* Breadcrumb bar — its own container (app-wide pattern) with copy-path */} - - - {/* Header: title + curation status -> description -> version -> tags below. - Title / description / version are read from the file's owl:Ontology node; - the curie is the real root class the grid is scoped to. */} - - - {data.meta.title} - {data.entry.curationStatus && ( - - )} - - {data.meta.description && ( - - {data.meta.description} - - )} - {data.meta.version && ( - - Version {data.meta.version} - - )} - - - Tags - - - - - - - + <> {/* Body: filter sidebar | results */} { message={snack} anchorOrigin={{ vertical: "bottom", horizontal: "center" }} /> - + ); }; diff --git a/src/components/CellCards/OntologyHeader.jsx b/src/components/CellCards/OntologyHeader.jsx new file mode 100644 index 00000000..7ab9651b --- /dev/null +++ b/src/components/CellCards/OntologyHeader.jsx @@ -0,0 +1,81 @@ +import PropTypes from "prop-types"; +import { useNavigate } from "react-router-dom"; +import { Box, Typography, Chip, Stack } from "@mui/material"; +import HomeOutlinedIcon from "@mui/icons-material/HomeOutlined"; +import BreadcrumbBar from "../common/BreadcrumbBar"; +import CustomTabs from "../common/CustomTabs"; +import { ONTOLOGY_TABS, ontologyPath } from "./config/gridConfig"; +import { vars } from "../../theme/variables"; + +const { gray200, gray500, gray600 } = vars; + +// Breadcrumb + ontology identity + tab bar. Shared by every tab of an ontology, so switching +// tabs changes only the body below it. +const OntologyHeader = ({ data, tab }) => { + const navigate = useNavigate(); + const tabIndex = Math.max(0, ONTOLOGY_TABS.findIndex((t) => t.path === tab)); + + return ( + <> + {/* Breadcrumb bar — its own container (app-wide pattern) with copy-path. + Crumbs mirror the /:org/ontology/:slug hierarchy. */} + + + {/* Header: title + curation status -> description -> version -> tags -> tabs. + Title / description / version are read from the file's owl:Ontology node; + the curie is the real root class the grid is scoped to. */} + + + {data.meta.title} + {data.entry.curationStatus && ( + + )} + + {data.meta.description && ( + + {data.meta.description} + + )} + {data.meta.version && ( + + Version {data.meta.version} + + )} + + + Tags + + + + + + + ({ label: t.label, disabled: t.path === undefined }))} + tabValue={tabIndex} + handleChange={(_e, i) => { + const next = ONTOLOGY_TABS[i]?.path; + if (next !== undefined) navigate(ontologyPath(data.entry, next)); + }} + parentBoxStyles={{ mt: 1, borderBottom: 0 }} + /> + + + ); +}; + +OntologyHeader.propTypes = { + data: PropTypes.object.isRequired, + tab: PropTypes.string.isRequired, +}; + +export default OntologyHeader; diff --git a/src/components/CellCards/OntologyHierarchyPanel.jsx b/src/components/CellCards/OntologyHierarchyPanel.jsx new file mode 100644 index 00000000..1775b874 --- /dev/null +++ b/src/components/CellCards/OntologyHierarchyPanel.jsx @@ -0,0 +1,85 @@ +import PropTypes from "prop-types"; +import { useCallback, useMemo, useState } from "react"; +import { Box, Button, Divider, Stack, Typography } from "@mui/material"; +import CustomSingleSelect from "../common/CustomSingleSelect"; +import OntologyHierarchyTree from "./OntologyHierarchyTree"; +import { RestartAlt, TargetCross } from "../../Icons"; + +// Local name of a curie or IRI: "ilxtr:NeuronPrecision" -> "NeuronPrecision". +const localName = (id) => String(id).split(/[:/#]/).filter(Boolean).pop() || id; + +// The "Ontology hierarchy" widget: the subClassOf tree of the ontology, opened on its root +// class (the starting selection the design calls for). Per the MVP decision this widget does +// not drive the Terms table beside it — the two are independent views of the same ontology. +const OntologyHierarchyPanel = ({ hierarchy, rootClass }) => { + const rootIds = useMemo(() => hierarchy.map((node) => node.id), [hierarchy]); + const [expandedItems, setExpandedItems] = useState(rootIds); + const [selectedItems, setSelectedItems] = useState([]); + + // Only one hierarchy exists for a neurdf ontology, but the label is built from the root + // class rather than hardcoded — the same rule the rest of this view follows. + const typeOptions = useMemo( + () => [{ value: "subClassOf", label: `sub class of ${localName(rootClass)}` }], + [rootClass] + ); + + const handleReset = useCallback(() => { + setExpandedItems(rootIds); + setSelectedItems([]); + }, [rootIds]); + + const handleFocusRoot = useCallback(() => { + setExpandedItems((prev) => [...new Set([...prev, ...rootIds])]); + setSelectedItems(rootIds); + }, [rootIds]); + + return ( + + Ontology hierarchy + + + + Type: + + {}} options={typeOptions} /> + + + + + + + {/* The sidebar scrolls independently of the table beside it (design decision). */} + + setExpandedItems(ids)} + selectedItems={selectedItems} + /> + + + ); +}; + +OntologyHierarchyPanel.propTypes = { + hierarchy: PropTypes.array.isRequired, + rootClass: PropTypes.string.isRequired, +}; + +export default OntologyHierarchyPanel; diff --git a/src/components/CellCards/OntologyHierarchyTree.jsx b/src/components/CellCards/OntologyHierarchyTree.jsx new file mode 100644 index 00000000..64335243 --- /dev/null +++ b/src/components/CellCards/OntologyHierarchyTree.jsx @@ -0,0 +1,104 @@ +import PropTypes from "prop-types"; +import { forwardRef, useMemo } from "react"; +import { Link, Stack, Typography } from "@mui/material"; +import { RichTreeView } from "@mui/x-tree-view/RichTreeView"; +import { TreeItem } from "@mui/x-tree-view/TreeItem"; +import ExpandLessOutlinedIcon from "@mui/icons-material/ExpandLessOutlined"; +import ChevronRightOutlinedIcon from "@mui/icons-material/ChevronRightOutlined"; +import { termLink } from "./config/gridConfig"; + +// Tree row: label + the term's CURIE, per the design. The CURIE is dropped when it *is* the +// label (a class with no rdfs:label falls back to its curie, and showing it twice is noise). +// +// The label is a real link so the term opens in a new tab (design decision), which also gets +// middle-click and keyboard activation for free. It carries the term's own colour rather than +// the link colour — the design draws these as plain text — and stops the click from reaching +// the item, so opening a term never doubles as expanding it. Expansion stays on the chevron. +const HierarchyTreeItem = forwardRef(function HierarchyTreeItem(props, ref) { + // `meta` arrives via slotProps and must not reach the DOM. + const { meta, label, itemId, ...treeItemProps } = props; + const { curie, href } = meta?.get(itemId) || {}; + + return ( + + {href ? ( + e.stopPropagation()} + > + {label} + + ) : ( + + {label} + + )} + {curie && curie !== label && ( + + {curie} + + )} + + } + /> + ); +}); + +HierarchyTreeItem.propTypes = { + meta: PropTypes.instanceOf(Map), + label: PropTypes.string, + itemId: PropTypes.string, +}; + +const OntologyHierarchyTree = ({ items, expandedItems, onExpandedItemsChange, selectedItems }) => { + // itemId -> { curie, href }, resolved once here so the item slot needs no walk of its own. + const meta = useMemo(() => { + const map = new Map(); + const stack = [...items]; + while (stack.length) { + const node = stack.pop(); + map.set(node.id, { curie: node.curie, href: termLink(node) }); + stack.push(...(node.children || [])); + } + return map; + }, [items]); + + return ( + item.id} + getItemLabel={(item) => item.label} + expandedItems={expandedItems} + onExpandedItemsChange={onExpandedItemsChange} + selectedItems={selectedItems} + slots={{ + item: HierarchyTreeItem, + expandIcon: ChevronRightOutlinedIcon, + collapseIcon: ExpandLessOutlinedIcon, + }} + slotProps={{ item: { meta } }} + /> + ); +}; + +OntologyHierarchyTree.propTypes = { + items: PropTypes.array.isRequired, + expandedItems: PropTypes.arrayOf(PropTypes.string).isRequired, + onExpandedItemsChange: PropTypes.func.isRequired, + selectedItems: PropTypes.arrayOf(PropTypes.string), +}; + +export default OntologyHierarchyTree; diff --git a/src/components/CellCards/OntologyPage.jsx b/src/components/CellCards/OntologyPage.jsx new file mode 100644 index 00000000..f282d8bb --- /dev/null +++ b/src/components/CellCards/OntologyPage.jsx @@ -0,0 +1,79 @@ +import { useState, useEffect } from "react"; +import { useParams, useLocation, Outlet } from "react-router-dom"; +import { Box, Typography, CircularProgress, Button } from "@mui/material"; +import OntologyHeader from "./OntologyHeader"; +import { ONTOLOGY_TABS } from "./config/gridConfig"; +import { loadOntology } from "./services/ontologyGridService"; +import { vars } from "../../theme/variables"; + +const { gray500, gray600 } = vars; + +// Layout route for one ontology: owns the (single, ~16MB) load plus the loading/error states, +// and renders the shared header. Each tab is a child route and reads the parsed ontology from +// the outlet context, so switching tabs never refetches. +const OntologyPage = () => { + const { slug } = useParams(); + const { pathname } = useLocation(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + + // The trailing segment after the slug identifies the tab ("" = Grid View). + const tail = pathname.split(`/ontology/${slug}`)[1] || ""; + const tab = ONTOLOGY_TABS.some((t) => t.path && `/${t.path}` === tail) + ? tail.slice(1) + : ""; + + useEffect(() => { + let active = true; + setLoading(true); + setError(null); + loadOntology(slug) + .then((res) => { + if (active) setData(res); + }) + .catch((err) => { + if (active) setError(err.message || "Failed to load ontology"); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [slug, reloadKey]); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + Could not load the ontology data. + + + {error} + + + + ); + } + + return ( + + + + + ); +}; + +export default OntologyPage; diff --git a/src/components/CellCards/OntologyTermsTable.jsx b/src/components/CellCards/OntologyTermsTable.jsx new file mode 100644 index 00000000..9aad9d2c --- /dev/null +++ b/src/components/CellCards/OntologyTermsTable.jsx @@ -0,0 +1,156 @@ +import PropTypes from "prop-types"; +import { useMemo, useState } from "react"; +import { Box, IconButton, Stack, Tooltip, Typography } from "@mui/material"; +import { DataGrid } from "@mui/x-data-grid"; +import OpenInNewOutlinedIcon from "@mui/icons-material/OpenInNewOutlined"; +import GridSearchBar from "./GridSearchBar"; +import { termLink } from "./config/gridConfig"; + +// Most of these terms describe themselves in far more than the two lines the design's row +// allows, so the text is capped at two and the cell's title carries the whole thing. +// +// 2.5rem is exactly two 1.25rem lines, which is also the cell's content box (3.5rem row less +// its 0.5rem padding). Capping on a line boundary is what keeps the overflow clean: a third +// line falls entirely outside the box instead of being sliced through the middle. Layout only +// — the font and colour come from the theme's MuiDataGrid cell — and hoisted so Emotion +// serialises it once rather than per rendered cell. +const capToTwoLines = { minWidth: 0, maxHeight: "2.5rem", overflow: "hidden" }; + +// Generated text is labelled as such in the tooltip: these terms carry no curated definition, +// and silently presenting a machine-built phenotype string as one would misreport provenance. +const definitionTitle = (row) => + row.definitionCurated + ? row.definition + : `${row.definition}\n\nGenerated from this term's phenotypes — it has no curated definition.`; + +// Column widths are the design's (280 / 176 / 176 / 448 / 72 of 1152) expressed as flex +// ratios, so the table keeps its proportions at any viewport. +const columns = [ + { + field: "label", + headerName: "Term", + flex: 280, + minWidth: 180, + renderCell: ({ row }) => ( + + {row.label} + + ), + }, + { field: "curie", headerName: "ID", flex: 176, minWidth: 130 }, + { field: "rdfType", headerName: "rdf:type", flex: 176, minWidth: 130 }, + { + field: "definition", + headerName: "Definition", + flex: 448, + minWidth: 200, + renderCell: ({ row }) => + row.definition ? ( + + {row.definition} + + ) : null, + }, + { + field: "actions", + headerName: "", + width: 72, + sortable: false, + align: "center", + renderCell: ({ row }) => + row.href ? ( + + + + + + ) : null, + }, +]; + +// Free-text filter over everything the table shows: partial match, case-insensitive, and the +// row order is preserved (design decision — the filter hides rows, it does not re-rank them). +const matches = (row, query) => + [row.label, row.curie, row.rdfType, row.definition] + .join(" ") + .toLowerCase() + .includes(query); + +// The "Terms" widget: every term in the ontology, whatever the hierarchy beside it is showing. +// The two widgets deliberately do not interact in this MVP. +const OntologyTermsTable = ({ cells }) => { + const [word, setWord] = useState(""); + + const rows = useMemo( + () => + cells.map((cell) => ({ + id: cell.id, + label: cell.label, + curie: cell.curie, + href: termLink(cell), + rdfType: cell.rdfTypes.join(", "), + definition: cell.definition || "", + definitionCurated: cell.definitionCurated ?? true, + })), + [cells] + ); + + const filteredRows = useMemo(() => { + const query = word.trim().toLowerCase(); + return query ? rows.filter((row) => matches(row, query)) : rows; + }, [rows, word]); + + return ( + + Terms + + + {filteredRows.length ? ( + + ) : ( + // The design calls for an empty state when the filter matches nothing, and rendering + // one also keeps the grid from ever holding zero rows: DataGrid v7 dereferences a + // null row node in useGridRowAriaAttributes when its last row is filtered away. + + + {rows.length + ? "No terms match this filter." + : "This ontology has no terms."} + + + )} + + + ); +}; + +OntologyTermsTable.propTypes = { + cells: PropTypes.array.isRequired, +}; + +export default OntologyTermsTable; diff --git a/src/components/CellCards/config/gridConfig.ts b/src/components/CellCards/config/gridConfig.ts index ce0dca85..eafe1d33 100644 --- a/src/components/CellCards/config/gridConfig.ts +++ b/src/components/CellCards/config/gridConfig.ts @@ -5,7 +5,8 @@ // --- ontology catalog (stands in for the not-yet-built search backend) ------ export interface OntologyEntry { - slug: string; // URL segment, e.g. "precision" + slug: string; // ontology URL segment, e.g. "precision" + org: string; // owning organization's URL segment (the app's /:title org route) label: string; // friendly search-result name (the header title comes from the file, not this) type: string; // shown as a result type chip + a header tag curie: string; // ontology file identity, shown under the search result (submittedBy) @@ -19,6 +20,7 @@ export interface OntologyEntry { export const ONTOLOGY_CATALOG: Record = { precision: { slug: "precision", + org: "precision", label: "Precision Cells", type: "Cell ontology", curie: "NPOprecisionCellType", @@ -29,9 +31,33 @@ export const ONTOLOGY_CATALOG: Record = { }, }; +// Canonical route to an ontology tab, mirroring the breadcrumb hierarchy: +// /[organization]/ontology/[ontology slug](/[tab]). +export const ontologyPath = (entry: OntologyEntry, tab = ""): string => + `/${entry.org}/ontology/${entry.slug}${tab ? `/${tab}` : ""}`; + // The single hardcoded search result until an ontology search backend exists. export const HARDCODED_RESULTS: OntologyEntry[] = [ONTOLOGY_CATALOG.precision]; +// --- ontology tabs ---------------------------------------------------------- + +export interface OntologyTab { + label: string; + path?: string; // route segment under /:org/ontology/:slug; absent = no view behind it yet +} + +// The tabs from the design. Only the two carrying a `path` are built; the rest are rendered +// disabled so the bar matches the design without offering dead links. +export const ONTOLOGY_TABS: OntologyTab[] = [ + { label: "Grid View", path: "" }, + { label: "Browse", path: "browse" }, + { label: "Specification" }, + { label: "Overview" }, + { label: "Variants" }, + { label: "Version History" }, + { label: "Discussions" }, +]; + // --- data source ------------------------------------------------------------ // The reasoned neurdf JSON-LD (requested with Accept: application/ld+json). @@ -110,6 +136,48 @@ export const DISPLAYED_PROPERTIES: string[] = [ "source", ]; -// External link target for a facet value / chip. All ref kinds resolve to their IRI; -// UBERON "internal view" mechanics are TBD (Tom), so link to the IRI for now. -export const linkFor = (iri?: string): string | undefined => iri || undefined; +// --- term links --------------------------------------------------------------- + +const OLS_BASE = "https://www.ebi.ac.uk/ols4"; + +// Curie prefix -> OLS ontology id, for the OBO-library ontologies referenced by these terms. +// A prefix that is absent is not an ontology OLS can show (NCBIGene is a sequence database, +// a DOI is a paper), so those keep their own IRI. +const OLS_ONTOLOGY_BY_PREFIX: Record = { + UBERON: "uberon", + CHEBI: "chebi", + NCBITaxon: "ncbitaxon", +}; + +// InterLex renders its own ILX terms; this is the app's route for one. +const interlexTermView = (curie: string): string | undefined => { + const match = /^ILX:(\d+)$/i.exec(curie); + // TODO point the group at the user's groupname once the API supports it — same TODO as + // SingleTermView/OverView/Hierarchy.jsx. + return match ? `/base/ilx_${match[1]}/overview` : undefined; +}; + +// Where a term opens (always a new tab), per the design decision: the internal view of the +// term when there is one, otherwise OLS for an ontology term we cannot render ourselves. +// +// Checked against the configured backend: `base/ilx_*` resolves, while `npokb`, `ilxtr` and +// `base/uberon_*` all answer 404. So an ILX term gets the internal view; an OBO term goes to +// OLS, because serving the internal view of an *external* term is still owed by the backend; +// and an InterLex-native term that is not an ILX id can only be named by its own IRI. +export const termLink = (ref?: { + curie?: string; + iri?: string; +}): string | undefined => { + if (!ref) return undefined; + const curie = ref.curie || ""; + const internal = interlexTermView(curie); + if (internal) return internal; + const ontology = OLS_ONTOLOGY_BY_PREFIX[curie.split(":")[0]]; + if (ontology) { + // OLS addresses a class by its IRI, encoded twice because the id is a path segment. + return ref.iri + ? `${OLS_BASE}/ontologies/${ontology}/classes/${encodeURIComponent(encodeURIComponent(ref.iri))}` + : `${OLS_BASE}/search?q=${encodeURIComponent(curie)}`; + } + return ref.iri || undefined; +}; diff --git a/src/components/CellCards/model/types.ts b/src/components/CellCards/model/types.ts index 239f9028..41fc3947 100644 --- a/src/components/CellCards/model/types.ts +++ b/src/components/CellCards/model/types.ts @@ -50,6 +50,9 @@ export interface CellTerm { curie: string; // display id, e.g. "npokb:1067" iri: string; // full IRI label: string; // ilxtr:localLabel -> rdfs:label -> curie + rdfTypes: string[]; // rdf:type as curies, owl:* preferred (e.g. ["owl:Class"]) + definition?: string; // best available description (see DEFINITION_KEYS) + definitionCurated?: boolean; // false when it is generated text rather than curated prose // Positive phenotypes keyed by local name (eqv ∪ ent, deduped). properties: Record; // Negated phenotypes (neurdf.*.neg) keyed by local name — rendered with a "not" modifier. @@ -57,6 +60,18 @@ export interface CellTerm { sources: ResolvedRef[]; // ilxtr:literatureCitation (a cell may have several) } +// One position of a class in the subClassOf hierarchy. A class with several parents is +// rendered under each of them, so `id` is the path that reached it (unique per position) +// while `termId` is the class itself (shared across its positions). +export interface HierarchyNode { + id: string; // e.g. "ilxtr:NeuronPrecision/npokb:1075/npokb:1014" + termId: string; + label: string; + curie: string; + iri: string; + children: HierarchyNode[]; +} + export interface OntologyMeta { iri: string; title: string; @@ -67,12 +82,14 @@ export interface OntologyMeta { export interface ParsedOntology { meta: OntologyMeta; cells: CellTerm[]; + hierarchy: HierarchyNode[]; // roots of the subClassOf tree (the root class, when there is one) } // A filter facet built from the data values across all cells. export interface FacetValue { key: string; // ResolvedRef.id — the stable value identity label: string; + curie: string; // compact form, which is what decides where the value links to iri: string; kind: RefKind; count: number; diff --git a/src/components/CellCards/services/ontologyGridService.ts b/src/components/CellCards/services/ontologyGridService.ts index 7e575ba3..4d5aae2a 100644 --- a/src/components/CellCards/services/ontologyGridService.ts +++ b/src/components/CellCards/services/ontologyGridService.ts @@ -11,13 +11,20 @@ import { PREDICATE_LABELS, PREDICATE_TOOLTIPS, } from "../config/gridConfig"; -import type { OntologyGraph, ParsedOntology, Facet, CellTerm } from "../model/types"; +import type { + OntologyGraph, + ParsedOntology, + Facet, + CellTerm, + HierarchyNode, +} from "../model/types"; import type { OntologyEntry } from "../config/gridConfig"; export interface LoadedOntology { entry: OntologyEntry; meta: ParsedOntology["meta"]; cells: CellTerm[]; + hierarchy: HierarchyNode[]; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -97,6 +104,7 @@ export const loadOntology = async (slug: string): Promise => { // (title/description/version) — no hardcoded overrides. meta: parsed.meta, cells: parsed.cells, + hierarchy: parsed.hierarchy, }; parsedCache.set(slug, loaded); return loaded; diff --git a/src/components/Header/Search.jsx b/src/components/Header/Search.jsx index 7cd06549..894a3fc5 100644 --- a/src/components/Header/Search.jsx +++ b/src/components/Header/Search.jsx @@ -20,7 +20,7 @@ import { GlobalDataContext } from "../../contexts/DataContext"; import { primeTermDataCache } from "../../hooks/useTermData"; import { SEARCH_TYPES } from "../../constants/types"; import { searchAll, elasticSearch } from "../../api/endpoints"; -import { HARDCODED_RESULTS } from "../CellCards/config/gridConfig"; +import { HARDCODED_RESULTS, ontologyPath } from "../CellCards/config/gridConfig"; import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined'; import { useEffect, useState, useCallback, useMemo, forwardRef, useContext } from 'react'; import { CloseIcon, ForwardIcon, SearchIcon, TermsIcon } from '../../Icons'; @@ -107,7 +107,7 @@ const Search = () => { const q = searchTerm.trim().toLowerCase(); return HARDCODED_RESULTS .filter((e) => !q || e.label.toLowerCase().includes(q) || e.curie.toLowerCase().includes(q)) - .map((e) => ({ label: e.label, name: e.label, submittedBy: e.curie, cellCardsSlug: e.slug })); + .map((e) => ({ label: e.label, name: e.label, submittedBy: e.curie, ontologyEntry: e })); }, [searchTerm]); const handleOpenList = () => setOpenList(true); @@ -119,9 +119,9 @@ const Search = () => { handleCloseList(); // CellCards ontology result → go straight to its grid view. - if (newInputValue.cellCardsSlug) { + if (newInputValue.ontologyEntry) { updateStoredSearchTerm(newInputValue.label); - navigate(`/cellcards/${newInputValue.cellCardsSlug}`); + navigate(ontologyPath(newInputValue.ontologyEntry)); return; } const groupName = getGroupName(); diff --git a/src/components/common/CustomTabs.jsx b/src/components/common/CustomTabs.jsx index aaa1ce5d..237ca7cd 100644 --- a/src/components/common/CustomTabs.jsx +++ b/src/components/common/CustomTabs.jsx @@ -70,7 +70,11 @@ const BasicTabs = ({ tabs, tabValue, handleChange, onMouseDown, parentBoxStyles, onMouseDown={(event) => event.preventDefault()} > { - tabs.map((tab, i) => ) + // A tab is either a plain label or { label, disabled } — the latter for a tab that + // is part of the design but has no view behind it yet. + tabs.map((tab, i) => typeof tab === 'string' + ? + : ) } @@ -78,7 +82,10 @@ const BasicTabs = ({ tabs, tabValue, handleChange, onMouseDown, parentBoxStyles, } BasicTabs.propTypes = { - tabs: PropTypes.array.isRequired, + tabs: PropTypes.arrayOf(PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ label: PropTypes.string.isRequired, disabled: PropTypes.bool }) + ])).isRequired, tabValue: PropTypes.number.isRequired, handleChange: PropTypes.func.isRequired, onMouseDown: PropTypes.func, diff --git a/src/parsers/neurdfParser.ts b/src/parsers/neurdfParser.ts index 4eb3e04c..c09f54a1 100644 --- a/src/parsers/neurdfParser.ts +++ b/src/parsers/neurdfParser.ts @@ -13,6 +13,7 @@ import type { CellProperty, OntologyMeta, ParsedOntology, + HierarchyNode, Facet, FacetValue, } from "../components/CellCards/model/types"; @@ -25,6 +26,23 @@ import type { // deliberately excluded — it is the verbose machine-generated string, never a title. const LABEL_KEYS = ["ilxtr:localLabel", "rdfs:label", "dc:title", "dcterms:title"]; +// Definition sources for the Terms table, best first. The first four are prose written by a +// curator. The last two are fallbacks so the column still describes a term that has none of +// those: ilxtr:genLabel is the phenotype string generated from the term's own axioms, and the +// curator note is a last resort. Everything past CURATED_DEFINITION_KEYS is reported as +// uncurated, so the UI can say where the text came from instead of passing generated output +// off as a definition (the same care LABEL_KEYS takes in excluding genLabel as a *title*). +const DEFINITION_KEYS = [ + "definition", + "skos:definition", + "NIFRID:definition", + "rdfs:comment", + "ilxtr:genLabel", + "ilxtr:curatorNote", +]; + +const CURATED_DEFINITION_KEYS = 4; + type LabelLike = | string | { "@value"?: string } @@ -253,17 +271,115 @@ const buildCell = (node: GraphNode, idx: Index): CellTerm => { } } + const definition = definitionOf(node); + return { id, curie, iri: toIri(id), label: labelFor(id, idx), + rdfTypes: rdfTypesOf(node), + definition: definition?.text, + definitionCurated: definition?.curated, properties, negated, sources, }; }; +// rdf:type for the Terms table. The design asks for the OWL type of the record +// (owl:Class / owl:ObjectProperty), so the neurdf marker types are dropped — but a record +// carrying no owl:* type shows every type it has rather than nothing. +const rdfTypesOf = (node: GraphNode): string[] => { + const all = asType(node["@type"]).map((t) => classify(t).curie); + const owl = all.filter((t) => t.startsWith("owl:")); + return owl.length ? owl : all; +}; + +const definitionOf = ( + node: GraphNode +): { text: string; curated: boolean } | undefined => { + for (let i = 0; i < DEFINITION_KEYS.length; i++) { + const text = firstString(node[DEFINITION_KEYS[i]]); + if (text) return { text, curated: i < CURATED_DEFINITION_KEYS }; + } + return undefined; +}; + +// Build the subClassOf hierarchy over the in-scope terms. +// +// The neurdf file is *reasoned*, so each term asserts a direct rdfs:subClassOf link to the root +// class **as well as** to its real parent(s) — taken literally that yields a flat list of 161 +// children. A transitive reduction drops every parent that another parent already implies, +// which recovers the curated shape (here 4 levels deep under the root). +// +// The result is a DAG, not a tree: 15 of the Precision terms have more than one direct parent. +// Each is rendered under every parent, so node ids are the path that reached them. +const buildHierarchy = ( + rootClass: string, + cells: CellTerm[], + idx: Index +): HierarchyNode[] => { + if (!rootClass) return []; + const inScope = new Set(cells.map((c) => c.id)); + const parentsOf = new Map(); + for (const cell of cells) { + parentsOf.set( + cell.id, + subClassRefs(idx.get(cell.id)).filter((p) => p === rootClass || inScope.has(p)) + ); + } + + const ancestorCache = new Map>(); + const ancestorsOf = (id: string): Set => { + const cached = ancestorCache.get(id); + if (cached) return cached; + const out = new Set(); + const stack = [...(parentsOf.get(id) || [])]; + while (stack.length) { + const cur = stack.pop() as string; + if (out.has(cur)) continue; + out.add(cur); + stack.push(...(parentsOf.get(cur) || [])); + } + ancestorCache.set(id, out); + return out; + }; + + const childrenOf = new Map(); + for (const cell of cells) { + const parents = parentsOf.get(cell.id) || []; + // Keep a parent only when no *other* parent already reaches it: that other parent is the + // more specific one, and this link is the entailed shortcut. + const direct = parents.filter((p) => !parents.some((q) => q !== p && ancestorsOf(q).has(p))); + for (const parent of direct.length ? direct : parents) { + const siblings = childrenOf.get(parent); + if (siblings) siblings.push(cell.id); + else childrenOf.set(parent, [cell.id]); + } + } + + const byId = new Map(cells.map((c) => [c.id, c])); + // `seen` is the current path — it stops a subClassOf cycle from recursing forever. + const nodeAt = (termId: string, path: string, seen: Set): HierarchyNode => { + const cell = byId.get(termId); + const kids = seen.has(termId) ? [] : childrenOf.get(termId) || []; + const nextSeen = new Set(seen).add(termId); + return { + id: path, + termId, + label: cell ? cell.label : labelFor(termId, idx), + curie: cell ? cell.curie : classify(termId).curie, + iri: cell ? cell.iri : toIri(termId), + children: kids + .map((kid) => nodeAt(kid, `${path}/${kid}`, nextSeen)) + .sort((a, b) => a.label.localeCompare(b.label)), + }; + }; + + return [nodeAt(rootClass, rootClass, new Set())]; +}; + export const parseOntologyMeta = (graph: GraphNode[]): OntologyMeta => { const onto = graph.find((n) => asType(n["@type"]).includes("owl:Ontology")); const iri = onto ? String(onto["@id"] ?? "") : ""; @@ -293,7 +409,11 @@ export const parseNeurdf = (data: OntologyGraph, rootClass: string): ParsedOntol : neurons; const cells = inScope.map((n) => buildCell(n, idx)); cells.sort((a, b) => a.label.localeCompare(b.label)); - return { meta: parseOntologyMeta(graph), cells }; + return { + meta: parseOntologyMeta(graph), + cells, + hierarchy: buildHierarchy(rootClass, cells, idx), + }; }; // Readable fallback title for a predicate with no configured label: drop the "has" prefix @@ -329,7 +449,15 @@ export const buildFacets = ( for (const v of prop.values) { const existing = counts.get(v.id); if (existing) existing.count += 1; - else counts.set(v.id, { key: v.id, label: v.label, iri: v.iri, kind: v.kind, count: 1 }); + else + counts.set(v.id, { + key: v.id, + label: v.label, + curie: v.curie, + iri: v.iri, + kind: v.kind, + count: 1, + }); } } if (counts.size >= minOptions) { diff --git a/src/theme/index.jsx b/src/theme/index.jsx index dfb0fe0a..d2add696 100644 --- a/src/theme/index.jsx +++ b/src/theme/index.jsx @@ -119,6 +119,14 @@ const theme = createTheme({ color: gray600, fontWeight: 600, }, + // Widget/section title (Figma "Text md/Medium" + Gray/800) — the "Ontology hierarchy" + // and "Terms" panel titles on the ontology Browse tab. + sectionTitle: { + color: gray800, + fontSize: '1rem', + fontWeight: 500, + lineHeight: 1.5, + }, }, components: { @@ -186,6 +194,11 @@ const theme = createTheme({ `, }, MuiTypography: { + defaultProps: { + variantMapping: { + sectionTitle: 'h2' + } + }, styleOverrides: { h6: { fontSize: '1.125rem', @@ -240,7 +253,6 @@ const theme = createTheme({ }, MuiRichTreeView: { styleOverrides: { - backgroundColor: "red", root: { "& .MuiTreeItem-root": { position: "relative", @@ -994,24 +1006,59 @@ const theme = createTheme({ }, }, }, + // The Terms table on the ontology Browse tab. Column-header and cell metrics mirror the + // MuiTable overrides above so the two table flavours read as one component. MuiDataGrid: { styleOverrides: { root: { - height: "90%", + // v7 paints this variable over the header and any pinned row, so the header fill + // has to be set here rather than on the columnHeaders slot. + "--DataGrid-containerBackground": gray50, borderColor: gray200, borderRadius: ".75rem", boxShadow: "0px 1px 3px 0px rgba(16, 24, 40, 0.10), 0px 1px 2px 0px rgba(16, 24, 40, 0.06)", - - "& .MuiDataGrid-columnHeaderRow": { - backgroundColor: "red", + // The design has no vertical rules between columns. + "& .MuiDataGrid-columnSeparator": { + display: "none", }, }, columnHeaders: { - width: "100% !important", - '& [role="row"]': { - backgroundColor: `${gray200} !important`, - border: "0 !important", + borderTopLeftRadius: ".75rem", + borderTopRightRadius: ".75rem", + }, + columnHeaderTitle: { + fontSize: "0.75rem", + fontWeight: 500, + color: gray600, + }, + columnHeader: { + padding: "0 1.5rem", + }, + cell: { + // 0.5rem of padding around two 1.25rem lines is exactly the 3.5rem row the grid + // asks for, so a wrapped label or definition fits without changing the row. + padding: "0.5rem 1.5rem", + // flex is restated because a cell rendering an element rather than bare text + // computes to display:block, which would leave alignItems inert and top-align + // that column against its neighbours. + display: "flex", + alignItems: "center", + whiteSpace: "normal", + // The grid otherwise sets line-height to the whole row height to centre a single + // line; flex does that job here, and a row-tall line-height would space wrapped + // text by 3.5rem a line. + lineHeight: "1.25rem", + // Content taller than the row is clipped here rather than spilling over the + // rows above and below it. + overflow: "hidden", + borderColor: gray200, + color: gray600, + // A centred column holds a control, not text: side padding would squeeze it and + // trip the cell's own text-overflow ellipsis. + "&.MuiDataGrid-cell--textCenter": { + paddingLeft: 0, + paddingRight: 0, }, }, }, diff --git a/yarn.lock b/yarn.lock index 076f9d92..7972f526 100644 --- a/yarn.lock +++ b/yarn.lock @@ -233,6 +233,11 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.25.7", "@babel/runtime@^7.28.6": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768" + integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw== + "@babel/template@^7.22.15", "@babel/template@^7.24.0": version "7.24.0" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.0.tgz#c6a524aa93a4a05d66aaf31654258fae69d87d50" @@ -922,6 +927,13 @@ resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.14.tgz#8a02ac129b70f3d82f2f9b76ded2c8d48e3fc8c9" integrity sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ== +"@mui/types@^7.4.12": + version "7.4.12" + resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.4.12.tgz#e4eba37a7506419ea5c5e0604322ba82b271bf46" + integrity sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w== + dependencies: + "@babel/runtime" "^7.28.6" + "@mui/utils@^5.15.14": version "5.15.14" resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.15.14.tgz#e414d7efd5db00bfdc875273a40c0a89112ade3a" @@ -932,6 +944,39 @@ prop-types "^15.8.1" react-is "^18.2.0" +"@mui/utils@^5.16.6 || ^6.0.0 || ^7.0.0": + version "7.3.11" + resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-7.3.11.tgz#493f46f053fe3a692e041b1b6b8295e2f46d9448" + integrity sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g== + dependencies: + "@babel/runtime" "^7.28.6" + "@mui/types" "^7.4.12" + "@types/prop-types" "^15.7.15" + clsx "^2.1.1" + prop-types "^15.8.1" + react-is "^19.2.3" + +"@mui/x-data-grid@^7.29.13": + version "7.29.13" + resolved "https://registry.yarnpkg.com/@mui/x-data-grid/-/x-data-grid-7.29.13.tgz#3bbba18ef29981c324bd0030496abb8e04e8be06" + integrity sha512-XHrZTvpa61eqSgUIevDzXYfYCbI7cbN/aRxSCaw2cZzQZ/USdpECYbnTEhgw5XgUlvkAK0mItKZmgKM4X7zT+Q== + dependencies: + "@babel/runtime" "^7.25.7" + "@mui/utils" "^5.16.6 || ^6.0.0 || ^7.0.0" + "@mui/x-internals" "7.29.0" + clsx "^2.1.1" + prop-types "^15.8.1" + reselect "^5.1.1" + use-sync-external-store "^1.0.0" + +"@mui/x-internals@7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@mui/x-internals/-/x-internals-7.29.0.tgz#1f353b697ed1bf5594ac549556ade2e6841f4bf5" + integrity sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA== + dependencies: + "@babel/runtime" "^7.25.7" + "@mui/utils" "^5.16.6 || ^6.0.0 || ^7.0.0" + "@mui/x-tree-view@^7.5.0": version "7.6.2" resolved "https://registry.yarnpkg.com/@mui/x-tree-view/-/x-tree-view-7.6.2.tgz#bc8fe46b2971539ec43056ae12a3805d15c13d92" @@ -1472,6 +1517,11 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== +"@types/prop-types@^15.7.15": + version "15.7.15" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" + integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== + "@types/react-dom@^18.2.22": version "18.2.25" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.25.tgz#2946a30081f53e7c8d585eb138277245caedc521" @@ -4514,6 +4564,11 @@ react-is@^18.2.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== +react-is@^19.2.3: + version "19.2.8" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.8.tgz#09826f9fbc187bc668e3e5c62edc001f804d5018" + integrity sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ== + react-refresh@^0.14.0: version "0.14.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" @@ -4642,6 +4697,11 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +reselect@^5.1.1: + version "5.2.0" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.2.0.tgz#f380ef7664332d26ea06c1cba04bdbbdcaa955f1" + integrity sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -5267,6 +5327,11 @@ urijs@^1.19.11: resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.11.tgz#204b0d6b605ae80bea54bea39280cdb7c9f923cc" integrity sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ== +use-sync-external-store@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + utility-types@^3.10.0: version "3.11.0" resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.11.0.tgz#607c40edb4f258915e901ea7995607fdf319424c" From 69d0c342a022532d710db4cedc36ebb34b8f6323 Mon Sep 17 00:00:00 2001 From: Filippo Ledda Date: Tue, 28 Jul 2026 17:37:26 +0200 Subject: [PATCH 05/11] Add claude specs --- .claude/skills/check-figma-design/SKILL.md | 96 ++++++++ .claude/skills/material-ui/SKILL.md | 250 +++++++++++++++++++++ CLAUDE.md | 23 ++ 3 files changed, 369 insertions(+) create mode 100644 .claude/skills/check-figma-design/SKILL.md create mode 100644 .claude/skills/material-ui/SKILL.md create mode 100644 CLAUDE.md diff --git a/.claude/skills/check-figma-design/SKILL.md b/.claude/skills/check-figma-design/SKILL.md new file mode 100644 index 00000000..3db614f8 --- /dev/null +++ b/.claude/skills/check-figma-design/SKILL.md @@ -0,0 +1,96 @@ +--- +name: check-figma-design +description: Read/verify a UI against its Figma design (read-only) using the Figma MCP tools — extract the node tree, screenshots, and design tokens, then map them to the interlex theme. Use whenever a task references Figma, a figma.com URL, or asks to match/verify a screen, header, component, spacing, or tokens against the design. +--- + +# Checking a design in Figma (read-only) + +This is the **inspect** workflow (design → verify/implement), not the authoring one. Do **not** +guess layout or tokens — read them from Figma. Every UI decision "per the design" must trace to a +node you actually fetched. + +## 0. Prerequisites + +The Figma tools are exposed as `mcp__claude_ai_Figma__*` (they are deferred — load with +`ToolSearch` query `figma` if not already visible). Confirm access with +`mcp__claude_ai_Figma__whoami`. If it fails, the connector isn't attached — tell the user to +authorize the Figma connector; do not try to run an auth flow. + +A local Figma **Dev Mode MCP server** (`127.0.0.1:3845`) may also be available and exposes the same +`get_metadata` / `get_screenshot` / `get_variable_defs` tools with no auth. Either works. + +## 1. Get `fileKey` and `nodeId` from the URL + +Every call needs **both** `fileKey` and `nodeId`. Omitting `fileKey` fails with +`MCP error -32602: Tool argument fileKey is required`. + +From a URL like +`figma.com/design/vjQBZc9IbwGcC0VxViBNp0/Interlex-UI?node-id=9236-61323&m=dev`: +- **fileKey** = the segment after `/design/` → `vjQBZc9IbwGcC0VxViBNp0` +- **nodeId** = the `node-id` value, converting the dash to a colon → `9236-61323` becomes `9236:61323` + +**Interlex file:** `vjQBZc9IbwGcC0VxViBNp0` ("Interlex-UI"). The CellCards grid screen (nav + header + +sidebar + grid) is the frame **`9236:58969`** ("Ontology Grid View"). + +## 2. The tools, in the order you use them + +1. **`get_metadata`** — the node tree: child frames with `id`, `name`, `x/y/width/height`, and + `hidden="true"` on hidden variants. This is your map. Read it first to find the sub-node you care + about (walk the named frames: "Updated header" → "header" → "Frame 362" …). +2. **`get_screenshot`** — a rendered PNG of a node. Returns JSON with `image_url` (short-lived — + treat like a secret). Download then view: + ``` + curl -sL -o /tmp/.../shot.png "" + ``` + then `Read` the PNG. The screenshot is **cropped to the requested node** — request a parent node + to see surrounding context. +3. **`get_variable_defs`** — design tokens for a node (colors like `Gray/600 #515252`, fonts like + `Text sm/Medium`, spacing, shadows). Use these to match styling. +4. **`get_design_context`** — only when implementing; call it **after** `get_metadata` (the metadata + tool result will remind you). Returns richer implementation detail for a subtree. + +## 3. Gotchas learned the hard way + +- **A node id is often only PART of a screen.** Check the `x/y` in the metadata: if a "Container" + starts at `y=354`, the header/breadcrumb lives in a **sibling frame above it**, not inside. Fetch + the **parent screen frame** (e.g. `9236:58969`) to see the whole thing, then drill down. +- **`hidden="true"` = a hidden variant/state** (copy-link states, alternate button groups, deferred + features). Ignore these for the default view unless you're implementing that state. +- **Node names are layer names, not guaranteed content.** A text node named `NPOprecisionCellType` + may render different (or stale) text. Confirm real strings from a screenshot and from the actual + data source, never from the layer name alone. +- **Coordinates are in the parent's space**, so nesting matters when reasoning about position. +- The screenshot JSON also gives `original_width/height` vs returned `width/height` — use the ratio + if you need pixel-accurate measurements off the image. + +## 4. Turn design into interlex code — theme only, no custom styling + +**Standing rule: do not add custom styling.** Build the UI from **existing Material UI components** +and the **interlex theme**. Do not introduce new colors, borders, radii, shadows, font sizes/weights, +or bespoke CSS — *unless the user explicitly asks for it*. If the design seems to need something the +theme doesn't have, prefer the closest existing token/variant/component, and surface the gap to the +user rather than inventing a style. + +Map what `get_variable_defs` returns onto the theme: +- Figma `Gray/*`, `Brand/*`, `Success/*` → `vars` in [src/theme/variables.js](src/theme/variables.js). +- Chips/badges → the `MuiChip` variants in [src/theme/index.jsx](src/theme/index.jsx) + (`color="secondary"` ≈ blue, `color="success"` ≈ green, `variant="outlined"` ≈ gray). +- Fonts (`Display sm/Semibold`, `Text sm/*`) → the nearest MUI `Typography` variant. +- Reuse the shared primitives in [src/components/common/](src/components/common/) (breadcrumbs, + selects, pagination, buttons…) before writing anything new. +- The **only** thing `sx` is for is **layout** — flex, grid, gap, padding, width. Never colors/type in `sx`. + +## 5. Iterate on the running app until it matches + +The app runs at **http://localhost:5173/** (start with `yarn dev` if it isn't up — check with a +`curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/`). **Getting the design right is a +loop, not a single pass:** navigate to the real route (e.g. `/cellcards/precision`), render it, +compare against the Figma node, adjust, and repeat until they line up. + +Do this against the live page, not from memory: +- Screenshot the running route (headless Playwright is set up in the scratchpad scripts) using the + **same crop region** as the Figma screenshot, and view them side by side — don't trust that the + code "looks right". +- Check both light/dark and both states of any toggle, and re-render after every change. +- Only call the design done once the live page and the Figma frame agree on structure, labels, + spacing, and tokens. diff --git a/.claude/skills/material-ui/SKILL.md b/.claude/skills/material-ui/SKILL.md new file mode 100644 index 00000000..8a687406 --- /dev/null +++ b/.claude/skills/material-ui/SKILL.md @@ -0,0 +1,250 @@ +--- +name: material-ui +description: Best practices for building UI with Material UI in this project — the customization ladder (sx vs styled vs theme), consuming the semantic palette instead of raw `vars`, picking the right component instead of Box, v5-vs-v7 API differences, and sx performance limits. Use whenever adding or restyling UI (cards, lists, tables, chips, dialogs, grids), touching src/theme/, choosing a color, reaching for `sx` with a color/radius/shadow/font in it, or copying a snippet from the MUI docs. +--- + +# Material UI in interlex + +**MUI v5** (`@mui/material ^5.15.15`), React 18, `ThemeProvider` + `CssBaseline` in +[src/App.jsx:280](src/App.jsx#L280). UI components are `.jsx` with `prop-types`; TypeScript is +confined to the data layer (`src/model`, `src/api`, `src/parsers`), so MUI's TS-specific advice +(`SxProps`, module augmentation) rarely applies in components. + +**Read §6 before copying any snippet from mui.com** — the live docs are v7 and several APIs differ. + +## 1. The customization ladder — use the narrowest rung that fits + +MUI defines four scopes of customization. Picking the wrong rung is the most common design-system +failure: one-off `sx` where a theme override belongs means the next component looks different. + +| Scope | Use | Rung | +| --- | --- | --- | +| One instance | `sx` prop | 1 — "the best option for a single instance in most cases" | +| A pattern reused across the app | `styled()` | 2 — already used in [CustomizedRadio](src/components/common/CustomizedRadio.jsx), [CustomFormField](src/components/common/CustomFormField.jsx) | +| *Every* instance of a component | theme `components.MuiX` | 3 — see §3 | +| Bare HTML elements | `CssBaseline` / `GlobalStyles` | 4 — hoist `GlobalStyles` out of the render so it isn't recalculated | + +Two rules that follow from this: + +- **`sx` beats theme `styleOverrides` on specificity.** So if you find yourself using `sx` to fight + a style the theme already sets, you're on the wrong rung — fix the theme. +- **In this project `sx` is for layout only** (flex, grid, gap, padding, width). Colors come from + the palette and typography/radii/shadows from the theme — see §4. This is the house rule in + [CLAUDE.md](CLAUDE.md). + +When overriding a state, **pair the state class with the component class** — +`& .MuiChip-root.Mui-disabled`, not `& .Mui-disabled` alone. State class names on their own are +documented as unstable selectors and leak into descendants. + +## 2. Reach for the component, not the `div` + +`Box` is a styled `div`. Reaching for it means opting out of the design system's semantics, +accessibility and theming. + +| Concept | Use | Not | +| --- | --- | --- | +| Card / tile / panel | `Card` + `CardContent` (+ `CardHeader`, `CardActions`) | bordered `Box` | +| Spaced run of children | `Stack` | `Box` with `display:flex; gap` | +| List of records | `List` / `ListItem` / `ListItemText` | a `Box` per row | +| Tabular data | `Table` family | `Grid` | +| Label / tag / badge | `Chip` | styled `Typography` | +| Centred max-width page body | `Container` | `Box` with `margin: auto` | +| Any text | a `Typography` variant | raw tags + `fontSize` | + +**Then check [src/components/common/](src/components/common/)** — breadcrumbs, selects, pagination, +buttons, checkboxes, dialogs and tree views already exist as project primitives. + +**Prefer props over styles.** `variant`, `size`, `color`, `disableGutters`, `dense`, `gutterBottom` +express intent and stay themeable; the equivalent `sx` does not. And use `Typography`'s `component` +prop to keep semantics independent of looks: `` +renders an `

` that *looks* like a subtitle — visual hierarchy and document outline shouldn't be +forced to agree. + +## 3. Theme overrides: three keys + +```js +MuiChip: { + defaultProps: { size: "small" }, // change defaults everywhere + styleOverrides: { root: {…}, label: {…} }, // per-slot styles; `root` is the outer element + variants: [ // conditional on props + { props: { variant: "dashed" }, style: {…} }, // later entries win + ], +} +``` + +- `styleOverrides` keys are **slot names**, not CSS — check the component's API page for its slots + (`root`, `label`, `icon`, …). Nested selectors are allowed inside a slot. +- `variants` accepts an object matcher or a callback (`(props) => props.variant === "dashed"`). +- `ownerState` callbacks in `styleOverrides` **are supported in v5** (deprecated only in v7). + `variants` does the same job and is forward-compatible — prefer it for anything new. +- `theme.unstable_sx({ px: 1, borderRadius: 1 })` lets you write `sx` shorthand *inside* the theme. + Experimental in v5, but it keeps theme code reading like call-site code. + +## 4. Colors come from the palette + +[src/theme/index.jsx](src/theme/index.jsx) defines a full semantic `palette`. +[src/theme/variables.js](src/theme/variables.js) (`vars`) remains the **source of truth** and feeds +it — but that's theme-layer plumbing. **Call sites consume the palette, not `vars`:** + +```jsx + + + + + ); + } + + if (!cell) { + const entry = ONTOLOGY_CATALOG[ontologySlug] || ONTOLOGY_CATALOG.precision; + return ( + navigate(ontologyPath(entry))}> + Browse the ontology + + } + /> + ); + } + + return ( + + + + ); +}; + +CellCardPanel.propTypes = { + term: PropTypes.string.isRequired, + group: PropTypes.string.isRequired, + // Reports the label resolved from the ontology graph, for the page's H1 and breadcrumb. + onTermLabel: PropTypes.func, +}; + +export default CellCardPanel; diff --git a/src/components/CellCards/CellCard/CellCardWidget.jsx b/src/components/CellCards/CellCard/CellCardWidget.jsx new file mode 100644 index 00000000..1d8d54d2 --- /dev/null +++ b/src/components/CellCards/CellCard/CellCardWidget.jsx @@ -0,0 +1,44 @@ +import PropTypes from "prop-types"; +import { Stack, Typography, Box } from "@mui/material"; + +/** + * The shell every Cell Card widget shares: a title row with optional action icons, then the + * widget body (Figma component "Widget header", 9535:96275). + * + * Intentionally NOT a `Card`. CLAUDE.md prefers Card/CardContent for a card, but this design + * is deliberately border-less and shadow-less — Sara, design review: "more minimalistic and + * clean without the borders". Widgets are separated by the column's Dividers, not by their own + * frames. This follows the existing OntologyHierarchyPanel pattern (Stack + sectionTitle), so + * please don't "fix" it into a Card. + */ +const CellCardWidget = ({ title, count, actions, children, id }) => ( + + + {title} + {count != null && ( + + {count} + + )} + {/* Actions sit hard right; `mr: -1` pulls the icon button's hit area back so the glyph + lines up with the column edge rather than the padding. */} + {actions && ( + + {actions} + + )} + + {children} + +); + +CellCardWidget.propTypes = { + title: PropTypes.string.isRequired, + // Secondary count beside the title, e.g. "21 cells" on Other cells from this source. + count: PropTypes.string, + actions: PropTypes.node, + children: PropTypes.node, + id: PropTypes.string, +}; + +export default CellCardWidget; diff --git a/src/components/CellCards/CellCard/DefinitionBanner.jsx b/src/components/CellCards/CellCard/DefinitionBanner.jsx new file mode 100644 index 00000000..e110668f --- /dev/null +++ b/src/components/CellCards/CellCard/DefinitionBanner.jsx @@ -0,0 +1,73 @@ +import { Fragment } from "react"; +import PropTypes from "prop-types"; +import { Alert, AlertTitle, Typography, Link } from "@mui/material"; +import TermValueLink from "./TermValueLink"; +import { toDoi } from "./citationService"; + +const joinRefs = (values, conjunction = "and") => + values.map((v, i) => ( + + {i > 0 && (i === values.length - 1 ? ` ${conjunction} ` : ", ")} + + + )); + +/** + * §2.3 Auto-generated Description (Figma 9239:67573, the "Definition" banner). + * + * Assembled from the cell's structured properties rather than read from a definition field: not + * one of the 161 Precision cells carries `definition`, `skos:definition`, `NIFRID:definition` or + * `rdfs:comment`. (The parser's `definition` therefore always falls through to + * `ilxtr:genLabel`, a ~300-character machine string — usable as a tooltip, not as prose.) + * + * Suppressed when fewer than two of species / soma location / marker genes are populated, so it + * never renders a sentence with holes in it. + */ +const DefinitionBanner = ({ cell }) => { + const taxon = cell.properties.hasInstanceInTaxon?.values || []; + const soma = cell.properties.hasSomaLocatedIn?.values || []; + const genes = cell.properties.hasNucleicAcidExpressionPhenotype?.values || []; + const baseClass = cell.properties.neurondmBaseClass?.values?.[0]; + const source = cell.sources?.[0]; + const doi = toDoi(source?.iri || source?.id); + + return ( + // The design's banner is a brand-tinted panel with a title, and carries no status icon. + + Definition + + This {baseClass ? baseClass.label : "cell"} + {taxon.length > 0 && <>, observed in {joinRefs(taxon)},} + {soma.length > 0 && ( + <> + {" "} + is located in {joinRefs(soma, cell.properties.hasSomaLocatedIn?.combinator === "or" ? "or" : "and")} + + )} + {genes.length > 0 && <> and expresses {joinRefs(genes)}} + {"."} + {source && ( + <> + {" "} + To access the source nomenclature, view{" "} + {doi ? ( + + {source.label && source.label !== source.curie ? source.label : doi} + + ) : ( + + )} + {"."} + + )} + + + ); +}; + +DefinitionBanner.propTypes = { + cell: PropTypes.object.isRequired, +}; + + +export default DefinitionBanner; diff --git a/src/components/CellCards/CellCard/PropertyList.jsx b/src/components/CellCards/CellCard/PropertyList.jsx new file mode 100644 index 00000000..3ed4b3cf --- /dev/null +++ b/src/components/CellCards/CellCard/PropertyList.jsx @@ -0,0 +1,117 @@ +import PropTypes from "prop-types"; +import { Stack, Divider, Typography, Chip, Box, Tooltip } from "@mui/material"; +import TermValueLink from "./TermValueLink"; +import { NOT_SPECIFIED } from "../config/cellCardConfig"; + +// How several values on one predicate read. neurdf distinguishes an owl:unionOf (the soma is in +// one of these) from independent assertions, and rendering both as "A, B" would state something +// the ontology does not: "dorsal root ganglion or trigeminal ganglion" is not the same claim as +// asserting both. 61 of the 161 Precision cells carry a union soma location, so this matters. +const joinValues = (combinator) => (combinator === "or" ? " or " : combinator === "and" ? " and " : ", "); + +/** + * One row: label left, value(s) right (Figma component "Biological properties item", + * 9535:92212 — a 50/50 split with the value right-aligned). + * + * A row with no value renders "not specified" when `required`, so the table keeps the fixed + * shape the design shows; otherwise it hides itself and the widget collapses. + */ +export const PropertyRow = ({ label, tooltip, prop, render = "text", required = false }) => { + const values = prop?.values || []; + if (!values.length && !required) return null; + + const labelNode = ( + + {label} + + ); + + return ( + + {/* The predicate's own ilxtr:shortDefinition, when the ontology ships one. */} + {tooltip ? ( + + {/* Tooltip needs a DOM node that can hold a ref and receive hover. */} + + {labelNode} + + + ) : ( + labelNode + )} + + {!values.length ? ( + // `notSpecified` is a theme variant: the greyed italic is a design token, not a call-site + // style. See src/theme/index.jsx typography. + {NOT_SPECIFIED} + ) : render === "chip" ? ( + + {values.map((v) => ( + // The theme caps chip labels at 20ch, so a long gene symbol needs its own title. + + ))} + + ) : ( + + {values.map((v, i) => ( + + {i > 0 && ( + + {joinValues(prop.combinator)} + + )} + + + ))} + + )} + + ); +}; + +PropertyRow.propTypes = { + label: PropTypes.string.isRequired, + tooltip: PropTypes.string, + prop: PropTypes.object, + render: PropTypes.oneOf(["text", "chip"]), + required: PropTypes.bool, +}; + +/** + * A divider-separated run of property rows. Rows are built by the calling widget from + * `cellCardConfig`, so order and membership are config, not JSX. + */ +const PropertyList = ({ rows }) => { + const visible = rows.filter((r) => r.required || r.prop?.values?.length); + if (!visible.length) return null; + + return ( + } gap={1} sx={{ "& > hr": { my: 0 } }}> + {visible.map((row) => ( + + ))} + + ); +}; + +PropertyList.propTypes = { + rows: PropTypes.arrayOf( + PropTypes.shape({ + localName: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + tooltip: PropTypes.string, + prop: PropTypes.object, + render: PropTypes.oneOf(["text", "chip"]), + required: PropTypes.bool, + }) + ).isRequired, +}; + +export default PropertyList; diff --git a/src/components/CellCards/CellCard/RelationshipGraphSvg.jsx b/src/components/CellCards/CellCard/RelationshipGraphSvg.jsx new file mode 100644 index 00000000..75aaed1e --- /dev/null +++ b/src/components/CellCards/CellCard/RelationshipGraphSvg.jsx @@ -0,0 +1,307 @@ +import { useMemo } from "react"; +import PropTypes from "prop-types"; +import * as dagre from "@dagrejs/dagre"; +import { Box } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; + +// Node box metrics. Widths are derived from the label rather than fixed: these cell names run +// long ("DRG C-NP.MRGPRX1 MRGPRX4 (Bhuiyan2025)") and a fixed box truncated them to the point of +// ambiguity. Heights are fixed — one or two lines. +const NODE_MIN_W = 148; +const NODE_MAX_W = 260; +const CHAR_W = 6.6; // approximate advance width of Inter at 12px +const NODE_H_1 = 34; // title only +const NODE_H_2 = 52; // title + subtitle +const RANK_SEP = 96; +const NODE_SEP = 40; +const PAD = 16; + +const nodeHeight = (n) => (n.subtitle ? NODE_H_2 : NODE_H_1); + +const nodeWidth = (n) => { + const longest = Math.max(n.label.length, (n.subtitle || "").length * 0.9); + return Math.min(NODE_MAX_W, Math.max(NODE_MIN_W, Math.round(longest * CHAR_W) + 24)); +}; + +// How many characters fit in a box of this width, so truncation matches the drawn box. +const fits = (width) => Math.floor((width - 20) / CHAR_W); +const clamp = (text, width) => + text.length > fits(width) ? `${text.slice(0, Math.max(1, fits(width) - 1))}…` : text; + +// Edge style per relation kind, matching the expanded legend (Figma 8917:35906): +// subclass-of and soma-location are dashed, asserted-subclass and expresses are solid, and only +// "expresses" is drawn in the brand colour. +const edgeStyle = (kind, palette) => { + switch (kind) { + case "subClassOf": + return { stroke: palette.grey[400], dash: "2 4" }; + case "somaLocation": + return { stroke: palette.grey[500], dash: "6 4" }; + case "expresses": + return { stroke: palette.primary.main, dash: undefined }; + case "assertedSubClassOf": + default: + return { stroke: palette.grey[400], dash: undefined }; + } +}; + +/** + * Lay out and draw the relationship graph. + * + * dagre computes ranks and positions; we draw the SVG ourselves so nodes keep the design's + * rounded-rect style (title + "npokb:998 · Bhuiyan2025" subtitle) and the four edge styles. + * + * Lateral satellites (soma location, expresses) are placed by hand afterwards rather than left + * to dagre: dagre guarantees the *rank* but the order within a rank is heuristic, so "soma on + * the left, expression on the right" would otherwise be luck. They are excluded from the layout + * graph entirely and pinned relative to the current node once it has a position. + */ +const RelationshipGraphSvg = ({ graph, onSelect, height }) => { + const theme = useTheme(); + + const layout = useMemo(() => { + const lateral = graph.edges.filter((e) => e.direction === "left" || e.direction === "right"); + const lateralIds = new Set(lateral.map((e) => e.to)); + const structural = graph.edges.filter((e) => !lateralIds.has(e.to)); + + const g = new dagre.graphlib.Graph({ multigraph: true }); + g.setGraph({ rankdir: "TB", ranksep: RANK_SEP, nodesep: NODE_SEP, marginx: PAD, marginy: PAD }); + g.setDefaultEdgeLabel(() => ({})); + + for (const n of graph.nodes) { + if (lateralIds.has(n.id)) continue; + g.setNode(n.id, { width: nodeWidth(n), height: nodeHeight(n) }); + } + structural.forEach((e, i) => { + if (g.hasNode(e.from) && g.hasNode(e.to)) g.setEdge(e.from, e.to, {}, `e${i}`); + }); + + dagre.layout(g); + + const positions = new Map(); + for (const id of g.nodes()) { + const { x, y } = g.node(id); + positions.set(id, { x, y }); + } + + const current = graph.nodes.find((n) => n.isCurrent); + const currentPos = current && positions.get(current.id); + + // Pin each satellite on its own side of the current node, on the same rank. dagre fixes the + // rank but not the order within it, so left/right would otherwise be luck. + for (const edge of lateral) { + if (!currentPos || !current) continue; + const node = graph.nodes.find((n) => n.id === edge.to); + if (!node) continue; + // Half of each box plus room for the caption between them. + const offset = nodeWidth(current) / 2 + nodeWidth(node) / 2 + 128; + positions.set(edge.to, { + x: currentPos.x + (edge.direction === "left" ? -offset : offset), + y: currentPos.y, + }); + } + + // Recompute the extent, since the pinned satellites sit outside dagre's own bounds. + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const n of graph.nodes) { + const p = positions.get(n.id); + if (!p) continue; + const h = nodeHeight(n); + const w = nodeWidth(n); + minX = Math.min(minX, p.x - w / 2); + maxX = Math.max(maxX, p.x + w / 2); + minY = Math.min(minY, p.y - h / 2); + maxY = Math.max(maxY, p.y + h / 2); + } + if (!Number.isFinite(minX)) return null; + + // Shift into a 0-based viewBox with padding on every side. + const dx = PAD - minX; + const dy = PAD - minY; + for (const [id, p] of positions) positions.set(id, { x: p.x + dx, y: p.y + dy }); + + return { + positions, + width: maxX - minX + PAD * 2, + height: maxY - minY + PAD * 2, + }; + }, [graph]); + + if (!layout) return null; + + const { palette } = theme; + + // Orthogonal connector: leave the source vertically, travel, then enter the target. Matches + // the design's right-angled elbows rather than dagre's spline points. + const path = (from, to, fromNode, toNode, direction) => { + if (direction === "left" || direction === "right") { + const sign = direction === "left" ? -1 : 1; + const x1 = from.x + (sign * nodeWidth(fromNode)) / 2; + const x2 = to.x - (sign * nodeWidth(toNode)) / 2; + return `M ${x1} ${from.y} L ${x2} ${to.y}`; + } + const fromH = nodeHeight(fromNode); + const toH = nodeHeight(toNode); + const up = to.y < from.y; + const y1 = from.y + (up ? -fromH / 2 : fromH / 2); + const y2 = to.y + (up ? toH / 2 : -toH / 2); + const mid = (y1 + y2) / 2; + return `M ${from.x} ${y1} L ${from.x} ${mid} L ${to.x} ${mid} L ${to.x} ${y2}`; + }; + + // First edge index per relation kind — the one that carries the caption. + const captioned = new Map(); + graph.edges.forEach((edge, i) => { + if (!captioned.has(edge.kind)) captioned.set(edge.kind, i); + }); + + return ( + + 560 ? layout.width : undefined }} + role="img" + aria-label="Relationship graph" + > + + {["subClassOf", "somaLocation", "expresses", "assertedSubClassOf"].map((kind) => { + const { stroke } = edgeStyle(kind, palette); + return ( + + + + ); + })} + + + {graph.edges.map((edge, i) => { + const from = layout.positions.get(edge.from); + const to = layout.positions.get(edge.to); + const fromNode = graph.nodes.find((n) => n.id === edge.from); + const toNode = graph.nodes.find((n) => n.id === edge.to); + if (!from || !to || !fromNode || !toNode) return null; + const { stroke, dash } = edgeStyle(edge.kind, palette); + // The design captions a *relation*, not every edge of it: four parents share one + // "subclass of" on the trunk. Repeating it per edge collided at the shared midpoint. + const label = captioned.has(edge.kind) && captioned.get(edge.kind) === i ? edge.label : null; + const lx = + edge.direction === "left" || edge.direction === "right" + ? (from.x + to.x) / 2 + : from.x; + const ly = (from.y + to.y) / 2; + return ( + + + {label && ( + <> + {/* A filled plate behind the caption so the connector does not run through it. */} + + + {label} + + + )} + + ); + })} + + {graph.nodes.map((node) => { + const p = layout.positions.get(node.id); + if (!p) return null; + const h = nodeHeight(node); + const w = nodeWidth(node); + const clickable = Boolean(node.ref && onSelect && !node.isCurrent); + return ( + onSelect(node) : undefined} + style={{ cursor: clickable ? "pointer" : "default" }} + > + + + {/* Truncate to the box rather than overflow it; carries the full text. */} + {clamp(node.label, w)} + {node.flagged ? "*" : ""} + + {node.subtitle && ( + + {clamp(node.subtitle, w)} + + )} + {[node.label, node.subtitle].filter(Boolean).join(" — ")} + + ); + })} + + + ); +}; + +RelationshipGraphSvg.propTypes = { + graph: PropTypes.shape({ + nodes: PropTypes.array.isRequired, + edges: PropTypes.array.isRequired, + }).isRequired, + onSelect: PropTypes.func, + height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), +}; + +export default RelationshipGraphSvg; diff --git a/src/components/CellCards/CellCard/TermValueLink.jsx b/src/components/CellCards/CellCard/TermValueLink.jsx new file mode 100644 index 00000000..5cd0e9cf --- /dev/null +++ b/src/components/CellCards/CellCard/TermValueLink.jsx @@ -0,0 +1,56 @@ +import PropTypes from "prop-types"; +import { Link, Typography } from "@mui/material"; +import { termLink } from "../config/gridConfig"; + +/** + * One resolved reference rendered as its label, linked where we can address the term. + * + * Link target comes from the shared `termLink()`: an InterLex ILX term goes to its own page, + * an OBO term (UBERON / CHEBI / NCBITaxon) to OLS, and anything else to its own IRI. A literal + * or an unaddressable reference renders as plain text rather than a dead link. + * + * Note there is no definition tooltip here. The shipped graph carries labels and synonyms but + * *zero* definitions for UBERON / NCBIGene / CHEBI / NCBITaxon / npokb, so spec §2.4's hover + * definition has no data source yet; adding one needs OLS/NCBI at runtime or new triples. + */ +const TermValueLink = ({ value, variant = "body2" }) => { + const href = termLink(value); + // A gene with no rdfs:label in the graph (26% of NCBIGene nodes) resolves to its CURIE — + // show it, per Tom: render the compacted form rather than dropping the value. + const text = value.label || value.curie; + + if (!href) { + return ( + + {text} + + ); + } + + return ( + + {text} + + ); +}; + +TermValueLink.propTypes = { + value: PropTypes.shape({ + id: PropTypes.string, + curie: PropTypes.string, + label: PropTypes.string, + iri: PropTypes.string, + kind: PropTypes.string, + }).isRequired, + variant: PropTypes.string, +}; + +export default TermValueLink; diff --git a/src/components/CellCards/CellCard/WidgetCommentButton.jsx b/src/components/CellCards/CellCard/WidgetCommentButton.jsx new file mode 100644 index 00000000..e1116dd9 --- /dev/null +++ b/src/components/CellCards/CellCard/WidgetCommentButton.jsx @@ -0,0 +1,164 @@ +import { useState, useContext } from "react"; +import PropTypes from "prop-types"; +import { useNavigate } from "react-router-dom"; +import { + IconButton, + Popover, + Stack, + Typography, + Button, + TextField, + Link, + Tooltip, + Alert, + Divider, +} from "@mui/material"; +import ChatBubbleOutlineOutlinedIcon from "@mui/icons-material/ChatBubbleOutlineOutlined"; +import { CloseIcon, SendComment } from "../../../Icons"; +import { GlobalDataContext } from "../../../contexts/DataContext"; +import { postComment, commentPlaceholder, ANONYMOUS_AUTHOR } from "./commentService"; + +/** + * The per-widget comment affordance (Figma 9272:85572 "commenting on cell card"). + * + * The design replaced the spec's pencil-per-widget with a comment bubble that opens a popover + * anchored to the widget, prefilled with the widget + cell context, plus a link out to the full + * thread. There is no inline Discussion widget in the centre column, and no voting UI — spec + * §2.6 and §2.8 are superseded on both counts. + * + * Commenting is not login-gated (spec §10.6): an unauthenticated comment posts as "Anonymous" + * with an optional sign-in upgrade offered in the footer. + */ +const WidgetCommentButton = ({ widgetTitle, cellLabel, termId, group = "base", discussionHref }) => { + const [anchorEl, setAnchorEl] = useState(null); + const [body, setBody] = useState(""); + const [status, setStatus] = useState(null); // { severity, message } + const [sending, setSending] = useState(false); + const { user } = useContext(GlobalDataContext); + const navigate = useNavigate(); + + const open = Boolean(anchorEl); + const author = user?.username || user?.name; + + const handleOpen = (e) => { + setAnchorEl(e.currentTarget); + setBody(commentPlaceholder(widgetTitle, cellLabel)); + setStatus(null); + }; + + const handleClose = () => { + setAnchorEl(null); + setSending(false); + }; + + const handleSend = async () => { + if (!body.trim()) return; + setSending(true); + setStatus(null); + try { + await postComment({ group, termId, body, widgetTitle, cellLabel, user }); + setStatus({ severity: "success", message: "Comment posted." }); + setBody(""); + } catch { + // The discussions endpoint is not deployed yet (404 for every term id, ILX included), so + // this is the expected path today. Say so rather than silently dropping the comment. + setStatus({ + severity: "error", + message: "Comments aren’t available yet — the discussions service is not deployed.", + }); + } finally { + setSending(false); + } + }; + + return ( + <> + + + + + + + + + + Comment + + + + + + setBody(e.target.value)} + placeholder="Add a comment" + autoFocus + /> + + {status && {status.message}} + + + + + + + + + {author ? ( + <>You are commenting as: {author} + ) : ( + <> + Posting as {ANONYMOUS_AUTHOR} ·{" "} + navigate("/login")}> + Sign in to post under your name + + + )} + + + {discussionHref && ( + { + handleClose(); + navigate(discussionHref); + }} + > + View all related discussions + + )} + + + + ); +}; + +WidgetCommentButton.propTypes = { + widgetTitle: PropTypes.string.isRequired, + cellLabel: PropTypes.string, + termId: PropTypes.string, + group: PropTypes.string, + // Route to the Discussions tab for this term. + discussionHref: PropTypes.string, +}; + +export default WidgetCommentButton; diff --git a/src/components/CellCards/CellCard/buildRelationGraph.js b/src/components/CellCards/CellCard/buildRelationGraph.js new file mode 100644 index 00000000..f231b7fc --- /dev/null +++ b/src/components/CellCards/CellCard/buildRelationGraph.js @@ -0,0 +1,130 @@ +import { + RELATION_PREDICATES, + SUBCLASS_EDGE_LABEL, + ASSERTED_SUBCLASS_EDGE_LABEL, +} from "../config/cellCardConfig"; + +// A cell label carries its provenance as a trailing parenthetical — "DRG PEP3.2 (Krauter2025)". +// The graph node shows the id and that provenance on its own line, so strip it from the title. +const splitProvenance = (label = "") => { + const m = /^(.*?)\s*\(([^()]+)\)\s*$/.exec(label); + return m ? { title: m[1], provenance: m[2] } : { title: label, provenance: undefined }; +}; + +const cellNode = (cell, isCurrent) => { + const { title, provenance } = splitProvenance(cell.label); + return { + id: cell.id, + label: title || cell.curie, + // Figma draws "npokb:998 · Bhuiyan2025" beneath the title. + subtitle: [cell.curie, provenance].filter(Boolean).join(" · "), + isCurrent, + ref: { id: cell.id, curie: cell.curie, label: cell.label, iri: cell.iri, kind: "interlex" }, + }; +}; + +const refNode = (ref) => ({ + id: ref.id, + label: ref.label || ref.curie, + isCurrent: false, + ref, +}); + +/** + * Build the relationship-graph model for one cell (§4.1, Figma 9478:72004). + * + * Shape, per the design: the subClassOf parent above, the current cell in the middle, its + * asserted-subclass mappings fanned out below, and two lateral satellites — soma location to the + * left and gene expression to the right. + * + * Which phenotype predicates may appear is `RELATION_PREDICATES` in cellCardConfig, not a + * hardcoded list here: Fahim asked for this explicitly, because drawing every phenotype would + * make the graph unreadable. + * + * @param {object} cell the CellTerm at the centre + * @param {object} neighbours { parents, children } from hierarchyNeighbours + */ +export const buildRelationGraph = (cell, neighbours = {}) => { + const nodes = [cellNode(cell, true)]; + const edges = []; + const seen = new Set([cell.id]); + + const add = (node) => { + if (seen.has(node.id)) return false; + seen.add(node.id); + nodes.push(node); + return true; + }; + + // Parents above (dotted "subclass of"). The edge runs parent -> cell so the layout puts the + // parent on the rank *above* and the arrow points down into the current node, as designed. + for (const parent of neighbours.parents || []) { + if (add(cellNode(parent, false))) { + edges.push({ + from: parent.id, + to: cell.id, + kind: "subClassOf", + label: SUBCLASS_EDGE_LABEL, + }); + } + } + + // Cross-nomenclature mappings below ("asserted subclass of"). These are the sibling records in + // other nomenclatures, which is what the design fans out under the current node. + // + // `flagged` draws the dashed border and the asterisk, and it means what the legend's footnote + // says: proposed evidence only. The parser derives evidence from the relation, which yields only + // "described" and "inferred" in the shipped graph, so no node is flagged today — the marker + // waits on a curator "don't add" flag. The legend footnote is shown only when one appears. + for (const mapping of cell.mappings || []) { + const node = refNode(mapping.ref); + node.flagged = mapping.evidence === "proposed"; + if (add(node)) { + edges.push({ + from: cell.id, + to: mapping.ref.id, + kind: "assertedSubClassOf", + label: ASSERTED_SUBCLASS_EDGE_LABEL, + }); + } + } + + // Direct subClassOf children, on the rank below (cell -> child, arrow pointing down at them). + for (const child of neighbours.children || []) { + if (add(cellNode(child, false))) { + edges.push({ + from: cell.id, + to: child.id, + kind: "subClassOf", + label: SUBCLASS_EDGE_LABEL, + }); + } + } + + // Lateral phenotype satellites. Several values collapse into one node ("SCGN, ADRA2C" in the + // design) so the graph shows the relation rather than one node per gene. + for (const predicate of RELATION_PREDICATES) { + const values = cell.properties[predicate.localName]?.values || []; + if (!values.length) continue; + const id = `${cell.id}::${predicate.localName}`; + nodes.push({ + id, + label: values.map((v) => v.label || v.curie).join(", "), + isCurrent: false, + // A single value is navigable; a collapsed list has no one target. + ref: values.length === 1 ? values[0] : undefined, + }); + seen.add(id); + edges.push({ + from: cell.id, + to: id, + kind: predicate.kind, + label: predicate.label, + direction: predicate.direction, + }); + } + + return { nodes, edges }; +}; + +export default buildRelationGraph; diff --git a/src/components/CellCards/CellCard/buildRows.js b/src/components/CellCards/CellCard/buildRows.js new file mode 100644 index 00000000..7e1fd8c5 --- /dev/null +++ b/src/components/CellCards/CellCard/buildRows.js @@ -0,0 +1,32 @@ +import { labelFor, PREDICATE_TOOLTIPS } from "../config/gridConfig"; + +/** + * Turn `cellCardConfig` rows + a cell into the row models PropertyList renders. + * + * Labels and tooltips prefer the ontology's own `ilxtr:displayLabel` / `ilxtr:shortDefinition` + * (14 of the 15 predicates Precision cells use ship both), falling back to gridConfig's + * hardcoded maps. That keeps the wording in the curators' hands: changing a displayLabel in the + * ontology changes the Cell Card, with no front-end edit. + * + * @param {Array} rows entries from cellCardConfig (localName, render, required) + * @param {object} cell the CellTerm + * @param {object} display predicateDisplay from the parse, keyed by local name + */ +export const buildRows = (rows, cell, display = {}) => + rows.map(({ localName, render, required }) => { + const meta = display[localName]; + return { + localName, + label: meta?.label || labelFor(localName), + tooltip: meta?.description || PREDICATE_TOOLTIPS[localName], + prop: cell.properties[localName], + render, + required, + }; + }); + +/** Does a cell have any value for at least one of these predicates? */ +export const hasAnyValue = (cell, localNames) => + localNames.some((n) => cell.properties[n]?.values?.length); + +export default buildRows; diff --git a/src/components/CellCards/CellCard/citationService.js b/src/components/CellCards/CellCard/citationService.js new file mode 100644 index 00000000..14f22ac7 --- /dev/null +++ b/src/components/CellCards/CellCard/citationService.js @@ -0,0 +1,80 @@ +/** + * Publication metadata for a DOI, from CrossRef. + * + * The ontology carries `ilxtr:literatureCitation` as a bare DOI IRI with **no node in the + * graph** — no title, no authors, no year for any citation. The spec anticipated this ("the + * publication info should be retrieved from a DOI API based on the DOI"), so the Source + * Publication widget resolves it at runtime. + * + * Failure is expected and cheap: on any error the widget falls back to the bare DOI link, which + * is what it had before. Nothing here ever blocks a card render. + */ + +const CROSSREF = "https://api.crossref.org/works"; + +// One promise per DOI for the session. Sibling cells share a publication, so the card and the +// "Other cells from this source" header would otherwise fetch the same DOI repeatedly. +const cache = new Map(); + +/** Reduce a DOI IRI ("https://doi.org/10.1016/…") to the bare DOI ("10.1016/…"). */ +export const toDoi = (value) => { + if (!value) return ""; + const s = String(value).trim(); + const m = /(?:^|doi\.org\/|^doi:)(10\.\d{4,9}\/\S+)$/i.exec(s); + return m ? m[1] : /^10\.\d{4,9}\//.test(s) ? s : ""; +}; + +const formatAuthors = (authors) => { + if (!Array.isArray(authors) || !authors.length) return undefined; + const name = (a) => a.family || a.name || a.given || ""; + const first = name(authors[0]); + if (!first) return undefined; + // The design shows "Bhuiyan et al" — a single surname plus et al., not the full list, which + // would not fit the 424px column. + return authors.length > 1 ? `${first} et al` : first; +}; + +const mapWork = (message, doi, url) => ({ + doi, + url, + title: (message.title || [])[0], + authors: formatAuthors(message.author), + // A preprint has no journal, so fall back to the publisher (bioRxiv, Cold Spring Harbor…). + journal: (message["container-title"] || [])[0] || message.publisher, + year: String(message.issued?.["date-parts"]?.[0]?.[0] || "") || undefined, + type: message.type, +}); + +/** + * Resolve one citation. Always resolves — never rejects — so callers can render optimistically. + * @param {string} value a DOI, a DOI IRI, or any other citation IRI + * @returns {Promise} + */ +export const fetchCitation = async (value) => { + const doi = toDoi(value); + const url = doi ? `https://doi.org/${doi}` : String(value || ""); + if (!doi) return url ? { doi: "", url } : null; + + if (cache.has(doi)) return cache.get(doi); + + const promise = (async () => { + try { + const res = await fetch(`${CROSSREF}/${encodeURI(doi)}`, { + headers: { Accept: "application/json" }, + credentials: "omit", + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + if (!body?.message) throw new Error("Unexpected CrossRef response"); + return mapWork(body.message, doi, url); + } catch { + // Unknown DOI, offline, or CrossRef down — the bare link is still useful. + return { doi, url }; + } + })(); + + cache.set(doi, promise); + return promise; +}; + +export default fetchCitation; diff --git a/src/components/CellCards/CellCard/commentService.js b/src/components/CellCards/CellCard/commentService.js new file mode 100644 index 00000000..49c5a403 --- /dev/null +++ b/src/components/CellCards/CellCard/commentService.js @@ -0,0 +1,47 @@ +import { getTermDiscussions } from "../../../api/endpoints/apiService"; +import { addToTermDiscussion } from "../../../api/endpoints/swaggerMockMissingEndpoints"; + +/** + * Comment thread access for the Cell Card's per-widget comment popover. + * + * Status of the backend, probed against uri.olympiangods.org: `/{group}/discussions/term/{id}` + * answers **404 for every id**, including a valid `ilx_*` term — the discussions endpoint is not + * deployed yet, independently of the npokb addressability gap. The existing Discussions tab + * papers over this with a "work in progress" dialog and a mock API; this service instead calls + * the real endpoint and reports failure, so the UI tells the truth and starts working the moment + * the endpoint ships. + * + * Anonymous posting is intended (spec §10.6: commenting requires no login, unauthenticated + * comments are attributed to "Anonymous"), so no auth gate is applied here. Whether the server + * will accept an unauthenticated POST cannot be verified until the route exists. + */ + +export const ANONYMOUS_AUTHOR = "Anonymous"; + +/** + * The widget a comment was raised from. The typed `Discussion` model has no tag field, so the + * context is carried as a prefix in the message body until one exists. + */ +export const withWidgetContext = (widgetTitle, cellLabel, body) => { + const context = widgetTitle && cellLabel ? `[${widgetTitle}] ` : ""; + return `${context}${body}`.trim(); +}; + +/** Prefilled text the popover opens with, matching the design's example wording. */ +export const commentPlaceholder = (widgetTitle, cellLabel) => + widgetTitle && cellLabel ? `Commenting on the ${widgetTitle} of the cell ${cellLabel}.` : ""; + +export const fetchComments = async (group, termId) => { + const data = await getTermDiscussions(group, termId); + return Array.isArray(data) ? data : data?.discussions || []; +}; + +export const postComment = async ({ group, termId, body, widgetTitle, cellLabel, user }) => { + const discussion = { + message: withWidgetContext(widgetTitle, cellLabel, body), + senderID: user?.id || user?.orcid || "anonymous", + senderUserName: user?.username || user?.name || ANONYMOUS_AUTHOR, + timestamp: new Date().toISOString(), + }; + return addToTermDiscussion(group, termId, discussion); +}; diff --git a/src/components/CellCards/CellCard/useCellTerm.js b/src/components/CellCards/CellCard/useCellTerm.js new file mode 100644 index 00000000..8099c317 --- /dev/null +++ b/src/components/CellCards/CellCard/useCellTerm.js @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; +import { loadOntology, findCell } from "../services/ontologyGridService"; +import { ONTOLOGY_CATALOG, DEFAULT_ONTOLOGY_SLUG } from "../config/gridConfig"; + +/** + * Resolve one cell from a context ontology. + * + * The Cell Card lives on the term page, which is outside the ontology layout route, so it + * cannot read the parsed graph off `useOutletContext`. It calls the same `loadOntology` the + * grid uses instead: that memoizes both the ~16MB fetch and the parse at module level, so + * arriving from a grid tile costs no network and no reparse, while a cold deep-link still + * works on its own. + * + * The card's data never comes from the InterLex term API — Precision cells are npokb-only and + * that endpoint 404s on them. `ontologySlug` is the *context* ontology (the `?ontology=` param + * written by the grid), which is a different thing from `DataContext.activeOntology`: the + * latter is an edit target, not a data source. + */ +const useCellTerm = (termSlug, ontologySlug = DEFAULT_ONTOLOGY_SLUG) => { + const [state, setState] = useState({ cell: null, data: null, loading: true, error: null }); + + useEffect(() => { + const slug = ontologySlug && ONTOLOGY_CATALOG[ontologySlug] ? ontologySlug : DEFAULT_ONTOLOGY_SLUG; + if (!termSlug) { + setState({ cell: null, data: null, loading: false, error: null }); + return undefined; + } + + let active = true; + setState((prev) => ({ ...prev, loading: true, error: null })); + + loadOntology(slug) + .then((data) => { + if (!active) return; + // A term that is not in this ontology is not an error — the tab simply has nothing to + // show, and the caller renders/hides accordingly. + setState({ cell: findCell(data, termSlug) || null, data, loading: false, error: null }); + }) + .catch((error) => { + if (active) setState({ cell: null, data: null, loading: false, error }); + }); + + return () => { + active = false; + }; + }, [termSlug, ontologySlug]); + + return state; +}; + +export default useCellTerm; diff --git a/src/components/CellCards/CellCard/widgetVisibility.js b/src/components/CellCards/CellCard/widgetVisibility.js new file mode 100644 index 00000000..7990555b --- /dev/null +++ b/src/components/CellCards/CellCard/widgetVisibility.js @@ -0,0 +1,38 @@ +/** + * Which widgets a given cell has anything to show for (§6.4 conditional rendering). + * + * Kept out of the widget files so each of those exports a component and nothing else — mixing a + * component and a helper in one module breaks Vite's fast refresh for that file. + * + * The rule throughout is: a widget with no data hides entirely rather than rendering an empty + * frame, so a sparse cell produces a clean, collapsed card. The one exception is the anatomical + * context image, which the design gives an explicit "not available" state. + */ + +// Species / soma location / marker genes. The auto-description needs at least two of these to +// read as a sentence rather than a fragment (spec §6). +const DESCRIPTION_PREDICATES = [ + "hasInstanceInTaxon", + "hasSomaLocatedIn", + "hasNucleicAcidExpressionPhenotype", +]; + +export const hasDefinition = (cell) => + DESCRIPTION_PREDICATES.filter((n) => cell.properties[n]?.values?.length).length >= 2; + +// Either the SPARC deep link or the atlas annotation pills. The link's triple does not exist in +// the shipped ontology, so in practice this is the atlas-pill branch (54 of 161 cells). +export const hasTranscriptomicProfile = (cell) => + Boolean( + cell.annotations?.sparcTranscriptomicsLinks?.length || + cell.annotations?.atlasAnnotation?.length + ); + +// `hasNervoSensusLink` has zero occurrences today, so this is false for every cell — the widget is +// built and wired, waiting on the triple (which the parser already recognises under any prefix). +export const hasCellGrouping = (cell) => + Boolean(cell.annotations?.nervoSensusLinks?.length); + +export const hasCrossNomenclature = (cell) => Boolean(cell.mappings?.length); + +export const hasSourcePublication = (cell) => Boolean(cell.sources?.length); diff --git a/src/components/CellCards/CellCard/widgets/AnatomicalContext.jsx b/src/components/CellCards/CellCard/widgets/AnatomicalContext.jsx new file mode 100644 index 00000000..126e59da --- /dev/null +++ b/src/components/CellCards/CellCard/widgets/AnatomicalContext.jsx @@ -0,0 +1,65 @@ +import PropTypes from "prop-types"; +import { Stack, Divider } from "@mui/material"; +import ImageNotSupportedOutlinedIcon from "@mui/icons-material/ImageNotSupportedOutlined"; +import CellCardWidget from "../CellCardWidget"; +import PropertyList, { PropertyRow } from "../PropertyList"; +import EmptyState from "../../../common/EmptyState"; +import { buildRows } from "../buildRows"; +import { ANATOMICAL_CONTEXT_ROWS } from "../../config/cellCardConfig"; + +export const TITLE = "Anatomical & Circuit Context"; + +/** + * §3.3 Anatomical & Circuit Context (Figma 9239:67695): the anatomical reading of the cell, + * followed by a schematic. + * + * Two things are conditional on triples the shipped ontology does not have: + * + * - **SPARC Maps row** — needs `hasSPARCMap`, which has zero occurrences (the `ilx:` prefix is not + * even in the file's @context). The row is hidden until it exists, per spec §6; the parser + * recognises the predicate under any prefix and lands it on `annotations.sparcMaps`. + * - **The schematic** — the design shows a labelled spinal-cord cross-section, with an explicit + * "Context image not available" empty state (Figma 9239:67736). We render that empty state: + * `ilxtr:hasLaminarTermination`, which would drive the laminae shading, also has zero + * occurrences, so a drawn diagram would be decorative rather than data-bearing. + */ +const AnatomicalContext = ({ cell, predicateDisplay, actions }) => { + // hasSPARCMap is a deep link, so the parser routes it to `annotations` rather than treating it + // as a phenotype. Shaped into a CellProperty here so PropertyRow renders it like any other row. + const sparcMaps = cell.annotations?.sparcMaps || []; + const sparcMap = sparcMaps.length + ? { localName: "hasSPARCMap", family: "eqv", negated: false, values: sparcMaps } + : undefined; + + return ( + + + + {sparcMap ? ( + } gap={1}> + + + ) : null} + + + + } + message="Context image not available" + supportingText="A spinal-cord schematic appears once laminar termination data is published for this cell." + /> + + ); +}; + +AnatomicalContext.propTypes = { + cell: PropTypes.object.isRequired, + predicateDisplay: PropTypes.object, + actions: PropTypes.node, +}; + +export default AnatomicalContext; diff --git a/src/components/CellCards/CellCard/widgets/BiologicalProperties.jsx b/src/components/CellCards/CellCard/widgets/BiologicalProperties.jsx new file mode 100644 index 00000000..defbc7dc --- /dev/null +++ b/src/components/CellCards/CellCard/widgets/BiologicalProperties.jsx @@ -0,0 +1,35 @@ +import PropTypes from "prop-types"; +import CellCardWidget from "../CellCardWidget"; +import PropertyList from "../PropertyList"; +import { buildRows } from "../buildRows"; +import { BIOLOGICAL_PROPERTY_ROWS } from "../../config/cellCardConfig"; + +export const TITLE = "Biological Properties"; + +/** + * §3.1 Biological Properties (Figma 9535:96273): the cell's phenotype table. + * + * Every row is `required`, so the table keeps the fixed shape the design shows — Function, + * Adaptation and Threshold read "not specified" rather than disappearing, which is what makes + * the absence legible to a curator. + * + * Note the design also shows a small grey method chip beside Neurotransmitter and Marker genes + * ("snRNA"). That is deliberately absent here: the graph has no rdf-star qualifiers and no + * `determinedByMethod` — the method vocabulary exists only as six unused + * `ilxtr:hasConnectionDeterminedBy*` property declarations, used on zero cells. When it is + * populated it will arrive as a *separate predicate*, not a qualifier on a value, so the chip + * needs a design revision rather than just data. + */ +const BiologicalProperties = ({ cell, predicateDisplay, actions }) => ( + + + +); + +BiologicalProperties.propTypes = { + cell: PropTypes.object.isRequired, + predicateDisplay: PropTypes.object, + actions: PropTypes.node, +}; + +export default BiologicalProperties; diff --git a/src/components/CellCards/CellCard/widgets/CellGrouping.jsx b/src/components/CellCards/CellCard/widgets/CellGrouping.jsx new file mode 100644 index 00000000..d1696e89 --- /dev/null +++ b/src/components/CellCards/CellCard/widgets/CellGrouping.jsx @@ -0,0 +1,95 @@ +import PropTypes from "prop-types"; +import { Stack, Typography, Alert, AlertTitle, IconButton, Link, Divider } from "@mui/material"; +import ScatterPlotOutlinedIcon from "@mui/icons-material/ScatterPlotOutlined"; +import { ArrowOutwardIcon } from "../../../../Icons"; +import CellCardWidget from "../CellCardWidget"; +import { NERVOSENSUS_GROUP_BY, MARKER_GENE_PREDICATE } from "../../config/cellCardConfig"; + +export const TITLE = "Interactive Cell Grouping"; + +const withGroupBy = (url, groupBy) => { + try { + const u = new URL(url); + u.searchParams.set("groupBy", groupBy); + return u.toString(); + } catch { + // Not an absolute URL — fall back to naive appending rather than dropping the link. + return `${url}${url.includes("?") ? "&" : "?"}groupBy=${groupBy}`; + } +}; + +/** + * §4.3 Interactive Cell Grouping / NervoSensus (Figma 9239:67802). + * + * Entirely conditional on `ilx:hasNervoSensusLink`, which has **zero occurrences** in the shipped + * graph — so in practice this widget is hidden today (see `hasCellGrouping`). It is built anyway + * because the shape is fully specified and it lights up with no code change once the triple lands. + * + * The tile is a MUI `Alert`, which is what the design itself reuses for this pattern (an icon, a + * title, supporting text and a trailing action) rather than a bespoke frame. + */ +const CellGrouping = ({ cell, actions }) => { + // A deep link, not a phenotype, so the parser puts it on `annotations` (matched by local + // name under any prefix) rather than on `properties`. + const link = cell.annotations?.nervoSensusLinks?.[0]; + const url = link?.iri || link?.id; + if (!url) return null; + + const genes = cell.properties[MARKER_GENE_PREDICATE]?.values || []; + + return ( + + } + action={ + + + + } + > + Explore in NervoSensus + Interactively group and compare cells by phenotype + + + + + + {genes.length > 0 && ( + + {`View pre-filtered for ${genes.map((g) => g.label || g.curie).join(", ")}.`} + + )} + + Group by + + {NERVOSENSUS_GROUP_BY.map((groupBy, i) => ( + + {i > 0 && ( + + · + + )} + + {groupBy} + + + ))} + + + ); +}; + +CellGrouping.propTypes = { + cell: PropTypes.object.isRequired, + actions: PropTypes.node, +}; + + +export default CellGrouping; diff --git a/src/components/CellCards/CellCard/widgets/CrossNomenclature.jsx b/src/components/CellCards/CellCard/widgets/CrossNomenclature.jsx new file mode 100644 index 00000000..be0f6d00 --- /dev/null +++ b/src/components/CellCards/CellCard/widgets/CrossNomenclature.jsx @@ -0,0 +1,105 @@ +import PropTypes from "prop-types"; +import { + Table, + TableHead, + TableBody, + TableRow, + TableCell, + Chip, + Typography, + Divider, + Stack, + Box, +} from "@mui/material"; +import CellCardWidget from "../CellCardWidget"; +import TermValueLink from "../TermValueLink"; +import { EVIDENCE_TONE, FLAGGED_FOOTNOTE } from "../../config/cellCardConfig"; + +export const TITLE = "Cross-Nomenclature Mapping"; + +// Evidence badge. "proposed" has no palette colour (the design uses Purple, which the theme does +// not define), so it falls back to an outlined chip rather than inlining a hex here — the gap is +// raised with design instead. +const EvidenceChip = ({ evidence }) => { + const tone = EVIDENCE_TONE[evidence]; + return tone === "outlined" ? ( + + ) : ( + + ); +}; + +EvidenceChip.propTypes = { evidence: PropTypes.string.isRequired }; + +/** + * §4.4 Cross-Nomenclature Mapping (Figma 9239:67833): how this cell type is named in other + * nomenclatures. + * + * Evidence is derived from the *relation*, not from a dedicated predicate — there isn't one: + * `TEMP:assertedSubClassOf` means the source explicitly describes this cell type ("described"), + * `TEMP:mapsTo` means a computational mapping ("inferred"). The parser does that derivation, so + * this component just renders it. + */ +const CrossNomenclature = ({ cell, actions }) => { + const mappings = cell.mappings || []; + if (!mappings.length) return null; + + const hasFlagged = mappings.some((m) => m.evidence === "proposed"); + + return ( + + + + + + Cell name + Evidence + Source + Reference + + + + {mappings.map((m) => ( + + + + {m.evidence === "proposed" && "*"} + + + + + + + {m.source || "—"} + + + + + {m.ref.curie} + + + + ))} + +
+
+ + {hasFlagged && ( + + + + {FLAGGED_FOOTNOTE} + + + )} +
+ ); +}; + +CrossNomenclature.propTypes = { + cell: PropTypes.object.isRequired, + actions: PropTypes.node, +}; + + +export default CrossNomenclature; diff --git a/src/components/CellCards/CellCard/widgets/OntologyHierarchy.jsx b/src/components/CellCards/CellCard/widgets/OntologyHierarchy.jsx new file mode 100644 index 00000000..67c27008 --- /dev/null +++ b/src/components/CellCards/CellCard/widgets/OntologyHierarchy.jsx @@ -0,0 +1,172 @@ +import { useMemo, useState } from "react"; +import PropTypes from "prop-types"; +import { Stack, Typography, IconButton, Tooltip } from "@mui/material"; +import CellCardWidget from "../CellCardWidget"; +import OntologyHierarchyTree from "../../OntologyHierarchyTree"; +import GridSearchBar from "../../GridSearchBar"; +import CustomSingleSelect from "../../../common/CustomSingleSelect"; +import EmptyState from "../../../common/EmptyState"; +import { RestartAlt, TargetCross } from "../../../../Icons"; + +export const TITLE = "Ontology hierarchy"; + +// The two directions the tree can be read in, as in the design's "Type:" select and in the +// existing SingleTermView Hierarchy widget. +const CHILDREN = "children"; +const SUPERCLASSES = "superclasses"; + +/** Path of hierarchy nodes from a root down to the first position of `termId`. */ +const findPath = (nodes, termId, trail = []) => { + for (const node of nodes) { + const next = [...trail, node]; + if (node.termId === termId) return next; + const deeper = findPath(node.children || [], termId, next); + if (deeper) return deeper; + } + return null; +}; + +/** + * §3.2 Ontology Hierarchy (Figma 9239:67685). + * + * Shows the cell's position in the subClassOf tree: the ancestor chain collapsed to a single + * spine, the cell highlighted, and its direct children one level below. The full ontology tree + * lives on the Browse tab; here it is scoped so the widget stays readable in a 424px column. + * + * The tree is built from the parse's already-reduced hierarchy (transitive edges removed), so a + * cell does not list its grandparents as parents. + */ +const OntologyHierarchy = ({ cell, hierarchy, rootLabel, actions }) => { + const [direction, setDirection] = useState(CHILDREN); + const [query, setQuery] = useState(""); + + const path = useMemo(() => findPath(hierarchy || [], cell.id), [hierarchy, cell.id]); + + // The node for the cell itself, and the spine above it. + const cellNode = path?.[path.length - 1]; + const ancestors = useMemo(() => (path ? path.slice(0, -1) : []), [path]); + + // The rendered tree, plus what it is showing out of what exists — the count line below reports + // the filtered figure, so it can never contradict the rows on screen. + const { items, shown, total } = useMemo(() => { + if (!cellNode) return { items: [], shown: 0, total: 0 }; + const matches = (label) => + !query.trim() || label.toLowerCase().includes(query.trim().toLowerCase()); + + if (direction === SUPERCLASSES) { + // Ancestors, outermost first, each nesting the next — the spine on its own. The query + // applies here too: leaving it out silently ignored the search box in this direction. + // Dropping a non-matching intermediate is honest because subClassOf is transitive, so a + // collapsed spine still only claims "ancestor of". + const spine = ancestors.filter((a) => matches(a.label)); + return { + items: [ + spine.reduceRight((child, node) => ({ ...node, children: [child] }), { + ...cellNode, + children: [], + }), + ], + shown: spine.length, + total: ancestors.length, + }; + } + + // Children view: the cell as the root, its direct children below it. + const all = cellNode.children || []; + const children = all.filter((c) => matches(c.label)).map((c) => ({ ...c, children: [] })); + return { + items: [{ ...cellNode, children }], + shown: children.length, + total: all.length, + }; + }, [cellNode, ancestors, direction, query]); + + // Expand the whole (small) scoped tree by default: with one spine and one level of children + // there is nothing worth hiding, and the design shows it open. + const [expanded, setExpanded] = useState(null); + const allIds = useMemo(() => { + const ids = []; + const walk = (nodes) => + nodes.forEach((n) => { + ids.push(n.id); + walk(n.children || []); + }); + walk(items); + return ids; + }, [items]); + const expandedItems = expanded ?? allIds; + + // Named once so the select option and the count line below cannot describe different things. + const directionLabel = { + [CHILDREN]: `sub class of ${rootLabel}`, + [SUPERCLASSES]: `super class of ${rootLabel}`, + }; + + return ( + + + + Type: + + + + { + setDirection(CHILDREN); + setQuery(""); + setExpanded(null); + }} + aria-label="Reset hierarchy" + > + + + + + setExpanded(allIds)} aria-label="Focus current term"> + + + + + + + + {items.length ? ( + <> + setExpanded(ids)} + selectedItems={cellNode ? [cellNode.id] : []} + /> + + {/* "3 of 7" while a query is filtering, so the number always matches the rows drawn. */} + {`Total number of ${directionLabel[direction]}: ${ + shown === total ? total : `${shown} of ${total}` + }`} + + + ) : ( + + )} + + ); +}; + +OntologyHierarchy.propTypes = { + cell: PropTypes.object.isRequired, + hierarchy: PropTypes.array, + // Display name of the ontology's root class, used in the select and the count line. + rootLabel: PropTypes.string, + actions: PropTypes.node, +}; + +export default OntologyHierarchy; diff --git a/src/components/CellCards/CellCard/widgets/RelatedCells.jsx b/src/components/CellCards/CellCard/widgets/RelatedCells.jsx new file mode 100644 index 00000000..dbb6d347 --- /dev/null +++ b/src/components/CellCards/CellCard/widgets/RelatedCells.jsx @@ -0,0 +1,68 @@ +import PropTypes from "prop-types"; +import { Stack, Typography, List, ListItem, ListItemButton, ListItemText, Link, Box } from "@mui/material"; +import CellCardWidget from "../CellCardWidget"; +import { toDoi } from "../citationService"; + +export const TITLE = "Other cells from this source"; + +// The list scrolls internally rather than stretching the column, per the design's flex:1 + +// overflow-y:auto on this widget. +const MAX_LIST_HEIGHT = "32rem"; + +/** + * §5.2 Other Cells from This Source (Figma 9239:67932): the sibling cell types described by the + * same publication. + * + * Tom, design review: this widget can only be populated when a context ontology is loaded — the + * sibling relationships are not in a single term's own graph. That holds here by construction: + * the Cell Card always renders from a full ontology load, so the siblings are simply the cells + * sharing a `literatureCitation`. + */ +const RelatedCells = ({ cell, related, onSelect, actions }) => { + if (!related?.length) return null; + + const doi = toDoi(cell.sources?.[0]?.iri || cell.sources?.[0]?.id); + + return ( + + {doi && ( + + Sharing DOI:{" "} + + {doi} + + + )} + + + + + {related.map((sibling) => ( + + {/* `cellCardTile` carries the outlined-row style; it is scoped to this class in the + theme so the app's other ListItemButtons (Header nav, About) stay flat. */} + onSelect?.(sibling)}> + + + + ))} + + + + + + {`${related.length} of ${related.length} · via ilxtr:literatureCitation`} + + + ); +}; + +RelatedCells.propTypes = { + cell: PropTypes.object.isRequired, + related: PropTypes.array, + // Called with the sibling CellTerm; the host decides how to navigate. + onSelect: PropTypes.func, + actions: PropTypes.node, +}; + +export default RelatedCells; diff --git a/src/components/CellCards/CellCard/widgets/RelationshipGraph.jsx b/src/components/CellCards/CellCard/widgets/RelationshipGraph.jsx new file mode 100644 index 00000000..a983a790 --- /dev/null +++ b/src/components/CellCards/CellCard/widgets/RelationshipGraph.jsx @@ -0,0 +1,168 @@ +import { useMemo, useState } from "react"; +import PropTypes from "prop-types"; +import { + Box, + Paper, + Stack, + Typography, + IconButton, + Tooltip, + Accordion, + AccordionSummary, + AccordionDetails, + Dialog, + DialogTitle, + DialogContent, +} from "@mui/material"; +import OpenInFullOutlinedIcon from "@mui/icons-material/OpenInFullOutlined"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import CellCardWidget from "../CellCardWidget"; +import RelationshipGraphSvg from "../RelationshipGraphSvg"; +import EmptyState from "../../../common/EmptyState"; +import { CloseIcon } from "../../../../Icons"; +import buildRelationGraph from "../buildRelationGraph"; +import { RELATION_LEGEND, FLAGGED_FOOTNOTE } from "../../config/cellCardConfig"; + +export const TITLE = "Relationship Graph"; + +// The legend's line samples, drawn with the same dash patterns as the edges themselves. +const LEGEND_DASH = { + subClassOf: "2 4", + somaLocation: "6 4", + assertedSubClassOf: undefined, + expresses: undefined, +}; + +const LegendSwatch = ({ kind }) => ( + + + +); + +LegendSwatch.propTypes = { kind: PropTypes.string.isRequired }; + +// `hasFlagged` gates the footnote: nothing in the shipped graph produces "proposed" evidence, so +// an unconditional footnote explains a marker no node can carry. Same rule as CrossNomenclature, +// which describes the same asterisk. +const Legend = ({ hasFlagged }) => ( + + + {RELATION_LEGEND.map(({ kind, label }) => ( + + + + {label} + + + ))} + + {hasFlagged && ( + + {FLAGGED_FOOTNOTE} + + )} + +); + +Legend.propTypes = { hasFlagged: PropTypes.bool }; + +/** + * §4.1 Relationship Graph (Figma 9478:72004 inline, 8917:35906 expanded). + * + * A new component rather than a reuse of GraphViewer/Graph.jsx: that one is a d3.cluster + * dendrogram taking a single predicate group, and it resolves `d3.select("#tooltip")` plus a + * global `d3.selectAll(".node--leaf-g")`, so two instances fight over the same DOM — and this + * widget needs two (inline and in the dialog). The Overview tab's graphs are left untouched. + */ +const RelationshipGraph = ({ cell, neighbours, onNavigate, actions }) => { + const [expanded, setExpanded] = useState(false); + const graph = useMemo(() => buildRelationGraph(cell, neighbours), [cell, neighbours]); + + // One node is always the cell itself; fewer than two means there is nothing to relate. + const hasRelations = graph.nodes.length > 1; + const hasFlagged = graph.nodes.some((n) => n.flagged); + + const handleSelect = (node) => { + if (node.ref && onNavigate) { + setExpanded(false); + onNavigate(node.ref); + } + }; + + const graphActions = ( + <> + {actions} + + setExpanded(true)} aria-label="Expand relationship graph"> + + + + + ); + + return ( + + {hasRelations ? ( + // An outlined surface, so the border and the 12px radius come from the theme + // (`.graphFrame`) rather than from a call-site `sx`. + + + + + {/* The design puts the legend in a collapsed bar at the bottom of the graph frame. */} + + }> + Legend + + + + + + + ) : ( + + )} + + setExpanded(false)} maxWidth="lg" fullWidth> + + setExpanded(false)} aria-label="Close" sx={{ ml: -1 }}> + + + {TITLE} + + + + + + + + + + ); +}; + +RelationshipGraph.propTypes = { + cell: PropTypes.object.isRequired, + neighbours: PropTypes.shape({ + parents: PropTypes.array, + children: PropTypes.array, + }), + // Called with a ResolvedRef when a node is clicked, so the host decides how to navigate. + onNavigate: PropTypes.func, + actions: PropTypes.node, +}; + +export default RelationshipGraph; diff --git a/src/components/CellCards/CellCard/widgets/SourcePublication.jsx b/src/components/CellCards/CellCard/widgets/SourcePublication.jsx new file mode 100644 index 00000000..0009ddaf --- /dev/null +++ b/src/components/CellCards/CellCard/widgets/SourcePublication.jsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { Stack, Typography, Link, Divider, Skeleton } from "@mui/material"; +import CellCardWidget from "../CellCardWidget"; +import { fetchCitation } from "../citationService"; +import { NOT_SPECIFIED } from "../../config/cellCardConfig"; + +export const TITLE = "Source publication"; + +/** + * §5.1 Source Publication (Figma 9239:67920): the formatted citation for the cell's primary + * source, plus a source-data pointer. + * + * The graph has only the DOI, so title / authors / journal / year come from CrossRef at render + * time (see citationService). While that is in flight the widget shows skeletons; if it fails the + * DOI link alone remains, which is exactly what the graph can support on its own. + */ +const SourcePublication = ({ cell, actions }) => { + const primary = cell.sources?.[0]; + const [citation, setCitation] = useState(null); + const [loading, setLoading] = useState(Boolean(primary)); + + useEffect(() => { + if (!primary) { + setCitation(null); + setLoading(false); + return undefined; + } + let active = true; + setLoading(true); + fetchCitation(primary.iri || primary.id).then((result) => { + if (!active) return; + setCitation(result); + setLoading(false); + }); + return () => { + active = false; + }; + }, [primary]); + + if (!primary) return null; + + const dataCitation = cell.annotations?.dataCitations?.[0]; + + return ( + + {loading ? ( + + + + + + ) : ( + + {citation?.title && ( + + {citation.title} + + )} + + {[citation?.authors, citation?.journal, citation?.year] + .filter(Boolean) + .join(citation?.authors ? " " : ", ")} + {citation?.doi && ( + <> + {[citation?.authors, citation?.journal, citation?.year].some(Boolean) ? " · " : ""} + DOI:{" "} + + {citation.doi} + + + )} + + + )} + + + + + Source data citation:{" "} + {dataCitation ? ( + + {dataCitation.label || dataCitation.curie} + + ) : ( + + {NOT_SPECIFIED} + + )} + + + ); +}; + +SourcePublication.propTypes = { + cell: PropTypes.object.isRequired, + actions: PropTypes.node, +}; + + +export default SourcePublication; diff --git a/src/components/CellCards/CellCard/widgets/TranscriptomicProfile.jsx b/src/components/CellCards/CellCard/widgets/TranscriptomicProfile.jsx new file mode 100644 index 00000000..35926f03 --- /dev/null +++ b/src/components/CellCards/CellCard/widgets/TranscriptomicProfile.jsx @@ -0,0 +1,91 @@ +import PropTypes from "prop-types"; +import { Typography, Chip, Divider, Box, Alert, Link } from "@mui/material"; +import { ArrowOutwardIcon } from "../../../../Icons"; +import CellCardWidget from "../CellCardWidget"; +import { + MARKER_GENE_PREDICATE, + SPARC_TRANSCRIPTOMICS_PREDICATE, +} from "../../config/cellCardConfig"; + +export const TITLE = "Transcriptomic profile"; + +/** + * §4.2 Transcriptomic Profile (Figma 9239:67782). + * + * Two states, and only the second is reachable today: + * + * 1. `hasSPARCTranscriptomicsLink` present → a deep link into the SPARC Portal, pre-filtered for + * this cell type. **Zero occurrences in the shipped graph** (the `ilx:` prefix is not even in + * its @context), so this branch is dormant but wired: the parser recognises the predicate under + * any prefix and puts it on `annotations.sparcTranscriptomicsLinks`. + * 2. Otherwise, `ilxtr:atlasAnnotation` present → the Precision dataset annotation pills, which + * is exactly what the design renders. Present on 54 of the 161 Precision cells. + * + * With neither, the widget hides itself (§6.4). `isCurated` decides whether the "link appears + * when the triple is added" note is worth showing — it is guidance for curators, not for readers. + */ +const TranscriptomicProfile = ({ cell, actions }) => { + const sparcLink = cell.annotations?.sparcTranscriptomicsLinks?.[0]; + const atlas = cell.annotations?.atlasAnnotation || []; + const genes = cell.properties[MARKER_GENE_PREDICATE]?.values || []; + + return ( + + {sparcLink ? ( + // A link tile, not a status message — the design gives it no severity icon. + + + Explore this cell type in the SPARC Portal + + + ) : ( + + SPARC Portal link not yet configured. Atlas dataset annotations: + + )} + + {atlas.length > 0 && ( + + {atlas.map((a) => ( + + ))} + + )} + + {genes.length > 0 && ( + + + Marker genes + + {genes.map((g) => ( + + ))} + + )} + + {!sparcLink && ( + <> + + {/* Guidance for curators, in one sentence: emphasising the predicate name would mean + typography at the call site, which the theme owns. */} + + {`SPARC Portal link appears when ${SPARC_TRANSCRIPTOMICS_PREDICATE} is added.`} + + + )} + + ); +}; + +TranscriptomicProfile.propTypes = { + cell: PropTypes.object.isRequired, + actions: PropTypes.node, +}; + + +export default TranscriptomicProfile; diff --git a/src/components/CellCards/OntologyGridPage.jsx b/src/components/CellCards/OntologyGridPage.jsx index 38a24035..3450a721 100644 --- a/src/components/CellCards/OntologyGridPage.jsx +++ b/src/components/CellCards/OntologyGridPage.jsx @@ -1,12 +1,14 @@ import { useState, useEffect, useMemo, useCallback } from "react"; -import { useOutletContext } from "react-router-dom"; -import { Box, Typography, Snackbar, Stack } from "@mui/material"; +import { useOutletContext, useNavigate } from "react-router-dom"; +import { Box, Typography, Stack } from "@mui/material"; import GridFilterSidebar from "./GridFilterSidebar"; import GridSearchBar from "./GridSearchBar"; import CellTileGrid from "./CellTileGrid"; import CustomSingleSelect from "../common/CustomSingleSelect"; import CustomPagination from "../common/CustomPagination"; -import { getFacets } from "./services/ontologyGridService"; +import { getFacets, curieToSlug } from "./services/ontologyGridService"; +import { ONTOLOGY_PARAM } from "./config/gridConfig"; +import { primeTermDataCache } from "../../hooks/useTermData"; import { vars } from "../../theme/variables"; const { gray200, gray600 } = vars; @@ -30,13 +32,13 @@ const cellFacetKeys = (cell, localName) => { // arrives through the outlet context, so switching tabs does not refetch it. const OntologyGridPage = () => { const { data } = useOutletContext(); + const navigate = useNavigate(); const [displayedOnly, setDisplayedOnly] = useState(true); const [checked, setChecked] = useState({}); // facet filter checks, keyed by facet localName const [selectedIds, setSelectedIds] = useState({}); // tiles picked via their checkbox const [word, setWord] = useState(""); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(24); - const [snack, setSnack] = useState(""); useEffect(() => { // Reset all filter/paging/selection state so it never leaks across ontologies. @@ -117,84 +119,91 @@ const OntologyGridPage = () => { setPage(1); }, []); + // Tile click -> that cell's Cell Card (the tile's primary action, per the design). + // + // Two things travel with the navigation. `?ontology=` is the context ontology, which is how the + // card knows which graph to resolve the term against. And the label is pushed into the term-data + // cache first: the term page would otherwise fetch `/{group}/npokb_998.jsonld`, which 404s for + // every Precision cell, and a cache hit skips the request entirely and titles the page at once. + const openCellCard = useCallback( + (cell) => { + const slug = curieToSlug(cell.curie); + primeTermDataCache(data.entry.org, slug, cell.label); + navigate( + `/${data.entry.org}/${slug}/cell-card?${ONTOLOGY_PARAM}=${encodeURIComponent(data.entry.slug)}` + ); + }, + [navigate, data] + ); + return ( - <> - {/* Body: filter sidebar | results */} - - setDisplayedOnly((v) => !v)} - /> - - - - - Showing {pageCells.length} of {total} cells - - - - - - Show on page: - - { - setPageSize(Number(v)); - setPage(1); - }} - options={PAGE_SIZES} - /> - + // Body: filter sidebar | results + + setDisplayedOnly((v) => !v)} + /> + + + + + Showing {pageCells.length} of {total} cells + + + + + + Show on page: + + { + setPageSize(Number(v)); + setPage(1); + }} + options={PAGE_SIZES} + /> - + + - - setSnack("The single-cell Cell Card view is coming in the next round.")} - selectedIds={selectedIds} - onToggleSelect={onToggleSelect} + + + + + {total > pageSize && ( + + setPage(p)} /> - - {total > pageSize && ( - - setPage(p)} - /> - - )} - + )} - - setSnack("")} - message={snack} - anchorOrigin={{ vertical: "bottom", horizontal: "center" }} - /> - + ); }; diff --git a/src/components/CellCards/OntologyHierarchyTree.jsx b/src/components/CellCards/OntologyHierarchyTree.jsx index 64335243..6939ecb6 100644 --- a/src/components/CellCards/OntologyHierarchyTree.jsx +++ b/src/components/CellCards/OntologyHierarchyTree.jsx @@ -17,7 +17,7 @@ import { termLink } from "./config/gridConfig"; const HierarchyTreeItem = forwardRef(function HierarchyTreeItem(props, ref) { // `meta` arrives via slotProps and must not reach the DOM. const { meta, label, itemId, ...treeItemProps } = props; - const { curie, href } = meta?.get(itemId) || {}; + const { curie, href, isCurrent } = meta?.get(itemId) || {}; return ( - {href ? ( + {/* The term whose page we are on is the anchor of the tree, so it reads as text rather + than a link to itself, in the theme's `currentTerm` variant — the semibold brand + emphasis is a design token, not a call-site style. */} + {href && !isCurrent ? ( ) : ( - + {label} )} @@ -62,18 +65,30 @@ HierarchyTreeItem.propTypes = { itemId: PropTypes.string, }; -const OntologyHierarchyTree = ({ items, expandedItems, onExpandedItemsChange, selectedItems }) => { - // itemId -> { curie, href }, resolved once here so the item slot needs no walk of its own. +const OntologyHierarchyTree = ({ + items, + expandedItems, + onExpandedItemsChange, + selectedItems, + currentTermId, +}) => { + // itemId -> { curie, href, isCurrent }, resolved once here so the item slot needs no walk of + // its own. `currentTermId` is a *term* id, matched against node.termId — a class with several + // parents appears at more than one path, and every one of those positions is "current". const meta = useMemo(() => { const map = new Map(); const stack = [...items]; while (stack.length) { const node = stack.pop(); - map.set(node.id, { curie: node.curie, href: termLink(node) }); + map.set(node.id, { + curie: node.curie, + href: termLink(node), + isCurrent: !!currentTermId && node.termId === currentTermId, + }); stack.push(...(node.children || [])); } return map; - }, [items]); + }, [items, currentTermId]); return ( = { + class: "secondary", + subtype: "success", + species: "default", +}; + +// Cross-nomenclature evidence badge tones. The design uses Success / Warning / Purple; the +// theme has no purple, so "proposed" falls back to an outlined chip rather than inlining a hex +// at the call site. Raised with design rather than invented here. +export const EVIDENCE_TONE: Record = { + described: "success", + inferred: "warning", + proposed: "outlined", +}; + +// Shown in place of a value when the predicate is absent (design: gray, italic). +export const NOT_SPECIFIED = "not specified"; diff --git a/src/components/CellCards/config/gridConfig.ts b/src/components/CellCards/config/gridConfig.ts index eafe1d33..dcdc831c 100644 --- a/src/components/CellCards/config/gridConfig.ts +++ b/src/components/CellCards/config/gridConfig.ts @@ -39,6 +39,22 @@ export const ontologyPath = (entry: OntologyEntry, tab = ""): string => // The single hardcoded search result until an ontology search backend exists. export const HARDCODED_RESULTS: OntologyEntry[] = [ONTOLOGY_CATALOG.precision]; +// Context ontology assumed when a Cell Card is opened without an `?ontology=` param (a shared +// link, or a term reached from search rather than from the grid). With one entry in the +// catalog this is unambiguous; it becomes a real lookup once there are several. +export const DEFAULT_ONTOLOGY_SLUG = "precision"; + +// Query param carrying the context ontology across a term-page navigation. Distinct from +// DataContext.activeOntology, which is the *edit* target shown as a chip in the header. +export const ONTOLOGY_PARAM = "ontology"; + +// Does this term slug address an InterLex record (`ilx_0101431`, `tmp_0381624`)? Precision cells +// are npokb-only today, so it is false for them — and every feature backed by the InterLex term +// API (Overview, Variants, Version history, Discussions) then has nothing to serve. One home for +// the rule so the tabs SingleTermView disables and the links the Cell Card suppresses cannot +// drift apart; it goes away once these cells are ingested with ILX ids. +export const isIlxTermSlug = (slug?: string): boolean => /^(ilx|tmp)[_:]/i.test(slug || ""); + // --- ontology tabs ---------------------------------------------------------- export interface OntologyTab { @@ -60,14 +76,33 @@ export const ONTOLOGY_TABS: OntologyTab[] = [ // --- data source ------------------------------------------------------------ -// The reasoned neurdf JSON-LD (requested with Accept: application/ld+json). +// The reasoned neurdf JSON-LD, upstream (requested with Accept: application/ld+json). // ACAO:* on the endpoint lets the browser fetch it directly; the body is served as // text/plain, so the service JSON.parses the text rather than trusting content-type. +// +// NOTE: scripts/fetch-neurdf.mjs parses this constant to know what to download. Renaming it +// breaks that script loudly (by design) — update both together. export const NEURDF_URL = "https://uri.olympiangods.org/base/ontologies/dns/raw.githubusercontent.com/SciCrunch/NIF-Ontology/neurons/ttl/npo-merged-reasoned-neurdf.ttl"; -// Optional same-origin dev fallback (served from /public) if the live endpoint is flaky. -export const NEURDF_FALLBACK_URL = "/data/npo-merged-neurdf.jsonld"; +// Same-origin copy. In production the container's entrypoint downloads it into the served /data/ +// directory shortly after start (deploy/fetch-neurdf-at-start.sh); locally, `yarn fetch-data` puts +// it in public/. It is not in the image, so it is **often absent** — during the first few tens of +// seconds of a container's life, or if the fetch failed, or in dev before anyone ran the script. +// +// Preferred when present, because upstream has no caching yet: ~9s for the 16MB body, flaky enough +// to need three retries, versus a local static GET nginx serves gzipped (1.3MB) with a long +// max-age. Absent, it 404s in milliseconds and the loader moves on to upstream — the same path the +// app used before any of this existed. +// +// Temporary shim: once the source server caches, delete this and PREFER_LOCAL_NEURDF along with +// the entrypoint script and the nginx /data/ location. +export const NEURDF_LOCAL_URL = "/data/npo-merged-neurdf.jsonld"; + +// Try the local copy first, then upstream. Set VITE_PREFER_LOCAL_NEURDF=false to invert this — +// useful in development when you want to verify against whatever the source is serving now. +export const PREFER_LOCAL_NEURDF = + import.meta.env?.VITE_PREFER_LOCAL_NEURDF !== "false"; // --- predicate display metadata --------------------------------------------- diff --git a/src/components/CellCards/model/types.ts b/src/components/CellCards/model/types.ts index 41fc3947..cf7c3963 100644 --- a/src/components/CellCards/model/types.ts +++ b/src/components/CellCards/model/types.ts @@ -36,14 +36,62 @@ export interface ResolvedRef { export type PredicateFamily = "eqv" | "ent"; +// How multiple values on one predicate combine. neurdf encodes this in the family segment: +// `neurdf.eqv.uo:` is an owl:unionOf (soma is in A *or* B — one of them, not both), and +// `neurdf.eqv.io:` is an owl:intersectionOf (A *and* B). A plain family carries no +// combinator: its several values are independent assertions. Dropping this distinction would +// render "soma in A or B" identically to "soma in A and B", which are different claims. +export type ValueCombinator = "or" | "and"; + // One phenotype/annotation predicate on a cell, normalised by local name across neurdf families. export interface CellProperty { localName: string; // e.g. "hasSomaLocatedIn" family: PredicateFamily; // eqv (asserted) preferred over ent (entailed) negated: boolean; // true when sourced from a neurdf.*.neg family + // Set only when every contributing key agreed on it; undefined for plain families or when + // a union/intersection key was merged with a plain one (mixed semantics are not expressible). + combinator?: ValueCombinator; values: ResolvedRef[]; } +// Cross-nomenclature relation types, derived from the *relation*, not a dedicated predicate: +// TEMP:assertedSubClassOf -> the source explicitly defines this cell type ("described"), +// TEMP:mapsTo -> a computational/logical mapping ("inferred"). A curator "don't add" flag +// would make it "proposed", but that modifier is not present in the shipped graph. +export type MappingEvidence = "described" | "inferred" | "proposed"; + +// One row of the Cross-Nomenclature Mapping table. +export interface CellMapping { + ref: ResolvedRef; // the mapped cell (npokb record) + evidence: MappingEvidence; + source?: string; // parenthetical provenance carried in the mapped term's label, e.g. "Bhuiyan2024" +} + +// Annotations that are not phenotypes: prose, ids, dataset pointers and deep links. Kept off +// `properties` so they can never leak into the grid's facets or tile rows (getFacets derives +// facets from `properties`), while still being available to the Cell Card. +// +// Only `atlasAnnotation` and `dataCitations` have a widget today. The rest are captured because +// the parse is the ontology→model seam and the fixture check pins them (checkParser.mjs), not +// because something renders them: `curatorNotes` is destined for the Discussions thread as +// system comments (spec §10.4), which is blocked on a service that 404s for every term id, and +// the three link fields are the widgets' forward-compatible path — see LINK_ANNOTATIONS. +export interface CellAnnotations { + atlasAnnotation: string[]; // ilxtr:atlasAnnotation — Precision dataset annotation pills + curatorNotes: string[]; // ilxtr:curatorNote — awaiting the Discussions wire-up (spec §10.4) + alertNotes: string[]; // ilxtr:alertNote — no widget yet + dataCitations: ResolvedRef[]; // ilxtr:dataCitation — e.g. a GEO accession + temporaryId?: string; // ilxtr:hasTemporaryId — no widget yet + generatedLabel?: string; // ilxtr:genLabel — the verbose phenotype string, no widget yet + errors: string[]; // ilxtr:error — e.g. ilxtr:NeurdfLogicalFlattened, no widget yet + // Deep links, matched by local name under any prefix (`ilx:` / `ilxtr:` / expanded IRI). Zero + // occurrences in the shipped graph: the widgets that read them render their "appears when the + // triple is added" state until the backend emits one. + sparcTranscriptomicsLinks: ResolvedRef[]; // hasSPARCTranscriptomicsLink — SPARC Portal + sparcMaps: ResolvedRef[]; // hasSPARCMap — the Anatomical Context row + nervoSensusLinks: ResolvedRef[]; // hasNervoSensusLink — the Interactive Cell Grouping tile +} + // A single cell record rendered as a grid tile. export interface CellTerm { id: string; // @id, e.g. "npokb:1067" @@ -58,6 +106,9 @@ export interface CellTerm { // Negated phenotypes (neurdf.*.neg) keyed by local name — rendered with a "not" modifier. negated: Record; sources: ResolvedRef[]; // ilxtr:literatureCitation (a cell may have several) + // Cross-nomenclature mappings (TEMP:assertedSubClassOf / TEMP:mapsTo / TEMP:subClassOf). + mappings: CellMapping[]; + annotations: CellAnnotations; } // One position of a class in the subClassOf hierarchy. A class with several parents is @@ -83,6 +134,9 @@ export interface ParsedOntology { meta: OntologyMeta; cells: CellTerm[]; hierarchy: HierarchyNode[]; // roots of the subClassOf tree (the root class, when there is one) + // Row labels / tooltips read from the ontology's own ilxtr:displayLabel + shortDefinition, + // keyed by predicate local name. See parsePredicateDisplay. + predicateDisplay: Record; } // A filter facet built from the data values across all cells. @@ -101,3 +155,69 @@ export interface Facet { tooltip?: string; values: FacetValue[]; } + +// --- Cell Card --------------------------------------------------------------- + +// Display metadata for a predicate, read from the ontology itself: the 70 ilxtr:* property +// nodes carry ilxtr:displayLabel + ilxtr:shortDefinition, which cover 14 of the 15 neurdf +// local names used by Precision cells. gridConfig's hardcoded maps are only the fallback. +export interface PredicateDisplay { + localName: string; + label: string; + description?: string; +} + +// One row of a Cell Card property widget, already resolved for rendering (see buildRows). +export interface PropertyRowModel { + localName: string; + label: string; + tooltip?: string; + prop?: CellProperty; // absent => the row renders "not specified" + render?: "text" | "chip"; // carried through from the row's cellCardConfig entry + required?: boolean; // keep the row (as "not specified") when the predicate is absent +} + +// A node in the relationship graph, before layout. +export type RelationEdgeKind = + | "subClassOf" + | "assertedSubClassOf" + | "somaLocation" + | "expresses"; + +export interface RelationNode { + id: string; + label: string; + subtitle?: string; // e.g. "npokb:998 · Bhuiyan2025" + isCurrent: boolean; + flagged?: boolean; // rendered with a "*" — proposed evidence only + ref?: ResolvedRef; // link target when the node is navigable +} + +export interface RelationEdge { + from: string; + to: string; + kind: RelationEdgeKind; + label: string; + // Which side of the current node the target sits on, copied from the RelationPredicate that + // produced the edge. Optional because the structural edges (subClassOf, assertedSubClassOf) + // leave it unset and take dagre's ranks; RelationshipGraphSvg pins the "left"/"right" ones + // itself, so dropping this field silently collapses every satellite onto a default rank. + direction?: "up" | "down" | "left" | "right"; +} + +export interface RelationGraphModel { + nodes: RelationNode[]; + edges: RelationEdge[]; +} + +// Publication metadata resolved from a DOI via CrossRef (the graph has none — DOIs are bare +// @id IRIs with no node), so every field is optional and the widget degrades to a bare link. +export interface Citation { + doi: string; + url: string; + title?: string; + authors?: string; + journal?: string; + year?: string; + type?: string; // CrossRef "type", e.g. "posted-content" for a preprint +} diff --git a/src/components/CellCards/services/ontologyGridService.ts b/src/components/CellCards/services/ontologyGridService.ts index 4d5aae2a..259fd7a3 100644 --- a/src/components/CellCards/services/ontologyGridService.ts +++ b/src/components/CellCards/services/ontologyGridService.ts @@ -5,7 +5,8 @@ import { parseNeurdf, buildFacets } from "../../../parsers/neurdfParser"; import { NEURDF_URL, - NEURDF_FALLBACK_URL, + NEURDF_LOCAL_URL, + PREFER_LOCAL_NEURDF, ONTOLOGY_CATALOG, DISPLAYED_PROPERTIES, PREDICATE_LABELS, @@ -25,6 +26,7 @@ export interface LoadedOntology { meta: ParsedOntology["meta"]; cells: CellTerm[]; hierarchy: HierarchyNode[]; + predicateDisplay: ParsedOntology["predicateDisplay"]; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -55,24 +57,44 @@ const fetchGraph = async (url: string): Promise => { const LIVE_RETRIES = 3; -const loadGraphWithRetry = async (): Promise => { - // Try the live endpoint (flaky on the large body), then the optional dev fallback. - let liveErr: unknown; +// Upstream is slow and flaky on the 16MB body, so it gets retries with backoff. The baked +// same-origin copy is a static file — it either exists or it does not, and retrying a 404 just +// delays the real attempt. +const fetchLocal = () => fetchGraph(NEURDF_LOCAL_URL); + +const fetchUpstream = async (): Promise => { + let lastErr: unknown; for (let i = 0; i < LIVE_RETRIES; i++) { try { return await fetchGraph(NEURDF_URL); } catch (err) { - liveErr = err; + lastErr = err; if (i < LIVE_RETRIES - 1) await sleep(600 * (i + 1)); } } + throw lastErr instanceof Error ? lastErr : new Error("Failed to load ontology data"); +}; + +const loadGraphWithRetry = async (): Promise => { + // Preferred source first (the baked copy in production), the other as the safety net, so a + // missing bake or a down upstream still leaves the page working. + const [primary, secondary] = PREFER_LOCAL_NEURDF + ? [fetchLocal, fetchUpstream] + : [fetchUpstream, fetchLocal]; + + let primaryErr: unknown; try { - return await fetchGraph(NEURDF_FALLBACK_URL); + return await primary(); + } catch (err) { + primaryErr = err; + } + try { + return await secondary(); } catch { - // The fallback is a dev-only convenience (gitignored, often absent). If it fails, - // surface the real live-endpoint error rather than the fallback's parse/HTML error. + // Report the *preferred* source's error: it is the one an operator needs to fix, and the + // secondary's error is usually just "404, no bake in this image". } - throw liveErr instanceof Error ? liveErr : new Error("Failed to load ontology data"); + throw primaryErr instanceof Error ? primaryErr : new Error("Failed to load ontology data"); }; // Fetch + JSON.parse only once per session. @@ -105,11 +127,87 @@ export const loadOntology = async (slug: string): Promise => { meta: parsed.meta, cells: parsed.cells, hierarchy: parsed.hierarchy, + predicateDisplay: parsed.predicateDisplay, }; parsedCache.set(slug, loaded); return loaded; }; +// --- single-term selectors (Cell Card) --------------------------------------- + +// A term arrives from the URL as a slug, where the ":" of a CURIE has become "_": +// "npokb_998" -> "npokb:998". ILX ids are accepted in either their slug or CURIE form so the +// same lookup keeps working once Precision cells are ingested with ILX ids. +export const slugToCurie = (slug: string): string => { + const s = decodeURIComponent(slug || "").trim(); + if (!s) return ""; + if (s.includes(":")) return s; + const at = s.indexOf("_"); + return at > 0 ? `${s.slice(0, at)}:${s.slice(at + 1)}` : s; +}; + +export const curieToSlug = (curie: string): string => (curie || "").replace(":", "_"); + +// Find one cell by any of the identifiers a link might carry: the URL slug, the CURIE, or the +// full IRI. Matching is case-insensitive on the prefix only ("NPOKB:998" is the same record), +// because the local part of an ILX/npokb id is numeric anyway. +export const findCell = (data: LoadedOntology, id: string): CellTerm | undefined => { + const wanted = slugToCurie(id); + if (!wanted) return undefined; + const lower = wanted.toLowerCase(); + return data.cells.find( + (c) => c.id === wanted || c.curie === wanted || c.iri === wanted || + c.curie.toLowerCase() === lower || c.id.toLowerCase() === lower + ); +}; + +// Sibling cells that cite the same publication ("Other cells from this source"). Excludes the +// current cell and preserves the parse's alphabetical order. +export const relatedBySource = (data: LoadedOntology, cell: CellTerm): CellTerm[] => { + const dois = new Set(cell.sources.map((s) => s.id)); + if (!dois.size) return []; + return data.cells.filter( + (c) => c.id !== cell.id && c.sources.some((s) => dois.has(s.id)) + ); +}; + +// Direct subClassOf parents and children of a cell, as ResolvedRefs. Both come from the +// hierarchy the parse already reduced (transitive edges removed), so a cell does not list its +// grandparents. Restricted to cells in scope — a parent outside the ontology (ilxtr: +// NeuronPrecision itself) is not a navigable cell card. +export const hierarchyNeighbours = ( + data: LoadedOntology, + cell: CellTerm +): { parents: CellTerm[]; children: CellTerm[] } => { + const parents: CellTerm[] = []; + const children: CellTerm[] = []; + const seenParent = new Set(); + const seenChild = new Set(); + const byTermId = new Map(data.cells.map((c) => [c.id, c])); + + const walk = (nodes: HierarchyNode[], parent?: HierarchyNode) => { + for (const node of nodes) { + if (node.termId === cell.id) { + const p = parent && byTermId.get(parent.termId); + if (p && !seenParent.has(p.id)) { + seenParent.add(p.id); + parents.push(p); + } + for (const child of node.children) { + const c = byTermId.get(child.termId); + if (c && !seenChild.has(c.id)) { + seenChild.add(c.id); + children.push(c); + } + } + } + walk(node.children, node); + } + }; + walk(data.hierarchy); + return { parents, children }; +}; + // Which predicates are present across the cells (+ "source" from literatureCitation). const presentNames = (cells: CellTerm[]): { found: Set; hasSource: boolean } => { const found = new Set(); diff --git a/src/components/SingleTermView/index.jsx b/src/components/SingleTermView/index.jsx index dac04d32..902fe115 100644 --- a/src/components/SingleTermView/index.jsx +++ b/src/components/SingleTermView/index.jsx @@ -15,7 +15,7 @@ import { } from "@mui/material"; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import ToggleButton from '@mui/material/ToggleButton'; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams, useNavigate, useLocation } from "react-router-dom"; import CustomBreadcrumbs from "../common/CustomBreadcrumbs"; import ForkRightIcon from '@mui/icons-material/ForkRight'; import { vars } from "../../theme/variables"; @@ -40,6 +40,8 @@ import FolderCopyOutlinedIcon from '@mui/icons-material/FolderCopyOutlined'; import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; import CreateNewFolderOutlinedIcon from '@mui/icons-material/CreateNewFolderOutlined'; import Discussion from "./Discussion"; +import CellCardPanel from "../CellCards/CellCard/CellCardPanel"; +import { ONTOLOGY_PARAM, isIlxTermSlug } from "../CellCards/config/gridConfig"; import { CodeIcon } from "../../Icons"; import CustomSingleSelect from "../common/CustomSingleSelect"; import CustomButtonGroup from "../common/CustomButtonGroup"; @@ -96,8 +98,14 @@ const buildDownloadFilename = (termId, label, ext) => { return `${base}.${ext}`; }; +// Tab indices. Cell Card leads, per the design (Figma 9533:72028), which shifts every other +// tab by one — these are named so the shift is stated once rather than as bare numbers. +const CELL_CARD_TAB = 0; +const OVERVIEW_TAB = 1; + const SingleTermView = () => { const { group, term, tab, versionHash } = useParams(); + const location = useLocation(); const navigate = useNavigate(); const [dataFormatAnchorEl, setDataFormatAnchorEl] = useState(null); const [isCodeViewVisible, setIsCodeViewVisible] = useState(false); @@ -121,6 +129,9 @@ const SingleTermView = () => { const openDataFormatMenu = Boolean(dataFormatAnchorEl); const { storedSearchTerm, updateStoredSearchTerm, user, activeOntology, setOntologyData } = useContext(GlobalDataContext); const [ontologySnackbar, setOntologySnackbar] = useState(null); // { severity, message } + // Label resolved by the Cell Card from the ontology graph. The term API cannot supply it for a + // precision cell (npokb ids 404), so without this the H1 would read "NPOKB:1067". + const [cellLabel, setCellLabel] = useState(null); // Whether the term currently in view is a member of the active ontology. const hasActiveOntology = !!activeOntology; @@ -130,26 +141,63 @@ const SingleTermView = () => { return !!termId && ontologyTerms.some((id) => extractIlxId(id) === termId); }, [term, activeOntology]); - // Tab mapping + // Is this term a cell type, and therefore does the Cell Card tab apply? + // + // This has to be answered *synchronously*, because it decides the default tab in the mount + // effect below — waiting on the ontology load (a ~16MB fetch) would block every term page. + // Two cheap signals, both available from the URL alone: + // - the term slug is an `npokb_*` id: all 161 Precision cells are npokb-only today; + // - an `?ontology=` param is present: the user arrived from an ontology grid. + // Revisit once Precision cells are ingested with ILX ids — at that point the slug shape stops + // being a reliable signal and the term's own @type should decide. + const contextOntology = new URLSearchParams(location.search).get(ONTOLOGY_PARAM); + useEffect(() => { + setCellLabel(null); + }, [term]); + + const isCellTerm = useMemo( + () => /^npokb[_:]/i.test(term || "") || Boolean(contextOntology), + [term, contextOntology] + ); + // Whether the InterLex term API can address this term at all. + const hasIlxId = useMemo(() => isIlxTermSlug(term), [term]); + const DEFAULT_TAB_INDEX = isCellTerm ? CELL_CARD_TAB : OVERVIEW_TAB; + + // Cell Card is the first tab, per the design, so `overview` is index 1 and every + // hardcoded index below shifts with it. OVERVIEW_TAB names that so the code says which + // tab it means rather than repeating a bare 1. const tabMapping = useMemo(() => ({ - 'overview': 0, - 'variants': 1, - 'history': 2, - 'discussions': 3 + 'cell-card': 0, + 'overview': 1, + 'variants': 2, + 'history': 3, + 'discussions': 4 }), []); - const tabNames = useMemo(() => ['overview', 'variants', 'history', 'discussions'], []); - const tabLabels = useMemo(() => ["Overview", "Variants", "Version history", "Discussions"], []); + const tabNames = useMemo(() => ['cell-card', 'overview', 'variants', 'history', 'discussions'], []); + const tabLabels = useMemo(() => { + // A precision cell has no ILX id, so the term API — and therefore every tab that reads it + // — has nothing to serve for it. Disable those rather than offer dead tabs; they light up + // once these cells are ingested with ILX ids. + const termTabsDisabled = isCellTerm && !hasIlxId; + return [ + { label: "Cell Card", disabled: !isCellTerm }, + { label: "Overview", disabled: termTabsDisabled }, + { label: "Variants", disabled: termTabsDisabled }, + { label: "Version history", disabled: termTabsDisabled }, + { label: "Discussions", disabled: termTabsDisabled }, + ]; + }, [isCellTerm, hasIlxId]); // Set initial tab value based on URL const [tabValue, setTabValue] = useState(() => { - return tabMapping[tab] !== undefined ? tabMapping[tab] : 0; + return tabMapping[tab] !== undefined ? tabMapping[tab] : DEFAULT_TAB_INDEX; }); // Memoize the displayed term label to prevent unnecessary re-renders const displayedTermLabel = useMemo(() => { - return termData || storedSearchTerm || searchTerm.toUpperCase().replace("_", ":"); - }, [termData, storedSearchTerm, searchTerm]); + return termData || cellLabel || storedSearchTerm || searchTerm.toUpperCase().replace("_", ":"); + }, [termData, cellLabel, storedSearchTerm, searchTerm]); // Memoize breadcrumb items to prevent unnecessary re-renders const breadcrumbItems = useMemo(() => [ @@ -163,8 +211,10 @@ const SingleTermView = () => { const handleChangeTabs = useCallback((event, newValue) => { setTabValue(newValue); const newTab = tabNames[newValue]; - navigate(`/${group}/${term}/${newTab}`, { replace: true }); - }, [navigate, group, term, tabNames]); + // Carry the query string across: the Cell Card's context ontology lives in `?ontology=`, + // and dropping it here would blank the card whenever the user came back to this tab. + navigate(`/${group}/${term}/${newTab}${location.search}`, { replace: true }); + }, [navigate, group, term, tabNames, location.search]); const handleForkDialogClose = useCallback(() => { setOpenForkDialog(false); @@ -274,39 +324,52 @@ const SingleTermView = () => { // Optimize tab URL synchronization useEffect(() => { - const newTabValue = tabMapping[tab] !== undefined ? tabMapping[tab] : 0; + // A URL can name a tab this term has no data for — `/cell-card` on a term that is not a cell + // type, or a term tab on a precision cell. Those tabs are disabled in the bar, so honouring + // the URL would mount a panel that can only render an empty state (and, for the Cell Card, + // pay a ~16MB fetch to find that out) while the Tabs bar shows nothing selected. Fall back to + // the default tab, which is always enabled: Cell Card for a cell type, Overview otherwise. + const requested = tabMapping[tab]; + const newTabValue = + requested !== undefined && !tabLabels[requested]?.disabled ? requested : DEFAULT_TAB_INDEX; if (newTabValue !== tabValue) { setTabValue(newTabValue); } - // If no tab is specified in URL (and not a version view), redirect to overview - if (!tab && !versionHash && group && term) { - navigate(`/${group}/${term}/overview`, { replace: true }); + // Rewrite the URL when it does not name the tab in view: no tab at all, or one that resolved + // elsewhere. Skipped on the version route, which has no `tab` segment to write into. + // Preserves the query string for the same reason handleChangeTabs does. + if (!versionHash && group && term && tab !== tabNames[newTabValue]) { + navigate(`/${group}/${term}/${tabNames[newTabValue]}${location.search}`, { replace: true }); } - }, [tab, tabMapping, navigate, group, term, tabValue, versionHash]); + }, [tab, tabMapping, tabLabels, navigate, group, term, tabValue, versionHash, tabNames, DEFAULT_TAB_INDEX, location.search]); const isItFork = actualGroup === 'base' ? false : true; // Use actualGroup instead of group // Memoize tab content to prevent unnecessary re-renders const tabContent = useMemo(() => { switch (tabValue) { - case 0: + case CELL_CARD_TAB: + return ; + case OVERVIEW_TAB: return ; - case 1: - return ; case 2: - return ; + return ; case 3: + return ; + case 4: return ; default: return ; } - }, [tabValue, searchTerm, isCodeViewVisible, selectedDataFormat, actualGroup, versionHash, versionsData, versionsLoading, versionsError, clearVersionsError]); + }, [tabValue, searchTerm, group, isCodeViewVisible, selectedDataFormat, actualGroup, versionHash, versionsData, versionsLoading, versionsError, clearVersionsError]); // Memoize the toggle button group for overview tab const toggleButtonGroup = useMemo(() => { - if (tabValue !== 0) return null; + // Overview owns the raw-data view; before Cell Card took index 0 this read `!== 0`, which + // would now follow the Cell Card instead. + if (tabValue !== OVERVIEW_TAB) return null; return ( diff --git a/src/components/common/EmptyState.jsx b/src/components/common/EmptyState.jsx new file mode 100644 index 00000000..5f09ffc2 --- /dev/null +++ b/src/components/common/EmptyState.jsx @@ -0,0 +1,38 @@ +import PropTypes from "prop-types"; +import { Stack, Typography } from "@mui/material"; + +/** + * The shared "nothing here" placeholder (Figma component "Empty state", e.g. 9239:67736 — + * a centred featured icon over a message and optional supporting text). + * + * Replaces the copies that had been inlined in CellTileGrid, OntologyTermsTable and Graph. + */ +const EmptyState = ({ icon, message, supportingText, actions, sx }) => ( + + {icon} + + {message} + + {supportingText && ( + + {supportingText} + + )} + {actions} + +); + +EmptyState.propTypes = { + icon: PropTypes.node, + message: PropTypes.string.isRequired, + supportingText: PropTypes.string, + actions: PropTypes.node, + sx: PropTypes.object, +}; + +export default EmptyState; diff --git a/src/parsers/__fixtures__/README.md b/src/parsers/__fixtures__/README.md new file mode 100644 index 00000000..446f65b6 --- /dev/null +++ b/src/parsers/__fixtures__/README.md @@ -0,0 +1,38 @@ +# neurdf parser fixtures + +`neurdf-precision-sample.jsonld` — a 106-node trim of the full ~16MB +`npo-merged-reasoned-neurdf.jsonld`, holding three Precision cells plus the label nodes and +`ilxtr:displayLabel` / `ilxtr:shortDefinition` property nodes they reference. + +The three cells are not arbitrary; each covers behaviour the Cell Card depends on: + +| Cell | Covers | +|---|---| +| `npokb:1067` | The richest cell (24 predicates): plain `neurdf.eqv:hasSomaLocatedIn`, marker genes (including one `NCBIGene` with no label in the graph), `ilxtr:dataCitation`, `ilxtr:genLabel`. | +| `npokb:934` | Soma location **only** via `neurdf.eqv.uo:hasSomaLocatedIn`, i.e. a JSON-LD `@list`. This is the regression the fixture exists for — `resolveValue` used to drop `@list` values, which silently lost soma location on 61 of the 161 Precision cells. Must parse to two values with `combinator: "or"`. | +| `npokb:1007` | `TEMP:assertedSubClassOf` + `TEMP:mapsTo` (→ the `described` / `inferred` evidence derivation), `ilxtr:atlasAnnotation`, `ilxtr:curatorNote`. | + +One behaviour cannot come from a trim of the real file: the three deep-link predicates +(`hasSPARCMap`, `hasNervoSensusLink`, `hasSPARCTranscriptomicsLink`) have **zero occurrences** +upstream. `checkParser.mjs` therefore builds a small synthetic graph of its own for those, carrying +one predicate in each prefix form (`ilx:`, `ilxtr:`, expanded IRI) to pin the claim that the widgets +reading them light up with no code change — and that they stay off `properties`, where they would +become facet options in the grid sidebar. Keep it in the script rather than in the fixture, so +regenerating the fixture cannot drop it. + +## Running the checks + +The root project has no unit-test runner (`test/` is a separate Puppeteer + Jest e2e package), so +the assertions live in a plain script: + +``` +yarn check-parser +``` + +That runs `scripts/run-ts.mjs`, which bundles the TypeScript parser with esbuild (already a vite +dependency) and executes it on plain Node — no runner dependency, and it works on the `node:18-alpine` +build image. It exits non-zero on failure and runs in CI after `build` and `lint` +(`.github/workflows/lint.yml`). + +Regenerate the fixture from the full graph with the snippet in this repo's git history if the +upstream ontology changes shape; the cells above should keep their listed properties. diff --git a/src/parsers/__fixtures__/buildFixture.mjs b/src/parsers/__fixtures__/buildFixture.mjs new file mode 100644 index 00000000..24eaf395 --- /dev/null +++ b/src/parsers/__fixtures__/buildFixture.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/** + * Regenerate `neurdf-precision-sample.jsonld` from the full ontology. + * + * yarn fetch-data --force # get current source data + * yarn build-fixture # trim it down to the fixture + * + * The fixture must be derived from the *source* file rather than hand-edited, so that when the + * upstream ontology changes shape the checks fail against real data instead of quietly passing + * against a stale hand-maintained sample. + * + * Reads the same `public/data/` copy the app serves, so whatever you just downloaded is what the + * fixture is cut from. Fails loudly if a chosen cell has lost a property the checks rely on — + * that is a signal to look at the upstream change, not to silently regenerate a weaker fixture. + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(here, "../../.."); +const SOURCE = process.env.NEURDF_OUT || resolve(ROOT, "public/data/npo-merged-neurdf.jsonld"); +const OUT = resolve(here, "neurdf-precision-sample.jsonld"); + +// Each cell is here for a reason; `requires` is asserted so a fixture rebuild cannot quietly +// drop the coverage the parser checks depend on. +const CELLS = [ + { + id: "npokb:1067", + why: "richest cell: plain soma location, marker genes, dataCitation, genLabel", + requires: [ + "neurdf.eqv:hasSomaLocatedIn", + "neurdf.eqv:hasNucleicAcidExpressionPhenotype", + "ilxtr:dataCitation", + "ilxtr:genLabel", + ], + }, + { + id: "npokb:934", + why: "soma location ONLY via neurdf.eqv.uo (@list) — the regression this fixture exists for", + requires: ["neurdf.eqv.uo:hasSomaLocatedIn"], + forbids: ["neurdf.eqv:hasSomaLocatedIn"], + }, + { + id: "npokb:1007", + why: "cross-nomenclature evidence derivation + atlas annotations + curator note", + requires: ["TEMP:assertedSubClassOf", "TEMP:mapsTo", "ilxtr:atlasAnnotation", "ilxtr:curatorNote"], + }, +]; + +// Keys worth keeping on a *referenced* node: enough to resolve a label and a display label, +// nothing else — this is what keeps the fixture at ~45KB instead of 16MB. +const REF_KEYS = ["@id", "@type", "rdfs:label", "ilxtr:displayLabel", "ilxtr:shortDefinition"]; + +const source = JSON.parse(readFileSync(SOURCE, "utf8")); +const graph = source["@graph"] || []; +const index = new Map(graph.filter((n) => n["@id"]).map((n) => [n["@id"], n])); + +// Every @id a node points at, including inside an @list (which is exactly the case the fixture +// is here to cover, so missing it would drop the label nodes for the union values). +const referencedIds = (node) => { + const out = new Set(); + const walk = (value) => { + if (Array.isArray(value)) value.forEach(walk); + else if (value && typeof value === "object") { + if (typeof value["@id"] === "string") out.add(value["@id"]); + if (value["@list"]) walk(value["@list"]); + } + }; + for (const [key, value] of Object.entries(node)) { + if (key !== "@id" && key !== "@type") walk(value); + } + return out; +}; + +const pick = (node, keys) => + Object.fromEntries(Object.entries(node).filter(([k]) => keys.includes(k))); + +const keep = new Map(); +const problems = []; + +for (const spec of CELLS) { + const node = index.get(spec.id); + if (!node) { + problems.push(`${spec.id} is missing from the source graph (was: ${spec.why})`); + continue; + } + for (const key of spec.requires || []) { + if (!(key in node)) problems.push(`${spec.id} no longer has ${key} — needed for: ${spec.why}`); + } + for (const key of spec.forbids || []) { + if (key in node) { + problems.push( + `${spec.id} now ALSO has ${key}, so it no longer isolates the @list-only case (${spec.why}) ` + + `— pick a different cell that has only the union form` + ); + } + } + keep.set(spec.id, node); + for (const ref of referencedIds(node)) { + if (ref.startsWith("_:")) continue; // restriction blank nodes carry nothing we render + const target = index.get(ref); + if (target && !keep.has(ref)) keep.set(ref, pick(target, REF_KEYS)); + } +} + +// The root class the parse scopes to, plus every predicate node carrying display metadata (the +// checks assert these are read from the graph rather than from the front end's fallback map). +const root = index.get("ilxtr:NeuronPrecision"); +keep.set("ilxtr:NeuronPrecision", root ? pick(root, REF_KEYS) : { "@id": "ilxtr:NeuronPrecision", "@type": "owl:Class" }); +for (const node of graph) { + const id = node["@id"]; + if (typeof id !== "string") continue; + if ("ilxtr:displayLabel" in node || "ilxtr:shortDefinition" in node) { + if (!keep.has(id)) keep.set(id, pick(node, REF_KEYS)); + } +} + +// The owl:Ontology node, so parseOntologyMeta has a title/version to read. +const ontology = graph.find((n) => { + const t = n["@type"]; + return t === "owl:Ontology" || (Array.isArray(t) && t.includes("owl:Ontology")); +}); +if (ontology) keep.set(ontology["@id"], ontology); + +if (problems.length) { + console.error("[fixture] source data no longer supports this fixture:\n - " + problems.join("\n - ")); + process.exit(2); +} + +const fixture = { "@context": source["@context"], "@graph": [...keep.values()] }; +writeFileSync(OUT, `${JSON.stringify(fixture, null, 1)}\n`); +console.log( + `[fixture] ${OUT}\n[fixture] ${fixture["@graph"].length} nodes from ${graph.length}, ` + + `${(JSON.stringify(fixture).length / 1024).toFixed(0)} KB` +); +for (const spec of CELLS) console.log(`[fixture] ${spec.id} — ${spec.why}`); diff --git a/src/parsers/__fixtures__/checkParser.mjs b/src/parsers/__fixtures__/checkParser.mjs new file mode 100644 index 00000000..81f5d32f --- /dev/null +++ b/src/parsers/__fixtures__/checkParser.mjs @@ -0,0 +1,151 @@ +// Assertions for neurdfParser against the trimmed fixture. See README.md in this directory. +// npx vite-node src/parsers/__fixtures__/checkParser.mjs +// Exits non-zero on the first failure. + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { parseNeurdf, parsePredicateDisplay } from "../neurdfParser"; +import { + hasCellGrouping, + hasTranscriptomicProfile, +} from "../../components/CellCards/CellCard/widgetVisibility"; + +const here = dirname(fileURLToPath(import.meta.url)); +const data = JSON.parse(readFileSync(join(here, "neurdf-precision-sample.jsonld"), "utf8")); + +let failed = 0; +const check = (name, actual, expected) => { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a === e) { + console.log(` ok ${name}`); + } else { + failed += 1; + console.log(` FAIL ${name}\n expected ${e}\n actual ${a}`); + } +}; + +const { cells, predicateDisplay } = parseNeurdf(data, "ilxtr:NeuronPrecision"); +const byId = Object.fromEntries(cells.map((c) => [c.id, c])); + +console.log("parseNeurdf"); +check("finds the three fixture cells", cells.length, 3); + +// --- the @list regression ----------------------------------------------------- +// npokb:934 carries soma location ONLY as neurdf.eqv.uo:hasSomaLocatedIn, a JSON-LD @list. +// Before the fix resolveValue ignored @list, so this property vanished entirely. +const union = byId["npokb:934"].properties.hasSomaLocatedIn; +console.log("\n@list unwrapping (npokb:934)"); +check("soma location is present", Boolean(union), true); +check( + "both list members resolve, with labels", + union?.values.map((v) => v.label), + ["dorsal root ganglion", "trigeminal ganglion"] +); +check("union semantics are preserved as 'or'", union?.combinator, "or"); + +// A plain family must NOT claim a combinator — its values are independent assertions, not +// alternatives, and rendering them as "A or B" would overstate what the ontology says. +console.log("\nplain family (npokb:1067)"); +const plain = byId["npokb:1067"].properties.hasSomaLocatedIn; +check("soma location resolves", plain?.values.map((v) => v.label), ["dorsal root ganglion"]); +check("no combinator on a plain family", plain?.combinator, undefined); + +// --- annotation allow-list ---------------------------------------------------- +console.log("\nannotations (npokb:1007)"); +const c1007 = byId["npokb:1007"]; +check("atlas annotations are captured", c1007.annotations.atlasAnnotation.length > 0, true); +check("curator note is captured", c1007.annotations.curatorNotes.length, 1); + +console.log("\nannotations (npokb:1067)"); +const c1067 = byId["npokb:1067"]; +check("data citation is captured", c1067.annotations.dataCitations.length, 1); +check("generated label is captured", typeof c1067.annotations.generatedLabel, "string"); +check("temporary id is captured", Boolean(c1067.annotations.temporaryId), true); + +// Annotations must stay OFF `properties`: the grid derives its filter facets from that object +// (getFacets), so leaking notes and ids in would add junk facets to the sidebar. +console.log("\nannotations do not leak into properties (grid facets)"); +const leaked = cells.flatMap((c) => + Object.keys(c.properties).filter((k) => /atlas|curator|alert|genLabel|Temporary|mapsTo|assertedSub/i.test(k)) +); +check("no annotation keys among properties", leaked, []); + +// --- cross-nomenclature evidence --------------------------------------------- +// Evidence is derived from which relation carried the mapping, since the graph has no dedicated +// evidence predicate: assertedSubClassOf/subClassOf -> described, mapsTo -> inferred. +console.log("\nmapping evidence derivation (npokb:1007)"); +const evidence = [...new Set(c1007.mappings.map((m) => m.evidence))].sort(); +check("both evidence kinds are derived", evidence, ["described", "inferred"]); +check( + "provenance is split out of the label", + c1007.mappings.every((m) => !m.source || !m.source.includes("(")), + true +); + +// --- display metadata read from the ontology --------------------------------- +console.log("\npredicate display metadata"); +check("displayLabel is read from the graph", predicateDisplay.hasSomaLocatedIn?.label, "Soma location"); +check("displayLabel for taxon", predicateDisplay.hasInstanceInTaxon?.label, "Observed in"); +check( + "shortDefinition is read as the tooltip", + typeof predicateDisplay.hasSomaLocatedIn?.description, + "string" +); +check( + "parsePredicateDisplay is callable on a raw graph", + Boolean(parsePredicateDisplay(data["@graph"]).hasSomaLocatedIn), + true +); + +// --- deep-link predicates (the widgets' forward-compatible path) --------------- +// hasSPARCMap / hasNervoSensusLink / hasSPARCTranscriptomicsLink have **zero occurrences** in the +// shipped ontology, so a trim of it cannot cover them — hence this synthetic graph. Three widgets +// promise to light up "with no code change" once the backend emits the triple, and that promise +// only holds if the parser (a) matches the predicate whatever prefix it arrives under and (b) keeps +// it off `properties`, where every key becomes a facet option in the grid sidebar. +const linkGraph = { + "@graph": [ + { + "@id": "npokb:9001", + "@type": ["owl:Class", "neurdf:Neuron"], + "rdfs:label": "synthetic deep-link cell", + "rdfs:subClassOf": [{ "@id": "ilxtr:NeuronPrecision" }], + // One predicate per prefix form on purpose: a CURIE the file's @context does not define, the + // readable `ilxtr:` CURIE, and the fully expanded IRI. + "ilx:hasSPARCMap": [{ "@id": "https://maps.sparc.science/dataset/1" }], + "ilxtr:hasNervoSensusLink": { "@id": "https://nervosensus.org/cell/9001" }, + "http://uri.interlex.org/tgbugs/uris/readable/hasSPARCTranscriptomicsLink": { + "@id": "https://sparc.science/data?cell=9001", + }, + }, + ], +}; + +console.log("\ndeep-link predicates (synthetic graph)"); +const linked = parseNeurdf(linkGraph, "ilxtr:NeuronPrecision").cells[0]; +check("the synthetic cell parses", linked?.curie, "npokb:9001"); +check( + "an `ilx:` CURIE lands on annotations", + linked?.annotations.sparcMaps.map((r) => r.iri), + ["https://maps.sparc.science/dataset/1"] +); +check( + "an `ilxtr:` CURIE lands on annotations", + linked?.annotations.nervoSensusLinks.map((r) => r.iri), + ["https://nervosensus.org/cell/9001"] +); +check( + "an expanded IRI lands on annotations", + linked?.annotations.sparcTranscriptomicsLinks.map((r) => r.iri), + ["https://sparc.science/data?cell=9001"] +); +check("deep links stay off properties", Object.keys(linked?.properties || {}), []); +// The bug this guards: the widgets used to look these up as `properties["ilx:has…"]`, a key the +// parser never writes, so they could not light up even with the triple present. +check("the NervoSensus widget lights up", hasCellGrouping(linked), true); +check("the Transcriptomic widget lights up on the link alone", hasTranscriptomicProfile(linked), true); + +console.log(failed ? `\n${failed} check(s) failed` : "\nall checks passed"); +process.exit(failed ? 1 : 0); diff --git a/src/parsers/__fixtures__/neurdf-precision-sample.jsonld b/src/parsers/__fixtures__/neurdf-precision-sample.jsonld new file mode 100644 index 00000000..5e4e7eaf --- /dev/null +++ b/src/parsers/__fixtures__/neurdf-precision-sample.jsonld @@ -0,0 +1,1335 @@ +{ + "@context": { + "AIBSSPEC": "http://api.brain-map.org/api/v2/data/Specimen/", + "AIBSTL": "http://api.brain-map.org/api/v2/data/TransgenicLine/", + "BFO": "http://purl.obolibrary.org/obo/BFO_", + "BIRNLEX": "http://uri.neuinfo.org/nif/nifstd/birnlex_", + "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", + "FMA": "http://purl.org/sig/ont/fma/fma", + "IAO": "http://purl.obolibrary.org/obo/IAO_", + "ILX": "http://uri.interlex.org/base/ilx_", + "JAX": "http://jaxmice.jax.org/strain/", + "MBA": "http://api.brain-map.org/api/v2/data/Structure/", + "MIRO": "http://uri.interlex.org/MIRO/uris/readable/", + "MMRRC": "http://www.mmrrc.org/catalog/getSDS.jsp?mmrrc_id=", + "NCBIGene": "http://www.ncbi.nlm.nih.gov/gene/", + "NCBITaxon": "http://purl.obolibrary.org/obo/NCBITaxon_", + "NIFEXT": "http://uri.neuinfo.org/nif/nifstd/nifext_", + "NIFRID": "http://uri.neuinfo.org/nif/nifstd/readable/", + "NIFSTD": "http://uri.neuinfo.org/nif/nifstd/", + "NLX": "http://uri.neuinfo.org/nif/nifstd/nlx_", + "NLXANAT": "http://uri.neuinfo.org/nif/nifstd/nlx_anat_", + "NLXCELL": "http://uri.neuinfo.org/nif/nifstd/nlx_cell_", + "NLXMOL": "http://uri.neuinfo.org/nif/nifstd/nlx_mol_", + "NLXNEURNT": "http://uri.neuinfo.org/nif/nifstd/nlx_neuron_nt_", + "NLXSUB": "http://uri.neuinfo.org/nif/nifstd/nlx_subcell_", + "NLXWIKI": "http://neurolex.org/wiki/", + "OBI": "http://purl.obolibrary.org/obo/OBI_", + "PATO": "http://purl.obolibrary.org/obo/PATO_", + "PAXRAT": "http://uri.interlex.org/paxinos/uris/rat/labels/", + "PMID": "http://www.ncbi.nlm.nih.gov/pubmed/", + "PR": "http://purl.obolibrary.org/obo/PR_", + "PTHR": "http://www.pantherdb.org/panther/family.do?clsAccession=PTHR", + "RO": "http://purl.obolibrary.org/obo/RO_", + "RRID": "https://scicrunch.org/resolver/RRID:", + "SAO": "http://uri.neuinfo.org/nif/nifstd/sao", + "SO": "http://purl.obolibrary.org/obo/SO_", + "TEMP": "http://uri.interlex.org/temp/uris/", + "TEMPIND": "http://uri.interlex.org/temp/uris/phenotype-indicators/", + "UBERON": "http://purl.obolibrary.org/obo/UBERON_", + "aibs.mus.lab": "http://uri.interlex.org/aibs/uris/mouse/labels/", + "aibs.mus.ver": "http://uri.interlex.org/aibs/uris/mouse/versions/", + "altTerm": "http://purl.obolibrary.org/obo/IAO_0000118", + "atom": "http://uri.interlex.org/tgbugs/uris/readable/atlas/", + "brick": "https://brickschema.org/schema/Brick#", + "csvw": "http://www.w3.org/ns/csvw#", + "curatorNote": "http://purl.obolibrary.org/obo/IAO_0000232", + "dc": "http://purl.org/dc/elements/1.1/", + "dcam": "http://purl.org/dc/dcam/", + "dcat": "http://www.w3.org/ns/dcat#", + "dcmitype": "http://purl.org/dc/dcmitype/", + "dcterms": "http://purl.org/dc/terms/", + "defSource": "http://purl.obolibrary.org/obo/IAO_0000119", + "definition": "http://purl.obolibrary.org/obo/IAO_0000115", + "doap": "http://usefulinc.com/ns/doap#", + "editorNote": "http://purl.obolibrary.org/obo/IAO_0000116", + "femrep": "http://uri.interlex.org/tgbugs/uris/readable/sparc-nlp/femrep/", + "foaf": "http://xmlns.com/foaf/0.1/", + "geo": "http://www.opengis.net/ont/geosparql#", + "hasMember": "http://purl.obolibrary.org/obo/RO_0002351", + "hasPart": "http://purl.obolibrary.org/obo/BFO_0000051", + "hasRole": "http://purl.obolibrary.org/obo/RO_0000087", + "ilx.hasDbXref": "http://uri.interlex.org/base/ilx_0381360", + "ilx.hasRole": "http://uri.interlex.org/base/ilx_0112784", + "ilx.partOf": "http://uri.interlex.org/base/ilx_0112785", + "ilx.relatedTo": "http://uri.interlex.org/base/ilx_0112796", + "ilx.type": "http://uri.interlex.org/base/readable/type/", + "ilxcr": "http://uri.interlex.org/composer/uris/readable/", + "ilxr": "http://uri.interlex.org/base/readable/", + "ilxtr": "http://uri.interlex.org/tgbugs/uris/readable/", + "importedFrom": "http://purl.obolibrary.org/obo/IAO_0000412", + "kidney": "http://uri.interlex.org/tgbugs/uris/readable/sparc-nlp/kidney/", + "liver": "http://uri.interlex.org/tgbugs/uris/readable/sparc-nlp/liver/", + "memberOf": "http://purl.obolibrary.org/obo/RO_0002350", + "mmset1": "http://uri.interlex.org/tgbugs/uris/readable/sparc-nlp/mmset1/", + "mmset2cn": "http://uri.interlex.org/tgbugs/uris/readable/sparc-nlp/mmset2cn/", + "mmset4": "http://uri.interlex.org/tgbugs/uris/readable/sparc-nlp/mmset4/", + "neurdf": "http://uri.interlex.org/tgbugs/uris/readable/neurdf/", + "neurdf.ent": "http://uri.interlex.org/tgbugs/uris/readable/neurdf/pred/ent/", + "neurdf.ent.neg": "http://uri.interlex.org/tgbugs/uris/readable/neurdf/pred/ent/neg/", + "neurdf.eqv": "http://uri.interlex.org/tgbugs/uris/readable/neurdf/pred/eqv/", + "neurdf.eqv.io": "http://uri.interlex.org/tgbugs/uris/readable/neurdf/pred/eqv/intsec/", + "neurdf.eqv.neg": "http://uri.interlex.org/tgbugs/uris/readable/neurdf/pred/eqv/neg/", + "neurdf.eqv.uo": "http://uri.interlex.org/tgbugs/uris/readable/neurdf/pred/eqv/union/", + "npokb": "http://uri.interlex.org/npo/uris/neurons/", + "odrl": "http://www.w3.org/ns/odrl/2/", + "org": "http://www.w3.org/ns/org#", + "overlaps": "http://purl.obolibrary.org/obo/RO_0002131", + "owl": "http://www.w3.org/2002/07/owl#", + "partOf": "http://purl.obolibrary.org/obo/BFO_0000050", + "prof": "http://www.w3.org/ns/dx/prof/", + "prostate": "http://uri.interlex.org/tgbugs/uris/readable/sparc-nlp/prostate/", + "prov": "http://www.w3.org/ns/prov#", + "qb": "http://purl.org/linked-data/cube#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "schema": "https://schema.org/", + "semves": "http://uri.interlex.org/tgbugs/uris/readable/sparc-nlp/semves/", + "senmot": "http://uri.interlex.org/tgbugs/uris/readable/sparc-nlp/senmot/", + "sh": "http://www.w3.org/ns/shacl#", + "skos": "http://www.w3.org/2004/02/skos/core#", + "sosa": "http://www.w3.org/ns/sosa/", + "ssn": "http://www.w3.org/ns/ssn/", + "swglnd": "http://uri.interlex.org/tgbugs/uris/readable/sparc-nlp/swglnd/", + "termEditor": "http://purl.obolibrary.org/obo/IAO_0000117", + "time": "http://www.w3.org/2006/time#", + "vann": "http://purl.org/vocab/vann/", + "void": "http://rdfs.org/ns/void#", + "wgs": "https://www.w3.org/2003/01/geo/wgs84_pos#", + "xsd": "http://www.w3.org/2001/XMLSchema#" + }, + "@graph": [ + { + "@id": "npokb:1067", + "@type": [ + "neurdf:Neuron", + "owl:Class" + ], + "TEMP:assertedSubClassOf": { + "@id": "npokb:1004" + }, + "TEMP:mapsTo": { + "@id": "TEMP:MISSING_" + }, + "ilxtr:dataCitation": { + "@id": "https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=GSE254789" + }, + "ilxtr:genLabel": "dorsal root ganglion (with-axon-presynaptic-element-in Rexed lamina II) (with-axon-sensory-subcellular-element-in skin of body) type C nerve fiber slowly adapting type 1 (SA1) nerve fiber +peptide +Mrgpra3 +NCBIGene:667742 +Trpv1 heat-sensitive high-threshold mechanosensitivity (HTM) (implies Mus musculus sensory) neuron (Precision)", + "ilxtr:hasTemporaryId": { + "@id": "http://uri.interlex.org/tgbugs/uris/readable/neurons/precision/99-mouse" + }, + "ilxtr:literatureCitation": { + "@id": "https://doi.org/10.1016/j.cell.2024.02.006" + }, + "ilxtr:neurondmBaseClass": "neuron", + "neurdf.ent:hasCircuitRolePhenotype": { + "@id": "ilxtr:SensoryPhenotype" + }, + "neurdf.ent:hasInstanceInTaxon": { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_10090" + }, + "neurdf.eqv:hasAdaptationPhenotype": { + "@id": "http://uri.interlex.org/base/ilx_0796517" + }, + "neurdf.eqv:hasAxonPhenotype": { + "@id": "http://uri.interlex.org/base/ilx_0796516" + }, + "neurdf.eqv:hasAxonPresynapticElementIn": { + "@id": "http://uri.interlex.org/base/ilx_0110092" + }, + "neurdf.eqv:hasAxonSensorySubcellularElementIn": { + "@id": "http://purl.obolibrary.org/obo/UBERON_0002097" + }, + "neurdf.eqv:hasAxonSensorySubcellularStructure": { + "@id": "http://purl.obolibrary.org/obo/UBERON_0035501" + }, + "neurdf.eqv:hasFunctionalPhenotype": { + "@id": "http://uri.interlex.org/base/ilx_0796528" + }, + "neurdf.eqv:hasNeurotransmitterPhenotype": { + "@id": "http://purl.obolibrary.org/obo/CHEBI_16670" + }, + "neurdf.eqv:hasNucleicAcidExpressionPhenotype": [ + { + "@id": "NCBIGene:233222" + }, + { + "@id": "NCBIGene:667742" + }, + { + "@id": "NCBIGene:193034" + } + ], + "neurdf.eqv:hasSomaLocatedIn": { + "@id": "http://purl.obolibrary.org/obo/UBERON_0000044" + }, + "neurdf.eqv:hasThresholdPhenotype": { + "@id": "http://uri.interlex.org/base/ilx_0796526" + }, + "owl:equivalentClass": { + "@id": "_:sg_3355_0_0" + }, + "rdfs:label": "DRG CGRP-theta1 (Qi2024)", + "rdfs:subClassOf": [ + { + "@id": "ilxtr:NeuronPrecision" + }, + { + "@id": "npokb:1052" + }, + { + "@id": "npokb:954" + }, + { + "@id": "npokb:1070" + }, + { + "@id": "_:sg_3214_220_0" + }, + { + "@id": "_:sg_3314_892_0" + } + ] + }, + { + "@id": "npokb:1004", + "@type": [ + "neurdf:Neuron", + "owl:Class" + ], + "rdfs:label": "DRG C-NP.MRGPRX1 MRGPRX4 (Bhuiyan2025)" + }, + { + "@id": "ilxtr:SensoryPhenotype", + "@type": "owl:Class", + "rdfs:label": "Sensory phenotype" + }, + { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_10090", + "@type": "owl:Class", + "rdfs:label": "Mus musculus" + }, + { + "@id": "http://uri.interlex.org/base/ilx_0796517", + "@type": "owl:Class", + "rdfs:label": "slowly adapting type 1 (SA1) nerve fiber" + }, + { + "@id": "http://uri.interlex.org/base/ilx_0796516", + "@type": "owl:Class", + "rdfs:label": "type C nerve fiber" + }, + { + "@id": "http://uri.interlex.org/base/ilx_0110092", + "@type": "owl:Class", + "rdfs:label": "Rexed lamina II" + }, + { + "@id": "http://purl.obolibrary.org/obo/UBERON_0002097", + "@type": "owl:Class", + "rdfs:label": "skin of body" + }, + { + "@id": "http://purl.obolibrary.org/obo/UBERON_0035501", + "@type": "owl:Class", + "rdfs:label": "unencapsulated tactile receptor" + }, + { + "@id": "http://uri.interlex.org/base/ilx_0796528", + "@type": "owl:Class", + "rdfs:label": "heat-sensitive phenotype" + }, + { + "@id": "http://purl.obolibrary.org/obo/CHEBI_16670", + "@type": "owl:Class", + "rdfs:label": "peptide" + }, + { + "@id": "NCBIGene:233222", + "@type": "owl:Class", + "rdfs:label": "Mrgpra3" + }, + { + "@id": "NCBIGene:667742", + "@type": "owl:Class" + }, + { + "@id": "NCBIGene:193034", + "@type": "owl:Class", + "rdfs:label": "Trpv1" + }, + { + "@id": "http://purl.obolibrary.org/obo/UBERON_0000044", + "@type": "owl:Class", + "rdfs:label": "dorsal root ganglion" + }, + { + "@id": "http://uri.interlex.org/base/ilx_0796526", + "@type": "owl:Class", + "rdfs:label": "high-threshold mechanosensitivity (HTM) phenotype" + }, + { + "@id": "ilxtr:NeuronPrecision", + "@type": "owl:Class" + }, + { + "@id": "npokb:1052", + "@type": [ + "owl:Class", + "neurdf:Neuron" + ], + "rdfs:label": "DRG NP2.1 (Krauter2025)" + }, + { + "@id": "npokb:954", + "@type": [ + "neurdf:Neuron", + "owl:Class" + ], + "rdfs:label": "DRG TG Mrgpra3+Trpv1 (Bhuiyan2024)" + }, + { + "@id": "npokb:1070", + "@type": [ + "owl:Class", + "neurdf:Neuron" + ], + "rdfs:label": "DRG CGRP-iota (Qi2024)" + }, + { + "@id": "npokb:934", + "@type": [ + "neurdf:Neuron", + "owl:Class" + ], + "ilxtr:atlasAnnotation": { + "@id": "TEMP:MISSING_" + }, + "ilxtr:genLabel": "(unionOf dorsal root ganglion trigeminal ganglion) type Aδ (delta) nerve fiber +Calca +Smr2 heat-sensitive high-threshold mechanosensitivity (HTM) (implies Cavia porcellus Homo sapiens Macaca mulatta Mus musculus sensory) neuron (Precision)", + "ilxtr:hasTemporaryId": { + "@id": "http://uri.interlex.org/tgbugs/uris/readable/neurons/precision/5-master" + }, + "ilxtr:literatureCitation": { + "@id": "https://doi.org/10.1126/sciadv.adj9173" + }, + "ilxtr:neurondmBaseClass": "neuron", + "neurdf.ent:hasCircuitRolePhenotype": { + "@id": "ilxtr:SensoryPhenotype" + }, + "neurdf.ent:hasInstanceInTaxon": [ + { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_10141" + }, + { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_9606" + }, + { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_9544" + }, + { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_10090" + } + ], + "neurdf.eqv.uo:hasSomaLocatedIn": { + "@list": [ + { + "@id": "http://purl.obolibrary.org/obo/UBERON_0000044" + }, + { + "@id": "http://purl.obolibrary.org/obo/UBERON_0001675" + } + ] + }, + "neurdf.eqv:hasAxonPhenotype": { + "@id": "http://uri.interlex.org/base/ilx_0796514" + }, + "neurdf.eqv:hasFunctionalPhenotype": { + "@id": "http://uri.interlex.org/base/ilx_0796528" + }, + "neurdf.eqv:hasNucleicAcidExpressionPhenotype": [ + { + "@id": "TEMPIND:Smr2" + }, + { + "@id": "TEMPIND:Calca" + } + ], + "neurdf.eqv:hasThresholdPhenotype": { + "@id": "http://uri.interlex.org/base/ilx_0796526" + }, + "owl:equivalentClass": { + "@id": "_:sg_4301_0_0" + }, + "rdfs:label": "DRG TG Calca+Smr2 (Bhuiyan2024)", + "rdfs:subClassOf": [ + { + "@id": "ilxtr:NeuronPrecision" + }, + { + "@id": "_:sg_3214_87_0" + }, + { + "@id": "_:sg_3314_834_0" + }, + { + "@id": "_:sg_4274_7_0" + }, + { + "@id": "_:sg_3172_132_0" + }, + { + "@id": "_:sg_4275_8_0" + } + ] + }, + { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_10141", + "@type": "owl:Class", + "rdfs:label": "Cavia porcellus" + }, + { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_9606", + "@type": "owl:Class", + "rdfs:label": "Homo sapiens" + }, + { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_9544", + "@type": "owl:Class", + "rdfs:label": "Macaca mulatta" + }, + { + "@id": "http://purl.obolibrary.org/obo/UBERON_0001675", + "@type": "owl:Class", + "rdfs:label": "trigeminal ganglion" + }, + { + "@id": "http://uri.interlex.org/base/ilx_0796514", + "@type": "owl:Class", + "rdfs:label": "type Aδ (delta) nerve fiber" + }, + { + "@id": "TEMPIND:Smr2", + "@type": "owl:Class", + "rdfs:label": "Smr2 (indicator)" + }, + { + "@id": "TEMPIND:Calca", + "@type": "owl:Class", + "rdfs:label": "Calca (indicator)" + }, + { + "@id": "npokb:1007", + "@type": [ + "neurdf:Neuron", + "owl:Class" + ], + "TEMP:assertedSubClassOf": [ + { + "@id": "npokb:1014" + }, + { + "@id": "npokb:1074" + } + ], + "TEMP:mapsTo": [ + { + "@id": "npokb:1037" + }, + { + "@id": "npokb:970" + } + ], + "ilxtr:atlasAnnotation": [ + "C-Thermo.RXFP1:U19_UPENN", + "C-Thermo.RXFP1:U19_WASHU", + "C-Thermo.RXFP1:U19_HMS", + "C-Thermo.RXFP1:U19_UTD" + ], + "ilxtr:curatorNote": "see Figure 1G of https://doi.org/10.1038/s42003-025-08315-1 for asserted equivalences to Yu et al., 2024. See Table S12 for asserted equivalences to Bhuyian et al., 2025.", + "ilxtr:genLabel": "dorsal root ganglion type C nerve fiber +NCBIGene:59350 heat-sensitive (implies Homo sapiens sensory) neuron (Precision)", + "ilxtr:hasTemporaryId": { + "@id": "http://uri.interlex.org/tgbugs/uris/readable/neurons/precision/39-human" + }, + "ilxtr:literatureCitation": { + "@id": "https://doi.org/10.1101/2025.11.05.686654" + }, + "ilxtr:neurondmBaseClass": "neuron", + "neurdf.ent:hasCircuitRolePhenotype": { + "@id": "ilxtr:SensoryPhenotype" + }, + "neurdf.ent:hasInstanceInTaxon": { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_9606" + }, + "neurdf.eqv:hasAxonPhenotype": { + "@id": "http://uri.interlex.org/base/ilx_0796516" + }, + "neurdf.eqv:hasFunctionalPhenotype": { + "@id": "http://uri.interlex.org/base/ilx_0796528" + }, + "neurdf.eqv:hasNucleicAcidExpressionPhenotype": { + "@id": "NCBIGene:59350" + }, + "neurdf.eqv:hasSomaLocatedIn": { + "@id": "http://purl.obolibrary.org/obo/UBERON_0000044" + }, + "owl:equivalentClass": { + "@id": "_:sg_3277_0_0" + }, + "rdfs:label": "DRG C-Thermo.RXFP1 (Bhuiyan2025)", + "rdfs:subClassOf": [ + { + "@id": "npokb:1075" + }, + { + "@id": "ilxtr:NeuronPrecision" + }, + { + "@id": "_:sg_3214_160_0" + }, + { + "@id": "_:sg_3172_170_0" + } + ] + }, + { + "@id": "npokb:1014", + "@type": [ + "neurdf:Neuron", + "owl:Class" + ], + "rdfs:label": "DRG cold nociceptor (Tavares-Ferreira2022)" + }, + { + "@id": "npokb:1074", + "@type": [ + "neurdf:Neuron", + "owl:Class" + ], + "rdfs:label": "DRG Trmp8 (Qi2024)" + }, + { + "@id": "npokb:1037", + "@type": [ + "neurdf:Neuron", + "owl:Class" + ], + "rdfs:label": "DRG Rxfp1 (Krauter2025)" + }, + { + "@id": "npokb:970", + "@type": [ + "owl:Class", + "neurdf:Neuron" + ], + "rdfs:label": "DRG Rxfp1 mouse (Bhuiyan2024)" + }, + { + "@id": "NCBIGene:59350", + "@type": "owl:Class" + }, + { + "@id": "npokb:1075", + "@type": [ + "neurdf:Neuron", + "owl:Class" + ], + "rdfs:label": "DRG unassigned (Qi2024)" + }, + { + "@id": "ilxtr:hasComputedMolecularPhenotypeFromRNA", + "@type": [ + "owl:IrreflexiveProperty", + "owl:ObjectProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Computed from RNA", + "ilxtr:shortDefinition": "Molecular phenotype computed from RNA-level data (e.g., transcriptomics).", + "rdfs:label": "hasComputedMolecularPhenotypeFromRNA" + }, + { + "@id": "ilxtr:isMemberOfCircuit", + "@type": [ + "owl:IrreflexiveProperty", + "owl:AsymmetricProperty", + "owl:ObjectProperty" + ], + "ilxtr:displayLabel": "Member of circuit", + "ilxtr:shortDefinition": "A neural circuit that this cell type is a member of.", + "rdfs:label": "isMemberOfCircuit" + }, + { + "@id": "ilxtr:hasElectrophysiologicalPhenotype", + "@type": [ + "owl:AsymmetricProperty", + "owl:ObjectProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Electrophyioslogy", + "ilxtr:shortDefinition": "The electrical properties and firing properties of the cell.", + "rdfs:label": "hasElectrophysiologicalPhenotype" + }, + { + "@id": "ilxtr:hasProcessLocatedIn", + "@type": [ + "owl:AsymmetricProperty", + "owl:ObjectProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Process location", + "ilxtr:shortDefinition": "An anatomical location of the cell type's processes (axon/dendrites).", + "rdfs:label": "hasProcessLocatedIn" + }, + { + "@id": "ilxtr:hasTaxonRank", + "@type": [ + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty", + "owl:ObjectProperty" + ], + "ilxtr:displayLabel": "Taxonomic rank", + "ilxtr:shortDefinition": "The taxonomic group of the last common ancestor with this cell type.", + "rdfs:label": "hasTaxonRank" + }, + { + "@id": "ilxtr:hasDevelopmentalType", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Developmental type", + "ilxtr:shortDefinition": "The developmental lineage for the cell type.", + "rdfs:label": "hasDevelopmentalType" + }, + { + "@id": "ilxtr:hasDendriteLocatedIn", + "@type": [ + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty", + "owl:ObjectProperty" + ], + "ilxtr:displayLabel": "Dendrite location", + "ilxtr:shortDefinition": "An anatomical location of the cell type's dendrites.", + "rdfs:label": "hasDendriteLocatedIn" + }, + { + "@id": "ilxtr:hasConnectionDeterminedByElectronMicroscopy", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Connection via electron microscopy", + "ilxtr:shortDefinition": "Connection determined by electron microscopy imaging.", + "rdfs:label": "hasConnectionDeterminedByElectronMicroscopy" + }, + { + "@id": "ilxtr:hasPhenotype", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Observed phenotype", + "ilxtr:shortDefinition": "The particular observable characteristics or phenotypes of a cell type.", + "rdfs:label": "hasPhenotype" + }, + { + "@id": "ilxtr:phenotypeCooccuresWith", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Co-occurs with", + "ilxtr:shortDefinition": "Another phenotype that is consistently observed alongside this one.", + "rdfs:label": "phenotypeCooccuresWith" + }, + { + "@id": "ilxtr:hasExpressionPhenotype", + "@type": [ + "owl:AsymmetricProperty", + "owl:ObjectProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Expresses", + "ilxtr:shortDefinition": "General phenotype based on gene or molecule expression levels.", + "rdfs:label": "hasExpressionPhenotype" + }, + { + "@id": "ilxtr:hasReverseConnectionPhenotype", + "@type": [ + "owl:AsymmetricProperty", + "owl:ObjectProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Receives input from", + "ilxtr:shortDefinition": "Synaptic connections where this cell type is the postsynaptic partner.", + "rdfs:label": "hasReverseConnectionPhenotype" + }, + { + "@id": "ilxtr:hasExperimentalPhenotype", + "@type": [ + "owl:IrreflexiveProperty", + "owl:ObjectProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Experimental observation", + "ilxtr:shortDefinition": "A phenotype defined by meeting specific experimental conditions.", + "rdfs:label": "hasExperimentalPhenotype" + }, + { + "@id": "ilxtr:hasConnectionPhenotype", + "@type": [ + "owl:ObjectProperty", + "owl:SymmetricProperty" + ], + "ilxtr:displayLabel": "Connectivity", + "ilxtr:shortDefinition": "Synaptic connections between this cell type and other cell types.", + "rdfs:label": "hasConnectionPhenotype" + }, + { + "@id": "ilxtr:hasReporterExpressionPhenotype", + "@type": [ + "owl:ObjectProperty", + "owl:IrreflexiveProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Reporter expression", + "ilxtr:shortDefinition": "Genetic reporter line (e.g., fluorescent marker).", + "rdfs:label": "hasReporterExpressionPhenotype" + }, + { + "@id": "ilxtr:hasDendriteMorphologicalPhenotype", + "@type": [ + "owl:AsymmetricProperty", + "owl:ObjectProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Dendrite morphology", + "ilxtr:shortDefinition": "The shape and branching pattern of the cell type's dendrites.", + "rdfs:label": "hasDendriteMorphologicalPhenotype" + }, + { + "@id": "ilxtr:hasBiologicalSex", + "@type": [ + "owl:ObjectProperty", + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Sex", + "ilxtr:shortDefinition": "The biological sex the cell type has been observed in.", + "rdfs:label": "hasBiologicalSex" + }, + { + "@id": "ilxtr:hasInstanceInTaxon", + "@type": [ + "owl:ObjectProperty", + "owl:IrreflexiveProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Observed in", + "ilxtr:shortDefinition": "A taxonomic group the cell type has been observed in.", + "rdfs:label": "hasInstanceInTaxon" + }, + { + "@id": "ilxtr:hasDriverExpressionInducedPhenotype", + "@type": [ + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty", + "owl:ObjectProperty" + ], + "ilxtr:displayLabel": "Induced driver expression", + "ilxtr:shortDefinition": "Genetic driver that must be induced to drive expression.", + "rdfs:label": "hasDriverExpressionInducedPhenotype" + }, + { + "@id": "ilxtr:hasProjectionPhenotype", + "@type": [ + "owl:AsymmetricProperty", + "owl:ObjectProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Projection target", + "ilxtr:shortDefinition": "Anatomical regions to which the cell type sends its axonal projections.", + "rdfs:label": "hasProjectionPhenotype" + }, + { + "@id": "ilxtr:hasMorphologicalPhenotype", + "@type": [ + "owl:AsymmetricProperty", + "owl:ObjectProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Morphology", + "ilxtr:shortDefinition": "The shape, size, and structural appearance of the cell type.", + "rdfs:label": "hasMorphologicalPhenotype" + }, + { + "@id": "ilxtr:hasAxonSensorySubcellularElementIn", + "@type": [ + "owl:ObjectProperty", + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Sensory structure location", + "ilxtr:shortDefinition": "An anatomical location of the cell type's axonal sensory subcellular elements.", + "rdfs:label": "hasAxonSensorySubcellularElementIn" + }, + { + "@id": "ilxtr:hasAxonPhenotype", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Axon type", + "ilxtr:shortDefinition": "Phenotypes specific to the cell type's axons.", + "rdfs:label": "hasAxonPhenotype" + }, + { + "@id": "ilxtr:hasConnectionDeterminedByCellFilling", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Connection via cell filling", + "ilxtr:shortDefinition": "Connection determined by filling individual cells with a dye or tracer.", + "rdfs:label": "hasConnectionDeterminedByCellFilling" + }, + { + "@id": "ilxtr:phenotypeOf", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Phenotype of", + "ilxtr:shortDefinition": "Links a phenotype back to the cell type or entity it characterizes.", + "rdfs:label": "phenotypeOf" + }, + { + "@id": "ilxtr:hasClassificationPhenotype", + "@type": [ + "owl:ObjectProperty", + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Used for classification ", + "ilxtr:shortDefinition": "A phenotype used to classify or categorize a cell type within an arbitrary taxonomy.", + "rdfs:label": "hasClassificationPhenotype" + }, + { + "@id": "ilxtr:phenotypeObservedInBrainRegion", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Observed in brain region", + "ilxtr:shortDefinition": "A brain region where a cell type with this phenotype has been observed.", + "rdfs:label": "phenotypeObservedInBrainRegion" + }, + { + "@id": "ilxtr:hasAxonPresynapticElementIn", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Terminates in", + "ilxtr:shortDefinition": "An anatomical location of the cell type's axonal presynaptic elements.", + "rdfs:label": "hasAxonPresynapticElementIn" + }, + { + "@id": "ilxtr:hasFunctionalCircuitRolePhenotype", + "@type": [ + "owl:ObjectProperty", + "owl:IrreflexiveProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Functional circuit role", + "ilxtr:shortDefinition": "The excitatory or inhibitory role played by the cell type in the circuit.", + "rdfs:label": "hasFunctionalCircuitRolePhenotype" + }, + { + "@id": "ilxtr:hasSomaLocationLaterality", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Laterality", + "ilxtr:shortDefinition": "The laterality (left, right, both) of the location of the somas of the cell type.", + "rdfs:label": "hasSomaLocationLaterality" + }, + { + "@id": "ilxtr:hasDevelopmentalStructure", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Developmental structure", + "ilxtr:shortDefinition": "An anatomical structure associated with the cell type's developmental origin.", + "rdfs:label": "hasDevelopmentalStructure" + }, + { + "@id": "ilxtr:hasNucleicAcidExpressionPhenotype", + "@type": [ + "owl:ObjectProperty", + "owl:IrreflexiveProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Marker genes", + "ilxtr:shortDefinition": "DNA or RNA expression patterns in the cell type.", + "rdfs:label": "hasNucleicAcidExpressionPhenotype" + }, + { + "@id": "ilxtr:hasDriverExpressionPhenotype", + "@type": [ + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty", + "owl:ObjectProperty" + ], + "ilxtr:displayLabel": "Driver expression", + "rdfs:label": "hasDriverExpressionPhenotype" + }, + { + "@id": "ilxtr:hasSomaPhenotype", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Soma characteristics", + "ilxtr:shortDefinition": "Phenotypes specific to the cell type's soma.", + "rdfs:label": "hasSomaPhenotype" + }, + { + "@id": "ilxtr:hasForwardConnectionPhenotype", + "@type": [ + "owl:AsymmetricProperty", + "owl:ObjectProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Connects onto", + "ilxtr:shortDefinition": "Synaptic connections where this cell type is the presynaptic partner.", + "rdfs:label": "hasForwardConnectionPhenotype" + }, + { + "@id": "ilxtr:hasAdaptationPhenotype", + "@type": [ + "owl:ObjectProperty", + "owl:IrreflexiveProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Adaptation", + "ilxtr:shortDefinition": "The electrophysiological adaptation behavior of the cell type.", + "rdfs:label": "hasAdaptationPhenotype" + }, + { + "@id": "ilxtr:hasComputedMolecularPhenotypeFromDNA", + "@type": [ + "owl:ObjectProperty", + "owl:IrreflexiveProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Computed from DNA", + "ilxtr:shortDefinition": "Molecular phenotype computed from DNA-level data (e.g., genomics).", + "rdfs:label": "hasComputedMolecularPhenotypeFromDNA" + }, + { + "@id": "ilxtr:hasDendritePhenotype", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Dendrite characteristics", + "ilxtr:shortDefinition": "Phenotypes specific to the cell type's dendrites.", + "rdfs:label": "hasDendritePhenotype" + }, + { + "@id": "ilxtr:hasFunctionalPhenotype", + "@type": [ + "owl:ObjectProperty", + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Function", + "ilxtr:shortDefinition": "An external function or behavior that results in the electrical activation of the cell type.", + "rdfs:label": "hasFunctionalPhenotype" + }, + { + "@id": "ilxtr:hasLocationPhenotype", + "@type": [ + "owl:IrreflexiveProperty", + "owl:ObjectProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Located in", + "ilxtr:shortDefinition": "An anatomical location of any part of a cell type.", + "rdfs:label": "hasLocationPhenotype" + }, + { + "@id": "ilxtr:hasInstanceInSpecies", + "@type": [ + "owl:IrreflexiveProperty", + "owl:AsymmetricProperty", + "owl:ObjectProperty" + ], + "ilxtr:displayLabel": "Observed in", + "ilxtr:shortDefinition": "A species the cell type has been observed in.", + "rdfs:label": "hasInstanceInSpecies" + }, + { + "@id": "ilxtr:hasMolecularPhenotype", + "@type": [ + "owl:ObjectProperty", + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Molecular phenotype", + "ilxtr:shortDefinition": "Molecular-level characteristics such as gene or protein expression.", + "rdfs:label": "hasMolecularPhenotype" + }, + { + "@id": "ilxtr:hasComputedMolecularPhenotype", + "@type": [ + "owl:ObjectProperty", + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Computed molecular phenotype", + "ilxtr:shortDefinition": "Molecular phenotype inferred computationally from experimental data.", + "rdfs:label": "hasComputedMolecularPhenotype" + }, + { + "@id": "ilxtr:hasAxonLeadingToSensorySubcellularElementIn", + "@type": [ + "owl:AsymmetricProperty", + "owl:ObjectProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Axon location", + "ilxtr:shortDefinition": "An anatomical location of the cell type's axons that lead to a senaroy subcellular element (including terminals).", + "rdfs:label": "hasAxonLeadingToSensorySubcellularElementIn" + }, + { + "@id": "ilxtr:hasConnectionDeterminedByViralTracing", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Connection via viral tracing", + "ilxtr:shortDefinition": "Connection determined by using viral tracers injected into tissue.", + "rdfs:label": "hasConnectionDeterminedByViralTracing" + }, + { + "@id": "ilxtr:hasDevelopmentalStage", + "@type": [ + "owl:IrreflexiveProperty", + "owl:ObjectProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Developmental stage", + "ilxtr:shortDefinition": "A life cycle stage during which the cell type is observed.", + "rdfs:label": "hasDevelopmentalStage" + }, + { + "@id": "ilxtr:receivesProjectionFrom", + "@type": [ + "owl:IrreflexiveProperty", + "owl:AsymmetricProperty", + "owl:ObjectProperty" + ], + "ilxtr:displayLabel": "Receives input from", + "ilxtr:shortDefinition": "Anatomical regions that send input to this cell type.", + "rdfs:label": "receivesProjectionFrom" + }, + { + "@id": "ilxtr:hasProteinExpressionPhenotype", + "@type": [ + "owl:ObjectProperty", + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Protein expression", + "ilxtr:shortDefinition": "Proteins expressed in the cell type.", + "rdfs:label": "hasProteinExpressionPhenotype" + }, + { + "@id": "ilxtr:hasSmallMoleculePhenotype", + "@type": [ + "owl:IrreflexiveProperty", + "owl:AsymmetricProperty", + "owl:ObjectProperty" + ], + "ilxtr:displayLabel": "Small molecule phenotype", + "ilxtr:shortDefinition": "Small molecules present in the cell type (e.g., neurotransmitters, metabolites).", + "rdfs:label": "hasSmallMoleculePhenotype" + }, + { + "@id": "ilxtr:hasDriverExpressionConstitutivePhenotype", + "@type": [ + "owl:IrreflexiveProperty", + "owl:ObjectProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Constitutive driver expression", + "ilxtr:shortDefinition": "Genetic driver that is always expressed if its promoter is active (not inducible).", + "rdfs:label": "hasDriverExpressionConstitutivePhenotype" + }, + { + "@id": "ilxtr:hasConnectionDeterminedByElectrophysiology", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Connection via electrophysiology", + "ilxtr:shortDefinition": "Connection determined by using electrophysiological recording methods.", + "rdfs:label": "hasConnectionDeterminedByElectrophysiology" + }, + { + "@id": "ilxtr:hasConnectionDeterminedByPharmacology", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Connection via pharmacology", + "ilxtr:shortDefinition": "Connection determined by using pharmacological manipulation.", + "rdfs:label": "hasConnectionDeterminedByPharmacology" + }, + { + "@id": "ilxtr:hasSensorySubcellularElementIn", + "@type": [ + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty", + "owl:ObjectProperty" + ], + "ilxtr:displayLabel": "Sensory ending location", + "ilxtr:shortDefinition": "An anatomical location of the cell type's sensory subcellular elements (axonal OR dendritic).", + "rdfs:label": "hasSensorySubcellularElementIn" + }, + { + "@id": "ilxtr:hasSomaLocatedIn", + "@type": [ + "owl:IrreflexiveProperty", + "owl:ObjectProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Soma location", + "ilxtr:shortDefinition": "An anatomical region where the cell body (soma) resides.", + "rdfs:label": "hasSomaLocatedIn" + }, + { + "@id": "ilxtr:hasDendriteSensorySubcellularElementIn", + "@type": [ + "owl:IrreflexiveProperty", + "owl:AsymmetricProperty", + "owl:ObjectProperty" + ], + "ilxtr:displayLabel": "Dendrite sensory element location", + "ilxtr:shortDefinition": "An anatomical location of the cell type's dendritic sensory subcellular elements.", + "rdfs:label": "hasDendriteSensorySubcellularElementIn" + }, + { + "@id": "ilxtr:hasThresholdPhenotype", + "@type": [ + "owl:ObjectProperty", + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Threshold", + "ilxtr:shortDefinition": "The threshold stimulus strength needed to activate or trigger the cell type.", + "rdfs:label": "hasThresholdPhenotype" + }, + { + "@id": "ilxtr:hasAxonLocatedIn", + "@type": [ + "owl:IrreflexiveProperty", + "owl:ObjectProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Axon location", + "ilxtr:shortDefinition": "An anatomical location of the cell type's axons (including terminals).", + "rdfs:label": "hasAxonLocatedIn" + }, + { + "@id": "ilxtr:hasPresynapticTerminalsIn", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Presynaptic terminal locations", + "ilxtr:shortDefinition": "An anatomical location of the cell type's presynaptic terminals (axonal OR dendritic).", + "rdfs:label": "hasPresynapticTerminalsIn" + }, + { + "@id": "ilxtr:hasNeurotransmitterPhenotype", + "@type": [ + "owl:IrreflexiveProperty", + "owl:ObjectProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Neurotransmitters", + "ilxtr:shortDefinition": "Neurotransmitters the cell type produces or releases.", + "rdfs:label": "hasNeurotransmitterPhenotype" + }, + { + "@id": "ilxtr:hasConnectionDeterminedBySynapticPhysiology", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Connection via synaptic physiology", + "ilxtr:shortDefinition": "Connection determined by measuring synaptic potentials or currents.", + "rdfs:label": "hasConnectionDeterminedBySynapticPhysiology" + }, + { + "@id": "ilxtr:hasPhenotypeModifier", + "@type": [ + "owl:IrreflexiveProperty", + "owl:ObjectProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Phenotype modifier", + "ilxtr:shortDefinition": "Specifies a condition or context that modifies how a phenotype is expressed.", + "rdfs:label": "hasPhenotypeModifier" + }, + { + "@id": "ilxtr:hasComputedPhenotype", + "@type": [ + "owl:IrreflexiveProperty", + "owl:ObjectProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Computed phenotype", + "ilxtr:shortDefinition": "A phenotype computer from data rather than directly observed.", + "rdfs:label": "hasComputedPhenotype" + }, + { + "@id": "ilxtr:hasLayerLocationPhenotype", + "@type": [ + "owl:IrreflexiveProperty", + "owl:AsymmetricProperty", + "owl:ObjectProperty" + ], + "ilxtr:displayLabel": "Layer location", + "ilxtr:shortDefinition": "A laminar structure (e.g. cortical layer, spinal layer) that any part of the of the cell type is located in.", + "rdfs:label": "hasLayerLocationPhenotype" + }, + { + "@id": "ilxtr:hasComputedMolecularPhenotypeFromProtein", + "@type": [ + "owl:ObjectProperty", + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Computed from protein", + "ilxtr:shortDefinition": "Molecular phenotype computed from protein-level data (e.g., proteomics).", + "rdfs:label": "hasComputedMolecularPhenotypeFromProtein" + }, + { + "@id": "ilxtr:hasSomaLocatedInLayer", + "@type": "owl:ObjectProperty", + "ilxtr:displayLabel": "Soma layer", + "ilxtr:shortDefinition": "A tissue layer in which the cell body is located.", + "rdfs:label": "hasSomaLocatedInLayer" + }, + { + "@id": "ilxtr:hasCircuitRolePhenotype", + "@type": [ + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty", + "owl:ObjectProperty" + ], + "ilxtr:displayLabel": "Circuit role", + "ilxtr:shortDefinition": "The role this cell type plays within a neural circuit.", + "rdfs:label": "hasCircuitRolePhenotype" + }, + { + "@id": "ilxtr:hasAnatomicalSystemPhenotype", + "@type": [ + "owl:ObjectProperty", + "owl:IrreflexiveProperty", + "owl:AsymmetricProperty" + ], + "ilxtr:displayLabel": "Anatomical system", + "ilxtr:shortDefinition": "An anatomical system (e.g., sympathetic, enteric) the cell type is part of.", + "rdfs:label": "hasAnatomicalSystemPhenotype" + }, + { + "@id": "ilxtr:hasAxonMorphologicalPhenotype", + "@type": [ + "owl:AsymmetricProperty", + "owl:ObjectProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Axon morphology", + "ilxtr:shortDefinition": "The shape and branching pattern of the cell type's axons.", + "rdfs:label": "hasAxonMorphologicalPhenotype" + }, + { + "@id": "ilxtr:hasProjectionLaterality", + "@type": [ + "owl:AsymmetricProperty", + "owl:ObjectProperty", + "owl:IrreflexiveProperty" + ], + "ilxtr:displayLabel": "Projection laterality", + "ilxtr:shortDefinition": "Projections are ipsilateral, contralateral, or bilateral.", + "rdfs:label": "hasProjectionLaterality" + }, + { + "@id": "ilxtr:hasPresynapticElementIn", + "@type": [ + "owl:AsymmetricProperty", + "owl:IrreflexiveProperty", + "owl:ObjectProperty" + ], + "ilxtr:displayLabel": "Presynaptic element location", + "ilxtr:shortDefinition": "An anatomical location of the cell type's presynaptic elements (axonal OR dendritic).", + "rdfs:label": "hasPresynapticElementIn" + }, + { + "@id": "https://raw.githubusercontent.com/SciCrunch/NIF-Ontology/neurons/ttl/npo-merged-reasoned-neurdf.ttl", + "@type": "owl:Ontology", + "MIRO:axiom_patterns": [ + { + "@id": "https://github.com/tgbugs/pyontutils/blob/master/neurondm/docs/basic-model.org" + }, + { + "@id": "https://github.com/SciCrunch/NIF-Ontology/blob/neurons/docs/Neurons.md" + } + ], + "MIRO:competition": { + "@id": "https://github.com/obophenotype/cell-ontology" + }, + "MIRO:dereferenceable_iris": "`npokb:' and `ilxtr:' identifiers are not currently dereferenceable, but they are planned to be.", + "MIRO:development_community": { + "@id": "https://github.com/SciCrunch/NIF-Ontology" + }, + "MIRO:development_environment": "Protégé, FaCT++, ELK, python, pyontutils, neurondm", + "MIRO:entity_deprecation_strategy": "Specific ontology ids are never deprecated. If you need to know whether the modeling of a class has changed, neurondm can be used to regenerate the deterministic identifiers that appear as `ilxtr:hasTemporaryId'.", + "MIRO:entity_metadata_policy": "`rdfs:label' must be present. `skos:prefLabel' must be present for common usage types, and should be present when possible for evidence based models. Since the constituent ontology files that make up the npo are generated using python, provenance is primarily stored in the git repository pointed to via the `prov:wasGeneratedBy' predicate in the `owl:Ontology' header for individual files, in this case that repository is `'", + "MIRO:entity_naming_convention": "`rdfs:label' must be automatically generated using neurondm. Labels are generated from the labels of the constituent phenotype values. Only restrictions specifying necessary criteria for defining the type (i.e. those that are contained in an `owl:equivalentClass' statement) are included when generating the name. The ordering of the dimensions is specified in https://github.com/tgbugs/pyontutils/blob/a5e9fcc70d56870e692c17cb7fadce735a281d02/neurondm/neurondm/core.py#L135-L389", + "MIRO:evaluation": "No external evaluation has been conducted. See https://doi.org/10.1007/s12021-022-09566-7", + "MIRO:example_of_use": { + "@id": "https://doi.org/10.1007/s12021-022-09566-7" + }, + "MIRO:identifier_generation_policy": "`npokb:[0-9]+' identifiers are assigned sequentially via https://github.com/tgbugs/pyontutils/blob/a5e9fcc70d56870e692c17cb7fadce735a281d02/nifstd/nifstd_tools/map-identifiers.py#L20-L49. An index of issued identifiers is retained at https://github.com/SciCrunch/NIF-Ontology/blob/neurons/ttl/generated/neurons/npokb-index.ttl. Note that the current implementation currently does not correctly handles cases where the constituent phenotypes of a class change.", + "MIRO:knowledge_acquisition_methodology": { + "@id": "https://doi.org/10.1007/s12021-022-09566-7" + }, + "MIRO:knowledge_representation_language": { + "@id": "http://www.w3.org/2002/07/owl" + }, + "MIRO:methodological_framework": { + "@id": "https://doi.org/10.1007/s12021-022-09566-7" + }, + "MIRO:need": "There is a need to bridge the gap between historical definitions of cell types from the literature, with cell types being defined using a wide variety of new techniques. There is also a need to be able to bridge and reconcile the wide variety of local terminology used across laboratories and place them on more solid foundations derived from the methodology used to make measurements on cells.", + "MIRO:scope_and_coverage": "Scope: neuron types, and the dimensions and values of their constituent phenotypes. Coverage: an inventory of common usage types from existing sources, as well as three examples of evidence based models of cell types from recent publications.", + "MIRO:source_knowledge_location": { + "@id": "https://doi.org/10.1007/s12021-022-09566-7" + }, + "MIRO:sustainability_plan": "The npo is maintained as part of the NIF-Ontology.", + "MIRO:testing": "See `MIRO:axiom_patterns' and paper https://doi.org/10.1007/s12021-022-09566-7", + "MIRO:versioning_policy": "Development versions of the ontology are released continuously and are identified by their git commit hashes. Stable versions are released periodically and can be accessed by their git tag.", + "dc:contributor": [ + "Ilias Ziogas - https://orcid.org/0000-0002-9210-2455", + "Susan Tappan - https://orcid.org/0000-0001-5120-3770", + "Mohameth François Sy - https://orcid.org/0000-0002-4603-9838", + "Monique Surles-Zeigler - https://orcid.org/0000-0002-2308-8813", + "Fahim Imam - https://orcid.org/0000-0003-4752-543X", + "Shreejoy Tripathy - https://orcid.org/0000-0002-1007-9061", + "Jyl Boline - https://orcid.org/0000-0001-5232-8364", + "Bernard de Bono - https://orcid.org/0000-0003-0638-5274" + ], + "dc:creator": [ + "Sean Hill - https://orcid.org/0000-0001-8055-860X", + "Tom Gillespie - https://orcid.org/0000-0002-7509-4801", + "Maryann Martone - https://orcid.org/0000-0002-8406-3871" + ], + "dc:description": "An ontology of neuron types based on the phenotypic dimensions of cells that can be measure experimentally.", + "dc:title": "The Neuron Phenotype Ontology", + "dcterms:license": { + "@id": "http://spdx.org/licenses/CC-BY-4.0" + }, + "doap:GitRespository": { + "@id": "https://github.com/SciCrunch/NIF-Ontology" + }, + "doap:audience": "researchers working on cell types and their phenotypes, whether they are collecting raw data, organizing existing data, or creating simulations of cell types", + "doap:bug-database": { + "@id": "https://github.com/SciCrunch/NIF-Ontology/issues" + }, + "ilxtr:imports": [ + { + "@id": "https://raw.githubusercontent.com/SciCrunch/NIF-Ontology/neurons/ttl/generated/parcellation/mbaslim.ttl" + }, + { + "@id": "https://raw.githubusercontent.com/SciCrunch/NIF-Ontology/neurons/ttl/bridge/neuron-bridge.ttl" + }, + { + "@id": "https://raw.githubusercontent.com/SciCrunch/NIF-Ontology/neurons/ttl/generated/allen-transgenic-lines.ttl" + }, + { + "@id": "http://purl.obolibrary.org/obo/bfo.owl" + } + ], + "ilxtr:indexNamespace": { + "@id": "npokb:" + }, + "owl:versionInfo": "2026-06-23", + "prov:wasGeneratedBy": { + "@id": "https://github.com/tgbugs/pyontutils/blob/master/neurondm/neurondm/build.py" + }, + "rdfs:label": "The Neuron Phenotype Ontology" + } + ] +} diff --git a/src/parsers/neurdfParser.ts b/src/parsers/neurdfParser.ts index c09f54a1..b58b94f0 100644 --- a/src/parsers/neurdfParser.ts +++ b/src/parsers/neurdfParser.ts @@ -11,11 +11,16 @@ import type { RefKind, CellTerm, CellProperty, + CellAnnotations, + CellMapping, + MappingEvidence, + ValueCombinator, OntologyMeta, ParsedOntology, HierarchyNode, Facet, FacetValue, + PredicateDisplay, } from "../components/CellCards/model/types"; // --- label + value helpers ------------------------------------------------- @@ -151,10 +156,20 @@ const labelFor = (id: string, idx: Index): string => { return classify(id).curie; // fall back to a compact curie when no label exists }; -// Turn a predicate value (ref object, array, or literal) into ResolvedRefs. +// Turn a predicate value (ref object, array, @list, or literal) into ResolvedRefs. +// +// `@list` matters: the union/intersection families serialise their members as a JSON-LD list +// ({"@list":[{...},{...}]}), and `neurdf.eqv.uo:hasSomaLocatedIn` is the *only* soma-location +// predicate on 61 of the 161 Precision cells. Treating a list object as an unrecognised value +// silently dropped the property for all of them. const resolveValue = (raw: unknown, idx: Index): ResolvedRef[] => { const out: ResolvedRef[] = []; const push = (item: unknown) => { + if (item && typeof item === "object" && "@list" in (item as object)) { + const list = (item as { "@list"?: unknown })["@list"]; + if (Array.isArray(list)) list.forEach(push); + return; + } if (item && typeof item === "object" && "@id" in (item as object)) { const id = String((item as JsonLdRefish)["@id"]); if (isBlankNode(id) || isMissingSentinel(id)) return; // skip restriction blanks + "not specified" sentinels @@ -179,27 +194,92 @@ type JsonLdRefish = { "@id"?: unknown }; // --- neurdf predicate families --------------------------------------------- -// "neurdf.eqv:hasSomaLocatedIn" -> { family:'eqv', negated:false, localName:'hasSomaLocatedIn' } -// "neurdf.eqv.neg:hasMorphologicalPhenotype" -> { family:'eqv', negated:true, ... } +// "neurdf.eqv:hasSomaLocatedIn" -> { family:'eqv', negated:false, localName:… } +// "neurdf.eqv.neg:hasMorphologicalPhenotype" -> { family:'eqv', negated:true, … } +// "neurdf.eqv.uo:hasSomaLocatedIn" -> { …, combinator:'or' } (owl:unionOf) +// "neurdf.eqv.io:hasExpressionPhenotype" -> { …, combinator:'and' } (owl:intersectionOf) const parseNeurdfKey = ( key: string -): { family: "eqv" | "ent"; negated: boolean; localName: string } | null => { +): { + family: "eqv" | "ent"; + negated: boolean; + combinator?: ValueCombinator; + localName: string; +} | null => { if (!key.startsWith("neurdf.")) return null; const [prefix, local] = key.split(":"); if (!local) return null; - const fam = prefix.replace("neurdf.", ""); // "eqv" | "ent" | "eqv.neg" | "ent.neg" + const fam = prefix.replace("neurdf.", ""); // "eqv" | "ent" | "eqv.neg" | "eqv.uo" | "eqv.io" | … return { family: fam.startsWith("ent") ? "ent" : "eqv", negated: fam.endsWith(".neg"), + combinator: fam.endsWith(".uo") ? "or" : fam.endsWith(".io") ? "and" : undefined, localName: local, }; }; -// A few literal-valued ilxtr predicates we surface directly (not neurdf refs). +// A few literal-valued ilxtr predicates we surface directly (not neurdf refs). These land on +// `properties`, so they are facetable and can appear on a tile row. const LITERAL_PREDICATES: Record = { "ilxtr:neurondmBaseClass": "neurondmBaseClass", }; +// Cross-nomenclature relations. The evidence type is derived from *which* relation it is, +// since the graph has no dedicated evidence predicate. +const MAPPING_PREDICATES: Record = { + "TEMP:assertedSubClassOf": "described", + "TEMP:subClassOf": "described", + "TEMP:mapsTo": "inferred", +}; + +// Prose / id / dataset annotations. Deliberately kept off `properties` so widening the parser +// cannot add new facets to the grid sidebar or new rows to a tile — they land on +// `CellTerm.annotations` instead, which only the Cell Card reads. +const TEXT_ANNOTATIONS = { + "ilxtr:atlasAnnotation": "atlasAnnotation", + "ilxtr:curatorNote": "curatorNotes", + "ilxtr:alertNote": "alertNotes", +} as const; + +// Deep-link targets for the Cell Card's external widgets (SPARC Portal, SPARC Maps, NervoSensus). +// None of these predicates occur in the shipped graph yet, and the front end must not need an edit +// when they arrive — so they are matched by *local name* and every prefix works: `ilx:`, `ilxtr:` +// or the expanded IRI all reduce to the same compact form, exactly as parsePredicateDisplay does +// for the display annotations. +// +// They land on `annotations` rather than `properties` for the same reason TEXT_ANNOTATIONS does: a +// deep-link URL is not a phenotype, and every key on `properties` becomes a facet option in the +// grid sidebar as soon as the "Displayed properties" toggle is off. +const LINK_ANNOTATIONS = { + hasSPARCTranscriptomicsLink: "sparcTranscriptomicsLinks", + hasSPARCMap: "sparcMaps", + hasNervoSensusLink: "nervoSensusLinks", +} as const; + +type LinkAnnotationField = (typeof LINK_ANNOTATIONS)[keyof typeof LINK_ANNOTATIONS]; + +const linkAnnotationField = (key: string): LinkAnnotationField | undefined => { + if (key.startsWith("neurdf.")) return undefined; // a phenotype family, never a deep link + const localName = classify(key).curie.split(":").pop() || ""; + return LINK_ANNOTATIONS[localName as keyof typeof LINK_ANNOTATIONS]; +}; + +// A source label like "DRG TG Calca+Bmpr1b human (Bhuiyan2024)" carries its provenance in a +// trailing parenthetical; the Cross-Nomenclature table shows it as its own Source column. +const trailingParenthetical = (label: string): string | undefined => + /\(([^()]+)\)\s*$/.exec(label)?.[1]; + +const emptyAnnotations = (): CellAnnotations => ({ + atlasAnnotation: [], + curatorNotes: [], + alertNotes: [], + dataCitations: [], + errors: [], + sparcTranscriptomicsLinks: [], + sparcMaps: [], + nervoSensusLinks: [], +}); + const asType = (t: unknown): string[] => Array.isArray(t) ? (t as string[]) : typeof t === "string" ? [t] : []; @@ -239,6 +319,8 @@ const buildCell = (node: GraphNode, idx: Index): CellTerm => { const { curie } = classify(id); const properties: Record = {}; const negated: Record = {}; + const annotations = emptyAnnotations(); + const mappings: CellMapping[] = []; let sources: ResolvedRef[] = []; for (const [key, raw] of Object.entries(node)) { @@ -246,6 +328,48 @@ const buildCell = (node: GraphNode, idx: Index): CellTerm => { sources = resolveValue(raw, idx); // a cell may cite several publications continue; } + if (key === "ilxtr:dataCitation") { + annotations.dataCitations = resolveValue(raw, idx); + continue; + } + if (key in TEXT_ANNOTATIONS) { + const field = TEXT_ANNOTATIONS[key as keyof typeof TEXT_ANNOTATIONS]; + // These are plain strings in the graph; resolveValue normalises the literal shapes. + annotations[field] = resolveValue(raw, idx).map((r) => r.label); + continue; + } + if (key === "ilxtr:hasTemporaryId") { + annotations.temporaryId = resolveValue(raw, idx)[0]?.curie; + continue; + } + if (key === "ilxtr:genLabel") { + annotations.generatedLabel = firstString(raw); + continue; + } + if (key === "ilxtr:error") { + annotations.errors = resolveValue(raw, idx).map((r) => r.curie); + continue; + } + const linkField = linkAnnotationField(key); + if (linkField) { + // The same link can be asserted under more than one prefix; merge rather than overwrite. + annotations[linkField] = mergeValues(annotations[linkField], resolveValue(raw, idx)); + continue; + } + if (key in MAPPING_PREDICATES) { + const evidence = MAPPING_PREDICATES[key]; + for (const ref of resolveValue(raw, idx)) { + // A cell can be related to the same record by more than one relation; the stronger + // claim (an explicit description) wins over an inferred mapping. + const prev = mappings.find((m) => m.ref.id === ref.id); + if (prev) { + if (evidence === "described") prev.evidence = evidence; + continue; + } + mappings.push({ ref, evidence, source: trailingParenthetical(ref.label) }); + } + continue; + } if (key in LITERAL_PREDICATES) { const local = LITERAL_PREDICATES[key]; const values = resolveValue(raw, idx); @@ -253,7 +377,7 @@ const buildCell = (node: GraphNode, idx: Index): CellTerm => { continue; } const parsed = parseNeurdfKey(key); - if (!parsed) continue; // skip @id/@type/owl:*/rdfs:*/ilxtr:error/genLabel/etc. + if (!parsed) continue; // skip @id/@type/owl:*/rdfs:*/etc. const values = resolveValue(raw, idx); if (!values.length) continue; const bucket = parsed.negated ? negated : properties; @@ -266,6 +390,9 @@ const buildCell = (node: GraphNode, idx: Index): CellTerm => { localName: parsed.localName, family: prev.family === "eqv" ? "eqv" : parsed.family, negated: parsed.negated, + // Only keep the combinator when both keys agree — a union merged with a plain + // assertion has no single "or"/"and" reading, so it is better left unstated. + combinator: prev.combinator === parsed.combinator ? prev.combinator : undefined, values: mergeValues(prev.values, values), }; } @@ -284,6 +411,8 @@ const buildCell = (node: GraphNode, idx: Index): CellTerm => { properties, negated, sources, + mappings, + annotations, }; }; @@ -400,6 +529,34 @@ export const parseOntologyMeta = (graph: GraphNode[]): OntologyMeta => { }; // Parse the graph into cells that are (transitively) subClassOf `rootClass`. +// Row labels and tooltips ship inside the ontology: the ilxtr:* property nodes carry +// ilxtr:displayLabel ("Soma location") and ilxtr:shortDefinition, which between them cover 14 +// of the 15 neurdf local names Precision cells use. Reading them here keeps the UI's wording +// in the curators' hands instead of a hardcoded map in the front end — gridConfig's +// PREDICATE_LABELS / PREDICATE_TOOLTIPS are only the fallback for what the file omits. +// +// Keyed by *local name* (`hasSomaLocatedIn`), because that is how a CellProperty is keyed, +// while the annotation lives on the `ilxtr:hasSomaLocatedIn` node. +export const parsePredicateDisplay = (graph: GraphNode[]): Record => { + const out: Record = {}; + for (const node of graph) { + const id = node["@id"]; + if (typeof id !== "string") continue; + const label = firstString(node["ilxtr:displayLabel"]); + const description = firstString(node["ilxtr:shortDefinition"]); + if (!label && !description) continue; + // "ilxtr:hasSomaLocatedIn" and the expanded IRI both reduce to the same local name. + const localName = classify(id).curie.split(":").pop() || ""; + if (!localName) continue; + out[localName] = { + localName, + label: label || out[localName]?.label || humanizeLocalName(localName), + description: description || out[localName]?.description, + }; + } + return out; +}; + export const parseNeurdf = (data: OntologyGraph, rootClass: string): ParsedOntology => { const graph = data["@graph"] || []; const idx = indexGraph(graph); @@ -413,6 +570,7 @@ export const parseNeurdf = (data: OntologyGraph, rootClass: string): ParsedOntol meta: parseOntologyMeta(graph), cells, hierarchy: buildHierarchy(rootClass, cells, idx), + predicateDisplay: parsePredicateDisplay(graph), }; }; diff --git a/src/theme/index.jsx b/src/theme/index.jsx index d2add696..4cad5c07 100644 --- a/src/theme/index.jsx +++ b/src/theme/index.jsx @@ -43,7 +43,11 @@ const { success400, blue200, blue700, - brand500 + brand500, + brand800, + success25, + warning25, + error25 } = vars; const theme = createTheme({ @@ -127,6 +131,24 @@ const theme = createTheme({ fontWeight: 500, lineHeight: 1.5, }, + // An absent value in a Cell Card property row ("not specified"): body2, greyed and italic + // (Figma "Biological properties item", empty state). + notSpecified: { + color: gray400, + fontSize: '0.875rem', + fontStyle: 'italic', + lineHeight: 1.4285, + }, + // The term the page is about, inside a hierarchy tree: body2 in brand, semibold, so it + // reads as the anchor of the tree rather than as another row. + currentTerm: { + // brand600 is palette.primary.main — the tone the call-site `sx` used before this + // became a variant, so the tree row keeps exactly its old colour. + color: brand600, + fontSize: '0.875rem', + fontWeight: 600, + lineHeight: 1.4285, + }, }, components: { @@ -1098,9 +1120,161 @@ const theme = createTheme({ }, }, }, + // Components the Cell Card design leans on that had no override. Without these they render + // as MUI defaults (MUI blue links, 4px radii, a shadowed Alert), and the only way to fix + // that at the call site would be `sx` colours, which the project forbids. Kept to what the + // design needs *everywhere*: per-instance choices (an Alert with no severity icon) belong + // at the call site, and a look only one widget wants is scoped to a class. + MuiAlert: { + styleOverrides: { + root: { + borderRadius: "0.75rem", + border: `1px solid ${gray200}`, + padding: "0.75rem 1rem", + fontSize: "0.875rem", + lineHeight: 1.4285, + }, + // Figma "Definition" banner: brand-tinted fill with a brand border. + standardInfo: { + backgroundColor: brand25, + borderColor: brand300, + color: gray700, + }, + standardSuccess: { + backgroundColor: success25, + borderColor: success200, + }, + standardWarning: { + backgroundColor: warning25, + borderColor: warning200, + }, + standardError: { + backgroundColor: error25, + borderColor: error200, + }, + message: { + padding: 0, + width: "100%", + }, + action: { + paddingTop: 0, + marginRight: 0, + }, + }, + }, + MuiAlertTitle: { + styleOverrides: { + root: { + fontSize: "0.875rem", + fontWeight: 600, + color: gray800, + marginBottom: "0.25rem", + }, + }, + }, + MuiLink: { + defaultProps: { + underline: "hover", + }, + styleOverrides: { + root: { + // Every value link in the Cell Card is brand-coloured and semibold, per the + // design's teal property values. + color: brand700, + fontWeight: 500, + cursor: "pointer", + "&:hover": { + color: brand800, + }, + }, + }, + }, + MuiSkeleton: { + defaultProps: { + animation: "wave", + }, + styleOverrides: { + root: { + backgroundColor: gray100, + borderRadius: "0.375rem", + }, + }, + }, + MuiList: { + styleOverrides: { + root: { + paddingTop: 0, + paddingBottom: 0, + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + // "Other cells from this source" tiles: an outlined row, not a filled list item. + // Scoped to the class rather than every ListItemButton in the app — the Header's + // nav dropdown and the About page link lists are ListItemButtons too. + "&.cellCardTile": { + border: `1px solid ${gray200}`, + borderRadius: "0.5rem", + padding: "0.5rem 0.75rem", + "&:hover": { + backgroundColor: gray25, + borderColor: gray300, + }, + }, + }, + }, + }, + MuiListItemText: { + styleOverrides: { + primary: { + fontSize: "0.875rem", + fontWeight: 500, + color: gray700, + }, + secondary: { + fontSize: "0.75rem", + color: gray500, + }, + }, + }, + MuiDialogTitle: { + styleOverrides: { + root: { + fontSize: "1rem", + fontWeight: 500, + color: gray800, + padding: "1rem 1.5rem", + borderBottom: `1px solid ${gray200}`, + }, + }, + }, + MuiDialogContent: { + styleOverrides: { + root: { + padding: "1.5rem", + }, + }, + }, + MuiPopover: { + styleOverrides: { + paper: { + borderRadius: "0.75rem", + border: `1px solid ${gray200}`, + boxShadow: paperShadow, + }, + }, + }, MuiPaper: { styleOverrides: { root: { + // Relationship Graph frame (Figma 9478:72004): a 12px outlined surface with the + // legend bar tucked inside it, so the corners have to clip. + "&.graphFrame": { + borderRadius: "0.75rem", + overflow: "hidden", + }, "&.authPaper": { borderRadius: "2rem", boxShadow: paperShadow, diff --git a/yarn.lock b/yarn.lock index 7972f526..2ce86597 100644 --- a/yarn.lock +++ b/yarn.lock @@ -286,6 +286,18 @@ dependencies: statuses "^2.0.1" +"@dagrejs/dagre@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@dagrejs/dagre/-/dagre-3.0.0.tgz#543f20188f7494db0f45d634f7b3760747f87f23" + integrity sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q== + dependencies: + "@dagrejs/graphlib" "4.0.1" + +"@dagrejs/graphlib@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@dagrejs/graphlib/-/graphlib-4.0.1.tgz#a9cf907cc5ddf9140a64360ad487766f17d1ee36" + integrity sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA== + "@emotion/babel-plugin@^11.11.0": version "11.11.0" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz#c2d872b6a7767a9d176d007f5b31f7d504bb5d6c" From b59eb6f613b333acbf4b4fb130fdda9682788359 Mon Sep 17 00:00:00 2001 From: Filippo Ledda Date: Tue, 28 Jul 2026 19:07:45 +0200 Subject: [PATCH 07/11] (cellcard) Update linting --- .github/workflows/lint.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e8dd0702..dbe5af27 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,3 +13,6 @@ jobs: yarn yarn run build yarn run lint + # The neurdf parser's only automated coverage (30 assertions against the trimmed + # fixture in src/parsers/__fixtures__). Plain node + esbuild, no extra dependency. + yarn run check-parser From 7e2cb7a0365275e55ec87c180c3e9d35bd074fc4 Mon Sep 17 00:00:00 2001 From: Filippo Ledda Date: Thu, 30 Jul 2026 10:53:36 +0200 Subject: [PATCH 08/11] (cellcard) Small styling tweaks --- .../CellCard/widgets/CrossNomenclature.jsx | 40 +++++++---- src/theme/index.jsx | 67 +++++++++++++++++-- src/theme/variables.js | 1 + 3 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/components/CellCards/CellCard/widgets/CrossNomenclature.jsx b/src/components/CellCards/CellCard/widgets/CrossNomenclature.jsx index be0f6d00..2c1dfcbc 100644 --- a/src/components/CellCards/CellCard/widgets/CrossNomenclature.jsx +++ b/src/components/CellCards/CellCard/widgets/CrossNomenclature.jsx @@ -1,6 +1,7 @@ import PropTypes from "prop-types"; import { Table, + TableContainer, TableHead, TableBody, TableRow, @@ -9,7 +10,7 @@ import { Typography, Divider, Stack, - Box, + Paper, } from "@mui/material"; import CellCardWidget from "../CellCardWidget"; import TermValueLink from "../TermValueLink"; @@ -31,6 +32,17 @@ const EvidenceChip = ({ evidence }) => { EvidenceChip.propTypes = { evidence: PropTypes.string.isRequired }; +// Column proportions are the design's (280 / 176 / 196 / 196 of 848) as percentages, so the table +// keeps its shape as the card's middle column resizes. A long cell name then wraps to the two +// 1.25rem lines the 4.5rem row already has room for, rather than pushing the other three columns +// against the right edge. +const COLUMNS = [ + { label: "Cell name", width: "33%" }, + { label: "Evidence", width: "21%" }, + { label: "Source", width: "23%" }, + { label: "Reference", width: "23%" }, +]; + /** * §4.4 Cross-Nomenclature Mapping (Figma 9239:67833): how this cell type is named in other * nomenclatures. @@ -48,14 +60,18 @@ const CrossNomenclature = ({ cell, actions }) => { return ( - + {/* The design's table sits on its own outlined surface, which is what `component={Paper}` + + `variant="outlined"` resolves to in the theme. TableContainer also brings the + horizontal scroll the four columns need once the card's middle column narrows. */} + - Cell name - Evidence - Source - Reference + {COLUMNS.map((column) => ( + + {column.label} + + ))} @@ -68,21 +84,19 @@ const CrossNomenclature = ({ cell, actions }) => { + {/* Source and reference are Text sm/Regular; their Gray/600 comes from the + theme's small-table cell, so `variant` is all these carry. */} - - {m.source || "—"} - + {m.source || "—"} - - {m.ref.curie} - + {m.ref.curie} ))}
-
+ {hasFlagged && ( diff --git a/src/theme/index.jsx b/src/theme/index.jsx index 4cad5c07..a2d881ec 100644 --- a/src/theme/index.jsx +++ b/src/theme/index.jsx @@ -6,6 +6,7 @@ const { white, brand600, paperShadow, + shadowSm, gray300, gray600, gray700, @@ -409,7 +410,12 @@ const theme = createTheme({ height: "1.375rem", padding: "0 0.375rem", fontSize: "0.75rem", - borderRadius: "0.375rem !important", + // The design's badge is a pill (Figma "Badge", 9239:67792 — measured against a rendered + // sweep, its corners match a radius of 10-11px on a 22px chip, which is what 1rem clamps + // to). Every colour slot below has always declared 1rem; the `0.375rem !important` that + // used to sit here overrode all of them, so no chip in the app was drawing its own radius. + // `.green-glow-chip` still pins 6px on its own class, which beats this on specificity. + borderRadius: "1rem", fontWeight: 500, width: "fit-content", maxWidth: "100%", @@ -496,10 +502,15 @@ const theme = createTheme({ textOverflow: "ellipsis", whiteSpace: "nowrap", }, + // An outlined chip is the design's badge without a hue (Figma "Badge", 9239:67792): a + // 1px rule in the 200 tone over the 50 fill, flat. The colour slots below already draw + // it that way, so this only has to stop overriding them with the heavier 300 rule and a + // raised shadow, neither of which the badge has. `.greenChip` restates its own shadow + // and still wins on specificity, so the one chip that is meant to lift keeps lifting. outlined: { - borderColor: gray300, + borderColor: gray200, color: gray700, - boxShadow: "0rem 0.0625rem 0.125rem 0rem rgba(16, 24, 40, 0.05)", + boxShadow: "none", }, colorPrimary: { padding: "0.13rem 0.5rem", @@ -946,6 +957,53 @@ const theme = createTheme({ }, }, }, + // The Cell Card widget tables (Figma "Table cell-editable", 9239:67836). The design + // gives them the same metrics as the table above — 2.75rem header, 4.5rem rows, + // 1.5rem gutters — so this carries only what it draws differently, and deliberately no + // geometry of its own. Its surface is the outlined MuiTableContainer below. + variants: [ + { + props: { size: "small" }, + style: { + // Column labels are Text xs/Medium in Gray/500, the same treatment a sortable + // header already gets from MuiTableSortLabel above. + "& .MuiTableHead-root .MuiTableCell-root": { + fontSize: "0.75rem", + color: gray500, + }, + "& .MuiTableCell-root": { + color: gray600, + // These rows carry no action — the design has no hover state for them, and a + // fill on the one cell under the pointer reads as a broken row highlight. + "&:hover": { + backgroundColor: "transparent", + }, + }, + // The last row's rule is the container's bottom border; drawing both doubles it. + "& .MuiTableBody-root .MuiTableRow-root:last-of-type .MuiTableCell-root": { + borderBottom: 0, + }, + }, + }, + ], + }, + MuiTableContainer: { + // `` is the design's table surface: + // Paper brings the Gray/200 rule and the white fill, this adds the 12px corners and + // shadow-sm. Scoped to the outlined variant because the tables that predate it supply + // their own bordered Paper wrapper and would end up with two rules. + variants: [ + { + props: { variant: "outlined" }, + style: { + // Beats MuiPaper's own 4px radius, which is a class of equal weight. + "&.MuiPaper-root": { + borderRadius: "0.75rem", + boxShadow: shadowSm, + }, + }, + }, + ], }, MuiPagination: { styleOverrides: { @@ -1038,8 +1096,7 @@ const theme = createTheme({ "--DataGrid-containerBackground": gray50, borderColor: gray200, borderRadius: ".75rem", - boxShadow: - "0px 1px 3px 0px rgba(16, 24, 40, 0.10), 0px 1px 2px 0px rgba(16, 24, 40, 0.06)", + boxShadow: shadowSm, // The design has no vertical rules between columns. "& .MuiDataGrid-columnSeparator": { display: "none", diff --git a/src/theme/variables.js b/src/theme/variables.js index b01c2b97..e7fcb4ce 100644 --- a/src/theme/variables.js +++ b/src/theme/variables.js @@ -80,6 +80,7 @@ export const vars = { success900: '#074D31', success950: '#053321', paperShadow: '0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03)', + shadowSm: '0px 1px 3px 0px rgba(16, 24, 40, 0.10), 0px 1px 2px 0px rgba(16, 24, 40, 0.06)', errorInputBoxShadow: '0px 1px 2px 0px rgba(16, 24, 40, 0.05), 0px 0px 0px 4px rgba(240, 68, 56, 0.24)', inputBoxShadow: '0px 1px 2px 0px rgba(16, 24, 40, 0.05)', inputErrorBoxShadow: '0px 0px 0px 4px rgba(240, 68, 56, 0.24)' From f61becef892e63c88fe28df9bdbe9958af3d0e9d Mon Sep 17 00:00:00 2001 From: Filippo Ledda Date: Thu, 30 Jul 2026 15:00:07 +0200 Subject: [PATCH 09/11] (cellcard) Handle cell card hierarchy behaviour --- .../CellCards/CellCard/CellCard.jsx | 2 + .../CellCards/CellCard/useCellTerm.js | 43 ++- .../CellCard/widgets/OntologyHierarchy.jsx | 265 ++++++++++++++---- .../CellCard/widgets/SourcePublication.jsx | 41 +-- .../CellCards/OntologyHierarchyTree.jsx | 49 +++- .../CellCards/services/ontologyGridService.ts | 7 + src/components/SingleTermView/index.jsx | 19 +- 7 files changed, 318 insertions(+), 108 deletions(-) diff --git a/src/components/CellCards/CellCard/CellCard.jsx b/src/components/CellCards/CellCard/CellCard.jsx index 065110d8..1bc9e626 100644 --- a/src/components/CellCards/CellCard/CellCard.jsx +++ b/src/components/CellCards/CellCard/CellCard.jsx @@ -63,6 +63,8 @@ const CellCard = ({ cell, data, group, termSlug, discussionHref, onNavigateToCel cell={cell} hierarchy={data.hierarchy} rootLabel={data.entry?.rootClass?.split(":").pop() || "root"} + // Same resolver the graph uses: a cell stays in the card, anything else opens its own page. + onNavigate={onNavigateToRef} actions={commentFor(HIERARCHY_TITLE)} />, { - const [state, setState] = useState({ cell: null, data: null, loading: true, error: null }); + const slug = ontologySlug && ONTOLOGY_CATALOG[ontologySlug] ? ontologySlug : DEFAULT_ONTOLOGY_SLUG; + // Carries the slug it was resolved against, so a switch of context ontology cannot be served + // the previous ontology's cell for the render before the effect runs. + const [state, setState] = useState({ slug, cell: null, data: null, loading: true, error: null }); + const cached = peekOntology(slug); useEffect(() => { - const slug = ontologySlug && ONTOLOGY_CATALOG[ontologySlug] ? ontologySlug : DEFAULT_ONTOLOGY_SLUG; if (!termSlug) { - setState({ cell: null, data: null, loading: false, error: null }); + setState({ slug, cell: null, data: null, loading: false, error: null }); return undefined; } + // Already parsed: resolved synchronously below, so there is nothing to load or to flag. + if (peekOntology(slug)) return undefined; let active = true; - setState((prev) => ({ ...prev, loading: true, error: null })); + setState({ slug, cell: null, data: null, loading: true, error: null }); loadOntology(slug) .then((data) => { if (!active) return; // A term that is not in this ontology is not an error — the tab simply has nothing to // show, and the caller renders/hides accordingly. - setState({ cell: findCell(data, termSlug) || null, data, loading: false, error: null }); + setState({ slug, cell: findCell(data, termSlug) || null, data, loading: false, error: null }); }) .catch((error) => { - if (active) setState({ cell: null, data: null, loading: false, error }); + if (active) setState({ slug, cell: null, data: null, loading: false, error }); }); return () => { active = false; }; - }, [termSlug, ontologySlug]); + }, [termSlug, slug]); - return state; + return useMemo(() => { + if (cached) { + return { + cell: termSlug ? findCell(cached, termSlug) || null : null, + data: cached, + loading: false, + error: null, + }; + } + // State left over from another ontology says nothing about this one; the effect is loading it. + if (state.slug !== slug) return { cell: null, data: null, loading: true, error: null }; + return { cell: state.cell, data: state.data, loading: state.loading, error: state.error }; + }, [cached, termSlug, slug, state]); }; export default useCellTerm; diff --git a/src/components/CellCards/CellCard/widgets/OntologyHierarchy.jsx b/src/components/CellCards/CellCard/widgets/OntologyHierarchy.jsx index 67c27008..e6ab7564 100644 --- a/src/components/CellCards/CellCard/widgets/OntologyHierarchy.jsx +++ b/src/components/CellCards/CellCard/widgets/OntologyHierarchy.jsx @@ -1,6 +1,6 @@ -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import PropTypes from "prop-types"; -import { Stack, Typography, IconButton, Tooltip } from "@mui/material"; +import { Box, Stack, Typography, IconButton, Tooltip } from "@mui/material"; import CellCardWidget from "../CellCardWidget"; import OntologyHierarchyTree from "../../OntologyHierarchyTree"; import GridSearchBar from "../../GridSearchBar"; @@ -10,10 +10,11 @@ import { RestartAlt, TargetCross } from "../../../../Icons"; export const TITLE = "Ontology hierarchy"; -// The two directions the tree can be read in, as in the design's "Type:" select and in the -// existing SingleTermView Hierarchy widget. -const CHILDREN = "children"; +// The three ways the design's "Type:" select can scope the tree: the ontology as a whole, or the +// anchor term read in either direction. +const ROOT_SUBCLASSES = "root"; const SUPERCLASSES = "superclasses"; +const SUBCLASSES = "subclasses"; /** Path of hierarchy nodes from a root down to the first position of `termId`. */ const findPath = (nodes, termId, trail = []) => { @@ -26,34 +27,88 @@ const findPath = (nodes, termId, trail = []) => { return null; }; +/** The tree with only the nodes that match, plus the ancestors needed to reach them. */ +const prune = (nodes, matches) => + nodes.reduce((kept, node) => { + const children = prune(node.children || [], matches); + if (children.length || matches(node.label)) kept.push({ ...node, children }); + return kept; + }, []); + +const countNodes = (nodes) => + nodes.reduce((n, node) => n + 1 + countNodes(node.children || []), 0); + +// How long the tree has to stop mutating before the highlighted row is scrolled to; see +// requestReveal. Long enough to bridge a slow re-render, short enough not to read as a delay. +const SETTLE_MS = 80; + +/** Every position id in a tree, in the order the rows are drawn. */ +const allIds = (nodes) => { + const ids = []; + const walk = (list) => + list.forEach((n) => { + ids.push(n.id); + walk(n.children || []); + }); + walk(nodes); + return ids; +}; + /** * §3.2 Ontology Hierarchy (Figma 9239:67685). * - * Shows the cell's position in the subClassOf tree: the ancestor chain collapsed to a single - * spine, the cell highlighted, and its direct children one level below. The full ontology tree - * lives on the Browse tab; here it is scoped so the widget stays readable in a 424px column. + * Shows a cell's position in the subClassOf tree, in one of three scopes: the whole ontology from + * its root class, or the *anchor* term's ancestor spine / direct children. The full tree fits here + * — 181 positions, four levels deep — so the scoped views are a readability choice for a 424px + * column, not a size limit. + * + * The anchor is deliberately *not* the term the page is on. Clicking a term navigates the card, + * and the requirement is that the tree does not move when it does: "when you click through the + * hierarchy, the hierarchy should stay stationary and the rest of the page should change around + * it" (meeting-3). So the tree, its scope label and its expansion all hang off `anchor`, which + * only the reset and focus buttons move, while the highlight follows `cell`. That also keeps the + * select honest: the option text describes the tree it produces, never the term you drifted to. * * The tree is built from the parse's already-reduced hierarchy (transitive edges removed), so a * cell does not list its grandparents as parents. */ -const OntologyHierarchy = ({ cell, hierarchy, rootLabel, actions }) => { - const [direction, setDirection] = useState(CHILDREN); +const OntologyHierarchy = ({ cell, hierarchy, rootLabel, onNavigate, actions }) => { + const [mode, setMode] = useState(SUPERCLASSES); const [query, setQuery] = useState(""); + // The term the tree is read around, and the expansion the user has since chosen (null = the + // default for the current scope). Neither follows `cell`: that is what keeps the tree still + // across a navigation. + const [anchorId, setAnchorId] = useState(cell.id); + const [expanded, setExpanded] = useState(null); + const treeBoxRef = useRef(null); - const path = useMemo(() => findPath(hierarchy || [], cell.id), [hierarchy, cell.id]); - - // The node for the cell itself, and the spine above it. - const cellNode = path?.[path.length - 1]; - const ancestors = useMemo(() => (path ? path.slice(0, -1) : []), [path]); + const anchorPath = useMemo(() => findPath(hierarchy || [], anchorId), [hierarchy, anchorId]); + const anchorNode = anchorPath?.[anchorPath.length - 1]; + const ancestors = useMemo(() => (anchorPath ? anchorPath.slice(0, -1) : []), [anchorPath]); + // Falls back to the cell's own label so the select still reads sensibly for a term the + // hierarchy does not place. + const anchorLabel = anchorNode?.label || cell.label; // The rendered tree, plus what it is showing out of what exists — the count line below reports - // the filtered figure, so it can never contradict the rows on screen. + // the filtered figure, so it can never contradict the rows on screen. Both counts exclude the + // row the tree hangs from, which is not one of its own sub/superclasses. const { items, shown, total } = useMemo(() => { - if (!cellNode) return { items: [], shown: 0, total: 0 }; - const matches = (label) => - !query.trim() || label.toLowerCase().includes(query.trim().toLowerCase()); + const needle = query.trim().toLowerCase(); + const matches = (label) => !needle || label.toLowerCase().includes(needle); + const roots = hierarchy || []; - if (direction === SUPERCLASSES) { + if (mode === ROOT_SUBCLASSES) { + const kept = needle ? prune(roots, matches) : roots; + return { + items: kept, + shown: countNodes(kept) - kept.length, + total: countNodes(roots) - roots.length, + }; + } + + if (!anchorNode) return { items: [], shown: 0, total: 0 }; + + if (mode === SUPERCLASSES) { // Ancestors, outermost first, each nesting the next — the spine on its own. The query // applies here too: leaving it out silently ignored the search box in this direction. // Dropping a non-matching intermediate is honest because subClassOf is transitive, so a @@ -62,7 +117,7 @@ const OntologyHierarchy = ({ cell, hierarchy, rootLabel, actions }) => { return { items: [ spine.reduceRight((child, node) => ({ ...node, children: [child] }), { - ...cellNode, + ...anchorNode, children: [], }), ], @@ -71,35 +126,116 @@ const OntologyHierarchy = ({ cell, hierarchy, rootLabel, actions }) => { }; } - // Children view: the cell as the root, its direct children below it. - const all = cellNode.children || []; + // Children view: the anchor as the root, its direct children below it. + const all = anchorNode.children || []; const children = all.filter((c) => matches(c.label)).map((c) => ({ ...c, children: [] })); return { - items: [{ ...cellNode, children }], + items: [{ ...anchorNode, children }], shown: children.length, total: all.length, }; - }, [cellNode, ancestors, direction, query]); + }, [hierarchy, anchorNode, ancestors, mode, query]); - // Expand the whole (small) scoped tree by default: with one spine and one level of children - // there is nothing worth hiding, and the design shows it open. - const [expanded, setExpanded] = useState(null); - const allIds = useMemo(() => { + // Open the scoped views completely: one spine, or one level of children, has nothing worth + // hiding, and the design shows it open. The whole ontology opens only down to the anchor, so + // the widget is not a wall of rows — unless a filter is on, where the point is to see the hits. + const defaultExpanded = useMemo(() => { + if (mode === ROOT_SUBCLASSES && !query.trim()) { + return anchorPath ? anchorPath.map((n) => n.id) : items.map((n) => n.id); + } + return allIds(items); + }, [mode, query, items, anchorPath]); + const expandedItems = expanded ?? defaultExpanded; + + // Every position of the term the page is on: a class with several parents (17 of them here) is + // highlighted wherever it appears, the same rule the tree's `isCurrent` styling follows. + const selectedItems = useMemo(() => { const ids = []; const walk = (nodes) => nodes.forEach((n) => { - ids.push(n.id); + if (n.termId === cell.id) ids.push(n.id); walk(n.children || []); }); walk(items); return ids; - }, [items]); - const expandedItems = expanded ?? allIds; + }, [items, cell.id]); + + // Scrolls the highlighted row into view inside the tree's own box. `block: "nearest"` keeps the + // scroll inside that box while the widget itself is on screen. + const reveal = useCallback(() => { + treeBoxRef.current + ?.querySelector('[aria-selected="true"]') + ?.scrollIntoView({ block: "nearest" }); + }, []); + + // Waits for the tree to settle before scrolling, rather than for a render or a fixed delay: + // RichTreeView draws its rows from its own item store, some renders after the props change, and + // building 181 rows can outlast a frame — so at effect time (and often a frame or two later) the + // row to scroll to is not in the DOM yet, and scrolling early is spent on the outgoing tree. The + // trailing timeout is what handles the opposite case, re-anchoring on the term the tree already + // shows, where nothing changes at all. + const requestReveal = useCallback(() => { + const box = treeBoxRef.current; + if (!box) return; + let timer; + const observer = new MutationObserver(() => restart()); + const settle = () => { + observer.disconnect(); + reveal(); + }; + const restart = () => { + clearTimeout(timer); + timer = setTimeout(settle, SETTLE_MS); + }; + observer.observe(box, { childList: true, subtree: true }); + restart(); + }, [reveal]); + + // Re-scoping is an explicit act, so each of these clears the expansion the user had built on + // the previous scope — otherwise a stale set would override the default for the new one. + const changeMode = useCallback( + (next) => { + setMode(next); + setExpanded(null); + // The whole-ontology scope is far taller than its box, so say where we are in it. + if (next === ROOT_SUBCLASSES) requestReveal(); + }, + [requestReveal] + ); + + const changeQuery = useCallback((next) => { + setQuery(next); + setExpanded(null); + }, []); + + const handleReset = useCallback(() => { + setMode(SUPERCLASSES); + setQuery(""); + setAnchorId(cell.id); + setExpanded(null); + }, [cell.id]); + + // Brings the tree back to the term in view after clicking through it — the one affordance that + // moves the anchor without changing anything else. + const handleFocus = useCallback(() => { + setAnchorId(cell.id); + setExpanded(null); + requestReveal(); + }, [cell.id, requestReveal]); + + // A node is handed to the parent as a *ref*, keyed by its term id rather than its position id, + // so a cell opens its Cell Card in place and a class that is not a cell here (the ontology's + // root) still resolves to the best page there is for it. + const handleSelectTerm = useCallback( + (node) => node && onNavigate?.({ id: node.termId, curie: node.curie, iri: node.iri }), + [onNavigate] + ); // Named once so the select option and the count line below cannot describe different things. - const directionLabel = { - [CHILDREN]: `sub class of ${rootLabel}`, - [SUPERCLASSES]: `super class of ${rootLabel}`, + const typeLabel = { + [ROOT_SUBCLASSES]: `sub class of ${rootLabel}`, + [SUPERCLASSES]: `super class of ${anchorLabel}`, + [SUBCLASSES]: `sub class of ${anchorLabel}`, }; return ( @@ -109,53 +245,60 @@ const OntologyHierarchy = ({ cell, hierarchy, rootLabel, actions }) => { Type:
({ + value, + label: typeLabel[value], + }))} isFormControlFullWidth /> - { - setDirection(CHILDREN); - setQuery(""); - setExpanded(null); - }} - aria-label="Reset hierarchy" - > + - setExpanded(allIds)} aria-label="Focus current term"> + - + {items.length ? ( <> - setExpanded(ids)} - selectedItems={cellNode ? [cellNode.id] : []} - /> + {/* The scoped views are a handful of rows, but the whole ontology is 181 — it scrolls in + its own box rather than stretching the column past the widgets below it, the same way + the Browse tab's hierarchy sidebar does. */} + + setExpanded(ids)} + selectedItems={selectedItems} + onSelectTerm={handleSelectTerm} + /> + {/* "3 of 7" while a query is filtering, so the number always matches the rows drawn. */} - {`Total number of ${directionLabel[direction]}: ${ + {`Total number of ${typeLabel[mode]}: ${ shown === total ? total : `${shown} of ${total}` }`} ) : ( - + )} ); @@ -166,6 +309,8 @@ OntologyHierarchy.propTypes = { hierarchy: PropTypes.array, // Display name of the ontology's root class, used in the select and the count line. rootLabel: PropTypes.string, + // Opens a term clicked in the tree, as a ResolvedRef-shaped { id, curie, iri }. + onNavigate: PropTypes.func, actions: PropTypes.node, }; diff --git a/src/components/CellCards/CellCard/widgets/SourcePublication.jsx b/src/components/CellCards/CellCard/widgets/SourcePublication.jsx index 0009ddaf..8de4c7d0 100644 --- a/src/components/CellCards/CellCard/widgets/SourcePublication.jsx +++ b/src/components/CellCards/CellCard/widgets/SourcePublication.jsx @@ -17,29 +17,30 @@ export const TITLE = "Source publication"; */ const SourcePublication = ({ cell, actions }) => { const primary = cell.sources?.[0]; + const doi = primary?.iri || primary?.id; + // Keyed by the DOI it was fetched for, and read only when that still matches. Navigating between + // cells re-renders this widget rather than remounting it (see useCellTerm), so plain state would + // caption the new cell with the previous cell's paper until the fetch came back. const [citation, setCitation] = useState(null); - const [loading, setLoading] = useState(Boolean(primary)); useEffect(() => { - if (!primary) { - setCitation(null); - setLoading(false); - return undefined; - } + if (!doi) return undefined; let active = true; - setLoading(true); - fetchCitation(primary.iri || primary.id).then((result) => { - if (!active) return; - setCitation(result); - setLoading(false); + fetchCitation(doi).then((result) => { + if (active) setCitation({ doi, result }); }); return () => { active = false; }; - }, [primary]); + }, [doi]); if (!primary) return null; + const resolved = citation?.doi === doi ? citation.result : null; + // A source with no IRI at all is never fetched, so it is not "loading" — it renders whatever the + // graph gave, which is the same fallback a failed lookup lands on. + const loading = Boolean(doi) && !resolved; + const dataCitation = cell.annotations?.dataCitations?.[0]; return ( @@ -52,21 +53,21 @@ const SourcePublication = ({ cell, actions }) => { ) : ( - {citation?.title && ( + {resolved?.title && ( - {citation.title} + {resolved.title} )} - {[citation?.authors, citation?.journal, citation?.year] + {[resolved?.authors, resolved?.journal, resolved?.year] .filter(Boolean) - .join(citation?.authors ? " " : ", ")} - {citation?.doi && ( + .join(resolved?.authors ? " " : ", ")} + {resolved?.doi && ( <> - {[citation?.authors, citation?.journal, citation?.year].some(Boolean) ? " · " : ""} + {[resolved?.authors, resolved?.journal, resolved?.year].some(Boolean) ? " · " : ""} DOI:{" "} - - {citation.doi} + + {resolved.doi} )} diff --git a/src/components/CellCards/OntologyHierarchyTree.jsx b/src/components/CellCards/OntologyHierarchyTree.jsx index 6939ecb6..db4d091c 100644 --- a/src/components/CellCards/OntologyHierarchyTree.jsx +++ b/src/components/CellCards/OntologyHierarchyTree.jsx @@ -10,14 +10,19 @@ import { termLink } from "./config/gridConfig"; // Tree row: label + the term's CURIE, per the design. The CURIE is dropped when it *is* the // label (a class with no rdfs:label falls back to its curie, and showing it twice is noise). // -// The label is a real link so the term opens in a new tab (design decision), which also gets -// middle-click and keyboard activation for free. It carries the term's own colour rather than -// the link colour — the design draws these as plain text — and stops the click from reaching -// the item, so opening a term never doubles as expanding it. Expansion stays on the chevron. +// The label is the row's activation target, not the row itself: it stops the click from reaching +// the item, so opening a term never doubles as expanding it. Expansion stays on the chevron. It +// carries the term's own colour rather than the link colour — the design draws these as plain +// text. +// +// Where it goes depends on the caller. Without `onSelectTerm` it is a real link that opens the +// term in a new tab (the Browse tab's behaviour), which gets middle-click and keyboard activation +// for free. With `onSelectTerm` the caller navigates in place instead — the Cell Card needs the +// tree to stay put while the page moves, which a new document cannot do. const HierarchyTreeItem = forwardRef(function HierarchyTreeItem(props, ref) { - // `meta` arrives via slotProps and must not reach the DOM. - const { meta, label, itemId, ...treeItemProps } = props; - const { curie, href, isCurrent } = meta?.get(itemId) || {}; + // `meta` and `onSelectTerm` arrive via slotProps and must not reach the DOM. + const { meta, onSelectTerm, label, itemId, ...treeItemProps } = props; + const { curie, href, isCurrent, node } = meta?.get(itemId) || {}; return ( { + e.stopPropagation(); + onSelectTerm(node); + }} + > + {label} + + ) : href && !isCurrent ? ( { - // itemId -> { curie, href, isCurrent }, resolved once here so the item slot needs no walk of - // its own. `currentTermId` is a *term* id, matched against node.termId — a class with several + // itemId -> { node, curie, href, isCurrent }, resolved once here so the item slot needs no walk + // of its own. `currentTermId` is a *term* id, matched against node.termId — a class with several // parents appears at more than one path, and every one of those positions is "current". const meta = useMemo(() => { const map = new Map(); @@ -81,6 +104,7 @@ const OntologyHierarchyTree = ({ while (stack.length) { const node = stack.pop(); map.set(node.id, { + node, curie: node.curie, href: termLink(node), isCurrent: !!currentTermId && node.termId === currentTermId, @@ -104,7 +128,7 @@ const OntologyHierarchyTree = ({ expandIcon: ChevronRightOutlinedIcon, collapseIcon: ExpandLessOutlinedIcon, }} - slotProps={{ item: { meta } }} + slotProps={{ item: { meta, onSelectTerm } }} /> ); }; @@ -116,6 +140,9 @@ OntologyHierarchyTree.propTypes = { selectedItems: PropTypes.arrayOf(PropTypes.string), // The term the page is about; every position of that class renders highlighted. currentTermId: PropTypes.string, + // Given a hierarchy node, take the caller somewhere in this same page. When set, term labels + // activate this instead of opening the term in a new tab. + onSelectTerm: PropTypes.func, }; export default OntologyHierarchyTree; diff --git a/src/components/CellCards/services/ontologyGridService.ts b/src/components/CellCards/services/ontologyGridService.ts index 259fd7a3..6d96b4e0 100644 --- a/src/components/CellCards/services/ontologyGridService.ts +++ b/src/components/CellCards/services/ontologyGridService.ts @@ -133,6 +133,13 @@ export const loadOntology = async (slug: string): Promise => { return loaded; }; +// Synchronous read of an already-parsed ontology, for callers that must resolve during render. +// Awaiting `loadOntology` costs a frame even on a cache hit, and that frame is a loading state: +// it unmounts the Cell Card and takes every widget's state with it. The hierarchy widget has to +// survive a cell → cell navigation (spec §3.2), so `useCellTerm` reads through this instead and +// only falls back to the async path on a cold load. +export const peekOntology = (slug: string): LoadedOntology | undefined => parsedCache.get(slug); + // --- single-term selectors (Cell Card) --------------------------------------- // A term arrives from the URL as a slug, where the ":" of a CURIE has become "_": diff --git a/src/components/SingleTermView/index.jsx b/src/components/SingleTermView/index.jsx index 902fe115..1a2ed837 100644 --- a/src/components/SingleTermView/index.jsx +++ b/src/components/SingleTermView/index.jsx @@ -131,6 +131,11 @@ const SingleTermView = () => { const [ontologySnackbar, setOntologySnackbar] = useState(null); // { severity, message } // Label resolved by the Cell Card from the ontology graph. The term API cannot supply it for a // precision cell (npokb ids 404), so without this the H1 would read "NPOKB:1067". + // + // Kept as { term, label } and read only when the term still matches, rather than being cleared + // by an effect on `term`: navigating between cells resolves the new cell synchronously (see + // useCellTerm), so the card reports its label in the *same* commit that a clearing effect would + // run in — and effects run child-before-parent, so the clear would land last and win. const [cellLabel, setCellLabel] = useState(null); // Whether the term currently in view is a member of the active ontology. @@ -151,9 +156,9 @@ const SingleTermView = () => { // Revisit once Precision cells are ingested with ILX ids — at that point the slug shape stops // being a reliable signal and the term's own @type should decide. const contextOntology = new URLSearchParams(location.search).get(ONTOLOGY_PARAM); - useEffect(() => { - setCellLabel(null); - }, [term]); + + const handleCellLabel = useCallback((label) => setCellLabel({ term, label }), [term]); + const resolvedCellLabel = cellLabel?.term === term ? cellLabel.label : null; const isCellTerm = useMemo( () => /^npokb[_:]/i.test(term || "") || Boolean(contextOntology), @@ -196,8 +201,8 @@ const SingleTermView = () => { // Memoize the displayed term label to prevent unnecessary re-renders const displayedTermLabel = useMemo(() => { - return termData || cellLabel || storedSearchTerm || searchTerm.toUpperCase().replace("_", ":"); - }, [termData, cellLabel, storedSearchTerm, searchTerm]); + return termData || resolvedCellLabel || storedSearchTerm || searchTerm.toUpperCase().replace("_", ":"); + }, [termData, resolvedCellLabel, storedSearchTerm, searchTerm]); // Memoize breadcrumb items to prevent unnecessary re-renders const breadcrumbItems = useMemo(() => [ @@ -351,7 +356,7 @@ const SingleTermView = () => { const tabContent = useMemo(() => { switch (tabValue) { case CELL_CARD_TAB: - return ; + return ; case OVERVIEW_TAB: return ; case 2: @@ -363,7 +368,7 @@ const SingleTermView = () => { default: return ; } - }, [tabValue, searchTerm, group, isCodeViewVisible, selectedDataFormat, actualGroup, versionHash, versionsData, versionsLoading, versionsError, clearVersionsError]); + }, [tabValue, searchTerm, group, handleCellLabel, isCodeViewVisible, selectedDataFormat, actualGroup, versionHash, versionsData, versionsLoading, versionsError, clearVersionsError]); // Memoize the toggle button group for overview tab const toggleButtonGroup = useMemo(() => { From accbdc759237957339c3bbef6ac77a128e420584 Mon Sep 17 00:00:00 2001 From: Filippo Ledda Date: Thu, 30 Jul 2026 15:54:14 +0200 Subject: [PATCH 10/11] (cellcard) Cell grouping link to nervosensus --- .../CellCards/CellCard/CellCard.jsx | 6 +- .../CellCards/CellCard/nervoSensusLink.js | 107 +++++++++++++++++ .../CellCards/CellCard/widgetVisibility.js | 9 +- .../CellCard/widgets/CellGrouping.jsx | 110 ++++++++++-------- .../CellCards/config/cellCardConfig.ts | 44 ++++++- src/parsers/__fixtures__/checkParser.mjs | 59 +++++++++- src/theme/index.jsx | 66 +++++++++++ 7 files changed, 338 insertions(+), 63 deletions(-) create mode 100644 src/components/CellCards/CellCard/nervoSensusLink.js diff --git a/src/components/CellCards/CellCard/CellCard.jsx b/src/components/CellCards/CellCard/CellCard.jsx index 1bc9e626..05a97a17 100644 --- a/src/components/CellCards/CellCard/CellCard.jsx +++ b/src/components/CellCards/CellCard/CellCard.jsx @@ -16,7 +16,6 @@ import { relatedBySource, hierarchyNeighbours } from "../services/ontologyGridSe import { hasDefinition, hasTranscriptomicProfile, - hasCellGrouping, hasCrossNomenclature, hasSourcePublication, } from "./widgetVisibility"; @@ -86,9 +85,8 @@ const CellCard = ({ cell, data, group, termSlug, discussionHref, onNavigateToCel hasTranscriptomicProfile(cell) && ( ), - hasCellGrouping(cell) && ( - - ), + // Unconditional: NervoSensus is a tool, not per-cell data (see the widget). + , hasCrossNomenclature(cell) && ( ), diff --git a/src/components/CellCards/CellCard/nervoSensusLink.js b/src/components/CellCards/CellCard/nervoSensusLink.js new file mode 100644 index 00000000..f4d8665d --- /dev/null +++ b/src/components/CellCards/CellCard/nervoSensusLink.js @@ -0,0 +1,107 @@ +import { + NERVOSENSUS_URL, + NERVOSENSUS_SPECIES, + NERVOSENSUS_SOMA, + NERVOSENSUS_AXON, + MARKER_GENE_PREDICATE, +} from "../config/cellCardConfig"; + +/** + * Build the NervoSensus deep link for a cell. + * + * NervoSensus is one app rather than a per-cell resource, so the widget always has somewhere to + * send the user. What varies is how precisely: the app reads a documented set of query params on + * load, so we pass everything the cell can supply and it applies what it recognises. + * + * Params (from the app's own deep-link handler; verified identical in the deployed build): + * atlasannotation opens that cell's modal, via its ATLAS_TO_CELL lookup + * species mouse | human | macaque | guinea pig + * location soma_drg | soma_tg + * axon fiber_a_beta | fiber_a_delta | fiber_c + * gene a gene symbol, matched against its own filter options + * + * Deliberately no attempt to know which values the app has data for: every filter is looked up + * against its own ` options actually use. +const filtered = buildNervoSensusLink(c1067); +const params = new URL(filtered.href).searchParams; +check("a cell without one is not precise", filtered.precise, false); +check("species label maps to the app's value", params.get("species"), "mouse"); +check("soma label maps to the app's value", params.get("location"), "soma_drg"); +check("axon label maps to the app's value", params.get("axon"), "fiber_c"); +// npokb:1067's first marker gene resolves to the symbol "Mrgpra3"; an unlabelled gene would be a +// CURIE, which the app has no filter option for and so must be omitted. +check("a resolved gene symbol is passed", params.get("gene"), "Mrgpra3"); +check( + "an unlabelled gene (CURIE) is not passed as a gene filter", + new URL( + buildNervoSensusLink({ + properties: { hasNucleicAcidExpressionPhenotype: { values: [{ label: "NCBIGene:667742" }] } }, + }).href + ).searchParams.get("gene"), + null +); +// A bare cell still gets a usable link — the widget renders for every cell. +check( + "a cell with nothing mappable still links to the app", + buildNervoSensusLink({}).href.startsWith("https://nervosensus.netlify.app/"), + true +); + console.log(failed ? `\n${failed} check(s) failed` : "\nall checks passed"); process.exit(failed ? 1 : 0); diff --git a/src/theme/index.jsx b/src/theme/index.jsx index a2d881ec..f40a05eb 100644 --- a/src/theme/index.jsx +++ b/src/theme/index.jsx @@ -642,6 +642,72 @@ const theme = createTheme({ defaultProps: { disableElevation: true, }, + // A full-width, two-line link tile that happens to be a Button, so the *whole* card is + // one clickable target with real button/anchor semantics (focus ring, keyboard, middle + // click) instead of a bordered Box with a link inside it. + // + // Here rather than in `sx` at the call site because it is a look, not layout: the fill, + // border, radius, shadow and type all come from Figma tokens (9239:67808 — Base/White, + // Gray/200, shadow-xs, Text sm Semibold/Regular in Gray/500). + variants: [ + { + props: { variant: "tile" }, + style: { + width: "100%", + height: "auto", + padding: "1rem", + gap: "1rem", + justifyContent: "flex-start", + textAlign: "left", + background: white, + border: `1px solid ${gray200}`, + borderRadius: "0.5rem", + boxShadow: "0rem 0.0625rem 0.125rem 0rem rgba(16, 24, 40, 0.05)", + color: gray500, + fontWeight: 400, + "&:hover": { + background: gray25, + borderColor: gray300, + }, + "&:focus-visible": { + borderColor: brand600, + boxShadow: `0rem 0rem 0rem 0.25rem ${alpha(brand500, 0.24)}`, + }, + // The boxed glyph on the left (Figma "Featured icon", 40x40). + "& .tileIcon": { + flexShrink: 0, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: "2.5rem", + height: "2.5rem", + border: `1px solid ${gray200}`, + borderRadius: "0.5rem", + color: gray500, + }, + "& .tileTitle": { + fontSize: "0.875rem", + lineHeight: 1.4285, + fontWeight: 600, + color: gray500, + }, + "& .tileSupporting": { + fontSize: "0.875rem", + lineHeight: 1.4285, + fontWeight: 400, + color: gray500, + }, + // Trailing affordance: a plain glyph pushed to the right edge, no button chrome + // of its own — the tile itself is the control. + "& .tileAction": { + flexShrink: 0, + marginLeft: "auto", + display: "inline-flex", + color: gray500, + }, + }, + }, + ], styleOverrides: { root: { fontSize: "0.875rem", From 80b5e0209d94f37000305d8e20f85fa3e75d5eb6 Mon Sep 17 00:00:00 2001 From: Filippo Ledda Date: Thu, 30 Jul 2026 18:21:42 +0200 Subject: [PATCH 11/11] (cellcard) Fix container --- .../CellCards/CellCard/CellCard.jsx | 32 +++++++++--------- .../CellCards/CellCard/CellCardPanel.jsx | 11 +++---- .../CellCards/OntologyBrowsePage.jsx | 6 ++-- src/components/CellCards/OntologyHeader.jsx | 6 ++-- src/components/common/BreadcrumbBar.jsx | 7 ++-- src/theme/index.jsx | 33 +++++++++++++++++++ 6 files changed, 64 insertions(+), 31 deletions(-) diff --git a/src/components/CellCards/CellCard/CellCard.jsx b/src/components/CellCards/CellCard/CellCard.jsx index 05a97a17..95b1b435 100644 --- a/src/components/CellCards/CellCard/CellCard.jsx +++ b/src/components/CellCards/CellCard/CellCard.jsx @@ -1,6 +1,6 @@ import { useMemo, Fragment } from "react"; import PropTypes from "prop-types"; -import { Box, Stack, Divider } from "@mui/material"; +import { Box, Container, Stack, Divider } from "@mui/material"; import DefinitionBanner from "./DefinitionBanner"; import WidgetCommentButton from "./WidgetCommentButton"; import BiologicalProperties, { TITLE as BIO_TITLE } from "./widgets/BiologicalProperties"; @@ -117,21 +117,23 @@ const CellCard = ({ cell, data, group, termSlug, discussionHref, onNavigateToCel )); return ( - - {hasDefinition(cell) && } + + + {hasDefinition(cell) && } - - - {withDividers(left)} - - - {withDividers(centre)} - - - {right} - - - + + + {withDividers(left)} + + + {withDividers(centre)} + + + {right} + + + + ); }; diff --git a/src/components/CellCards/CellCard/CellCardPanel.jsx b/src/components/CellCards/CellCard/CellCardPanel.jsx index 1b7e50b0..b7c886af 100644 --- a/src/components/CellCards/CellCard/CellCardPanel.jsx +++ b/src/components/CellCards/CellCard/CellCardPanel.jsx @@ -1,7 +1,7 @@ import { useCallback, useEffect } from "react"; import PropTypes from "prop-types"; import { useNavigate, useLocation } from "react-router-dom"; -import { Box, Stack, Skeleton, Alert, AlertTitle, Button, Typography } from "@mui/material"; +import { Box, Container, Stack, Skeleton, Alert, AlertTitle, Button, Typography } from "@mui/material"; import CellCard from "./CellCard"; import EmptyState from "../../common/EmptyState"; import useCellTerm from "./useCellTerm"; @@ -30,12 +30,11 @@ const Scroll = ({ children }) => ( Scroll.propTypes = { children: PropTypes.node }; const LoadingSkeleton = () => ( - @@ -47,7 +46,7 @@ const LoadingSkeleton = () => ( ))} ))} - + ); /** @@ -102,7 +101,7 @@ const CellCardPanel = ({ term, group, onTermLabel }) => { if (error) { return ( - + Could not load the ontology @@ -112,7 +111,7 @@ const CellCardPanel = ({ term, group, onTermLabel }) => { - + ); } diff --git a/src/components/CellCards/OntologyBrowsePage.jsx b/src/components/CellCards/OntologyBrowsePage.jsx index dbfe81ac..e3059481 100644 --- a/src/components/CellCards/OntologyBrowsePage.jsx +++ b/src/components/CellCards/OntologyBrowsePage.jsx @@ -1,5 +1,5 @@ import { useOutletContext } from "react-router-dom"; -import { Box, Grid } from "@mui/material"; +import { Container, Grid } from "@mui/material"; import OntologyHierarchyPanel from "./OntologyHierarchyPanel"; import OntologyTermsTable from "./OntologyTermsTable"; @@ -14,7 +14,7 @@ const OntologyBrowsePage = () => { const panelHeight = { xs: "32rem", md: 1 }; return ( - + { - + ); }; diff --git a/src/components/CellCards/OntologyHeader.jsx b/src/components/CellCards/OntologyHeader.jsx index 7ab9651b..0bfd844a 100644 --- a/src/components/CellCards/OntologyHeader.jsx +++ b/src/components/CellCards/OntologyHeader.jsx @@ -1,6 +1,6 @@ import PropTypes from "prop-types"; import { useNavigate } from "react-router-dom"; -import { Box, Typography, Chip, Stack } from "@mui/material"; +import { Container, Typography, Chip, Stack } from "@mui/material"; import HomeOutlinedIcon from "@mui/icons-material/HomeOutlined"; import BreadcrumbBar from "../common/BreadcrumbBar"; import CustomTabs from "../common/CustomTabs"; @@ -33,7 +33,7 @@ const OntologyHeader = ({ data, tab }) => { {/* Header: title + curation status -> description -> version -> tags -> tabs. Title / description / version are read from the file's owl:Ontology node; the curie is the real root class the grid is scoped to. */} - + {data.meta.title} {data.entry.curationStatus && ( @@ -68,7 +68,7 @@ const OntologyHeader = ({ data, tab }) => { }} parentBoxStyles={{ mt: 1, borderBottom: 0 }} /> - + ); }; diff --git a/src/components/common/BreadcrumbBar.jsx b/src/components/common/BreadcrumbBar.jsx index 08b1213c..b505b6c7 100644 --- a/src/components/common/BreadcrumbBar.jsx +++ b/src/components/common/BreadcrumbBar.jsx @@ -1,6 +1,6 @@ import PropTypes from "prop-types"; import { useState } from "react"; -import { Box, Link, Tooltip, Typography } from "@mui/material"; +import { Box, Container, Link, Tooltip, Typography } from "@mui/material"; import ContentCopyOutlinedIcon from "@mui/icons-material/ContentCopyOutlined"; import CustomBreadcrumbs from "./CustomBreadcrumbs"; import { vars } from "../../theme/variables"; @@ -25,13 +25,12 @@ const BreadcrumbBar = ({ breadcrumbItems, copyPath, permalink, copyLabel, rightC }; return ( - {rightContent} - + ); }; diff --git a/src/theme/index.jsx b/src/theme/index.jsx index f40a05eb..dd3ceefd 100644 --- a/src/theme/index.jsx +++ b/src/theme/index.jsx @@ -395,7 +395,40 @@ const theme = createTheme({ }, MuiContainer: { + // A bare Container is a full-bleed page section, not MUI's centred `lg` box. The + // capped ones (Header banner, Footer, About, Partners) pass `maxWidth` explicitly. + defaultProps: { + maxWidth: false, + }, + + // Header band: title/breadcrumb/tabs above a divider, hence no bottom padding. + // `variant` is not in Container's API; MUI matches it off ownerState anyway and + // drops it before the DOM. + variants: [ + { props: { variant: "header" }, style: { paddingTop: "1.5rem" } }, + ], + styleOverrides: { + // Page gutter. 5rem = (1920 - 1760) / 2, from the Figma frame's content column. + // + // On `maxWidthFalse` (the slot `maxWidth={false}` resolves to) and not `root`, so + // the capped `maxWidth="xl"` containers keep MUI's gutters — for those, padding + // adds to the centring offset instead of setting the margin. + // + // The `sm` restatement is required, not redundant: styleOverrides merge into + // Container's style object, so a flat property lands in MUI's key position, ahead + // of MUI's own `@media (min-width:600px)` gutter, which then wins from 600px up. + // + // Unconditional for now: docs/container-layout-migration.md. + maxWidthFalse: { + paddingLeft: "5rem", + paddingRight: "5rem", + "@media (min-width:600px)": { + paddingLeft: "5rem", + paddingRight: "5rem", + }, + }, + maxWidthXl: { "@media screen and (min-width: 96rem)": { maxWidth: "104.5rem",