Commit a3812f69 by PLN (Algolia)

Dunbar: NLP PAss

parent fbdcaf0f
...@@ -141,8 +141,7 @@ export default function DunbarApp() { ...@@ -141,8 +141,7 @@ export default function DunbarApp() {
router.push(`/dunbar/event/${eventSlug(e)}`, undefined, { shallow: true }); router.push(`/dunbar/event/${eventSlug(e)}`, undefined, { shallow: true });
}; };
if (!authed) { // Deep-link handling: friend/event/search routes hydrate initial tab/selection
// Deep-link handling: friend/event/search routes hydrate initial tab/selection
useEffect(() => { useEffect(() => {
if (!router || !router.asPath) return; if (!router || !router.asPath) return;
const as = router.asPath || ''; const as = router.asPath || '';
...@@ -180,6 +179,8 @@ export default function DunbarApp() { ...@@ -180,6 +179,8 @@ export default function DunbarApp() {
} }
}, [router?.asPath, friends, derived.eventIndex, actions]); }, [router?.asPath, friends, derived.eventIndex, actions]);
if (!authed) {
return ( return (
<div className={styles.lockWrap}> <div className={styles.lockWrap}>
<h2 className={styles.title}>Dunbar</h2> <h2 className={styles.title}>Dunbar</h2>
...@@ -283,7 +284,12 @@ export default function DunbarApp() { ...@@ -283,7 +284,12 @@ export default function DunbarApp() {
)} )}
{tab === 'stats' && ( {tab === 'stats' && (
<StatsTab stats={derived.stats} anniversaries={derived.anniversaries} openFriend={openFriendDetail} /> <StatsTab
stats={derived.stats}
anniversaries={derived.anniversaries}
eventIndex={derived.eventIndex}
openFriend={openFriendDetail}
/>
)} )}
</div> </div>
); );
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
isoDate, isoDate,
} from '@/lib/dunbar'; } from '@/lib/dunbar';
import { extractTags } from '@/lib/dunbar'; import { extractTags } from '@/lib/dunbar';
import { detectLang, topKeywordsForDocs, extractTopics } from '@/lib/dunbar-nlp';
export default function EventsTab({ friends, addEvent, updateEvent, selectedEventId, eventIndex, openEvent }) { export default function EventsTab({ friends, addEvent, updateEvent, selectedEventId, eventIndex, openEvent }) {
// Creation form state // Creation form state
...@@ -58,6 +59,36 @@ export default function EventsTab({ friends, addEvent, updateEvent, selectedEven ...@@ -58,6 +59,36 @@ export default function EventsTab({ friends, addEvent, updateEvent, selectedEven
// Timeline groups from merged eventIndex // Timeline groups from merged eventIndex
const groups = useMemo(() => groupEventsByDay(eventIndex), [eventIndex]); const groups = useMemo(() => groupEventsByDay(eventIndex), [eventIndex]);
// NLP: keywords per event (TF-IDF over corpus) and language guess
const keywordData = useMemo(() => {
const docs = (eventIndex || []).map((e) => ({
id: e.id,
text: `${e.title || ''} ${e.notes || ''}`,
}));
const corpusText = docs.map((d) => d.text).join(' ');
const lang = detectLang(corpusText) || null;
const top = topKeywordsForDocs(docs, { lang, topK: 6 });
const byId = new Map(top.map((d) => [d.id, d.keywords]));
return { byId, lang };
}, [eventIndex]);
// Topics (beta) — computed on demand
const [topics, setTopics] = useState([]);
const [topicsLoading, setTopicsLoading] = useState(false);
const runTopics = async () => {
try {
setTopicsLoading(true);
const docs = (eventIndex || []).map((e) => ({
id: e.id,
text: `${e.title || ''} ${e.notes || ''}`,
}));
const res = await extractTopics(docs, { topics: 5, termsPerTopic: 6, lang: keywordData.lang || null });
setTopics(res);
} finally {
setTopicsLoading(false);
}
};
// Selected event editor state // Selected event editor state
const selectedEvent = useMemo( const selectedEvent = useMemo(
() => (selectedEventId ? (eventIndex || []).find((e) => e.id === selectedEventId) : null), () => (selectedEventId ? (eventIndex || []).find((e) => e.id === selectedEventId) : null),
...@@ -219,7 +250,26 @@ export default function EventsTab({ friends, addEvent, updateEvent, selectedEven ...@@ -219,7 +250,26 @@ export default function EventsTab({ friends, addEvent, updateEvent, selectedEven
{/* Timeline + Editor */} {/* Timeline + Editor */}
<div className={styles.card}> <div className={styles.card}>
<div className={styles.cardHeader}>Timeline</div> <div className={styles.cardHeader}>
<span>Timeline</span>
<button
className={styles.btnSecondary}
onClick={runTopics}
disabled={topicsLoading || !(eventIndex || []).length}
title="Compute topics from titles+notes (local)"
>
{topicsLoading ? 'Topics…' : 'Topics (beta)'}
</button>
</div>
{topics && topics.length > 0 ? (
<div className={styles.tagRow} style={{ margin: '8px 0' }}>
{topics.map((t, i) => (
<span key={i} className={styles.tagChip} title={t.terms.map(([term]) => term).join(', ')}>
Topic {i + 1}: {t.terms.slice(0, 3).map(([term]) => term).join(' / ')}
</span>
))}
</div>
) : null}
{/* Event Editor */} {/* Event Editor */}
{selectedEvent ? ( {selectedEvent ? (
...@@ -329,6 +379,16 @@ export default function EventsTab({ friends, addEvent, updateEvent, selectedEven ...@@ -329,6 +379,16 @@ export default function EventsTab({ friends, addEvent, updateEvent, selectedEven
</div> </div>
) : null; ) : null;
})()} })()}
{(() => {
const kws = keywordData.byId.get(e.id) || [];
return kws.length ? (
<div className={styles.tagRow} style={{ marginTop: 4 }}>
{kws.slice(0, 6).map(([term]) => (
<span key={term} className={styles.tagChip}>{term}</span>
))}
</div>
) : null;
})()}
<div style={{ color: '#888', marginTop: 4, fontSize: 12 }}> <div style={{ color: '#888', marginTop: 4, fontSize: 12 }}>
{isoDate(e.date)} {isoDate(e.date)}
</div> </div>
......
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import styles from '@/styles/dunbar.module.css'; import styles from '@/styles/dunbar.module.css';
import { extractLocations } from '@/lib/dunbar-nlp';
export default function FriendsList({ export default function FriendsList({
friends, friends,
...@@ -35,6 +36,22 @@ export default function FriendsList({ ...@@ -35,6 +36,22 @@ export default function FriendsList({
return friends.filter((f) => f.name.toLowerCase().includes(q)); return friends.filter((f) => f.name.toLowerCase().includes(q));
}, [friends, filter]); }, [friends, filter]);
// Offline location mentions per friend (from notes + events)
const locByFriend = useMemo(() => {
const m = new Map();
for (const f of friends) {
let text = '';
text += ' ' + (f.notes || '');
for (const ev of f.events || []) {
text += ' ' + (ev.title || '') + ' ' + (ev.notes || '') + ' ' + (ev.location || '');
}
const locs = extractLocations(text).map((l) => l.name);
const uniq = Array.from(new Set(locs));
m.set(f.id, uniq);
}
return m;
}, [friends]);
const handleAdd = () => { const handleAdd = () => {
const n = name.trim(); const n = name.trim();
if (!n) return; if (!n) return;
...@@ -143,6 +160,10 @@ export default function FriendsList({ ...@@ -143,6 +160,10 @@ export default function FriendsList({
)} )}
<div className={styles.itemMeta}> <div className={styles.itemMeta}>
&nbsp;·&nbsp;{evCount} events · {connCount} connections &nbsp;·&nbsp;{evCount} events · {connCount} connections
{(() => {
const locs = locByFriend.get(f.id) || [];
return locs.length ? <> · 📍 {locs.slice(0, 2).join(', ')}</> : null;
})()}
</div> </div>
<div className={styles.itemRight} aria-hidden></div> <div className={styles.itemRight} aria-hidden></div>
<button <button
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
drawLabel, drawLabel,
clamp, clamp,
} from '@/lib/dunbar'; } from '@/lib/dunbar';
import { extractLocations } from '@/lib/dunbar-nlp';
// We only rely on d3-force for physics. Zoom/pan/drag implemented manually to avoid extra deps. // We only rely on d3-force for physics. Zoom/pan/drag implemented manually to avoid extra deps.
export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
...@@ -36,14 +37,77 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -36,14 +37,77 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
y: (sy - transform.y) / transform.k, y: (sy - transform.y) / transform.k,
}); });
// Location filter (offline gazetteer)
const [locFilter, setLocFilter] = useState('');
// Selection + UI state
const [selectedId, setSelectedId] = useState(null);
const [focusNeighbors, setFocusNeighbors] = useState(false);
// labelDensity: 'none' | 'focus' | 'all'
const [labelDensity, setLabelDensity] = useState('focus');
const [searchText, setSearchText] = useState('');
const searchInputRef = useRef(null);
const locByFriend = useMemo(() => {
const m = new Map();
for (const f of friends) {
let text = '';
text += ' ' + (f.notes || '');
for (const ev of f.events || []) {
text += ' ' + (ev.title || '') + ' ' + (ev.notes || '') + ' ' + (ev.location || '');
}
const locs = extractLocations(text).map((l) => l.name);
m.set(f.id, Array.from(new Set(locs)));
}
return m;
}, [friends]);
const locationOptions = useMemo(() => {
const counts = new Map();
for (const [, locs] of locByFriend.entries()) {
for (const name of locs) counts.set(name, (counts.get(name) || 0) + 1);
}
return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 50)
.map(([name]) => name);
}, [locByFriend]);
const filteredFriends = useMemo(() => {
if (!locFilter) return friends;
return friends.filter((f) => (locByFriend.get(f.id) || []).includes(locFilter));
}, [friends, locFilter, locByFriend]);
// Search (by name) results
const searchResults = useMemo(() => {
const q = (searchText || '').toLowerCase().trim();
if (!q) return [];
return friends
.filter((f) => (f.name || '').toLowerCase().includes(q))
.slice(0, 10);
}, [friends, searchText]);
// Neighbor set for selection
const neighborSet = useMemo(() => {
if (!selectedId) return new Set();
const set = new Set();
for (const [a, b] of edgesFromFriends(filteredFriends)) {
if (String(a) === String(selectedId)) set.add(String(b));
if (String(b) === String(selectedId)) set.add(String(a));
}
return set;
}, [filteredFriends, selectedId]);
// Graph data derived from friends // Graph data derived from friends
const nodes = useMemo(() => friends.map(f => ({ const nodes = useMemo(() => filteredFriends.map(f => ({
id: String(f.id), id: String(f.id),
name: f.name, name: f.name,
})), [friends]); })), [filteredFriends]);
const degreeM = useMemo(() => degreeMap(friends), [friends]); const degreeM = useMemo(() => degreeMap(filteredFriends), [filteredFriends]);
const links = useMemo(() => edgesFromFriends(friends).map(([a,b]) => ({ source: String(a), target: String(b) })), [friends]); const links = useMemo(
() => edgesFromFriends(filteredFriends).map(([a,b]) => ({ source: String(a), target: String(b) })),
[filteredFriends]
);
// Node state (positions) persisted across renders // Node state (positions) persisted across renders
const nodeStateRef = useRef(new Map()); // id -> {x,y,vx,vy} const nodeStateRef = useRef(new Map()); // id -> {x,y,vx,vy}
...@@ -154,13 +218,20 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -154,13 +218,20 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
ctx.scale(transform.k, transform.k); ctx.scale(transform.k, transform.k);
// Draw links // Draw links
ctx.lineWidth = 1 / transform.k;
ctx.strokeStyle = '#e0e0e0';
for (const l of links) { for (const l of links) {
const a = getNodeState(String(l.source)); const a = getNodeState(String(l.source));
const b = getNodeState(String(l.target)); const b = getNodeState(String(l.target));
if (!a || !b) continue; if (!a || !b) continue;
const isFocus =
selectedId &&
(String(l.source) === String(selectedId) || String(l.target) === String(selectedId));
ctx.beginPath(); ctx.beginPath();
ctx.lineWidth = (isFocus ? 2 : 1) / transform.k;
ctx.strokeStyle = isFocus
? '#5a9960'
: focusNeighbors && selectedId
? 'rgba(224,224,224,0.35)'
: '#e0e0e0';
ctx.moveTo(a.x, a.y); ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y); ctx.lineTo(b.x, b.y);
ctx.stroke(); ctx.stroke();
...@@ -170,14 +241,28 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -170,14 +241,28 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
for (const n of nodes) { for (const n of nodes) {
const s = getNodeState(n.id); const s = getNodeState(n.id);
const r = nodeRadius(n.id); const r = nodeRadius(n.id);
const isSel = selectedId && String(n.id) === String(selectedId);
const isNbr = selectedId && neighborSet.has(String(n.id));
ctx.beginPath(); ctx.beginPath();
ctx.fillStyle = nodeColor(n.id); if (isSel) {
ctx.fillStyle = '#2c5530';
} else if (isNbr) {
ctx.fillStyle = '#5a9960';
} else {
ctx.fillStyle =
focusNeighbors && selectedId ? 'rgba(160,192,160,0.4)' : nodeColor(n.id);
}
ctx.arc(s.x, s.y, r, 0, Math.PI * 2); ctx.arc(s.x, s.y, r, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
} }
// Labels: world coords, offset by node radius; keep pixel size constant with inverse scaling // Labels: based on density
for (const n of nodes) { for (const n of nodes) {
const showLabel =
labelDensity === 'all' ||
(labelDensity === 'focus' &&
(String(n.id) === String(selectedId) || neighborSet.has(String(n.id))));
if (!showLabel) continue;
const s = getNodeState(n.id); const s = getNodeState(n.id);
const r = nodeRadius(n.id); const r = nodeRadius(n.id);
const fontPx = 12 / transform.k; const fontPx = 12 / transform.k;
...@@ -192,7 +277,9 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -192,7 +277,9 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
// Interaction // Interaction
const stateRef = useRef({ const stateRef = useRef({
draggingNodeId: null, pendingDragId: null, // node id awaiting threshold before dragging
draggingNodeId: null, // active drag
downAt: { x: 0, y: 0 }, // screen coords where mouse down occurred
dragOffset: { x: 0, y: 0 }, dragOffset: { x: 0, y: 0 },
panning: false, panning: false,
panStart: { x: 0, y: 0 }, panStart: { x: 0, y: 0 },
...@@ -205,11 +292,11 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -205,11 +292,11 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
const pickNodeAt = (sx, sy) => { const pickNodeAt = (sx, sy) => {
const { x, y } = worldFromScreen(sx, sy); const { x, y } = worldFromScreen(sx, sy);
// simple hit test // simple hit test with expanded radius
for (let i = nodes.length - 1; i >= 0; i--) { for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i]; const n = nodes[i];
const s = getNodeState(n.id); const s = getNodeState(n.id);
const r = nodeRadius(n.id); const r = nodeRadius(n.id) + 4;
const dx = x - s.x; const dx = x - s.x;
const dy = y - s.y; const dy = y - s.y;
if (dx * dx + dy * dy <= r * r) return n.id; if (dx * dx + dy * dy <= r * r) return n.id;
...@@ -229,15 +316,13 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -229,15 +316,13 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
// start link draft // start link draft
stateRef.current.linkDraftFrom = id; stateRef.current.linkDraftFrom = id;
} else { } else {
// start dragging node // prepare to drag node (threshold)
stateRef.current.draggingNodeId = id; stateRef.current.pendingDragId = id;
stateRef.current.downAt = { x: sx, y: sy };
const { x, y } = worldFromScreen(sx, sy); const { x, y } = worldFromScreen(sx, sy);
const s = getNodeState(id); const s = getNodeState(id);
stateRef.current.dragOffset = { x: s.x - x, y: s.y - y }; stateRef.current.dragOffset = { x: s.x - x, y: s.y - y };
// Nudge simulation // Nudge simulation once dragging starts
if (simRef.current) {
simRef.current.alphaTarget(0.1).restart();
}
} }
} else { } else {
// start panning // start panning
...@@ -253,6 +338,17 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -253,6 +338,17 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
const sy = e.clientY - rect.top; const sy = e.clientY - rect.top;
stateRef.current.lastMouse = { x: sx, y: sy }; stateRef.current.lastMouse = { x: sx, y: sy };
// activate dragging if threshold exceeded
if (stateRef.current.pendingDragId && !stateRef.current.draggingNodeId) {
const dx = sx - stateRef.current.downAt.x;
const dy = sy - stateRef.current.downAt.y;
if (dx * dx + dy * dy > 16) {
stateRef.current.draggingNodeId = stateRef.current.pendingDragId;
stateRef.current.pendingDragId = null;
if (simRef.current) simRef.current.alphaTarget(0.1).restart();
}
}
if (stateRef.current.draggingNodeId) { if (stateRef.current.draggingNodeId) {
const id = stateRef.current.draggingNodeId; const id = stateRef.current.draggingNodeId;
const { x, y } = worldFromScreen(sx, sy); const { x, y } = worldFromScreen(sx, sy);
...@@ -293,6 +389,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -293,6 +389,7 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
if (stateRef.current.draggingNodeId) { if (stateRef.current.draggingNodeId) {
const id = stateRef.current.draggingNodeId; const id = stateRef.current.draggingNodeId;
stateRef.current.draggingNodeId = null; stateRef.current.draggingNodeId = null;
stateRef.current.pendingDragId = null;
// release fixed position so sim can settle (unless physics off) // release fixed position so sim can settle (unless physics off)
if (simRef.current) { if (simRef.current) {
const d3node = simRef.current.nodes().find(n => n.id === id); const d3node = simRef.current.nodes().find(n => n.id === id);
...@@ -311,7 +408,10 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -311,7 +408,10 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
toggleRel?.(from, overId); toggleRel?.(from, overId);
} }
} else if (!editMode && overId) { } else if (!editMode && overId) {
// View mode click node -> open friend detail // Click node: select and center; optionally open detail on double-click later
setSelectedId(overId);
centerOnNode(overId);
// keep existing behavior: open friend detail
openFriendDetail?.(overId); openFriendDetail?.(overId);
} }
requestDraw(); requestDraw();
...@@ -381,6 +481,20 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -381,6 +481,20 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
// Keyboard navigation scoped to container // Keyboard navigation scoped to container
const onKeyDown = (e) => { const onKeyDown = (e) => {
const PAN = 40; const PAN = 40;
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
e.preventDefault();
searchInputRef.current?.focus();
return;
}
if (e.key.toLowerCase() === 'f') {
// center on selection
if (selectedId) centerOnNode(selectedId);
return;
}
if (e.key === 'Escape') {
setSelectedId(null);
return;
}
if (e.key === 'ArrowLeft') setTransform(t => ({ ...t, x: t.x + PAN })); if (e.key === 'ArrowLeft') setTransform(t => ({ ...t, x: t.x + PAN }));
else if (e.key === 'ArrowRight') setTransform(t => ({ ...t, x: t.x - PAN })); else if (e.key === 'ArrowRight') setTransform(t => ({ ...t, x: t.x - PAN }));
else if (e.key === 'ArrowUp') setTransform(t => ({ ...t, y: t.y + PAN })); else if (e.key === 'ArrowUp') setTransform(t => ({ ...t, y: t.y + PAN }));
...@@ -396,6 +510,24 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -396,6 +510,24 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
containerRef.current?.focus(); containerRef.current?.focus();
}, []); }, []);
// Center viewport on a node
const centerOnNode = (id) => {
const s = getNodeState(String(id));
if (!s) return;
const rect = containerRef.current?.getBoundingClientRect();
const w = rect ? rect.width : size.w;
const h = rect ? rect.height : size.h;
// Target center in screen coords
const cx = w / 2;
const cy = h / 2;
setTransform((t) => {
const x = cx - s.x * t.k;
const y = cy - s.y * t.k;
return { ...t, x, y };
});
requestDraw();
};
return ( return (
<div> <div>
<div className={styles.graphToolbar}> <div className={styles.graphToolbar}>
...@@ -406,39 +538,145 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) { ...@@ -406,39 +538,145 @@ export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
> >
Mode: {editMode ? 'Edit' : 'View'} Mode: {editMode ? 'Edit' : 'View'}
</button> </button>
<select
className={styles.select}
value={locFilter}
onChange={(e) => setLocFilter(e.target.value)}
title="Filter by location (offline gazetteer)"
>
<option value="">All locations</option>
{locationOptions.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
<button
className={styles.btnSecondary}
onClick={() => selectedId && centerOnNode(selectedId)}
disabled={!selectedId}
title="Center on selection"
>
Center
</button>
<button
className={styles.btnSecondary}
onClick={() => setFocusNeighbors(v => !v)}
title="Focus selected + neighbors"
>
Focus: {focusNeighbors ? 'ON' : 'OFF'}
</button>
<select
className={styles.select}
value={labelDensity}
onChange={(e) => setLabelDensity(e.target.value)}
title="Label density"
>
<option value="none">Labels: None</option>
<option value="focus">Labels: Focus</option>
<option value="all">Labels: All</option>
</select>
<span className={styles.badge}>{nodes.length} nodes · {links.length} edges</span> <span className={styles.badge}>{nodes.length} nodes · {links.length} edges</span>
</div> </div>
{editMode && ( {editMode && (
<div className={styles.banner}>Edit mode: Drag from one node to another to toggle a connection.</div> <div className={styles.banner}>Edit mode: Drag from one node to another to toggle a connection.</div>
)} )}
<div <div className={styles.twoCol} style={{ gap: 12 }}>
ref={containerRef} {/* Sidebar */}
className={styles.canvasWrap} <div className={styles.card}>
tabIndex={0} <div className={styles.cardHeader}><span>Find</span></div>
role="application" <div className={styles.row} style={{ marginBottom: 8 }}>
aria-label="Network graph" <input
onKeyDown={onKeyDown} ref={searchInputRef}
style={{ outline: 'none' }} className={styles.input}
> placeholder="Search by name (Ctrl/Cmd+F)"
<canvas value={searchText}
ref={canvasRef} onChange={(e) => setSearchText(e.target.value)}
width={size.w} style={{ width: '100%' }}
height={size.h} />
onMouseDown={onMouseDown} </div>
onMouseMove={onMouseMove} <div className={styles.listScroll} style={{ maxHeight: '30vh' }}>
onMouseUp={onMouseUp} {searchResults.map((f) => (
onWheel={onWheel} <div
/> key={f.id}
<div className={styles.floatingControls}> className={styles.listItem}
<button className={styles.ctrlBtn} onClick={() => panBy(-40, 0)} aria-label="Pan left"></button> onClick={() => {
<button className={styles.ctrlBtn} onClick={() => panBy(0, -40)} aria-label="Pan up"></button> setSelectedId(String(f.id));
<button className={styles.ctrlBtn} onClick={() => panBy(40, 0)} aria-label="Pan right"></button> centerOnNode(String(f.id));
<button className={styles.ctrlBtn} onClick={() => zoomAt(1 / 1.2)} aria-label="Zoom out"></button> }}
<button className={styles.ctrlBtn} onClick={() => setTransform({ k: 1, x: 0, y: 0 })} aria-label="Reset"></button> style={
<button className={styles.ctrlBtn} onClick={() => zoomAt(1.2)} aria-label="Zoom in"></button> String(selectedId) === String(f.id) ? { background: '#f5fbf7' } : undefined
<button className={`${styles.ctrlBtn} ${styles.ctrlWide}`} onClick={() => setEditMode(v => !v)} aria-label="Toggle edit"> }
{editMode ? 'Edit:ON' : 'Edit:OFF'} >
</button> <div className={styles.itemTitle}>{f.name}</div>
</div>
))}
{searchText && searchResults.length === 0 && (
<div style={{ padding: 8, color: '#666' }}>No matches.</div>
)}
</div>
{selectedId ? (
<div style={{ marginTop: 8 }}>
<div className={styles.cardHeader}><span>Selection</span></div>
<div className={styles.row} style={{ flexWrap: 'wrap' }}>
<button
className={styles.btnSecondary}
onClick={() => centerOnNode(selectedId)}
title="Center on selection"
>
Center
</button>
<button
className={styles.btnSecondary}
onClick={() => setSelectedId(null)}
title="Clear selection"
>
Clear
</button>
<button
className={styles.btnSecondary}
onClick={() => openFriendDetail?.(selectedId)}
title="Open friend detail"
>
Open
</button>
</div>
<div className={styles.itemMeta} style={{ marginTop: 6 }}>
Neighbors: {neighborSet.size}
</div>
</div>
) : null}
</div>
{/* Canvas */}
<div
ref={containerRef}
className={styles.canvasWrap}
tabIndex={0}
role="application"
aria-label="Network graph"
onKeyDown={onKeyDown}
style={{ outline: 'none' }}
>
<canvas
ref={canvasRef}
width={size.w}
height={size.h}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onWheel={onWheel}
/>
<div className={styles.floatingControls}>
<button className={styles.ctrlBtn} onClick={() => panBy(-40, 0)} aria-label="Pan left"></button>
<button className={styles.ctrlBtn} onClick={() => panBy(0, -40)} aria-label="Pan up"></button>
<button className={styles.ctrlBtn} onClick={() => panBy(40, 0)} aria-label="Pan right"></button>
<button className={styles.ctrlBtn} onClick={() => zoomAt(1 / 1.2)} aria-label="Zoom out"></button>
<button className={styles.ctrlBtn} onClick={() => setTransform({ k: 1, x: 0, y: 0 })} aria-label="Reset"></button>
<button className={styles.ctrlBtn} onClick={() => zoomAt(1.2)} aria-label="Zoom in"></button>
<button className={`${styles.ctrlBtn} ${styles.ctrlWide}`} onClick={() => setEditMode(v => !v)} aria-label="Toggle edit">
{editMode ? 'Edit:ON' : 'Edit:OFF'}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
suggestTags, suggestTags,
suggestPersons, suggestPersons,
} from '@/lib/dunbar-search'; } from '@/lib/dunbar-search';
import { tokenize } from '@/lib/dunbar-nlp';
export default function SearchTab({ friends, openFriend, openEvent }) { export default function SearchTab({ friends, openFriend, openEvent }) {
const [q, setQ] = useState(''); const [q, setQ] = useState('');
...@@ -76,6 +77,35 @@ export default function SearchTab({ friends, openFriend, openEvent }) { ...@@ -76,6 +77,35 @@ export default function SearchTab({ friends, openFriend, openEvent }) {
return <>{parts}</>; return <>{parts}</>;
}; };
// Query tokens for highlight (non-hashtag, length>=3)
const queryTokens = useMemo(
() =>
tokenize(q || '', { keepHashtags: true, removeDiacritics: true }).filter(
(t) => !t.startsWith('#') && t.length >= 3
),
[q]
);
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const renderHighlight = (text = '', tokens = []) => {
if (!tokens || tokens.length === 0) return text;
const pattern = new RegExp(`(${tokens.map(escapeRegExp).join('|')})`, 'gi');
const parts = String(text).split(pattern);
const tokenSet = new Set(tokens.map((t) => t.toLowerCase()));
return (
<>
{parts.map((part, i) =>
tokenSet.has(String(part).toLowerCase()) ? (
<mark key={i} style={{ backgroundColor: '#fff2a8', padding: '0 2px' }}>{part}</mark>
) : (
<span key={i}>{part}</span>
)
)}
</>
);
};
const tagSuggestions = useMemo(() => (indexes ? suggestTags(indexes, q) : []), [indexes, q]); const tagSuggestions = useMemo(() => (indexes ? suggestTags(indexes, q) : []), [indexes, q]);
const personSuggestions = useMemo(() => (indexes ? suggestPersons(indexes, q) : []), [indexes, q]); const personSuggestions = useMemo(() => (indexes ? suggestPersons(indexes, q) : []), [indexes, q]);
...@@ -162,7 +192,7 @@ export default function SearchTab({ friends, openFriend, openEvent }) { ...@@ -162,7 +192,7 @@ export default function SearchTab({ friends, openFriend, openEvent }) {
<div className={styles.listScroll} style={{ maxHeight: '50vh' }}> <div className={styles.listScroll} style={{ maxHeight: '50vh' }}>
{(results.friends || []).map((f) => ( {(results.friends || []).map((f) => (
<div key={f.id} className={styles.listItem} onClick={() => openFriend?.(f.refId)}> <div key={f.id} className={styles.listItem} onClick={() => openFriend?.(f.refId)}>
<div className={styles.itemTitle}>{f.name}</div> <div className={styles.itemTitle}>{renderHighlight(f.name, queryTokens)}</div>
{(f.tags && f.tags.length) ? ( {(f.tags && f.tags.length) ? (
<div className={styles.tagRow}> <div className={styles.tagRow}>
{f.tags.slice(0, 8).map((t) => ( {f.tags.slice(0, 8).map((t) => (
...@@ -188,7 +218,7 @@ export default function SearchTab({ friends, openFriend, openEvent }) { ...@@ -188,7 +218,7 @@ export default function SearchTab({ friends, openFriend, openEvent }) {
onClick={() => openEvent?.(e)} onClick={() => openEvent?.(e)}
title="Ouvrir l’événement" title="Ouvrir l’événement"
> >
{e.title || '(untitled)'} {renderHighlight(e.title || '(untitled)', queryTokens)}
</div> </div>
<div style={{ whiteSpace: 'pre-wrap' }}> <div style={{ whiteSpace: 'pre-wrap' }}>
{renderNotesWithTags(e.notes || '')} {renderNotesWithTags(e.notes || '')}
......
import React from 'react'; import React from 'react';
import styles from '@/styles/dunbar.module.css'; import styles from '@/styles/dunbar.module.css';
import { isoDate } from '@/lib/dunbar'; import { isoDate } from '@/lib/dunbar';
import { detectLang, topKeywordsForDocs, extractLocations } from '@/lib/dunbar-nlp';
export default function StatsTab({ stats, anniversaries = [], openFriend }) { export default function StatsTab({ stats, anniversaries = [], eventIndex = [], openFriend }) {
if (!stats) return null; if (!stats) return null;
const items = [ const items = [
{ label: 'Connections', value: stats.connections }, { label: 'Connections', value: stats.connections },
...@@ -11,6 +12,46 @@ export default function StatsTab({ stats, anniversaries = [], openFriend }) { ...@@ -11,6 +12,46 @@ export default function StatsTab({ stats, anniversaries = [], openFriend }) {
{ label: 'Avg Events / Friend', value: stats.avgEventsPerFriend }, { label: 'Avg Events / Friend', value: stats.avgEventsPerFriend },
]; ];
// Aggregate text insights (local-only): top keywords and locations across all events
const textInsights = React.useMemo(() => {
const docs = (eventIndex || []).map((e) => ({
id: e.id,
text: `${e.title || ''} ${e.notes || ''} ${e.location || ''}`,
}));
if (!docs.length) return { topTerms: [], topLocations: [] };
const corpusText = docs.map((d) => d.text).join(' ');
const lang = detectLang(corpusText) || null;
// Aggregate keywords by summing TF-IDF heads across docs
const perDoc = topKeywordsForDocs(docs, { lang, topK: 8 });
const agg = new Map();
for (const d of perDoc) {
for (const [term, score] of d.keywords) {
agg.set(term, (agg.get(term) || 0) + score);
}
}
const topTerms = Array.from(agg.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 12)
.map(([term, score]) => ({ term, score }));
// Count location mentions (unique per event)
const locCounts = new Map();
for (const e of eventIndex || []) {
const locs = extractLocations(`${e.title || ''} ${e.notes || ''} ${e.location || ''}`).map((l) => l.name);
for (const name of new Set(locs)) {
locCounts.set(name, (locCounts.get(name) || 0) + 1);
}
}
const topLocations = Array.from(locCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([name, count]) => ({ name, count }));
return { topTerms, topLocations };
}, [eventIndex]);
// Group upcoming anniversaries by date (YYYY-MM-DD) // Group upcoming anniversaries by date (YYYY-MM-DD)
const grouped = anniversaries.reduce((acc, it) => { const grouped = anniversaries.reduce((acc, it) => {
const k = isoDate(it.date); const k = isoDate(it.date);
...@@ -33,6 +74,32 @@ export default function StatsTab({ stats, anniversaries = [], openFriend }) { ...@@ -33,6 +74,32 @@ export default function StatsTab({ stats, anniversaries = [], openFriend }) {
))} ))}
</div> </div>
{(textInsights.topTerms.length > 0 || textInsights.topLocations.length > 0) && (
<div style={{ marginTop: 16 }}>
<div className={styles.cardHeader}><span>Text Insights</span></div>
{textInsights.topTerms.length > 0 ? (
<div style={{ marginBottom: 8 }}>
<div className={styles.itemMeta} style={{ marginBottom: 4 }}>Top keywords</div>
<div className={styles.tagRow}>
{textInsights.topTerms.map(({ term }) => (
<span key={term} className={styles.tagChip}>{term}</span>
))}
</div>
</div>
) : null}
{textInsights.topLocations.length > 0 ? (
<div>
<div className={styles.itemMeta} style={{ marginBottom: 4 }}>Top locations</div>
<div className={styles.tagRow}>
{textInsights.topLocations.map(({ name, count }) => (
<span key={name} className={styles.tagChip}>📍 {name} × {count}</span>
))}
</div>
</div>
) : null}
</div>
)}
{annivDays.length > 0 && ( {annivDays.length > 0 && (
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<div className={styles.cardHeader}> <div className={styles.cardHeader}>
......
...@@ -4,6 +4,7 @@ import styles from "./layout.module.css"; ...@@ -4,6 +4,7 @@ import styles from "./layout.module.css";
import utilStyles from "../styles/utils.module.css"; import utilStyles from "../styles/utils.module.css";
import Link from "next/link"; import Link from "next/link";
import Router from 'next/router' import Router from 'next/router'
import { useRouter } from 'next/router'
const name = "PLN"; const name = "PLN";
export const siteTitle = "PLN's Works"; export const siteTitle = "PLN's Works";
...@@ -12,6 +13,9 @@ export const twitterHandle = "@PaulLouisNech"; ...@@ -12,6 +13,9 @@ export const twitterHandle = "@PaulLouisNech";
export const description = "PLN's Selected Works"; export const description = "PLN's Selected Works";
export default function Layout({ children, home }) { export default function Layout({ children, home }) {
const router = useRouter();
const path = router?.asPath || router?.pathname || '';
const isDunbar = path.startsWith('/dunbar');
// Simple feedback launcher: prompts for text then opens default mail client // Simple feedback launcher: prompts for text then opens default mail client
const handleFeedbackMail = () => { const handleFeedbackMail = () => {
try { try {
...@@ -98,16 +102,20 @@ export default function Layout({ children, home }) { ...@@ -98,16 +102,20 @@ export default function Layout({ children, home }) {
> >
</a> </a>
{' '}|{' '} {isDunbar && (
<button <>
type="button" {' '}|{' '}
onClick={handleFeedbackMail} <button
className={utilStyles.backButton} type="button"
style={{ cursor: 'pointer', border: 'none', background: 'transparent', padding: 0 }} onClick={handleFeedbackMail}
title="Send feedback about Dunbar" className={utilStyles.backButton}
> style={{ cursor: 'pointer', border: 'none', background: 'transparent', padding: 0 }}
Feedback (dunbar@nech.pl) title="Send feedback about Dunbar"
</button> >
Feedback (dunbar@nech.pl)
</button>
</>
)}
</footer> </footer>
</div> </div>
); );
......
/**
* Dunbar NLP utilities (client-only, local-first).
* Zero network calls. Pure functions usable in browser.
*
* Exports:
* - stripDiacritics, normalizeText
* - detectLang
* - tokenize, ngrams
* - removeStopwords
* - computeTfIdf, topKeywordsForDocs
* - extractLocations
* - extractTopics (beta, with graceful fallback)
*
* Notes:
* - We reuse stopword lists from the 'stopword' package (already in deps).
* - We avoid heavy stemming here; Minisearch in dunbar-search.js already does FR stemming for search.
*/
import { fr as FR_LIST, en as EN_LIST } from 'stopword';
// ---------- Normalization ----------
/** Remove diacritics but preserve base letters (é → e). */
export function stripDiacritics(s = '') {
try {
return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
} catch {
// Fallback for environments missing normalize()
return s;
}
}
/**
* Normalize text for analysis:
* - lowercases
* - optionally strip diacritics
* - collapses whitespace
* - preserves hashtags when requested
*/
export function normalizeText(text = '', { removeDiacritics = true, preserveHashtags = true } = {}) {
let t = String(text || '').toLowerCase();
if (removeDiacritics) t = stripDiacritics(t);
if (!preserveHashtags) t = t.replace(/#/g, ' ');
// collapse whitespace
t = t.replace(/\s+/g, ' ').trim();
return t;
}
// ---------- Language detection (simple heuristic) ----------
const FR_STOP_SET = new Set(FR_LIST || []);
const EN_STOP_SET = new Set(EN_LIST || []);
/**
* Very light lang detection: compare stopword hits FR vs EN.
* Returns 'fr' | 'en' | null (if undecided).
*/
export function detectLang(text = '') {
const norm = normalizeText(text, { removeDiacritics: true, preserveHashtags: false });
const tokens = (norm.match(/[\p{L}\p{N}]+/gu) || []).filter(Boolean);
let frScore = 0;
let enScore = 0;
for (const t of tokens) {
if (FR_STOP_SET.has(t)) frScore++;
if (EN_STOP_SET.has(t)) enScore++;
}
if (frScore === 0 && enScore === 0) return null;
if (frScore > enScore) return 'fr';
if (enScore > frScore) return 'en';
return null;
}
// ---------- Tokenization, stopwords, n-grams ----------
/**
* Tokenize text:
* - keeps hashtags (#word) if keepHashtags=true
* - returns lowercase tokens
*/
export function tokenize(text = '', { keepHashtags = true, removeDiacritics = true } = {}) {
const hashtags = keepHashtags ? (text.match(/#[\p{L}\p{N}_-]+/gu) || []).map((t) => t.toLowerCase()) : [];
const norm = normalizeText(text.replace(/#/g, ' '), { removeDiacritics, preserveHashtags: false });
const words = (norm.match(/[\p{L}\p{N}][\p{L}\p{N}'’_-]*/gu) || []).map((w) => w.toLowerCase());
// dedupe while preserving order for hashtags
const seen = new Set();
const out = [];
for (const h of hashtags) {
if (!seen.has(h)) {
seen.add(h);
out.push(h);
}
}
for (const w of words) {
if (!seen.has(w)) {
seen.add(w);
out.push(w);
}
}
return out;
}
/** Generate n-grams (array of strings joined by space) from a token array. */
export function ngrams(tokens = [], n = 2) {
const res = [];
for (let i = 0; i <= tokens.length - n; i++) {
res.push(tokens.slice(i, i + n).join(' '));
}
return res;
}
/** Remove stopwords per language (keeps hashtags always). */
export function removeStopwords(tokens = [], lang = null) {
if (!lang) return tokens;
const set = lang === 'fr' ? FR_STOP_SET : lang === 'en' ? EN_STOP_SET : null;
if (!set) return tokens;
return tokens.filter((t) => t.startsWith('#') || !set.has(t));
}
// ---------- TF-IDF + keywords ----------
/**
* Build per-document TF-IDF vectors.
* docs: [{ id, text }]
* returns:
* {
* termsByDoc: Map(id -> Map(term -> tfidf)),
* df: Map(term -> docFreq),
* idf: Map(term -> idf),
* tokensByDoc: Map(id -> tokens),
* }
*/
export function computeTfIdf(
docs = [],
{ lang = null, includeNGrams = false, nGramSizes = [2], maxVocab = 5000 } = {}
) {
const tokensByDoc = new Map();
const termFreqByDoc = new Map();
const df = new Map();
// 1) tokenize + local term frequencies
for (const d of docs) {
const toks = removeStopwords(tokenize(d.text || '', { keepHashtags: true, removeDiacritics: true }), lang);
const withN = [toks];
if (includeNGrams) {
for (const n of nGramSizes) withN.push(ngrams(toks, n));
}
const all = withN.flat();
tokensByDoc.set(d.id, all);
const tf = new Map();
for (const t of all) {
tf.set(t, (tf.get(t) || 0) + 1);
}
termFreqByDoc.set(d.id, tf);
// update document frequency
for (const term of new Set(all)) {
df.set(term, (df.get(term) || 0) + 1);
}
}
// 2) vocabulary capping (optional)
if (maxVocab && df.size > maxVocab) {
// keep most frequent terms across docs
const topTerms = Array.from(df.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, maxVocab)
.map(([t]) => t);
const keep = new Set(topTerms);
for (const [docId, tf] of termFreqByDoc.entries()) {
for (const term of Array.from(tf.keys())) {
if (!keep.has(term)) tf.delete(term);
}
}
for (const term of Array.from(df.keys())) {
if (!keep.has(term)) df.delete(term);
}
}
// 3) compute IDF
const N = docs.length || 1;
const idf = new Map();
for (const [term, dfi] of df.entries()) {
const val = Math.log((N + 1) / (dfi + 1)) + 1; // smooth IDF
idf.set(term, val);
}
// 4) compute TF-IDF per doc (normalize with augmented TF)
const termsByDoc = new Map();
for (const [docId, tf] of termFreqByDoc.entries()) {
let tfMax = 1;
for (const v of tf.values()) tfMax = Math.max(tfMax, v);
const vec = new Map();
for (const [term, freq] of tf.entries()) {
const tfw = 0.5 + (0.5 * freq) / tfMax;
const score = tfw * (idf.get(term) || 0);
vec.set(term, score);
}
termsByDoc.set(docId, vec);
}
return { termsByDoc, df, idf, tokensByDoc };
}
/**
* Get top keywords per doc.
* returns array: [{ id, keywords: Array<[term, score]> }]
*/
export function topKeywordsForDocs(docs = [], { lang = null, topK = 8, includeNGrams = false } = {}) {
const { termsByDoc } = computeTfIdf(docs, { lang, includeNGrams });
const res = [];
for (const d of docs) {
const vec = termsByDoc.get(d.id) || new Map();
const sorted = Array.from(vec.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, topK);
res.push({ id: d.id, keywords: sorted });
}
return res;
}
// ---------- Locations (offline gazetteer) ----------
const MINI_GAZETTEER = [
// Countries (FR/EN names)
{ type: 'country', name: 'france', aliases: ['république française'], iso2: 'FR' },
{ type: 'country', name: 'germany', aliases: ['deutschland', 'allemagne'], iso2: 'DE' },
{ type: 'country', name: 'spain', aliases: ['españa', 'espagne'], iso2: 'ES' },
{ type: 'country', name: 'united states', aliases: ['usa', 'us', 'etats-unis', 'états-unis', 'u.s.'], iso2: 'US' },
{ type: 'country', name: 'united kingdom', aliases: ['uk', 'u.k.', 'royaume-uni', 'britain'], iso2: 'GB' },
{ type: 'country', name: 'italy', aliases: ['italia', 'italie'], iso2: 'IT' },
{ type: 'country', name: 'belgium', aliases: ['belgique'], iso2: 'BE' },
{ type: 'country', name: 'switzerland', aliases: ['schweiz', 'suisse', 'svizzera'], iso2: 'CH' },
// Cities (sample, extend as needed)
{ type: 'city', name: 'paris', country: 'FR', aliases: [] },
{ type: 'city', name: 'lyon', country: 'FR', aliases: [] },
{ type: 'city', name: 'marseille', country: 'FR', aliases: [] },
{ type: 'city', name: 'berlin', country: 'DE', aliases: [] },
{ type: 'city', name: 'barcelona', country: 'ES', aliases: ['barcelone'] },
{ type: 'city', name: 'london', country: 'GB', aliases: ['londres'] },
{ type: 'city', name: 'geneva', country: 'CH', aliases: ['genève', 'geneve'] },
{ type: 'city', name: 'brussels', country: 'BE', aliases: ['bruxelles'] },
];
/** Escape regex special chars */
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Extract location mentions using a small offline gazetteer.
* Returns unique matches: [{ type, name, country?, iso2? }]
*/
export function extractLocations(text = '', { gazetteer = MINI_GAZETTEER } = {}) {
const normText = ' ' + normalizeText(text, { removeDiacritics: true, preserveHashtags: true }) + ' ';
const found = [];
const seen = new Set();
for (const entry of gazetteer) {
const names = [entry.name, ...(entry.aliases || [])]
.map((n) => stripDiacritics(n.toLowerCase().trim()))
.filter(Boolean);
for (const n of names) {
// match on word boundaries in normalized text
const pattern = new RegExp(`(^|\\s)${escapeRegExp(n)}(\\s|[.,;:!?])`, 'i');
if (pattern.test(normText)) {
const key = `${entry.type}:${entry.name}:${entry.country || entry.iso2 || ''}`;
if (!seen.has(key)) {
seen.add(key);
found.push({
type: entry.type,
name: entry.name,
country: entry.country,
iso2: entry.iso2,
});
}
break; // avoid duplicate alias hits
}
}
}
return found;
}
// ---------- Topics (beta) ----------
/**
* Try to extract topics with a dynamic import of 'lda' if available.
* If not, fall back to a simple co-occurrence-based grouping derived from TF-IDF.
*
* docs: [{ id, text }]
* returns: Array<{ terms: Array<[term, score]>, documents: Array<id> }>
*/
export async function extractTopics(
docs = [],
{ topics = 5, termsPerTopic = 6, lang = null } = {}
) {
// Attempt dynamic LDA if user installs a tiny LDA package like 'lda'
try {
const mod = await import('lda'); // will throw if not installed
const lda = mod.default || mod;
// 'lda' expects an array of documents (strings). Signature: lda(docs, numberOfTopics, termsPerTopic, alpha?, eta?, random?)
const topicSets = lda(
docs.map((d) => String(d.text || '')),
topics,
termsPerTopic
);
// topicSets: Array of Array<{ term, probability } | [term, prob] >
return topicSets.map((topic) => {
const terms = topic.map((t) => {
if (Array.isArray(t)) return [t[0], t[1]];
if (t && typeof t === 'object') return [t.term, t.probability ?? t.prob];
return [String(t), 0];
});
return { terms, documents: [] };
});
} catch {
// Fallback: build pseudo-topics from TF-IDF heads
return fallbackTopics(docs, { topics, termsPerTopic, lang });
}
}
function fallbackTopics(docs, { topics = 5, termsPerTopic = 6, lang = null } = {}) {
const { termsByDoc } = computeTfIdf(docs, { lang, includeNGrams: false });
// Global scores
const global = new Map();
for (const [, vec] of termsByDoc.entries()) {
for (const [term, score] of vec.entries()) {
global.set(term, (global.get(term) || 0) + score);
}
}
const topTerms = Array.from(global.entries()).sort((a, b) => b[1] - a[1]).slice(0, topics);
const topicsOut = [];
for (const [headTerm, headScore] of topTerms) {
// collect co-occurring terms from docs that contain the head
const co = new Map();
const docIds = new Set();
for (const [docId, vec] of termsByDoc.entries()) {
if (vec.has(headTerm)) {
docIds.add(docId);
for (const [term, s] of vec.entries()) {
if (term === headTerm) continue;
co.set(term, (co.get(term) || 0) + s);
}
}
}
const topCo = Array.from(co.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, Math.max(0, termsPerTopic - 1));
topicsOut.push({
terms: [[headTerm, headScore], ...topCo],
documents: Array.from(docIds),
});
}
return topicsOut;
}
export default {
stripDiacritics,
normalizeText,
detectLang,
tokenize,
ngrams,
removeStopwords,
computeTfIdf,
topKeywordsForDocs,
extractLocations,
extractTopics,
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment