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 ( +
  1. + + {i + 1}. + +
    +
    + +
    + {action && ( + + {action.label} + + + )} +
    +
  2. + ); + })} +
+ ); +} 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, ''); +}