136 lines
4.8 KiB
TypeScript
136 lines
4.8 KiB
TypeScript
|
|
/**
|
|||
|
|
* AnomalyHighlights — prominent UAP / cryptid anomaly panel for the document
|
|||
|
|
* page. The clean reading version is the default body, but the investigative
|
|||
|
|
* "destaque" of every flagged passage must stay visible regardless of which
|
|||
|
|
* view (reading or scan) is active. Identical type+rationale flags are grouped
|
|||
|
|
* and each group links to the per-page scan where the anomaly was detected.
|
|||
|
|
*/
|
|||
|
|
import Link from "next/link";
|
|||
|
|
|
|||
|
|
export interface AnomalyFlag {
|
|||
|
|
chunk_id: string;
|
|||
|
|
page: number;
|
|||
|
|
type: string | null;
|
|||
|
|
rationale: string | null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clean(v: string | null): string | null {
|
|||
|
|
const s = typeof v === "string" ? v.trim() : "";
|
|||
|
|
return s && s.toLowerCase() !== "null" ? s : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Group {
|
|||
|
|
type: string | null;
|
|||
|
|
rationale: string | null; // shown only when the group has a single flag
|
|||
|
|
count: number;
|
|||
|
|
pages: number[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Group by anomaly type so the panel stays a scannable "destaque" overview.
|
|||
|
|
// Per-passage rationale is kept only when a type has exactly one flag; the full
|
|||
|
|
// per-chunk rationale remains available in the "trechos · scan original" view.
|
|||
|
|
function groupFlags(flags: AnomalyFlag[]): Group[] {
|
|||
|
|
const m = new Map<string, Group>();
|
|||
|
|
for (const f of flags) {
|
|||
|
|
const type = clean(f.type);
|
|||
|
|
const rationale = clean(f.rationale);
|
|||
|
|
const key = type ?? "anomalia";
|
|||
|
|
const g = m.get(key) ?? { type, rationale, count: 0, pages: [] };
|
|||
|
|
g.count += 1;
|
|||
|
|
g.rationale = g.count === 1 ? rationale : null;
|
|||
|
|
if (!g.pages.includes(f.page)) g.pages.push(f.page);
|
|||
|
|
m.set(key, g);
|
|||
|
|
}
|
|||
|
|
return Array.from(m.values())
|
|||
|
|
.map((g) => ({ ...g, pages: g.pages.sort((a, b) => a - b) }))
|
|||
|
|
.sort((a, b) => b.count - a.count || a.pages[0] - b.pages[0]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function pad(p: number): string {
|
|||
|
|
return String(p).padStart(3, "0");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function PageChips({ docId, pages }: { docId: string; pages: number[] }) {
|
|||
|
|
const shown = pages.slice(0, 14);
|
|||
|
|
const extra = pages.length - shown.length;
|
|||
|
|
return (
|
|||
|
|
<span className="inline-flex flex-wrap gap-1 align-middle">
|
|||
|
|
{shown.map((p) => (
|
|||
|
|
<Link
|
|||
|
|
key={p}
|
|||
|
|
href={`/d/${docId}/p${pad(p)}`}
|
|||
|
|
className="font-mono text-[10px] px-1.5 py-0.5 border border-[rgba(127,219,255,0.30)] text-[#7fdbff] rounded hover:border-[#00ff9c] hover:text-[#00ff9c]"
|
|||
|
|
>
|
|||
|
|
p{p}
|
|||
|
|
</Link>
|
|||
|
|
))}
|
|||
|
|
{extra > 0 && <span className="font-mono text-[10px] text-[#5a6678]">+{extra}</span>}
|
|||
|
|
</span>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function AnomalyHighlights({
|
|||
|
|
docId,
|
|||
|
|
ufo,
|
|||
|
|
cryptid,
|
|||
|
|
}: {
|
|||
|
|
docId: string;
|
|||
|
|
ufo: AnomalyFlag[];
|
|||
|
|
cryptid: AnomalyFlag[];
|
|||
|
|
}) {
|
|||
|
|
if (ufo.length === 0 && cryptid.length === 0) return null;
|
|||
|
|
const ufoGroups = groupFlags(ufo);
|
|||
|
|
const cryptidGroups = groupFlags(cryptid);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<section className="mb-6 border border-[rgba(0,255,156,0.40)] bg-[rgba(0,255,156,0.05)] rounded p-4">
|
|||
|
|
{ufo.length > 0 && (
|
|||
|
|
<>
|
|||
|
|
<h2 className="font-mono text-sm text-[#00ff9c] mb-3 flex items-center gap-2">
|
|||
|
|
🛸 Anomalias UAP destacadas
|
|||
|
|
<span className="text-[#5a6678]">
|
|||
|
|
({ufo.length} {ufo.length === 1 ? "trecho" : "trechos"} · {ufoGroups.length}{" "}
|
|||
|
|
{ufoGroups.length === 1 ? "tipo" : "tipos"})
|
|||
|
|
</span>
|
|||
|
|
</h2>
|
|||
|
|
<ul className="space-y-2.5">
|
|||
|
|
{ufoGroups.map((g, i) => (
|
|||
|
|
<li key={i} className="text-sm text-[#c8d4e6] leading-relaxed">
|
|||
|
|
<span className="font-mono text-[#00ff9c]">🛸 {g.type ?? "anomalia"}</span>
|
|||
|
|
{g.count > 1 && (
|
|||
|
|
<span className="font-mono text-[10px] text-[#5a6678]"> ×{g.count}</span>
|
|||
|
|
)}
|
|||
|
|
{g.rationale && <span className="text-[#c8d4e6]"> — {g.rationale}</span>}{" "}
|
|||
|
|
<PageChips docId={docId} pages={g.pages} />
|
|||
|
|
</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{cryptid.length > 0 && (
|
|||
|
|
<div className={ufo.length > 0 ? "mt-4 pt-4 border-t border-[rgba(155,93,229,0.25)]" : ""}>
|
|||
|
|
<h2 className="font-mono text-sm text-[#9b5de5] mb-3 flex items-center gap-2">
|
|||
|
|
👁 Anomalias cryptid destacadas
|
|||
|
|
<span className="text-[#5a6678]">
|
|||
|
|
({cryptid.length} {cryptid.length === 1 ? "trecho" : "trechos"})
|
|||
|
|
</span>
|
|||
|
|
</h2>
|
|||
|
|
<ul className="space-y-2.5">
|
|||
|
|
{cryptidGroups.map((g, i) => (
|
|||
|
|
<li key={i} className="text-sm text-[#c8d4e6] leading-relaxed">
|
|||
|
|
<span className="font-mono text-[#9b5de5]">👁 {g.type ?? "anomalia"}</span>
|
|||
|
|
{g.count > 1 && (
|
|||
|
|
<span className="font-mono text-[10px] text-[#5a6678]"> ×{g.count}</span>
|
|||
|
|
)}
|
|||
|
|
{g.rationale && <span className="text-[#c8d4e6]"> — {g.rationale}</span>}{" "}
|
|||
|
|
<PageChips docId={docId} pages={g.pages} />
|
|||
|
|
</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</section>
|
|||
|
|
);
|
|||
|
|
}
|