Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
W
www
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
1
Issues
1
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
PLN
www
Commits
a3812f69
Commit
a3812f69
authored
Sep 14, 2025
by
PLN (Algolia)
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Dunbar: NLP PAss
parent
fbdcaf0f
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
860 additions
and
63 deletions
+860
-63
DunbarApp.js
next/components/dunbar/DunbarApp.js
+9
-3
EventsTab.js
next/components/dunbar/EventsTab.js
+61
-1
FriendsList.js
next/components/dunbar/FriendsList.js
+21
-0
NetworkTab.js
next/components/dunbar/NetworkTab.js
+284
-46
SearchTab.js
next/components/dunbar/SearchTab.js
+32
-2
StatsTab.js
next/components/dunbar/StatsTab.js
+68
-1
layout.js
next/components/layout.js
+18
-10
index.js
next/lib/dunbar-nlp/index.js
+367
-0
No files found.
next/components/dunbar/DunbarApp.js
View file @
a3812f69
...
@@ -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
>
);
);
...
...
next/components/dunbar/EventsTab.js
View file @
a3812f69
...
@@ -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
>
...
...
next/components/dunbar/FriendsList.js
View file @
a3812f69
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
...
...
next/components/dunbar/NetworkTab.js
View file @
a3812f69
...
@@ -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
(()
=>
f
ilteredF
riends
.
map
(
f
=>
({
id
:
String
(
f
.
id
),
id
:
String
(
f
.
id
),
name
:
f
.
name
,
name
:
f
.
name
,
})),
[
friends
]);
})),
[
f
ilteredF
riends
]);
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
>
...
...
next/components/dunbar/SearchTab.js
View file @
a3812f69
...
@@ -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
||
''
)}
...
...
next/components/dunbar/StatsTab.js
View file @
a3812f69
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
}
>
...
...
next/components/layout.js
View file @
a3812f69
...
@@ -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
>
);
);
...
...
next/lib/dunbar-nlp/index.js
0 → 100644
View file @
a3812f69
/**
* 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
(
/
[\u
0300-
\u
036f
]
/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}
]
+/g
u
)
||
[]).
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}_-
]
+/g
u
)
||
[]).
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}'’_-
]
*/g
u
)
||
[]).
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
,
};
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment