diff --git a/site/src/components/parameters/ParameterDetailPage.tsx b/site/src/components/parameters/ParameterDetailPage.tsx
index 8e193ce..ac90118 100644
--- a/site/src/components/parameters/ParameterDetailPage.tsx
+++ b/site/src/components/parameters/ParameterDetailPage.tsx
@@ -1,19 +1,16 @@
import { useMemo, useEffect, useState } from 'react';
import { getRichParameter, getAllParameters, type RichParameter } from '../../data/loader';
import { getProvenance, type ProvEntry } from '../../data/provenance';
-import { Card, CardHeader, CardContent, CardFooter } from '../../ui/card';
+import { Card, CardContent } from '../../ui/card';
import { Badge } from '../../ui/badge';
-import { Button } from '../../ui/button';
import { RangeBar } from './RangeBar';
import { categoryColors, categoryLabels } from '../../styles/category-colors';
import { ProvenanceBadges } from './provenance/ProvenanceBadges';
import { SourcesExplorer } from './provenance/SourcesExplorer';
import { CorrelationMiniMatrix } from './provenance/CorrelationMiniMatrix';
-import { linkifyCitations } from '../../lib/doi';
-
-function nameToSlug(name: string): string {
- return name.toLowerCase().replace(/\s+/g, '_').replace(/[()\/]/g, '').replace(/:/, '');
-}
+import { RelatedParametersList } from './RelatedParametersList';
+import { ReferencesList } from './ReferencesList';
+import { humanize, categoryKey, paramNameToSlug } from '../../lib/humanize';
interface ParameterDetailPageProps {
paramId: string;
@@ -38,10 +35,10 @@ function formatList(val: unknown): string[] {
const result: string[] = [];
for (const [k, v] of Object.entries(obj)) {
if (Array.isArray(v)) {
- result.push(`**${k.charAt(0).toUpperCase() + k.slice(1)}**:`);
+ result.push(`**${humanize(k)}**:`);
result.push(...formatList(v).map(s => ` ${s}`));
} else {
- result.push(`**${k}**: ${String(v)}`);
+ result.push(`**${humanize(k)}**: ${String(v)}`);
}
}
return result;
@@ -99,7 +96,7 @@ function BulletList({ items }: { items: string[] }) {
function InfoRow({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
- {label}
+ {label}
{value}
);
@@ -144,14 +141,15 @@ export function ParameterDetailPage({ paramId }: ParameterDetailPageProps) {
}
const p = richParam as RichParameter;
- const catId = p.category.toLowerCase().replace(/_/g, '-');
- const catLabel = categoryLabels[catId] || p.category.replace(/_/g, ' ');
+ const catId = categoryKey(p.category);
+ const catLabel = categoryLabels[catId] || humanize(p.category);
const catColor = categoryColors[catId] || '#737373';
+ const subcatLabel = humanize(p.subcategory ?? '');
+ const dataTypeLabel = humanize(p.data_type ?? '');
const typicalItems = formatList(p.typical_values);
const methodItems = formatList(p.measurement_methods);
const factorItems = formatList(p.affecting_factors);
- const relatedItems = formatList(p.related_parameters);
const issueBase = `https://github.com/${REPO}/issues/new`;
const issueBody = encodeURIComponent(`**Parameter:** ${p.name}\n**Category:** ${catLabel}\n\n`);
@@ -175,18 +173,25 @@ export function ParameterDetailPage({ paramId }: ParameterDetailPageProps) {
{/* Header */}
-
+
+
{p.name}
-
- {p.description}
-
+ {p.description && (
+
+ {p.description}
+
+ )}
-
+
{catLabel}
- {p.subcategory && (
- {p.subcategory}
+ {subcatLabel && (
+ {subcatLabel}
)}
@@ -207,7 +212,7 @@ export function ParameterDetailPage({ paramId }: ParameterDetailPageProps) {
Type
- {(p.data_type || '').toLowerCase()}
+ {dataTypeLabel || '--'}
@@ -243,7 +248,7 @@ export function ParameterDetailPage({ paramId }: ParameterDetailPageProps) {
)}
-
+
{/* Main content — left 2 columns */}
{/* Definition */}
@@ -344,7 +349,7 @@ export function ParameterDetailPage({ paramId }: ParameterDetailPageProps) {
@@ -392,18 +397,29 @@ export function ParameterDetailPage({ paramId }: ParameterDetailPageProps) {
)}
+
+ {/* References */}
+ {p.references && (
+
+
+
+
+
+ )}
{/* Sidebar — right column */}
-
+
{/* Properties */}
Properties
- {p.subcategory && }
+ {subcatLabel && }
-
+
{p.min_value != null && }
{p.max_value != null && }
0 ? p.usage_count.toLocaleString() : 'None yet'} />
@@ -421,37 +437,11 @@ export function ParameterDetailPage({ paramId }: ParameterDetailPageProps) {
)}
{/* Related Parameters */}
- {relatedItems.length > 0 && (
+ {!!p.related_parameters && (
Related Parameters
-
-
-
- )}
-
- {/* References */}
- {p.references && (
-
-
- References
-
- {linkifyCitations(p.references).map((seg, i) =>
- seg.href ? (
-
- {seg.text}
-
- ) : (
-
{seg.text}
- )
- )}
-
+
)}
diff --git a/site/src/components/parameters/ParameterTable.tsx b/site/src/components/parameters/ParameterTable.tsx
index d747c84..1d240fa 100644
--- a/site/src/components/parameters/ParameterTable.tsx
+++ b/site/src/components/parameters/ParameterTable.tsx
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../../ui/table';
import { Badge } from '../../ui/badge';
-import { categoryColors } from '../../styles/category-colors';
+import { categoryColors, categoryLabels } from '../../styles/category-colors';
+import { humanize } from '../../lib/humanize';
import type { Parameter } from '../../data/types';
type SortKey = 'name' | 'unit' | 'category' | 'subcategory' | 'typical';
@@ -106,11 +107,11 @@ function ParameterTableRow({
size="sm"
style={{ borderColor: categoryColors[p.category] || '#737373', color: categoryColors[p.category] || '#737373' }}
>
- {p.category}
+ {categoryLabels[p.category] || humanize(p.category)}
- {p.subcategory}
+ {humanize(p.subcategory)}
{p.range?.typical != null ? `${p.range.typical}` : '-'}
diff --git a/site/src/components/parameters/ReferencesList.tsx b/site/src/components/parameters/ReferencesList.tsx
new file mode 100644
index 0000000..f467da1
--- /dev/null
+++ b/site/src/components/parameters/ReferencesList.tsx
@@ -0,0 +1,90 @@
+import { useMemo } from 'react';
+import { parseReferences, referenceHref, linkifyCitations } from '../../lib/citations';
+
+interface Props {
+ text: string | null | undefined;
+}
+
+// Render the citation body. Embedded DOIs and URLs are linkified in place
+// so the original citation typography (authors, year, journal) reads as it
+// was authored.
+function Inline({ text }: { text: string }) {
+ const segs = linkifyCitations(text);
+ return (
+ <>
+ {segs.map((seg, i) =>
+ seg.href ? (
+
+ {seg.text}
+
+ ) : (
+ {seg.text}
+ )
+ )}
+ >
+ );
+}
+
+// Drop markdown emphasis so the rendered citation isn't littered with
+// asterisks/underscores. Bolding the journal/title isn't useful here.
+function stripMd(s: string): string {
+ return s
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/__(.+?)__/g, '$1')
+ .replace(/\*(.+?)\*/g, '$1')
+ .replace(/_(.+?)_/g, '$1');
+}
+
+export function ReferencesList({ text }: Props) {
+ const refs = useMemo(() => parseReferences(text), [text]);
+
+ if (refs.length === 0) return null;
+
+ return (
+
+ {refs.map((ref, i) => {
+ const href = referenceHref(ref);
+ const cleaned = stripMd(ref.raw);
+ const action = ref.doi
+ ? { label: 'Open paper (DOI)', href: href! }
+ : ref.url
+ ? { label: 'Open source', href: href! }
+ : href
+ ? { label: 'Search on Google Scholar', href }
+ : null;
+ return (
+ -
+
+ {i + 1}.
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/site/src/components/parameters/RelatedParametersList.tsx b/site/src/components/parameters/RelatedParametersList.tsx
new file mode 100644
index 0000000..853887e
--- /dev/null
+++ b/site/src/components/parameters/RelatedParametersList.tsx
@@ -0,0 +1,90 @@
+import { useMemo } from 'react';
+import { getAllRichParameters } from '../../data/loader';
+import { paramNameToSlug } from '../../lib/humanize';
+
+interface RelatedItem {
+ name: string;
+ relationship?: string | null;
+}
+
+interface Props {
+ related: unknown;
+}
+
+// Coerce the heterogeneous `related_parameters` payload into a uniform shape.
+// The rich definitions store this as one of:
+// - Array<{ name, relationship }> (most common)
+// - Array (legacy entries)
+// - object map { name: relationship }
+// - null
+function normalize(val: unknown): RelatedItem[] {
+ if (!val) return [];
+ if (Array.isArray(val)) {
+ return val
+ .map((item): RelatedItem | null => {
+ if (typeof item === 'string') return { name: item };
+ if (item && typeof item === 'object') {
+ const o = item as Record;
+ const name = typeof o.name === 'string' ? o.name : null;
+ if (!name) return null;
+ const rel = typeof o.relationship === 'string' ? o.relationship : null;
+ return { name, relationship: rel };
+ }
+ return null;
+ })
+ .filter((x): x is RelatedItem => x !== null);
+ }
+ if (typeof val === 'object') {
+ return Object.entries(val as Record).map(([name, rel]) => ({
+ name,
+ relationship: typeof rel === 'string' ? rel : null,
+ }));
+ }
+ return [];
+}
+
+export function RelatedParametersList({ related }: Props) {
+ const items = useMemo(() => normalize(related), [related]);
+ const knownSlugs = useMemo(() => {
+ const set = new Set();
+ for (const p of getAllRichParameters()) set.add(paramNameToSlug(p.name));
+ return set;
+ }, []);
+
+ if (items.length === 0) return null;
+
+ return (
+
+ {items.map((item, i) => {
+ const slug = paramNameToSlug(item.name);
+ const isLinked = knownSlugs.has(slug);
+ const href = isLinked ? `#/parameter/${slug}` : null;
+ return (
+ -
+ {href ? (
+
+ {item.name}
+ →
+
+ ) : (
+
+ {item.name}
+
+ )}
+ {item.relationship && (
+
+ {item.relationship}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/site/src/lib/citations.test.ts b/site/src/lib/citations.test.ts
new file mode 100644
index 0000000..d789a8a
--- /dev/null
+++ b/site/src/lib/citations.test.ts
@@ -0,0 +1,89 @@
+import { describe, it, expect } from 'vitest';
+import { parseReferences, referenceHref, splitReferences } from './citations';
+
+describe('splitReferences', () => {
+ it('splits numbered lists', () => {
+ const text = `1. Smith et al. 2020. *J. Foo*, 5(2), 1-10.\n2. Jones et al. 2021. *J. Bar*, 6(3), 11-20.`;
+ expect(splitReferences(text)).toHaveLength(2);
+ });
+
+ it('splits bulleted lists with hyphens', () => {
+ const text = `- Smith et al. (2020). Title A.\n- Jones et al. (2021). Title B.`;
+ expect(splitReferences(text)).toHaveLength(2);
+ });
+
+ it('splits paragraphs separated by blank lines', () => {
+ const text = `Smith et al. 2020. Foo.\n\nJones et al. 2021. Bar.`;
+ expect(splitReferences(text)).toHaveLength(2);
+ });
+
+ it('returns an empty array for empty input', () => {
+ expect(splitReferences('')).toEqual([]);
+ expect(splitReferences(' ')).toEqual([]);
+ });
+});
+
+describe('parseReferences', () => {
+ it('extracts a DOI when present', () => {
+ const text = `1. Smith et al. (2020). A study. *J. Foo*. doi: 10.1016/j.foo.2020.01.001`;
+ const refs = parseReferences(text);
+ expect(refs).toHaveLength(1);
+ expect(refs[0].doi).toBe('10.1016/j.foo.2020.01.001');
+ });
+
+ it('falls back to a non-DOI URL', () => {
+ const text = `1. Some Report (2019). https://example.org/report.pdf`;
+ const refs = parseReferences(text);
+ expect(refs[0].doi).toBeNull();
+ expect(refs[0].url).toBe('https://example.org/report.pdf');
+ });
+
+ it('extracts a quoted title for Scholar fallback', () => {
+ const text = `1. Smith et al. (2020). "Bioelectrochemical systems study". J. Foo.`;
+ const refs = parseReferences(text);
+ expect(refs[0].title).toBe('Bioelectrochemical systems study');
+ });
+
+ it('returns an empty array for null/empty input', () => {
+ expect(parseReferences(null)).toEqual([]);
+ expect(parseReferences('')).toEqual([]);
+ });
+});
+
+describe('referenceHref', () => {
+ it('prefers DOI over URL', () => {
+ const href = referenceHref({
+ raw: '',
+ doi: '10.1016/foo',
+ url: 'https://example.org',
+ title: null,
+ });
+ expect(href).toBe('https://doi.org/10.1016/foo');
+ });
+
+ it('falls through to URL when no DOI', () => {
+ const href = referenceHref({
+ raw: '',
+ doi: null,
+ url: 'https://example.org/paper.pdf',
+ title: null,
+ });
+ expect(href).toBe('https://example.org/paper.pdf');
+ });
+
+ it('falls back to a Scholar search keyed off the title', () => {
+ const href = referenceHref({
+ raw: '',
+ doi: null,
+ url: null,
+ title: 'A title',
+ });
+ expect(href).toBe('https://scholar.google.com/scholar?q=A%20title');
+ });
+
+ it('returns null when nothing actionable is present', () => {
+ expect(
+ referenceHref({ raw: '', doi: null, url: null, title: null })
+ ).toBeNull();
+ });
+});
diff --git a/site/src/lib/citations.ts b/site/src/lib/citations.ts
new file mode 100644
index 0000000..da5e904
--- /dev/null
+++ b/site/src/lib/citations.ts
@@ -0,0 +1,81 @@
+import { doiUrl, linkifyCitations } from './doi';
+
+export interface ParsedReference {
+ // The original list-item text, with the leading marker stripped.
+ raw: string;
+ // First DOI found anywhere in the entry, if any.
+ doi: string | null;
+ // First non-DOI URL found, if any (some entries link to a report PDF).
+ url: string | null;
+ // Best-effort title pulled from the citation. Only used as a hint for a
+ // Google Scholar fallback when there's no DOI.
+ title: string | null;
+}
+
+// Split a free-form references blob into individual entries. Authors store
+// references as either numbered ("1.", "2.") or bulleted ("- ", "* ") lists,
+// or as paragraphs separated by blank lines. We try all three.
+export function splitReferences(text: string): string[] {
+ const trimmed = text.trim();
+ if (!trimmed) return [];
+
+ // Match list markers at the start of a line: "1.", "12.", "- ", "* ".
+ const listMarker = /^\s*(?:\d{1,3}\.\s+|[-*]\s+)/m;
+
+ if (listMarker.test(trimmed)) {
+ // Split on a newline that is immediately followed by a list marker.
+ const parts = trimmed
+ .split(/\n(?=\s*(?:\d{1,3}\.\s+|[-*]\s+))/)
+ .map((p) => p.replace(/^\s*(?:\d{1,3}\.\s+|[-*]\s+)/, '').trim())
+ .filter(Boolean);
+ if (parts.length > 0) return parts;
+ }
+
+ // Fallback: split on blank lines.
+ return trimmed.split(/\n\s*\n+/).map((p) => p.trim()).filter(Boolean);
+}
+
+const DOI_RE = /\b10\.\d{4,9}\/[-._;()/:A-Z0-9]+?(?=[.,;:!?]*(?:\s|$|[<>"')]))/i;
+const URL_RE = /\bhttps?:\/\/[^\s<>"')]+?(?=[.,;:!?]*(?:\s|$|[<>"')]))/i;
+
+// Pull the most paper-title-looking phrase out of a single citation line.
+// Heuristic: the longest run between a year-paren and either a journal-italic
+// marker ("*Journal*") or another period. Falls back to the first quoted run.
+function extractTitle(s: string): string | null {
+ const quoted = s.match(/"([^"]{8,200})"/);
+ if (quoted) return quoted[1].trim();
+ const afterYear = s.match(/\(\d{4}\)\.\s*([^*_."]{8,200})\./);
+ if (afterYear) return afterYear[1].trim();
+ return null;
+}
+
+export function parseReferences(text: string | null | undefined): ParsedReference[] {
+ if (!text) return [];
+ const items = splitReferences(text);
+ return items.map((raw) => {
+ const doiMatch = raw.match(DOI_RE);
+ const urlMatch = raw.match(URL_RE);
+ return {
+ raw,
+ doi: doiMatch ? doiMatch[0] : null,
+ url: urlMatch ? urlMatch[0] : null,
+ title: extractTitle(raw),
+ };
+ });
+}
+
+// Resolve a reference to the best available external link:
+// 1. DOI URL (preferred)
+// 2. Direct URL embedded in the citation
+// 3. Google Scholar search keyed off the extracted title
+export function referenceHref(ref: ParsedReference): string | null {
+ if (ref.doi) return doiUrl(ref.doi);
+ if (ref.url) return ref.url;
+ if (ref.title) {
+ return `https://scholar.google.com/scholar?q=${encodeURIComponent(ref.title)}`;
+ }
+ return null;
+}
+
+// Re-export so callers don't need to pull from two files.
+export { linkifyCitations };
diff --git a/site/src/lib/humanize.test.ts b/site/src/lib/humanize.test.ts
new file mode 100644
index 0000000..b449442
--- /dev/null
+++ b/site/src/lib/humanize.test.ts
@@ -0,0 +1,52 @@
+import { describe, it, expect } from 'vitest';
+import { humanize, categoryKey, paramNameToSlug } from './humanize';
+
+describe('humanize', () => {
+ it('replaces underscores and dashes with spaces and title-cases each word', () => {
+ expect(humanize('humidity-parameters')).toBe('Humidity Parameters');
+ expect(humanize('humidity_parameters')).toBe('Humidity Parameters');
+ expect(humanize('ENVIRONMENTAL')).toBe('Environmental');
+ });
+
+ it('preserves common acronyms', () => {
+ expect(humanize('mfc-anode')).toBe('MFC Anode');
+ expect(humanize('ph_sensor')).toBe('pH Sensor');
+ expect(humanize('co2-flux')).toBe('CO₂ Flux');
+ });
+
+ it('handles empty inputs', () => {
+ expect(humanize('')).toBe('');
+ expect(humanize(null)).toBe('');
+ expect(humanize(undefined)).toBe('');
+ });
+
+ it('collapses repeated separators', () => {
+ expect(humanize('foo--bar__baz')).toBe('Foo Bar Baz');
+ });
+});
+
+describe('categoryKey', () => {
+ it('lowercases and converts underscores to dashes', () => {
+ expect(categoryKey('ENVIRONMENTAL')).toBe('environmental');
+ expect(categoryKey('material_physics')).toBe('material-physics');
+ expect(categoryKey('reactor-design')).toBe('reactor-design');
+ });
+
+ it('handles empty inputs', () => {
+ expect(categoryKey('')).toBe('');
+ expect(categoryKey(null)).toBe('');
+ });
+});
+
+describe('paramNameToSlug', () => {
+ it('lowercases and underscore-separates words', () => {
+ expect(paramNameToSlug('Absolute Humidity')).toBe('absolute_humidity');
+ expect(paramNameToSlug('CO2 Reduction')).toBe('co2_reduction');
+ });
+
+ it('strips parens, slashes, and colons', () => {
+ expect(paramNameToSlug('Open-Circuit Voltage (OCV)')).toBe('open-circuit_voltage_ocv');
+ expect(paramNameToSlug('Anode/Cathode Ratio')).toBe('anodecathode_ratio');
+ expect(paramNameToSlug('Note: details')).toBe('note_details');
+ });
+});
diff --git a/site/src/lib/humanize.ts b/site/src/lib/humanize.ts
new file mode 100644
index 0000000..314d784
--- /dev/null
+++ b/site/src/lib/humanize.ts
@@ -0,0 +1,75 @@
+// Acronyms and special tokens that should retain a specific casing when a
+// raw id like "co2-reduction" or "ph_sensor" gets humanized into Title Case.
+const SPECIAL_TOKENS: Record = {
+ mfc: 'MFC',
+ mec: 'MEC',
+ mes: 'MES',
+ mdc: 'MDC',
+ pem: 'PEM',
+ doi: 'DOI',
+ api: 'API',
+ ph: 'pH',
+ co2: 'CO₂',
+ o2: 'O₂',
+ n2: 'N₂',
+ h2: 'H₂',
+ h2o: 'H₂O',
+ nox: 'NOx',
+ daq: 'DAQ',
+ iot: 'IoT',
+ ai: 'AI',
+ ml: 'ML',
+ uv: 'UV',
+ ir: 'IR',
+ rf: 'RF',
+ dna: 'DNA',
+ rna: 'RNA',
+ cod: 'COD',
+ bod: 'BOD',
+ toc: 'TOC',
+ ec: 'EC',
+ ocv: 'OCV',
+ iec: 'IEC',
+ cem: 'CEM',
+ aem: 'AEM',
+};
+
+function titleCaseWord(word: string): string {
+ if (!word) return word;
+ const lower = word.toLowerCase();
+ if (SPECIAL_TOKENS[lower]) return SPECIAL_TOKENS[lower];
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
+}
+
+// Turn ids like "humidity-parameters", "humidity_parameters", or
+// "ENVIRONMENTAL" into a clean Title Case label for display.
+export function humanize(s: string | null | undefined): string {
+ if (!s) return '';
+ return s
+ .replace(/[_-]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .split(' ')
+ .map(titleCaseWord)
+ .join(' ');
+}
+
+// Normalise a category id so it matches the keys in categoryLabels /
+// categoryColors. The raw values in our JSON come in three flavours:
+// "ENVIRONMENTAL" — rich definitions
+// "environmental" — index.json
+// "monitoring-control" — already kebab
+export function categoryKey(raw: string | null | undefined): string {
+ if (!raw) return '';
+ return raw.toLowerCase().replace(/_/g, '-');
+}
+
+// Convert a parameter display name into a URL slug used by the hash router.
+// The same transform is used on read in getRichParameter() so links round-trip.
+export function paramNameToSlug(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/\s+/g, '_')
+ .replace(/[()/]/g, '')
+ .replace(/:/g, '');
+}