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 + + } + /> + ); + } + + 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/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/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 new file mode 100644 index 00000000..b58b94f0 --- /dev/null +++ b/src/parsers/neurdfParser.ts @@ -0,0 +1,635 @@ +// 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, + CellAnnotations, + CellMapping, + MappingEvidence, + ValueCombinator, + OntologyMeta, + ParsedOntology, + HierarchyNode, + Facet, + FacetValue, + PredicateDisplay, +} 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"]; + +// 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 } + | 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, @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 + 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:… } +// "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; + 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" | "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). 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] : []; + +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 = {}; + const annotations = emptyAnnotations(); + const mappings: CellMapping[] = []; + 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 === "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); + 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:*/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, + // 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), + }; + } + } + + 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, + mappings, + annotations, + }; +}; + +// 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"] ?? "") : ""; + 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`. +// 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); + 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, + hierarchy: buildHierarchy(rootClass, cells, idx), + predicateDisplay: parsePredicateDisplay(graph), + }; +}; + +// 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, + curie: v.curie, + iri: v.iri, + kind: v.kind, + count: 1, + }); + } + } + if (counts.size >= minOptions) { + facets.push({ + localName, + title: titles[localName] || humanizeLocalName(localName), + tooltip: tooltips[localName], + // 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) + ), + }); + } + } + return facets; +}; diff --git a/src/theme/index.jsx b/src/theme/index.jsx index 4984e038..dd3ceefd 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 { @@ -6,6 +6,7 @@ const { white, brand600, paperShadow, + shadowSm, gray300, gray600, gray700, @@ -35,10 +36,86 @@ const { error700, gray400, brand200, - errorInputBoxShadow + errorInputBoxShadow, + black, + brand400, + error400, + warning400, + success400, + blue200, + blue700, + brand500, + brand800, + success25, + warning25, + error25 } = 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, @@ -47,6 +124,32 @@ 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, + }, + // 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: { @@ -114,6 +217,11 @@ const theme = createTheme({ `, }, MuiTypography: { + defaultProps: { + variantMapping: { + sectionTitle: 'h2' + } + }, styleOverrides: { h6: { fontSize: '1.125rem', @@ -128,9 +236,46 @@ const theme = createTheme({ } } }, + // `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, 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)}` + } + } + } + }, + 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", root: { "& .MuiTreeItem-root": { position: "relative", @@ -250,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", @@ -265,9 +443,16 @@ 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%", + minWidth: 0, "&.rounded": { padding: "0.125rem 0.5rem 0.125rem 0.375rem", @@ -341,11 +526,24 @@ 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", }, + // 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", @@ -477,6 +675,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", @@ -792,6 +1056,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: { @@ -874,24 +1185,58 @@ 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", + boxShadow: shadowSm, + // 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, }, }, }, @@ -931,9 +1276,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/src/theme/variables.js b/src/theme/variables.js index 40a108e5..e7fcb4ce 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', @@ -73,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)' diff --git a/yarn.lock b/yarn.lock index 076f9d92..2ce86597 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" @@ -281,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" @@ -922,6 +939,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 +956,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 +1529,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 +4576,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 +4709,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 +5339,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"