SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
9.1 KB · 178 lines tsx
Raw Blame History
1import Link from 'next/link';2import { SourcesTable } from '@/components/entity/blocks';3import { ViewBeacon } from '@/components/layout/view-beacon';4import { BreadcrumbLd, Breadcrumbs } from '@/components/meta/breadcrumb-ld';5import { EntityBadge } from '@/components/ui/badges';6import { EntityLink, QualityMark } from '@/components/ui/entity';7import { Container, Note, Section } from '@/components/ui/section';8import { api, safe } from '@/lib/api';9import { fmtAgo, fmtDate, fmtInt } from '@/lib/format';10import { routes, SITE_NAME, SITE_URL } from '@/lib/site';11import type { EntityDetail, EntitySummary } from '@/lib/types';1213/*14  Researcher page (server, async): affiliations (stated relations / organization), papers (`authored` out), coauthors15  (researchers named on the same papers — from the papers' `authored` relations). Public professional data only; the16  page says plainly when the record is a name-only row created from an author list.17*/1819function relItems(d: EntityDetail, types: string[], predicates?: string[]): EntitySummary[] {20  const out: EntitySummary[] = [];21  const seen = new Set<string>();22  for (const g of d.relations ?? []) {23    if (predicates && !predicates.includes(g.predicate)) continue;24    for (const it of g.items) if (types.includes(it.entity_type) && !seen.has(it.id)) {25      seen.add(it.id);26      out.push(it);27    }28  }29  return out;30}3132export async function ResearcherPage({ d, canonical }: { d: EntityDetail; canonical: string }) {33  const papers = relItems(d, ['paper'], ['authored']);34  const affiliations = relItems(d, ['company', 'organization', 'lab', 'university']);35  const details = await Promise.all(papers.slice(0, 8).map((p) => safe(api.entity(p.slug))));36  const co = new Map<string, EntitySummary & { shared: number }>();37  for (const pd of details) {38    if (!pd) continue;39    for (const r of relItems(pd, ['researcher'], ['authored'])) {40      if (r.id === d.id) continue;41      const cur = co.get(r.id);42      if (cur) cur.shared += 1;43      else co.set(r.id, { ...r, shared: 1 });44    }45  }46  const coauthors = [...co.values()].sort((a, b) => b.shared - a.shared || a.name.localeCompare(b.name));47  const nameOnly = !d.identifiers?.length && !d.organization && affiliations.length === 0;48  const crumbs = [{ name: SITE_NAME, href: '/' }, { name: 'Researchers', href: '/explore/researcher' }, { name: d.name, href: canonical }];49  const ld = { '@context': 'https://schema.org', '@type': 'Person', name: d.name, url: `${SITE_URL}${canonical}`, affiliation: (d.organization ? [d.organization] : affiliations).map((o) => ({ '@type': 'Organization', name: o.name })), identifier: d.identifiers?.map((i) => ({ '@type': 'PropertyValue', propertyID: i.scheme, value: i.value })) };5051  return (52    <Container wide>53      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />54      <BreadcrumbLd items={crumbs} />55      <ViewBeacon path={canonical} />56      <Breadcrumbs items={crumbs} />57      <header className="pb-6 pt-4 md:pb-8 md:pt-5">58        <div className="flex flex-wrap items-center gap-2">59          <EntityBadge type="researcher" />60          {nameOnly && <span className="rounded-[3px] bg-surface-2 px-1.5 text-[10px] uppercase tracking-wide text-ink-3">name-only record</span>}61        </div>62        <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">63          <div className="min-w-0">64            <h1 className="display text-[30px] md:text-[44px]">{d.name}</h1>65            <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[15px] text-ink-2">66              {d.organization && (67                <Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="font-medium text-ink hover:text-accent">68                  {d.organization.name}69                </Link>70              )}71              <span className="tnum text-ink-3">72                {fmtInt(papers.length)} paper{papers.length === 1 ? '' : 's'} in the atlas73              </span>74            </p>75            {nameOnly && <Note className="mt-3 max-w-2xl">This record was created from a paper's author list. It carries no identifier (ORCID, OpenAlex…) and no affiliation, so homonyms may be merged and spelling variants split. Only public professional data — papers and stated affiliations — is shown.</Note>}76          </div>77          <div className="shrink-0 text-xs text-ink-3 lg:text-right">78            <QualityMark q={d.quality?.score} label />79            <p className="mt-1" title={d.updated_at}>80              Updated {fmtAgo(d.updated_at)} · first seen {fmtDate(d.first_seen_at)}81            </p>82            <p className="mono mt-0.5 text-[11px]">{d.id}</p>83          </div>84        </div>85      </header>8687      <div className="grid gap-10 pb-16 lg:grid-cols-[minmax(0,1fr)_22rem]">88        <div className="min-w-0">89          <Section id="affiliations" eyebrow="Affiliations" title={<>Affiliations <span className="tnum text-base font-normal text-ink-3">{fmtInt(affiliations.length + (d.organization ? 1 : 0))}</span></>} hairline={false} className="pt-0">90            {d.organization || affiliations.length ? (91              <ul className="divide-y divide-rule border-y border-rule text-sm">92                {d.organization && (93                  <li className="flex items-center gap-2 py-2">94                    <EntityBadge type="company" small />95                    <Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="text-ink hover:text-accent hover:underline">96                      {d.organization.name}97                    </Link>98                  </li>99                )}100                {affiliations.map((o) => (101                  <li key={o.id} className="flex items-center gap-2 py-2">102                    <EntityBadge type={o.entity_type} small />103                    <EntityLink e={o} />104                  </li>105                ))}106              </ul>107            ) : (108              <p className="text-sm text-ink-3">No affiliation stated by a source.</p>109            )}110          </Section>111          <Section id="papers" eyebrow="Research" title={<>Papers <span className="tnum text-base font-normal text-ink-3">{fmtInt(papers.length)}</span></>}>112            {papers.length === 0 ? (113              <p className="text-sm text-ink-3">No paper linked.</p>114            ) : (115              <ul className="divide-y divide-rule border-y border-rule">116                {papers.map((p) => (117                  <li key={p.id} className="py-2 text-sm">118                    <EntityLink e={p} />119                    <span className="tnum ml-2 text-xs text-ink-3">{typeof p.attributes?.published_at === 'string' ? fmtDate(p.attributes.published_at as string) : ''}</span>120                  </li>121                ))}122              </ul>123            )}124          </Section>125          <Section id="coauthors" eyebrow="Network" title={<>Co-authors <span className="tnum text-base font-normal text-ink-3">{fmtInt(coauthors.length)}</span></>} lede={papers.length > 8 ? 'From the first 8 papers.' : 'Researchers named on the same papers.'}>126            {coauthors.length === 0 ? (127              <p className="text-sm text-ink-3">No co-author record found on the linked papers.</p>128            ) : (129              <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">130                {coauthors.map((c) => (131                  <li key={c.id} className="flex items-center justify-between gap-2 border-b border-rule py-1.5 text-sm">132                    <EntityLink e={c} className="truncate" />133                    <span className="tnum shrink-0 text-xs text-ink-3">134                      {c.shared} shared135                    </span>136                  </li>137                ))}138              </ul>139            )}140          </Section>141          <Section id="sources" eyebrow="Sources" title={<>Sources <span className="tnum text-base font-normal text-ink-3">{fmtInt(d.sources?.length ?? 0)}</span></>}>142            <SourcesTable sources={d.sources ?? []} />143          </Section>144        </div>145        <aside className="min-w-0 space-y-8">146          <section>147            <p className="eyebrow mb-2">Identifiers</p>148            {d.identifiers?.length ? (149              <ul className="space-y-0.5 text-xs">150                {d.identifiers.map((i) => (151                  <li key={`${i.scheme}:${i.value}`} className="flex gap-2">152                    <span className="mono text-ink-3">{i.scheme}</span>153                    <span className="mono truncate text-ink-2">{i.value}</span>154                  </li>155                ))}156              </ul>157            ) : (158              <p className="text-xs text-ink-3">None recorded.</p>159            )}160          </section>161          {d.aliases?.length > 1 && (162            <section>163              <p className="eyebrow mb-2">Also written as</p>164              <p className="text-sm text-ink-2">{d.aliases.filter((x) => x !== d.name).join(' · ')}</p>165            </section>166          )}167          <section>168            <p className="eyebrow mb-2">Graph</p>169            <Link href={`/graph/${encodeURIComponent(d.slug)}?mode=research`} className="link text-sm">170              Research network →171            </Link>172          </section>173        </aside>174      </div>175    </Container>176  );177}178