Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ shadows.
The `material-ui` skill covers how this project's theme is put together and which MUI defaults it
does *not* define (the ones that bite when you introduce a new component). The
`check-figma-design` skill covers working from the Figma file.

## Coding best practices
- Avoid redundant comments: make the code the source of truth with proper naming and functions refactorings. Only use comments about the why some code is written, not the what nor the how. Clean comments that do not comply at the end of each session.
364 changes: 364 additions & 0 deletions docs/cell-card.md

Large diffs are not rendered by default.

54 changes: 51 additions & 3 deletions nginx/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ server {
# reasons: it needs real caching, and it must 404 when absent rather than fall through to
# `try_files $uri /index.html` — the loader treats an HTML body as a failure and falls back to
# upstream, but only if it actually gets one it can recognise, and a 404 is much clearer.
location /data/ {
#
# `^~`, not a plain prefix: a matching regex location would otherwise win, and the term-file
# rule below matches any `/{seg}/{prefix}_{id}.jsonld`. Today's filename has no underscore so
# it does not, but the day it gains one the whole ontology would be proxied upstream and 404 —
# the Cell Card dark in production, nothing else affected. This is the one route that must
# never be proxied, so say so rather than depend on a filename.
location ^~ /data/ {
root /usr/share/nginx/html/;
try_files $uri =404;

Expand All @@ -33,6 +39,21 @@ server {
gzip_comp_level 5;
}

# Runtime configuration (the Cell Cards mappings document). Its own location for the same two
# reasons `/data/` has one: it must 404 when absent rather than fall through to
# `try_files $uri /index.html` — the loader treats an HTML body as a failure, but a 404 says so
# much more clearly — and it must *not* be cached, since the point of fetching it at runtime is
# that an operator can edit the file in place and reload.
location ^~ /config/ {
root /usr/share/nginx/html/;
try_files $uri =404;

types { }
default_type application/json;

add_header Cache-Control "no-cache" always;
}

# Proxy for Elasticsearch API requests
location /api/elasticsearch {
proxy_pass https://api.scicrunch.io/elastic/v1/Interlex_pr/_search;
Expand Down Expand Up @@ -91,7 +112,30 @@ server {
add_header Access-Control-Expose-Headers X-Redirect-Location always;
}

location ~ ^/[^/]+/(tmp|ilx)_.*\.(html|ttl|jsonld|n3|owl|csv)$ {
# "Is this external id mapped to an InterLex record?" — /{group}/uris/{prefix}/{id} answers
# 404 while unmapped, the record otherwise. It needs its own location because the fallback is
# `try_files $uri /index.html`: unproxied, every probe would read 200 + the SPA shell, i.e.
# "mapped", and light up term tabs that have nothing behind them.
# Cannot shadow ^/[^/]+/ontologies/uris/ below — that one has two segments before `uris`.
location ~ ^/[^/]+/uris/.+ {
proxy_pass https://uri.olympiangods.org;
proxy_set_header Host uri.olympiangods.org;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
proxy_set_header Cookie $http_cookie;
proxy_ssl_verify off;

# CORS headers
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
}

# Any `{prefix}_{id}` term, not just ilx_/tmp_: once an npokb id is mapped to a record its
# tabs light up, and they read this endpoint. The extension allow-list keeps the rule off
# asset paths.
location ~ ^/[^/]+/[A-Za-z][A-Za-z0-9.-]*_[^/]*\.(html|ttl|jsonld|n3|owl|csv)$ {
proxy_pass https://uri.olympiangods.org;
proxy_set_header Host uri.olympiangods.org;
proxy_set_header X-Real-IP $remote_addr;
Expand Down Expand Up @@ -248,7 +292,11 @@ server {
return 200 '{"location":"$upstream_http_location"}';
}

location /static/ {
# `^~` for the same reason `/data/` has it: the term-file regex below matches any
# `/{seg}/{prefix}_{id}.(html|ttl|jsonld|n3|owl|csv)`, which a dropped-in file like
# `user_guide.html` or `export_2026.csv` satisfies. Without the guard a plain prefix location
# loses to that regex and the file gets proxied upstream and 404s instead of serving locally.
location ^~ /static/ {
autoindex on;
alias /usr/share/nginx/html/static/;
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"date-fns": "^3.6.0",
"dotenv": "16.4.7",
"express": "^4.21.2",
"jotai": "^2.20.2",
"react": "^18.3.1",
"react-cookie": "^8.0.1",
"react-dom": "^18.3.1",
Expand Down
194 changes: 194 additions & 0 deletions public/config/cell-card-mappings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
{
"$comment": "Cell Cards configuration — cellcard-spec/mappings.md §1 (grid tile) and §2 (cell card). Fetched at runtime by src/components/CellCards/config/mappingsService.ts, so editing this file next to the served bundle changes what the views read, with no rebuild. Two parts: `fields` binds each fixed field of the data model to the source that fills it, best first (a fallback chain); `regions` lists the additional fields each part of the UI puts on screen. `default` applies to every ontology, `ontologies.<slug>` overrides it for one, and any key may be omitted — the layer beneath shows through (built-in values live in config/mappingDefaults.ts).",
"version": 1,

"default": {
"fields": {
"$comment": "Model fields and where they read from. `title` and `description` are ordered fallback chains — spec §1.1: 'ilxtr:localLabel (if no localLabel, then rdfs:label)'. Source keys are matched against the graph exactly as written, because `definition`, `skos:definition` and `NIFRID:definition` are three different sources sharing one local name. Where the prefix is not yet settled, `{ \"sources\": [...], \"matchLocalName\": true }` matches the predicate's local name under any prefix.",

"title": ["ilxtr:localLabel", "rdfs:label", "dc:title", "dcterms:title"],
"description": [
"definition",
"skos:definition",
"NIFRID:definition",
"rdfs:comment",
"ilxtr:genLabel",
"ilxtr:curatorNote"
],
"curatedDescriptionSources": 4,

"cellType": ["neurdf:Neuron"],
"parent": ["rdfs:subClassOf"],

"literatureCitation": ["ilxtr:literatureCitation"],
"dataCitation": ["ilxtr:dataCitation"],
"atlasAnnotation": ["ilxtr:atlasAnnotation"],
"curatorNote": ["ilxtr:curatorNote"],
"alertNote": ["ilxtr:alertNote"],
"temporaryId": ["ilxtr:hasTemporaryId"],
"generatedLabel": ["ilxtr:genLabel"],
"error": ["ilxtr:error"],

"sparcTranscriptomicsLink": {
"sources": ["ilx:hasSPARCTranscriptomicsLink"],
"matchLocalName": true
},
"sparcMap": { "sources": ["ilx:hasSPARCMap"], "matchLocalName": true },
"nervoSensusLink": { "sources": ["ilx:hasNervoSensusLink"], "matchLocalName": true },

"literalProperties": ["ilxtr:neurondmBaseClass"],

"crossNomenclature": {
"described": ["TEMP:assertedSubClassOf", "TEMP:subClassOf"],
"inferred": ["TEMP:mapsTo"],
"proposed": []
},

"predicateLabel": ["ilxtr:displayLabel"],
"predicateDescription": ["ilxtr:shortDefinition"],

"ontologyTitle": ["dc:title", "dcterms:title", "rdfs:label", "skos:prefLabel"],
"ontologyDescription": ["dc:description", "dcterms:description", "rdfs:comment"],
"ontologyVersion": ["owl:versionInfo"],

"missingValuePrefixes": ["TEMP:MISSING"]
},

"$predicatesComment": "Row labels come from the ontology's own ilxtr:displayLabel where it has one; these are the fallback, plus the tooltip text from pyontutils neuron_phenotype_edges.csv (displayDescription). ilxtr:literatureCitation is addressed as `source` below: the parser lifts citations onto CellTerm.sources.",
"predicates": {
"ilxtr:neurondmBaseClass": { "label": "Cell class" },
"ilxtr:hasInstanceInTaxon": {
"label": "Species",
"tooltip": "Species the cell type is observed in."
},
"ilxtr:hasSomaLocatedIn": {
"label": "Soma location",
"tooltip": "Anatomical location of the cell body (soma)."
},
"ilxtr:hasCircuitRolePhenotype": {
"label": "Circuit role",
"tooltip": "Excitatory / inhibitory circuit role."
},
"ilxtr:hasFunctionalPhenotype": {
"label": "Physiology",
"tooltip": "Functional / physiological properties."
},
"ilxtr:hasAxonPhenotype": { "label": "Axon type", "tooltip": "Axon fiber type." },
"ilxtr:hasAdaptationPhenotype": { "label": "Adaptation" },
"ilxtr:hasThresholdPhenotype": { "label": "Threshold" },
"ilxtr:hasNeurotransmitterPhenotype": {
"label": "Neurotransmitter",
"tooltip": "Neurotransmitters the cell type produces or releases."
},
"ilxtr:hasNucleicAcidExpressionPhenotype": {
"label": "Marker genes",
"tooltip": "Marker genes expressed by the cell type."
},
"ilxtr:hasBiologicalSex": { "label": "Sex" },
"ilxtr:hasMorphologicalPhenotype": { "label": "Morphology" },
"ilxtr:literatureCitation": {
"label": "Source",
"tooltip": "Source publication for the cell type."
}
},

"regions": {
"tile": {
"$comment": "§1.1 Grid Tile: header chips (left to right), then the property rows, then the source footer. A row with no value hides itself and the tile shrinks. `tone` picks the chip palette; `label` pins a row label for the tile only.",
"headerChips": [
{ "predicate": "ilxtr:neurondmBaseClass", "tone": "class" },
{ "predicate": "ilxtr:hasAxonPhenotype", "tone": "subtype" },
{ "predicate": "ilxtr:hasInstanceInTaxon", "tone": "species" }
],
"rows": [
{ "predicate": "ilxtr:hasSomaLocatedIn", "render": "text" },
{ "predicate": "ilxtr:hasNucleicAcidExpressionPhenotype", "render": "chip" },
{ "predicate": "ilxtr:hasFunctionalPhenotype", "render": "chip" }
],
"footer": { "predicate": "ilxtr:literatureCitation" }
},

"filters": {
"$comment": "§1.2 Grid Filter Bar. `minOptions` hides a facet offering fewer distinct values — one option cannot narrow anything. `displayed` lists what the \"Displayed properties\" toggle restricts the sidebar to; omitted, it is derived from the tile above so the two cannot drift apart.",
"minOptions": 2
},

"cellCard": {
"definition": {
"$comment": "§2.1 Auto-Generated Description: one predicate per sentence token. The banner is suppressed unless `minPredicates` of species/somaLocation/markerGenes are populated, so it never renders a sentence with holes in it.",
"cellClass": "ilxtr:neurondmBaseClass",
"species": "ilxtr:hasInstanceInTaxon",
"somaLocation": "ilxtr:hasSomaLocatedIn",
"markerGenes": "ilxtr:hasNucleicAcidExpressionPhenotype",
"minPredicates": 2
},

"biologicalProperties": {
"$comment": "§2.2, in the design's order (Figma 9535:96273). Every row is `required`, so the table keeps a fixed shape and an absent phenotype reads \"not specified\" rather than disappearing.",
"rows": [
{ "predicate": "ilxtr:hasInstanceInTaxon", "required": true },
{ "predicate": "ilxtr:hasSomaLocatedIn", "required": true },
{ "predicate": "ilxtr:neurondmBaseClass", "required": true },
{ "predicate": "ilxtr:hasCircuitRolePhenotype", "required": true },
{ "predicate": "ilxtr:hasFunctionalPhenotype", "required": true },
{ "predicate": "ilxtr:hasAxonPhenotype", "required": true },
{ "predicate": "ilxtr:hasAdaptationPhenotype", "required": true },
{ "predicate": "ilxtr:hasThresholdPhenotype", "required": true },
{ "predicate": "ilxtr:hasNeurotransmitterPhenotype", "required": true },
{
"predicate": "ilxtr:hasNucleicAcidExpressionPhenotype",
"render": "chip",
"required": true
}
]
},

"anatomicalContext": {
"$comment": "§2.3 (Figma 9239:67695). Soma location repeats from Biological Properties by design — it is the anchor for the anatomical reading.",
"rows": [
{ "predicate": "ilxtr:hasSomaLocatedIn", "required": true },
{ "predicate": "ilxtr:hasAxonSensorySubcellularElementIn", "required": true },
{ "predicate": "ilxtr:hasAxonPresynapticElementIn", "required": true },
{ "predicate": "ilxtr:hasCircuitRolePhenotype", "required": true }
]
},

"relationshipGraph": {
"$comment": "§2.4. Only the predicates listed here are drawn — every phenotype would make the graph unreadable. `kind` selects the stroke and must be one of subClassOf | assertedSubClassOf | somaLocation | expresses; `direction` places the target relative to the current node. The two structural edges come from the hierarchy and the mapping table, so they carry a label rather than a predicate.",
"predicates": [
{
"predicate": "ilxtr:hasSomaLocatedIn",
"kind": "somaLocation",
"label": "soma location",
"direction": "left"
},
{
"predicate": "ilxtr:hasNucleicAcidExpressionPhenotype",
"kind": "expresses",
"label": "expresses",
"direction": "right"
}
],
"legend": [
{ "kind": "subClassOf", "label": "Sub class of" },
{ "kind": "somaLocation", "label": "Soma location" },
{ "kind": "assertedSubClassOf", "label": "Asserted Subclass of" },
{ "kind": "expresses", "label": "expresses" }
],
"subClassOfLabel": "subclass of",
"assertedSubClassOfLabel": "asserted subclass of"
},

"transcriptomicProfile": {
"$comment": "§2.5. The gene chips read the same predicate as the Biological Properties row rather than duplicating the list.",
"markerGenePredicate": "ilxtr:hasNucleicAcidExpressionPhenotype"
}
}
}
},

"ontologies": {
"$comment": "Per-ontology overrides, keyed by the catalogue slug in config/gridConfig.ts. Precision uses the defaults above unchanged.",
"precision": {}
}
}
12 changes: 12 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,18 @@ function MainContent() {
<Route index element={<OntologyGridPage />} />
<Route path="browse" element={<OntologyBrowsePage />} />
</Route>
{/* A term read inside an ontology: the same page as the plain term route below, with
the context ontology named by the path. A sibling of the ontology route rather than a
child of it — the term page brings its own header, and the layout route above would
render the ontology's on top of it. */}
<Route
path="/:group/ontology/:ontologySlug/:term/:tab?"
element={
<PageContainer>
<SingleTermView />
</PageContainer>
}
/>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/forgot" element={<ForgotPassword />} />
Expand Down
44 changes: 44 additions & 0 deletions src/api/endpoints/apiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { API_CONFIG } from "../../config";
import termParser from "../../parsers/termParser";
import { jsonldToTriplesAndEdges, PART_OF_IRI } from '../../parsers/hierarchies-parser'
import { buildPredicateGroupsForFocus } from "../../parsers/predicateParser";
import { termUriMappingPath } from "../../components/CellCards/config/gridConfig";

// Error enriched with the queried URL + the backend's message, so the UI can
// show a meaningful dialog instead of a bare "HTTP 404".
Expand Down Expand Up @@ -236,6 +237,49 @@ export const getSelectedTermLabel = async (searchTerm: string, group: string = '
}
};

// --- InterLex record mapping --------------------------------------------------------------
//
// A Precision cell is addressed by an external id (`npokb_991`), and the term API can only serve
// it once curation maps that id to an InterLex record. /{group}/uris/{prefix}/{id} is where the
// backend answers: 404 with "has not been mapped to an InterLex id" until it exists. Everything
// term-API-backed — Overview, Variants, Version history, Discussions — hangs off that answer, so
// it is probed once per group+slug and remembered for the session.
const recordMappingCache = new Map<string, Promise<boolean>>();

const probeUriMapping = async (path: string): Promise<boolean> => {
// `redirect: manual` on purpose: a mapped id may answer with a redirect to the record on
// another origin, and following it would fail the CORS check and read as "unmapped". A
// redirect still says what we need to know — something is behind this id. The browser hands it
// back opaque (type "opaqueredirect", status 0); Node reports the 3xx as-is.
const response = await fetch(path, { credentials: 'include', redirect: 'manual' });
return response.type === 'opaqueredirect' || response.ok
|| (response.status >= 300 && response.status < 400);
};

/** Is `slug` mapped to an InterLex record the term API can serve? */
export const hasInterLexRecord = (slug: string, group: string = 'base'): Promise<boolean> => {
const path = termUriMappingPath(group, slug);
const basePath = termUriMappingPath('base', slug);
if (!path || !basePath) return Promise.resolve(false);

const key = `${group}:${slug}`;
if (!recordMappingCache.has(key)) {
// Falls back to `base` exactly as getSelectedTermLabel does: a curated mapping lives there,
// so a term viewed under another group is still addressable through it.
const probe = probeUriMapping(path)
.then(found => (found || group === 'base' ? found : probeUriMapping(basePath)))
.catch(() => {
// The probe itself failed (network error), not a confirmed "unmapped" answer from the
// backend — drop the cache entry so the next call retries instead of the tabs staying
// disabled for the rest of the session on a transient failure.
recordMappingCache.delete(key);
return false;
});
recordMappingCache.set(key, probe);
}
return recordMappingCache.get(key) as Promise<boolean>;
};

export const createNewEntity = async ({ group, data }: { group: string; data: any; session?: string }): Promise<{ termId: string | null; raw: string; status: number }> => {
const endpoint = `/${group}${API_CONFIG.REAL_API.CREATE_NEW_ENTITY}`;

Expand Down
Loading
Loading