@causalmap/filter-engine 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +4 -0
- package/package.json +2 -1
- package/src/apply-filters.js +117 -34
- package/src/link-display-transforms.js +459 -430
- package/src/link-traversal.js +167 -144
|
@@ -1,430 +1,459 @@
|
|
|
1
|
-
// Pure link-display transforms shared by the UI pipeline
|
|
2
|
-
|
|
3
|
-
export function getLinkCustomColumnValue(link, fieldName) {
|
|
4
|
-
const raw = String(fieldName || '').trim()
|
|
5
|
-
if (!raw || !link || typeof link !== 'object') return undefined
|
|
6
|
-
const key = raw.startsWith('l_') ? raw.slice(2) : raw
|
|
7
|
-
const directRaw = link[raw]
|
|
8
|
-
if (directRaw !== undefined && directRaw !== null && directRaw !== '') return directRaw
|
|
9
|
-
const directCanonical = link[`l_${key}`]
|
|
10
|
-
if (directCanonical !== undefined && directCanonical !== null && directCanonical !== '') return directCanonical
|
|
11
|
-
const custom = link.metadata?.custom_columns
|
|
12
|
-
if (custom && typeof custom === 'object') {
|
|
13
|
-
const customVal = custom[key]
|
|
14
|
-
if (customVal !== undefined && customVal !== null && customVal !== '') return customVal
|
|
15
|
-
}
|
|
16
|
-
// Enriched/API links: source metadata merged as `s_<safe>` (slashes/spaces → `_`; `#` kept), same as app-api + DataService.
|
|
17
|
-
const rawTrim = String(raw).trim()
|
|
18
|
-
for (const suf of [rawTrim, rawTrim.replace(/^#/, '').trim()]) {
|
|
19
|
-
if (!suf) continue
|
|
20
|
-
const sk = `s_${suf.replace(/[\s/]/g, '_')}`
|
|
21
|
-
const fromSource = link[sk]
|
|
22
|
-
if (fromSource !== undefined && fromSource !== null && fromSource !== '') return fromSource
|
|
23
|
-
}
|
|
24
|
-
return undefined
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function applyLabelByGroupToFactors(links, filter, options = {}) {
|
|
28
|
-
if (!filter?.applyToFactors || !String(filter?.selectedField || '').trim()) return links
|
|
29
|
-
const field = String(filter.selectedField).trim()
|
|
30
|
-
const mode = filter.displayMode || 'tally'
|
|
31
|
-
const which = filter.which || 'sources'
|
|
32
|
-
const sigLevel = (typeof filter.sigLevel === 'number') ? filter.sigLevel : 0.05
|
|
33
|
-
const ordinalCorrection = filter.ordinalCorrection === true
|
|
34
|
-
const isChiMode = isChiSquareMode(mode)
|
|
35
|
-
const chiSqCritical1df = chiSquareCritical1df(sigLevel)
|
|
36
|
-
const trendTest = typeof options.trendTest === 'function' ? options.trendTest : null
|
|
37
|
-
|
|
38
|
-
const factorLinks = new Map()
|
|
39
|
-
const addLink = (factor, link) => {
|
|
40
|
-
if (!factor) return
|
|
41
|
-
if (!factorLinks.has(factor)) factorLinks.set(factor, [])
|
|
42
|
-
factorLinks.get(factor).push(link)
|
|
43
|
-
}
|
|
44
|
-
links.forEach(link => {
|
|
45
|
-
addLink(String(link?.cause || '').trim(), link)
|
|
46
|
-
addLink(String(link?.effect || '').trim(), link)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
const buildDisplay = (factorLinksList) => {
|
|
50
|
-
if (!factorLinksList.length) return ''
|
|
51
|
-
return which === 'sources'
|
|
52
|
-
? labelByGroupSourcesDisplay({ bundleLinks: factorLinksList, allLinks: links, field, mode, sigLevel, ordinalCorrection, chiSqCritical1df, trendTest, factorMode: true })
|
|
53
|
-
: labelByGroupCitationsDisplay({ bundleLinks: factorLinksList, allLinks: links, field, mode, sigLevel, ordinalCorrection, chiSqCritical1df, trendTest, factorMode: true })
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const factorDisplay = new Map()
|
|
57
|
-
factorLinks.forEach((factorLinksList, factor) => {
|
|
58
|
-
const display = buildDisplay(factorLinksList)
|
|
59
|
-
if (display) factorDisplay.set(factor, display)
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
return links.map(link => {
|
|
63
|
-
const cause = String(link?.cause || '').trim()
|
|
64
|
-
const effect = String(link?.effect || '').trim()
|
|
65
|
-
const causeDisplay = factorDisplay.get(cause)
|
|
66
|
-
const effectDisplay = factorDisplay.get(effect)
|
|
67
|
-
return {
|
|
68
|
-
...link,
|
|
69
|
-
cause: causeDisplay ? `${cause} ${causeDisplay}` : cause,
|
|
70
|
-
effect: effectDisplay ? `${effect} ${effectDisplay}` : effect
|
|
71
|
-
}
|
|
72
|
-
})
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function computeLabelByGroupEdgeLabel(edgeLinks, allLinks, filter, options = {}) {
|
|
76
|
-
const bundleCount = Array.isArray(edgeLinks) ? edgeLinks.length : 0
|
|
77
|
-
if (!filter || filter.applyToLinks === false || !String(filter.selectedField || '').trim()) return String(bundleCount)
|
|
78
|
-
const mode = filter.displayMode || 'tally'
|
|
79
|
-
const params = {
|
|
80
|
-
bundleLinks: Array.isArray(edgeLinks) ? edgeLinks : [],
|
|
81
|
-
allLinks: Array.isArray(allLinks) ? allLinks : [],
|
|
82
|
-
field: String(filter.selectedField || '').trim(),
|
|
83
|
-
mode,
|
|
84
|
-
sigLevel: (typeof filter.sigLevel === 'number') ? filter.sigLevel : 0.05,
|
|
85
|
-
ordinalCorrection: filter.ordinalCorrection === true,
|
|
86
|
-
chiSqCritical1df: chiSquareCritical1df((typeof filter.sigLevel === 'number') ? filter.sigLevel : 0.05),
|
|
87
|
-
trendTest: typeof options.trendTest === 'function' ? options.trendTest : null,
|
|
88
|
-
factorMode: false
|
|
89
|
-
}
|
|
90
|
-
// Match UI/filter defaults: tally by unique sources unless user chose citations explicitly.
|
|
91
|
-
const label = filter.which !== 'citations'
|
|
92
|
-
? labelByGroupSourcesDisplay(params)
|
|
93
|
-
: labelByGroupCitationsDisplay(params)
|
|
94
|
-
return label || String(bundleCount)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
if (!
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
if (!
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (!
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if (
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
1
|
+
// Pure link-display transforms shared by the UI pipeline and the app API.
|
|
2
|
+
|
|
3
|
+
export function getLinkCustomColumnValue(link, fieldName) {
|
|
4
|
+
const raw = String(fieldName || '').trim()
|
|
5
|
+
if (!raw || !link || typeof link !== 'object') return undefined
|
|
6
|
+
const key = raw.startsWith('l_') ? raw.slice(2) : raw
|
|
7
|
+
const directRaw = link[raw]
|
|
8
|
+
if (directRaw !== undefined && directRaw !== null && directRaw !== '') return directRaw
|
|
9
|
+
const directCanonical = link[`l_${key}`]
|
|
10
|
+
if (directCanonical !== undefined && directCanonical !== null && directCanonical !== '') return directCanonical
|
|
11
|
+
const custom = link.metadata?.custom_columns
|
|
12
|
+
if (custom && typeof custom === 'object') {
|
|
13
|
+
const customVal = custom[key]
|
|
14
|
+
if (customVal !== undefined && customVal !== null && customVal !== '') return customVal
|
|
15
|
+
}
|
|
16
|
+
// Enriched/API links: source metadata merged as `s_<safe>` (slashes/spaces → `_`; `#` kept), same as app-api + DataService.
|
|
17
|
+
const rawTrim = String(raw).trim()
|
|
18
|
+
for (const suf of [rawTrim, rawTrim.replace(/^#/, '').trim()]) {
|
|
19
|
+
if (!suf) continue
|
|
20
|
+
const sk = `s_${suf.replace(/[\s/]/g, '_')}`
|
|
21
|
+
const fromSource = link[sk]
|
|
22
|
+
if (fromSource !== undefined && fromSource !== null && fromSource !== '') return fromSource
|
|
23
|
+
}
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function applyLabelByGroupToFactors(links, filter, options = {}) {
|
|
28
|
+
if (!filter?.applyToFactors || !String(filter?.selectedField || '').trim()) return links
|
|
29
|
+
const field = String(filter.selectedField).trim()
|
|
30
|
+
const mode = filter.displayMode || 'tally'
|
|
31
|
+
const which = filter.which || 'sources'
|
|
32
|
+
const sigLevel = (typeof filter.sigLevel === 'number') ? filter.sigLevel : 0.05
|
|
33
|
+
const ordinalCorrection = filter.ordinalCorrection === true
|
|
34
|
+
const isChiMode = isChiSquareMode(mode)
|
|
35
|
+
const chiSqCritical1df = chiSquareCritical1df(sigLevel)
|
|
36
|
+
const trendTest = typeof options.trendTest === 'function' ? options.trendTest : null
|
|
37
|
+
|
|
38
|
+
const factorLinks = new Map()
|
|
39
|
+
const addLink = (factor, link) => {
|
|
40
|
+
if (!factor) return
|
|
41
|
+
if (!factorLinks.has(factor)) factorLinks.set(factor, [])
|
|
42
|
+
factorLinks.get(factor).push(link)
|
|
43
|
+
}
|
|
44
|
+
links.forEach(link => {
|
|
45
|
+
addLink(String(link?.cause || '').trim(), link)
|
|
46
|
+
addLink(String(link?.effect || '').trim(), link)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const buildDisplay = (factorLinksList) => {
|
|
50
|
+
if (!factorLinksList.length) return ''
|
|
51
|
+
return which === 'sources'
|
|
52
|
+
? labelByGroupSourcesDisplay({ bundleLinks: factorLinksList, allLinks: links, field, mode, sigLevel, ordinalCorrection, chiSqCritical1df, trendTest, factorMode: true })
|
|
53
|
+
: labelByGroupCitationsDisplay({ bundleLinks: factorLinksList, allLinks: links, field, mode, sigLevel, ordinalCorrection, chiSqCritical1df, trendTest, factorMode: true })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const factorDisplay = new Map()
|
|
57
|
+
factorLinks.forEach((factorLinksList, factor) => {
|
|
58
|
+
const display = buildDisplay(factorLinksList)
|
|
59
|
+
if (display) factorDisplay.set(factor, display)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
return links.map(link => {
|
|
63
|
+
const cause = String(link?.cause || '').trim()
|
|
64
|
+
const effect = String(link?.effect || '').trim()
|
|
65
|
+
const causeDisplay = factorDisplay.get(cause)
|
|
66
|
+
const effectDisplay = factorDisplay.get(effect)
|
|
67
|
+
return {
|
|
68
|
+
...link,
|
|
69
|
+
cause: causeDisplay ? `${cause} ${causeDisplay}` : cause,
|
|
70
|
+
effect: effectDisplay ? `${effect} ${effectDisplay}` : effect
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function computeLabelByGroupEdgeLabel(edgeLinks, allLinks, filter, options = {}) {
|
|
76
|
+
const bundleCount = Array.isArray(edgeLinks) ? edgeLinks.length : 0
|
|
77
|
+
if (!filter || filter.applyToLinks === false || !String(filter.selectedField || '').trim()) return String(bundleCount)
|
|
78
|
+
const mode = filter.displayMode || 'tally'
|
|
79
|
+
const params = {
|
|
80
|
+
bundleLinks: Array.isArray(edgeLinks) ? edgeLinks : [],
|
|
81
|
+
allLinks: Array.isArray(allLinks) ? allLinks : [],
|
|
82
|
+
field: String(filter.selectedField || '').trim(),
|
|
83
|
+
mode,
|
|
84
|
+
sigLevel: (typeof filter.sigLevel === 'number') ? filter.sigLevel : 0.05,
|
|
85
|
+
ordinalCorrection: filter.ordinalCorrection === true,
|
|
86
|
+
chiSqCritical1df: chiSquareCritical1df((typeof filter.sigLevel === 'number') ? filter.sigLevel : 0.05),
|
|
87
|
+
trendTest: typeof options.trendTest === 'function' ? options.trendTest : null,
|
|
88
|
+
factorMode: false
|
|
89
|
+
}
|
|
90
|
+
// Match UI/filter defaults: tally by unique sources unless user chose citations explicitly.
|
|
91
|
+
const label = filter.which !== 'citations'
|
|
92
|
+
? labelByGroupSourcesDisplay(params)
|
|
93
|
+
: labelByGroupCitationsDisplay(params)
|
|
94
|
+
return label || String(bundleCount)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Combine Opposites colouring: a link end or a factor runs from the unflipped colour, where none of its links was
|
|
98
|
+
// coded at the opposite pole, through purple, where half were, to the flipped red, where all were. Going through a
|
|
99
|
+
// purple rather than straight from one end to the other keeps a mix reading as a colour of its own, where a straight
|
|
100
|
+
// blend of blue and red is a muddy grey. Together bundling uses this blue; Separate bundling passes the map's main
|
|
101
|
+
// link colour as `unflipped` when one is chosen. The Rubicon report draws its maps with the same colours.
|
|
102
|
+
// The two poles are a muted blue and a muted red rather than the brand's light blue, which is too close to the purple
|
|
103
|
+
// to read as "blue" beside it and too pale for a thin line on white (2:1); each of the three is at least 3:1 on white
|
|
104
|
+
// and on the report's dark panel.
|
|
105
|
+
export const OPPOSITES_UNFLIPPED_COLOUR = '#4f7cc4'
|
|
106
|
+
export const OPPOSITES_MIXED_COLOUR = '#9b59b6'
|
|
107
|
+
export const OPPOSITES_FLIPPED_COLOUR = '#c4524c'
|
|
108
|
+
|
|
109
|
+
export function interpolateHexColour(fromHex, toHex, t) {
|
|
110
|
+
const parse = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]
|
|
111
|
+
const [r1, g1, b1] = parse(fromHex)
|
|
112
|
+
const [r2, g2, b2] = parse(toHex)
|
|
113
|
+
const tt = Math.max(0, Math.min(1, Number(t) || 0))
|
|
114
|
+
const hex = (a, b) => Math.round(a + (b - a) * tt).toString(16).padStart(2, '0')
|
|
115
|
+
return `#${hex(r1, r2)}${hex(g1, g2)}${hex(b1, b2)}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The colour for a share (0 to 1) of links coded at the opposite pole. */
|
|
119
|
+
export function flippedShareColour(share, unflipped = OPPOSITES_UNFLIPPED_COLOUR) {
|
|
120
|
+
const t = Math.max(0, Math.min(1, Number(share) || 0))
|
|
121
|
+
return t <= 0.5
|
|
122
|
+
? interpolateHexColour(unflipped, OPPOSITES_MIXED_COLOUR, t * 2)
|
|
123
|
+
: interpolateHexColour(OPPOSITES_MIXED_COLOUR, OPPOSITES_FLIPPED_COLOUR, t * 2 - 1)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getRenderedEdgeBundleGroups(links, filters = []) {
|
|
127
|
+
const combineOppFilter = Array.isArray(filters)
|
|
128
|
+
? filters.find(f => f?.type === 'combine-opposites' && f.enabled !== false)
|
|
129
|
+
: null
|
|
130
|
+
const separateBundles = !!combineOppFilter && combineOppFilter.bundleStrategy !== 'together'
|
|
131
|
+
const groups = new Map()
|
|
132
|
+
for (const link of (Array.isArray(links) ? links : [])) {
|
|
133
|
+
const cause = String(link?.cause || '').trim()
|
|
134
|
+
const effect = String(link?.effect || '').trim()
|
|
135
|
+
if (!cause || !effect) continue
|
|
136
|
+
const edgeKey = separateBundles
|
|
137
|
+
? `${cause}|||${effect}|||${link?.flipped_cause === true ? 'F' : '-'}${link?.flipped_effect === true ? 'F' : '-'}`
|
|
138
|
+
: `${cause}|||${effect}`
|
|
139
|
+
if (!groups.has(edgeKey)) groups.set(edgeKey, [])
|
|
140
|
+
groups.get(edgeKey).push(link)
|
|
141
|
+
}
|
|
142
|
+
return groups
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function isMapCustomColumnsFilterConfigured(filter) {
|
|
146
|
+
return !!(
|
|
147
|
+
filter?.type === 'map-custom-columns' &&
|
|
148
|
+
(
|
|
149
|
+
String(filter.labelField || '').trim() ||
|
|
150
|
+
String(filter.widthField || '').trim() ||
|
|
151
|
+
String(filter.colourField || '').trim()
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function computeMapCustomLabelForLinkBundle(edgeLinks, filter) {
|
|
157
|
+
const field = String(filter?.labelField || '').trim()
|
|
158
|
+
if (!field) return ''
|
|
159
|
+
const aggregation = String(filter?.labelAggregation || 'unique')
|
|
160
|
+
const values = getMapCustomTextValues(edgeLinks, field)
|
|
161
|
+
if (!values.length) return ''
|
|
162
|
+
if (aggregation === 'average' || aggregation === 'sum') {
|
|
163
|
+
const nums = getMapCustomNumericValues(edgeLinks, field)
|
|
164
|
+
if (!nums.length) return ''
|
|
165
|
+
const total = nums.reduce((sum, n) => sum + n, 0)
|
|
166
|
+
return formatMapCustomNumericValue(aggregation === 'sum' ? total : (total / nums.length))
|
|
167
|
+
}
|
|
168
|
+
if (aggregation === 'all') return values.join(', ')
|
|
169
|
+
if (aggregation === 'tally') {
|
|
170
|
+
const counts = new Map()
|
|
171
|
+
values.forEach(value => counts.set(value, (counts.get(value) || 0) + 1))
|
|
172
|
+
return Array.from(counts.entries())
|
|
173
|
+
.sort((a, b) => (b[1] - a[1]) || String(a[0]).localeCompare(String(b[0])))
|
|
174
|
+
.map(([value, count]) => `${value}: ${count}`)
|
|
175
|
+
.join(', ')
|
|
176
|
+
}
|
|
177
|
+
const unique = []
|
|
178
|
+
const seen = new Set()
|
|
179
|
+
values.forEach(value => {
|
|
180
|
+
const lower = value.toLowerCase()
|
|
181
|
+
if (seen.has(lower)) return
|
|
182
|
+
seen.add(lower)
|
|
183
|
+
unique.push(value)
|
|
184
|
+
})
|
|
185
|
+
return unique.join(', ')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function computeMapCustomWidthForLinkBundle(edgeLinks, filter) {
|
|
189
|
+
const field = String(filter?.widthField || '').trim()
|
|
190
|
+
if (!field) return null
|
|
191
|
+
const nums = getMapCustomNumericValues(edgeLinks, field)
|
|
192
|
+
if (!nums.length) return null
|
|
193
|
+
const aggregation = String(filter?.widthAggregation || 'average')
|
|
194
|
+
if (aggregation === 'sum') return nums.reduce((sum, n) => sum + n, 0)
|
|
195
|
+
if (aggregation === 'max') return Math.max(...nums)
|
|
196
|
+
return nums.reduce((sum, n) => sum + n, 0) / nums.length
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function computeMapCustomColourForLinkBundle(edgeLinks, allLinks, filter) {
|
|
200
|
+
const field = String(filter?.colourField || '').trim()
|
|
201
|
+
if (!field) return ''
|
|
202
|
+
const values = getMapCustomTextValues(edgeLinks, field)
|
|
203
|
+
if (!values.length) return ''
|
|
204
|
+
const allValues = getMapCustomTextValues(allLinks, field)
|
|
205
|
+
const allNumeric = allValues.length > 0 && allValues.every(value => Number.isFinite(Number(value)))
|
|
206
|
+
if (!allNumeric) return ''
|
|
207
|
+
const nums = getMapCustomNumericValues(edgeLinks, field)
|
|
208
|
+
const allNums = getMapCustomNumericValues(allLinks, field)
|
|
209
|
+
if (!nums.length || !allNums.length) return ''
|
|
210
|
+
const aggregation = String(filter?.colourAggregation || 'mode')
|
|
211
|
+
const aggValue = aggregation === 'max'
|
|
212
|
+
? Math.max(...nums)
|
|
213
|
+
: aggregation === 'mode'
|
|
214
|
+
? computeNumericMode(nums)
|
|
215
|
+
: nums.reduce((sum, n) => sum + n, 0) / nums.length
|
|
216
|
+
if (!Number.isFinite(Number(aggValue))) return ''
|
|
217
|
+
return mapNumericValueToMutedDivergingColour(Number(aggValue), Math.min(...allNums), Math.max(...allNums))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function attachReservedCmLinkFields(links, filters = [], options = {}) {
|
|
221
|
+
if (!Array.isArray(links) || !links.length) return links
|
|
222
|
+
const stripped = links.map(link => {
|
|
223
|
+
const out = { ...link }
|
|
224
|
+
delete out._cm_link_label
|
|
225
|
+
delete out._cm_link_width
|
|
226
|
+
delete out._cm_link_colour
|
|
227
|
+
return out
|
|
228
|
+
})
|
|
229
|
+
const activeFilters = Array.isArray(filters) ? filters.filter(filter => filter && filter.enabled !== false) : []
|
|
230
|
+
const bundleGroups = getRenderedEdgeBundleGroups(stripped, activeFilters)
|
|
231
|
+
for (const filter of activeFilters) {
|
|
232
|
+
if (filter.type === 'custom-links-label' && filter.applyToLinks !== false && String(filter.selectedField || '').trim()) {
|
|
233
|
+
for (const bundleLinks of bundleGroups.values()) {
|
|
234
|
+
const label = computeLabelByGroupEdgeLabel(bundleLinks, stripped, filter, options)
|
|
235
|
+
for (const link of bundleLinks) link._cm_link_label = label
|
|
236
|
+
}
|
|
237
|
+
} else if (isMapCustomColumnsFilterConfigured(filter)) {
|
|
238
|
+
for (const bundleLinks of bundleGroups.values()) {
|
|
239
|
+
if (String(filter.labelField || '').trim()) {
|
|
240
|
+
const label = computeMapCustomLabelForLinkBundle(bundleLinks, filter)
|
|
241
|
+
for (const link of bundleLinks) link._cm_link_label = label
|
|
242
|
+
}
|
|
243
|
+
if (String(filter.widthField || '').trim()) {
|
|
244
|
+
const width = computeMapCustomWidthForLinkBundle(bundleLinks, filter)
|
|
245
|
+
for (const link of bundleLinks) link._cm_link_width = Number.isFinite(Number(width)) ? Number(width) : null
|
|
246
|
+
}
|
|
247
|
+
if (String(filter.colourField || '').trim()) {
|
|
248
|
+
const colour = computeMapCustomColourForLinkBundle(bundleLinks, stripped, filter)
|
|
249
|
+
for (const link of bundleLinks) link._cm_link_colour = colour || ''
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return stripped
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function labelByGroupSourcesDisplay(params) {
|
|
258
|
+
const { bundleLinks, allLinks, field, mode, factorMode } = params
|
|
259
|
+
const valueSets = new Map()
|
|
260
|
+
bundleLinks.forEach(link => {
|
|
261
|
+
const value = getLinkCustomColumnValue(link, field)
|
|
262
|
+
const sourceId = String(link?.source_id || '').trim()
|
|
263
|
+
if (value === undefined || value === null || value === '' || !sourceId) return
|
|
264
|
+
if (!valueSets.has(value)) valueSets.set(value, new Set())
|
|
265
|
+
valueSets.get(value).add(sourceId)
|
|
266
|
+
})
|
|
267
|
+
if (!valueSets.size) return ''
|
|
268
|
+
if (mode === 'tally') return sortedSetCounts(valueSets).map(([value, count]) => `${value}: ${count}`).join(' ')
|
|
269
|
+
|
|
270
|
+
const totalSets = new Map()
|
|
271
|
+
allLinks.forEach(link => {
|
|
272
|
+
const value = getLinkCustomColumnValue(link, field)
|
|
273
|
+
const sourceId = String(link?.source_id || '').trim()
|
|
274
|
+
if (value === undefined || value === null || value === '' || !sourceId) return
|
|
275
|
+
if (!totalSets.has(value)) totalSets.set(value, new Set())
|
|
276
|
+
totalSets.get(value).add(sourceId)
|
|
277
|
+
})
|
|
278
|
+
if (mode === 'percentage') {
|
|
279
|
+
return sortedSetCounts(valueSets).map(([value, count]) => {
|
|
280
|
+
const total = totalSets.get(value)?.size ?? count
|
|
281
|
+
const pct = total > 0 ? Math.round((count / total) * 100) : 0
|
|
282
|
+
return `${value}: ${pct}%`
|
|
283
|
+
}).join(' ')
|
|
284
|
+
}
|
|
285
|
+
if (isChiSquareMode(mode)) {
|
|
286
|
+
return chiSquareDisplay({
|
|
287
|
+
...params,
|
|
288
|
+
observedEntries: sortedSetCounts(valueSets),
|
|
289
|
+
totalCounts: new Map(Array.from(totalSets.entries()).map(([value, set]) => [value, set.size])),
|
|
290
|
+
bundleSize: new Set(bundleLinks.map(link => link?.source_id).filter(Boolean)).size,
|
|
291
|
+
grandTotal: new Set(allLinks.map(link => link?.source_id).filter(Boolean)).size,
|
|
292
|
+
factorMode
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
return ''
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function labelByGroupCitationsDisplay(params) {
|
|
299
|
+
const { bundleLinks, allLinks, field, mode, factorMode } = params
|
|
300
|
+
const valueCounts = countValues(bundleLinks, field)
|
|
301
|
+
if (!valueCounts.size) return ''
|
|
302
|
+
if (mode === 'tally') return sortedCounts(valueCounts).map(([value, count]) => `${value}: ${count}`).join(' ')
|
|
303
|
+
|
|
304
|
+
const totalCounts = countValues(allLinks, field)
|
|
305
|
+
if (mode === 'percentage') {
|
|
306
|
+
return sortedCounts(valueCounts).map(([value, count]) => {
|
|
307
|
+
const total = totalCounts.get(value) || count
|
|
308
|
+
const pct = total > 0 ? Math.round((count / total) * 100) : 0
|
|
309
|
+
return `${value}: ${pct}%`
|
|
310
|
+
}).join(' ')
|
|
311
|
+
}
|
|
312
|
+
if (isChiSquareMode(mode)) {
|
|
313
|
+
return chiSquareDisplay({
|
|
314
|
+
...params,
|
|
315
|
+
observedEntries: sortedCounts(valueCounts),
|
|
316
|
+
totalCounts,
|
|
317
|
+
bundleSize: bundleLinks.length,
|
|
318
|
+
grandTotal: allLinks.length,
|
|
319
|
+
factorMode
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
return ''
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function chiSquareDisplay(params) {
|
|
326
|
+
const { observedEntries, totalCounts, bundleSize, grandTotal, mode, chiSqCritical1df, factorMode } = params
|
|
327
|
+
const ordinal = ordinalTrendDisplay(params)
|
|
328
|
+
if (ordinal !== null) {
|
|
329
|
+
if (!ordinal) return factorMode ? '' : String(bundleSize)
|
|
330
|
+
return factorMode ? ordinal : `${bundleSize} (${ordinal.replace(/^⭐/, '')})`
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const significantValues = []
|
|
334
|
+
observedEntries.forEach(([value, observed]) => {
|
|
335
|
+
const valueTotal = totalCounts.get(value) || 0
|
|
336
|
+
if (valueTotal === 0 || grandTotal === 0) return
|
|
337
|
+
const expected = (bundleSize * valueTotal) / grandTotal
|
|
338
|
+
const chisq = expected > 0 ? Math.pow(observed - expected, 2) / expected : 0
|
|
339
|
+
if (chisq > chiSqCritical1df) {
|
|
340
|
+
const arrow = observed > expected ? '▲' : '▼'
|
|
341
|
+
if (mode === 'chisq-counts') significantValues.push(`${value}: ${observed}${arrow}`)
|
|
342
|
+
else if (mode === 'chisq-counts-totals') significantValues.push(`${value}: ${observed}/${valueTotal}${arrow}`)
|
|
343
|
+
else significantValues.push(`${value}${arrow}`)
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
if (factorMode) return significantValues.length ? `⭐${significantValues.join(', ')}` : ''
|
|
347
|
+
return significantValues.length ? `${bundleSize} (${significantValues.join(', ')})` : String(bundleSize)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function ordinalTrendDisplay(params) {
|
|
351
|
+
const { ordinalCorrection, trendTest, totalCounts, observedEntries, sigLevel } = params
|
|
352
|
+
if (!ordinalCorrection || !trendTest) return null
|
|
353
|
+
const scored = Array.from(totalCounts.keys()).map(value => ({ value, score: parseLeadingInt(value) }))
|
|
354
|
+
const numericLike = scored.length >= 2 && scored.every(item => item.score !== null)
|
|
355
|
+
if (!numericLike) return null
|
|
356
|
+
const observedMap = new Map(observedEntries)
|
|
357
|
+
scored.sort((a, b) => (a.score - b.score) || String(a.value).localeCompare(String(b.value)))
|
|
358
|
+
const observedCounts = scored.map(item => observedMap.get(item.value) || 0)
|
|
359
|
+
const totals = scored.map(item => totalCounts.get(item.value) || 0)
|
|
360
|
+
const scores = scored.map(item => item.score)
|
|
361
|
+
const result = trendTest(observedCounts, totals, scores)
|
|
362
|
+
if (result?.isValid && result.pValue < sigLevel && (result.direction === 'up' || result.direction === 'down')) {
|
|
363
|
+
return result.direction === 'up' ? '⭐▲' : '⭐▼'
|
|
364
|
+
}
|
|
365
|
+
return ''
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function getMapCustomTextValues(edgeLinks, fieldName) {
|
|
369
|
+
return (Array.isArray(edgeLinks) ? edgeLinks : [])
|
|
370
|
+
.map(link => getLinkCustomColumnValue(link, fieldName))
|
|
371
|
+
.map(value => String(value ?? '').trim())
|
|
372
|
+
.filter(Boolean)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function getMapCustomNumericValues(edgeLinks, fieldName) {
|
|
376
|
+
return getMapCustomTextValues(edgeLinks, fieldName)
|
|
377
|
+
.map(value => Number(value))
|
|
378
|
+
.filter(value => Number.isFinite(value))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function countValues(links, field) {
|
|
382
|
+
const counts = new Map()
|
|
383
|
+
links.forEach(link => {
|
|
384
|
+
const value = getLinkCustomColumnValue(link, field)
|
|
385
|
+
if (value !== undefined && value !== null && value !== '') counts.set(value, (counts.get(value) || 0) + 1)
|
|
386
|
+
})
|
|
387
|
+
return counts
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function sortedCounts(counts) {
|
|
391
|
+
return Array.from(counts.entries()).sort((a, b) => String(a[0]).localeCompare(String(b[0])))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function sortedSetCounts(sets) {
|
|
395
|
+
return Array.from(sets.entries())
|
|
396
|
+
.map(([value, set]) => [value, set.size])
|
|
397
|
+
.sort((a, b) => String(a[0]).localeCompare(String(b[0])))
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function isChiSquareMode(mode) {
|
|
401
|
+
return mode === 'chisq' || mode === 'chisq-counts' || mode === 'chisq-counts-totals'
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function chiSquareCritical1df(sigLevel) {
|
|
405
|
+
if (sigLevel === 0.1) return 2.71
|
|
406
|
+
if (sigLevel === 0.01) return 6.63
|
|
407
|
+
if (sigLevel === 0.005) return 7.88
|
|
408
|
+
return 3.84
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function parseLeadingInt(value) {
|
|
412
|
+
const match = String(value ?? '').trim().match(/^([+-]?\d+)/)
|
|
413
|
+
if (!match) return null
|
|
414
|
+
const n = parseInt(match[1], 10)
|
|
415
|
+
return Number.isFinite(n) ? n : null
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function formatMapCustomNumericValue(value) {
|
|
419
|
+
const n = Number(value)
|
|
420
|
+
if (!Number.isFinite(n)) return ''
|
|
421
|
+
if (Number.isInteger(n)) return String(n)
|
|
422
|
+
return n.toFixed(2).replace(/\.?0+$/, '')
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function computeNumericMode(values) {
|
|
426
|
+
const counts = new Map()
|
|
427
|
+
values.forEach(value => counts.set(value, (counts.get(value) || 0) + 1))
|
|
428
|
+
return Array.from(counts.entries()).sort((a, b) => (b[1] - a[1]) || (b[0] - a[0]))[0]?.[0] ?? null
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function mapNumericValueToMutedDivergingColour(value, minValue, maxValue) {
|
|
432
|
+
const valueNum = Number(value)
|
|
433
|
+
const minNum = Number(minValue)
|
|
434
|
+
const maxNum = Number(maxValue)
|
|
435
|
+
const whiteish = { r: 245, g: 245, b: 245 }
|
|
436
|
+
const mutedGreen = { r: 120, g: 155, b: 120 }
|
|
437
|
+
const mutedBlue = { r: 107, g: 140, b: 174 }
|
|
438
|
+
|
|
439
|
+
if (!Number.isFinite(valueNum) || !Number.isFinite(minNum) || !Number.isFinite(maxNum)) return interpolateRgbColor(whiteish, whiteish, 0)
|
|
440
|
+
if (minNum === maxNum) {
|
|
441
|
+
if (valueNum > 0) return interpolateRgbColor(mutedGreen, mutedGreen, 0)
|
|
442
|
+
if (valueNum < 0) return interpolateRgbColor(mutedBlue, mutedBlue, 0)
|
|
443
|
+
return interpolateRgbColor(whiteish, whiteish, 0)
|
|
444
|
+
}
|
|
445
|
+
if (minNum < 0 && maxNum > 0) {
|
|
446
|
+
if (valueNum >= 0) return interpolateRgbColor(whiteish, mutedGreen, maxNum === 0 ? 0 : (valueNum / maxNum))
|
|
447
|
+
return interpolateRgbColor(whiteish, mutedBlue, minNum === 0 ? 0 : (valueNum / minNum))
|
|
448
|
+
}
|
|
449
|
+
if (maxNum <= 0) return interpolateRgbColor(whiteish, mutedBlue, (maxNum === minNum) ? 0 : ((valueNum - maxNum) / (minNum - maxNum)))
|
|
450
|
+
return interpolateRgbColor(whiteish, mutedGreen, (maxNum === minNum) ? 0 : ((valueNum - minNum) / (maxNum - minNum)))
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function interpolateRgbColor(fromRgb, toRgb, t) {
|
|
454
|
+
const tt = Math.max(0, Math.min(1, Number(t) || 0))
|
|
455
|
+
const r = Math.round(fromRgb.r + (toRgb.r - fromRgb.r) * tt)
|
|
456
|
+
const g = Math.round(fromRgb.g + (toRgb.g - fromRgb.g) * tt)
|
|
457
|
+
const b = Math.round(fromRgb.b + (toRgb.b - fromRgb.b) * tt)
|
|
458
|
+
return `rgb(${r}, ${g}, ${b})`
|
|
459
|
+
}
|