437 lines
23 KiB
TypeScript
437 lines
23 KiB
TypeScript
|
|
/**
|
|||
|
|
* Universal frontmatter renderer.
|
|||
|
|
*
|
|||
|
|
* Introspects ANY entity/doc/page/etc. frontmatter and renders each field with
|
|||
|
|
* its semantic UI primitive. The schema (CLAUDE-schema-full.md) IS the contract.
|
|||
|
|
*
|
|||
|
|
* Grouping strategy (in order):
|
|||
|
|
* IDENTITY canonical_name, *_id, aliases, disambiguation_note
|
|||
|
|
* CLASSIFICATION highest_classification, classification_markings, language_detected, content_classification
|
|||
|
|
* METRICS total_mentions, documents_count, page_count, totals, qualities
|
|||
|
|
* RELATIONS primary_*, key_entities, observed_in_event, observers, related, ...
|
|||
|
|
* EVIDENCE redactions, signatures_observed, tables_detected, images_detected
|
|||
|
|
* TEMPORAL date_start/end, dates, last_*
|
|||
|
|
* SPATIAL coordinates, primary_location
|
|||
|
|
* ENRICHMENT enrichment_status, external_sources, verified_facts
|
|||
|
|
* FLAGS flags
|
|||
|
|
*/
|
|||
|
|
import {
|
|||
|
|
Users, Building2, MapPin, CalendarRange, Disc3, Plane, Crosshair, BookOpen,
|
|||
|
|
Files, Image as ImageIcon, Table2, Eye, AlertTriangle, FileSearch,
|
|||
|
|
FileWarning, Stamp, PenSquare, Bookmark, Hash, Layers, Zap,
|
|||
|
|
} from "lucide-react";
|
|||
|
|
import { FmWikiLink, FmWikiLinkList } from "./fm/wikilink";
|
|||
|
|
import {
|
|||
|
|
FmConfidence, FmClassification, FmEnrichmentBadge, FmContentChip,
|
|||
|
|
FmPageTypeChip, FmLanguageChip, FmChip, FmStat, FmCoordinates,
|
|||
|
|
FmQualityDot, FmFlag, FmTimestamp,
|
|||
|
|
} from "./fm/badges";
|
|||
|
|
import { FmBboxThumb } from "./fm/bbox-thumb";
|
|||
|
|
import { FmExternalSources } from "./fm/external-sources";
|
|||
|
|
import type { AnyFrontmatter, EntityRef, EntitiesExtracted } from "@/lib/fm-types";
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
fm: AnyFrontmatter;
|
|||
|
|
/** Page-derived context — needed for bbox thumbnails to know which PNG to slice. */
|
|||
|
|
pageCtx?: { docId: string; pageNum: number };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isNonEmptyArray(x: unknown): x is unknown[] {
|
|||
|
|
return Array.isArray(x) && x.length > 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Maps a class key under entities_extracted to its proper /e/<folder>/<id> namespace. */
|
|||
|
|
const ENTITY_NS: Record<keyof EntitiesExtracted, { label: string; ns: string; icon: React.ReactNode }> = {
|
|||
|
|
people: { label: "People", ns: "people", icon: <Users size={11} /> },
|
|||
|
|
organizations: { label: "Organizations", ns: "org", icon: <Building2 size={11} /> },
|
|||
|
|
locations: { label: "Locations", ns: "loc", icon: <MapPin size={11} /> },
|
|||
|
|
events: { label: "Events", ns: "event", icon: <CalendarRange size={11} /> },
|
|||
|
|
uap_objects: { label: "UAP Objects", ns: "uap", icon: <Disc3 size={11} /> },
|
|||
|
|
vehicles: { label: "Vehicles", ns: "vehicle", icon: <Plane size={11} /> },
|
|||
|
|
operations: { label: "Operations", ns: "op", icon: <Crosshair size={11} /> },
|
|||
|
|
concepts: { label: "Concepts", ns: "concept", icon: <BookOpen size={11} /> },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/** Render an entity ref (extracted from a page) as a wiki-link.
|
|||
|
|
* Uses the entity's `name` slugified — best-effort. */
|
|||
|
|
function entityRefToLink(ref: EntityRef, ns: string): string {
|
|||
|
|
const name = ref.name ?? "";
|
|||
|
|
if (!name) return "";
|
|||
|
|
const id = name
|
|||
|
|
.normalize("NFD")
|
|||
|
|
.replace(/[̀-ͯ]/g, "")
|
|||
|
|
.toLowerCase()
|
|||
|
|
.replace(/[^a-z0-9-]+/g, "-")
|
|||
|
|
.replace(/-+/g, "-")
|
|||
|
|
.replace(/^-|-$/g, "");
|
|||
|
|
return `${ns}/${id}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function Section({
|
|||
|
|
title, icon, count, children,
|
|||
|
|
}: { title: string; icon?: React.ReactNode; count?: number; children: React.ReactNode }) {
|
|||
|
|
return (
|
|||
|
|
<section>
|
|||
|
|
<h3 className="font-mono text-[10px] text-[#8896aa] uppercase tracking-widest mb-2 flex items-center gap-1.5">
|
|||
|
|
{icon}
|
|||
|
|
<span>{title}</span>
|
|||
|
|
{count !== undefined && <span className="text-[#5a6678]">({count})</span>}
|
|||
|
|
</h3>
|
|||
|
|
{children}
|
|||
|
|
</section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function FrontmatterPanel({ fm, pageCtx }: Props) {
|
|||
|
|
const sections: React.ReactNode[] = [];
|
|||
|
|
|
|||
|
|
/* ── CLASSIFICATION BANNER (top, prominent) ───────────────── */
|
|||
|
|
const markings = fm.classification_markings ?? [];
|
|||
|
|
const hasMarkings = markings.length > 0 || fm.highest_classification;
|
|||
|
|
if (hasMarkings) {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="cls" title="Classification" icon={<Bookmark size={12} className="text-[#ff3344]" />}>
|
|||
|
|
<div className="flex flex-wrap gap-1.5">
|
|||
|
|
{fm.highest_classification && (
|
|||
|
|
<FmClassification level={fm.highest_classification} caveats={undefined} />
|
|||
|
|
)}
|
|||
|
|
{markings.map((m, i) => (
|
|||
|
|
<FmClassification key={i} level={m.level} caveats={m.caveats} />
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── ALIASES ──────────────────────────────────────────────── */
|
|||
|
|
if (isNonEmptyArray(fm.aliases)) {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="aliases" title="Aliases" icon={<Hash size={12} />}>
|
|||
|
|
<div className="flex flex-wrap gap-1">
|
|||
|
|
{(fm.aliases as string[]).slice(0, 16).map((a, i) => (
|
|||
|
|
<span key={i} className="font-mono text-[10px] text-[#7fdbff] bg-[rgba(127,219,255,0.06)] border border-[rgba(127,219,255,0.32)] rounded px-1.5 py-0.5">
|
|||
|
|
{a}
|
|||
|
|
</span>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── DISAMBIGUATION ───────────────────────────────────────── */
|
|||
|
|
if (fm.disambiguation_note) {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="disamb" title="Disambiguation" icon={<AlertTriangle size={12} className="text-[#f5c542]" />}>
|
|||
|
|
<p className="text-sm text-[#f5c542] border-l-2 border-[#f5c542] pl-3">{fm.disambiguation_note}</p>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── CONTENT & PAGE TYPE ──────────────────────────────────── */
|
|||
|
|
const cc = (fm.content_classification ?? []) as string[];
|
|||
|
|
const wantsContent = cc.length > 0 || fm.page_type || fm.language_detected || isNonEmptyArray(fm.languages_detected);
|
|||
|
|
if (wantsContent) {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="content" title="Content" icon={<FileSearch size={12} />}>
|
|||
|
|
<div className="flex flex-wrap gap-1.5 items-center">
|
|||
|
|
{fm.page_type && <FmPageTypeChip type={fm.page_type} />}
|
|||
|
|
{cc.map((k, i) => <FmContentChip key={i} kind={k as Parameters<typeof FmContentChip>[0]["kind"]} />)}
|
|||
|
|
{fm.language_detected && <FmLanguageChip code={fm.language_detected} />}
|
|||
|
|
{(fm.languages_detected ?? []).map((l, i) => <FmLanguageChip key={i} code={l} />)}
|
|||
|
|
</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── METRICS ──────────────────────────────────────────────── */
|
|||
|
|
const metrics: React.ReactNode[] = [];
|
|||
|
|
if (typeof fm.total_mentions === "number") metrics.push(<FmStat key="m1" icon={<Hash size={9} />} label="mentions" value={fm.total_mentions} color="cyan" />);
|
|||
|
|
if (typeof fm.documents_count === "number") metrics.push(<FmStat key="m2" icon={<Files size={9} />} label="documents" value={fm.documents_count} color="cyan" />);
|
|||
|
|
if (typeof fm.page_count === "number") metrics.push(<FmStat key="m3" icon={<Files size={9} />} label="pages" value={fm.page_count} color="amber" />);
|
|||
|
|
if (typeof fm.total_redactions === "number" && fm.total_redactions > 0) metrics.push(<FmStat key="m4" icon={<FileWarning size={9} />} label="redactions" value={fm.total_redactions} color="red" />);
|
|||
|
|
if (typeof fm.total_signatures === "number" && fm.total_signatures > 0) metrics.push(<FmStat key="m5" icon={<PenSquare size={9} />} label="signatures" value={fm.total_signatures} color="violet" />);
|
|||
|
|
if (typeof fm.total_tables === "number" && fm.total_tables > 0) metrics.push(<FmStat key="m6" icon={<Table2 size={9} />} label="tables" value={fm.total_tables} color="cyan" />);
|
|||
|
|
if (typeof fm.total_images === "number" && fm.total_images > 0) metrics.push(<FmStat key="m7" icon={<ImageIcon size={9} />} label="images" value={fm.total_images} color="cyan" />);
|
|||
|
|
if (typeof fm.ocr_quality_score === "number") metrics.push(<FmStat key="m8" icon={<Zap size={9} />} label="ocr" value={`${Math.round(fm.ocr_quality_score * 100)}%`} color="soft" />);
|
|||
|
|
if (typeof fm.vision_quality_score === "number") metrics.push(<FmStat key="m9" icon={<Eye size={9} />} label="vision" value={`${Math.round(fm.vision_quality_score * 100)}%`} color="soft" />);
|
|||
|
|
if (metrics.length > 0) {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="metrics" title="Stats" icon={<Layers size={12} />}>
|
|||
|
|
<div className="flex flex-wrap gap-2">{metrics}</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── KEY ENTITIES (document-level rollup) ─────────────────── */
|
|||
|
|
if (fm.key_entities) {
|
|||
|
|
const ke = fm.key_entities as EntitiesExtracted;
|
|||
|
|
for (const k of Object.keys(ENTITY_NS) as Array<keyof EntitiesExtracted>) {
|
|||
|
|
const refs = ke[k] ?? [];
|
|||
|
|
if (!isNonEmptyArray(refs)) continue;
|
|||
|
|
const meta = ENTITY_NS[k];
|
|||
|
|
const links = (refs as EntityRef[]).map((r) => entityRefToLink(r, meta.ns)).filter(Boolean);
|
|||
|
|
sections.push(
|
|||
|
|
<Section key={`ke-${k}`} title={`Key ${meta.label}`} icon={meta.icon} count={refs.length}>
|
|||
|
|
<FmWikiLinkList items={links} size="sm" />
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── ENTITIES EXTRACTED (page-level) ──────────────────────── */
|
|||
|
|
if (fm.entities_extracted) {
|
|||
|
|
const ee = fm.entities_extracted as EntitiesExtracted;
|
|||
|
|
for (const k of Object.keys(ENTITY_NS) as Array<keyof EntitiesExtracted>) {
|
|||
|
|
const refs = ee[k] ?? [];
|
|||
|
|
if (!isNonEmptyArray(refs)) continue;
|
|||
|
|
const meta = ENTITY_NS[k];
|
|||
|
|
const links = (refs as EntityRef[]).map((r) => entityRefToLink(r, meta.ns)).filter(Boolean);
|
|||
|
|
sections.push(
|
|||
|
|
<Section key={`ee-${k}`} title={meta.label} icon={meta.icon} count={refs.length}>
|
|||
|
|
<FmWikiLinkList items={links} size="xs" />
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── EVIDENCE: redactions ─────────────────────────────────── */
|
|||
|
|
if (isNonEmptyArray(fm.redactions)) {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="redactions" title="Redactions" icon={<FileWarning size={12} className="text-[#ff3344]" />} count={fm.redactions!.length}>
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
{fm.redactions!.map((r, i) => (
|
|||
|
|
<div key={i} className="flex items-center gap-2 border border-[rgba(255,51,68,0.32)] bg-[rgba(255,51,68,0.04)] rounded p-1.5">
|
|||
|
|
<FmBboxThumb bbox={r.bbox} docId={pageCtx?.docId} pageNum={pageCtx?.pageNum} width={64} height={48} label={r.description} />
|
|||
|
|
<div className="text-[10px] font-mono">
|
|||
|
|
<div className="text-[#ff3344] font-semibold">{r.code ?? "REDACTED"}</div>
|
|||
|
|
{r.description && <div className="text-[#8896aa] max-w-[160px] truncate">{r.description}</div>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── EVIDENCE: signatures ─────────────────────────────────── */
|
|||
|
|
if (isNonEmptyArray(fm.signatures_observed)) {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="sigs" title="Signatures" icon={<PenSquare size={12} className="text-[#bb6bd9]" />} count={fm.signatures_observed!.length}>
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
{fm.signatures_observed!.map((s, i) => (
|
|||
|
|
<div key={i} className="flex items-center gap-2 border border-[rgba(187,107,217,0.32)] bg-[rgba(187,107,217,0.04)] rounded p-1.5">
|
|||
|
|
<FmBboxThumb bbox={s.bbox} docId={pageCtx?.docId} pageNum={pageCtx?.pageNum} width={64} height={48} />
|
|||
|
|
<div className="text-[10px] font-mono">
|
|||
|
|
<div className="text-[#bb6bd9] font-semibold">{s.signer_inferred ?? "unknown signer"}</div>
|
|||
|
|
<FmConfidence band={s.confidence_band} />
|
|||
|
|
{s.notes && <div className="text-[#8896aa] max-w-[160px] truncate">{s.notes}</div>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── EVIDENCE: tables ─────────────────────────────────────── */
|
|||
|
|
if (isNonEmptyArray(fm.tables_detected)) {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="tabs" title="Tables on this page" icon={<Table2 size={12} className="text-[#1e9eb5]" />} count={fm.tables_detected!.length}>
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
{fm.tables_detected!.map((t, i) => (
|
|||
|
|
<div key={i} className="flex items-center gap-2 border border-[rgba(30,158,181,0.32)] bg-[rgba(30,158,181,0.04)] rounded p-1.5">
|
|||
|
|
<FmBboxThumb bbox={t.bbox} docId={pageCtx?.docId} pageNum={pageCtx?.pageNum} width={64} height={48} />
|
|||
|
|
<div className="text-[10px] font-mono">
|
|||
|
|
{t.table_id ? <FmWikiLink target={`table/${t.table_id}`} size="xs" /> : <span className="text-[#1e9eb5]">inline table</span>}
|
|||
|
|
<div className="text-[#8896aa] mt-1">
|
|||
|
|
{t.col_count_estimate ?? "?"}×{t.row_count_estimate ?? "?"}
|
|||
|
|
{t.spans_multi_page && <span className="text-[#f5c542] ml-1">multi-page</span>}
|
|||
|
|
</div>
|
|||
|
|
{t.headers_summary && <div className="text-[#5a6678] max-w-[160px] truncate">{t.headers_summary}</div>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── EVIDENCE: images detected ────────────────────────────── */
|
|||
|
|
if (isNonEmptyArray(fm.images_detected)) {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="imgs" title="Images on this page" icon={<ImageIcon size={12} className="text-[#ffeb99]" />} count={fm.images_detected!.length}>
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
{fm.images_detected!.map((im, i) => (
|
|||
|
|
<div key={i} className="flex items-center gap-2 border border-[rgba(255,235,153,0.32)] bg-[rgba(255,235,153,0.04)] rounded p-1.5">
|
|||
|
|
<FmBboxThumb bbox={im.bbox} docId={pageCtx?.docId} pageNum={pageCtx?.pageNum} width={64} height={64} />
|
|||
|
|
<div className="text-[10px] font-mono">
|
|||
|
|
<div className="text-[#ffeb99] font-semibold">{im.image_type ?? "image"}</div>
|
|||
|
|
{im.caption_ocr && <div className="text-[#8896aa] max-w-[160px]">{im.caption_ocr}</div>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── UAP OBSERVATION ──────────────────────────────────────── */
|
|||
|
|
if (fm.uap_observation_fields) {
|
|||
|
|
const u = fm.uap_observation_fields;
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="uap" title="UAP Observation" icon={<Disc3 size={12} className="text-[#ff3344]" />}>
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
{u.shape && <FmChip color="red" icon={<Disc3 size={9} />} label={`shape: ${u.shape}`} />}
|
|||
|
|
{u.color && <FmChip color="red" label={`color: ${u.color}`} />}
|
|||
|
|
{u.size_estimate && <FmChip color="red" label={`size: ${u.size_estimate}`} />}
|
|||
|
|
{u.altitude_ft !== null && u.altitude_ft !== undefined && <FmChip color="cyan" label={`alt: ${u.altitude_ft} ft`} />}
|
|||
|
|
{u.speed_kts !== null && u.speed_kts !== undefined && <FmChip color="cyan" label={`speed: ${u.speed_kts} kts`} />}
|
|||
|
|
{u.bearing_deg !== null && u.bearing_deg !== undefined && <FmChip color="cyan" label={`bearing: ${u.bearing_deg}°`} />}
|
|||
|
|
{u.distance_nm !== null && u.distance_nm !== undefined && <FmChip color="cyan" label={`dist: ${u.distance_nm} nm`} />}
|
|||
|
|
{u.duration_seconds && <FmChip color="cyan" label={`duration: ${u.duration_seconds}s`} />}
|
|||
|
|
{u.coordinates && <FmCoordinates lat={u.coordinates.lat ?? undefined} lon={u.coordinates.lon ?? undefined} raw={u.coordinates.raw_text} />}
|
|||
|
|
</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── UAP-OBJECT specific fields (entity-level) ────────────── */
|
|||
|
|
if (fm.entity_class === "uap_object") {
|
|||
|
|
const u = fm;
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="uapent" title="Object profile" icon={<Disc3 size={12} className="text-[#ff3344]" />}>
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
{u.shape && <FmChip color="red" label={`shape: ${u.shape}`} />}
|
|||
|
|
{u.color && <FmChip color="red" label={`color: ${u.color}`} />}
|
|||
|
|
{u.size_estimate_m && <FmChip color="red" label={`size: ${u.size_estimate_m.min ?? "?"}–${u.size_estimate_m.max ?? "?"} m`} />}
|
|||
|
|
{u.altitude_ft && <FmChip color="cyan" label={`alt: ${u.altitude_ft.min ?? "?"}–${u.altitude_ft.max ?? "?"} ft`} />}
|
|||
|
|
{u.speed_kts && <FmChip color="cyan" label={`speed: ${u.speed_kts.min ?? "?"}–${u.speed_kts.max ?? "?"} kts`} />}
|
|||
|
|
{isNonEmptyArray(u.maneuver_descriptors) && u.maneuver_descriptors!.map((m, i) => <FmChip key={i} color="violet" label={m} />)}
|
|||
|
|
{isNonEmptyArray(u.features) && u.features!.map((f, i) => <FmChip key={`f${i}`} color="amber" label={f} />)}
|
|||
|
|
{u.confidence_band_overall && <FmConfidence band={u.confidence_band_overall} />}
|
|||
|
|
</div>
|
|||
|
|
{u.observed_in_event && (
|
|||
|
|
<div className="mt-2 text-[10px] text-[#5a6678] font-mono">observed in: <FmWikiLink target={u.observed_in_event} size="xs" /></div>
|
|||
|
|
)}
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── LOCATION specific ────────────────────────────────────── */
|
|||
|
|
if (fm.entity_class === "location") {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="loc" title="Location" icon={<MapPin size={12} className="text-[#3fde6a]" />}>
|
|||
|
|
<div className="flex flex-wrap gap-2 items-center">
|
|||
|
|
{fm.location_type && <FmChip color="green" label={fm.location_type} />}
|
|||
|
|
{fm.country && <FmChip color="soft" label={Array.isArray(fm.country) ? fm.country.join(", ") : fm.country} />}
|
|||
|
|
{fm.region && <FmChip color="soft" label={fm.region} />}
|
|||
|
|
{fm.coordinates && (
|
|||
|
|
<FmCoordinates lat={fm.coordinates.lat ?? undefined} lon={fm.coordinates.lon ?? undefined} raw={fm.coordinates.raw_text} />
|
|||
|
|
)}
|
|||
|
|
{fm.parent_location && <FmWikiLink target={fm.parent_location} size="xs" />}
|
|||
|
|
</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── EVENT specific ───────────────────────────────────────── */
|
|||
|
|
if (fm.entity_class === "event") {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="evt" title="Event" icon={<CalendarRange size={12} className="text-[#ffa500]" />}>
|
|||
|
|
<div className="flex flex-wrap gap-2 items-center">
|
|||
|
|
{fm.event_class && <FmChip color="amber" label={fm.event_class} />}
|
|||
|
|
{fm.date_start && <FmChip color="amber" label={`from: ${fm.date_start}`} />}
|
|||
|
|
{fm.date_end && fm.date_end !== fm.date_start && <FmChip color="amber" label={`to: ${fm.date_end}`} />}
|
|||
|
|
{fm.date_confidence && <FmConfidence band={fm.date_confidence} />}
|
|||
|
|
{fm.primary_location && <FmWikiLink target={fm.primary_location} size="xs" />}
|
|||
|
|
{isNonEmptyArray(fm.observers) && fm.observers!.map((o, i) => <FmWikiLink key={`o${i}`} target={o} size="xs" />)}
|
|||
|
|
{isNonEmptyArray(fm.uap_objects) && fm.uap_objects!.map((u, i) => <FmWikiLink key={`u${i}`} target={u} size="xs" />)}
|
|||
|
|
</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── PERSON specific ──────────────────────────────────────── */
|
|||
|
|
if (fm.entity_class === "person") {
|
|||
|
|
const dates = fm.dates ?? {};
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="psn" title="Person" icon={<Users size={12} className="text-[#ff6ec7]" />}>
|
|||
|
|
<div className="flex flex-wrap gap-2 items-center">
|
|||
|
|
{fm.primary_role && <FmChip color="violet" label={fm.primary_role} />}
|
|||
|
|
{fm.primary_organization && <FmChip color="amber" label={fm.primary_organization} />}
|
|||
|
|
{isNonEmptyArray(fm.roles) && fm.roles!.map((r, i) => <FmChip key={i} color="violet" label={r} />)}
|
|||
|
|
{(dates.born || dates.died) && (
|
|||
|
|
<FmChip color="soft" label={`${dates.born ?? "?"} — ${dates.died ?? "?"}`} />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── ORGANIZATION specific ────────────────────────────────── */
|
|||
|
|
if (fm.entity_class === "organization") {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="org" title="Organization" icon={<Building2 size={12} className="text-[#ff8a4d]" />}>
|
|||
|
|
<div className="flex flex-wrap gap-2 items-center">
|
|||
|
|
{fm.organization_type && <FmChip color="amber" label={fm.organization_type} />}
|
|||
|
|
{fm.country && <FmChip color="soft" label={Array.isArray(fm.country) ? fm.country.join(", ") : fm.country} />}
|
|||
|
|
{fm.founded && <FmChip color="soft" label={`founded: ${fm.founded}`} />}
|
|||
|
|
</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── CONCEPT/VEHICLE/OPERATION quick chips ────────────────── */
|
|||
|
|
if (fm.entity_class === "concept") {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="cncpt" title="Concept" icon={<BookOpen size={12} className="text-[#06d6a0]" />}>
|
|||
|
|
<div className="flex flex-wrap gap-2 items-center">
|
|||
|
|
{fm.concept_class && <FmChip color="green" label={fm.concept_class} />}
|
|||
|
|
{fm.domain && <FmChip color="soft" label={fm.domain} />}
|
|||
|
|
</div>
|
|||
|
|
{fm.definition_short && <p className="text-sm text-[#c8d4e6] mt-2">{fm.definition_short}</p>}
|
|||
|
|
{fm.definition_short_pt_br && <p className="text-xs text-[#8896aa] mt-1 italic">{fm.definition_short_pt_br}</p>}
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── ENRICHMENT ───────────────────────────────────────────── */
|
|||
|
|
if (fm.enrichment_status || isNonEmptyArray(fm.external_sources)) {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="enr" title="Enrichment" icon={<Stamp size={12} />}>
|
|||
|
|
<div className="flex items-center gap-2 mb-1">
|
|||
|
|
<FmEnrichmentBadge status={fm.enrichment_status} />
|
|||
|
|
{fm.last_enriched && <FmTimestamp value={fm.last_enriched} label="last" />}
|
|||
|
|
</div>
|
|||
|
|
<FmExternalSources sources={fm.external_sources} />
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── FLAGS ────────────────────────────────────────────────── */
|
|||
|
|
if (isNonEmptyArray(fm.flags)) {
|
|||
|
|
sections.push(
|
|||
|
|
<Section key="flags" title="Flags" icon={<AlertTriangle size={12} className="text-[#f5c542]" />}>
|
|||
|
|
<div className="flex flex-wrap gap-1">
|
|||
|
|
{fm.flags!.map((f, i) => <FmFlag key={i} flag={f} />)}
|
|||
|
|
</div>
|
|||
|
|
</Section>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── TIMESTAMPS (compact footer) ──────────────────────────── */
|
|||
|
|
const ts: React.ReactNode[] = [];
|
|||
|
|
if (fm.last_ingest) ts.push(<FmTimestamp key="li" value={fm.last_ingest} label="ingest" />);
|
|||
|
|
if (fm.last_lint) ts.push(<FmTimestamp key="ll" value={fm.last_lint} label="lint" />);
|
|||
|
|
if (fm.last_enriched && !fm.external_sources) ts.push(<FmTimestamp key="le" value={fm.last_enriched} label="enriched" />);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-5">
|
|||
|
|
{sections}
|
|||
|
|
{ts.length > 0 && (
|
|||
|
|
<div className="flex flex-wrap gap-3 pt-2 border-t border-[rgba(0,255,156,0.08)]">{ts}</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|