@causalmap/filter-engine 0.1.0 → 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.
@@ -1,430 +1,459 @@
1
- // Pure link-display transforms shared by the UI pipeline, app API, and MapDog maps.
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
- export function getRenderedEdgeBundleGroups(links, filters = []) {
98
- const combineOppFilter = Array.isArray(filters)
99
- ? filters.find(f => f?.type === 'combine-opposites' && f.enabled !== false)
100
- : null
101
- const separateBundles = !!combineOppFilter && combineOppFilter.bundleStrategy !== 'together'
102
- const groups = new Map()
103
- for (const link of (Array.isArray(links) ? links : [])) {
104
- const cause = String(link?.cause || '').trim()
105
- const effect = String(link?.effect || '').trim()
106
- if (!cause || !effect) continue
107
- const edgeKey = separateBundles
108
- ? `${cause}|||${effect}|||${link?.flipped_cause === true ? 'F' : '-'}${link?.flipped_effect === true ? 'F' : '-'}`
109
- : `${cause}|||${effect}`
110
- if (!groups.has(edgeKey)) groups.set(edgeKey, [])
111
- groups.get(edgeKey).push(link)
112
- }
113
- return groups
114
- }
115
-
116
- export function isMapCustomColumnsFilterConfigured(filter) {
117
- return !!(
118
- filter?.type === 'map-custom-columns' &&
119
- (
120
- String(filter.labelField || '').trim() ||
121
- String(filter.widthField || '').trim() ||
122
- String(filter.colourField || '').trim()
123
- )
124
- )
125
- }
126
-
127
- export function computeMapCustomLabelForLinkBundle(edgeLinks, filter) {
128
- const field = String(filter?.labelField || '').trim()
129
- if (!field) return ''
130
- const aggregation = String(filter?.labelAggregation || 'unique')
131
- const values = getMapCustomTextValues(edgeLinks, field)
132
- if (!values.length) return ''
133
- if (aggregation === 'average' || aggregation === 'sum') {
134
- const nums = getMapCustomNumericValues(edgeLinks, field)
135
- if (!nums.length) return ''
136
- const total = nums.reduce((sum, n) => sum + n, 0)
137
- return formatMapCustomNumericValue(aggregation === 'sum' ? total : (total / nums.length))
138
- }
139
- if (aggregation === 'all') return values.join(', ')
140
- if (aggregation === 'tally') {
141
- const counts = new Map()
142
- values.forEach(value => counts.set(value, (counts.get(value) || 0) + 1))
143
- return Array.from(counts.entries())
144
- .sort((a, b) => (b[1] - a[1]) || String(a[0]).localeCompare(String(b[0])))
145
- .map(([value, count]) => `${value}: ${count}`)
146
- .join(', ')
147
- }
148
- const unique = []
149
- const seen = new Set()
150
- values.forEach(value => {
151
- const lower = value.toLowerCase()
152
- if (seen.has(lower)) return
153
- seen.add(lower)
154
- unique.push(value)
155
- })
156
- return unique.join(', ')
157
- }
158
-
159
- export function computeMapCustomWidthForLinkBundle(edgeLinks, filter) {
160
- const field = String(filter?.widthField || '').trim()
161
- if (!field) return null
162
- const nums = getMapCustomNumericValues(edgeLinks, field)
163
- if (!nums.length) return null
164
- const aggregation = String(filter?.widthAggregation || 'average')
165
- if (aggregation === 'sum') return nums.reduce((sum, n) => sum + n, 0)
166
- if (aggregation === 'max') return Math.max(...nums)
167
- return nums.reduce((sum, n) => sum + n, 0) / nums.length
168
- }
169
-
170
- export function computeMapCustomColourForLinkBundle(edgeLinks, allLinks, filter) {
171
- const field = String(filter?.colourField || '').trim()
172
- if (!field) return ''
173
- const values = getMapCustomTextValues(edgeLinks, field)
174
- if (!values.length) return ''
175
- const allValues = getMapCustomTextValues(allLinks, field)
176
- const allNumeric = allValues.length > 0 && allValues.every(value => Number.isFinite(Number(value)))
177
- if (!allNumeric) return ''
178
- const nums = getMapCustomNumericValues(edgeLinks, field)
179
- const allNums = getMapCustomNumericValues(allLinks, field)
180
- if (!nums.length || !allNums.length) return ''
181
- const aggregation = String(filter?.colourAggregation || 'mode')
182
- const aggValue = aggregation === 'max'
183
- ? Math.max(...nums)
184
- : aggregation === 'mode'
185
- ? computeNumericMode(nums)
186
- : nums.reduce((sum, n) => sum + n, 0) / nums.length
187
- if (!Number.isFinite(Number(aggValue))) return ''
188
- return mapNumericValueToMutedDivergingColour(Number(aggValue), Math.min(...allNums), Math.max(...allNums))
189
- }
190
-
191
- export function attachReservedCmLinkFields(links, filters = [], options = {}) {
192
- if (!Array.isArray(links) || !links.length) return links
193
- const stripped = links.map(link => {
194
- const out = { ...link }
195
- delete out._cm_link_label
196
- delete out._cm_link_width
197
- delete out._cm_link_colour
198
- return out
199
- })
200
- const activeFilters = Array.isArray(filters) ? filters.filter(filter => filter && filter.enabled !== false) : []
201
- const bundleGroups = getRenderedEdgeBundleGroups(stripped, activeFilters)
202
- for (const filter of activeFilters) {
203
- if (filter.type === 'custom-links-label' && filter.applyToLinks !== false && String(filter.selectedField || '').trim()) {
204
- for (const bundleLinks of bundleGroups.values()) {
205
- const label = computeLabelByGroupEdgeLabel(bundleLinks, stripped, filter, options)
206
- for (const link of bundleLinks) link._cm_link_label = label
207
- }
208
- } else if (isMapCustomColumnsFilterConfigured(filter)) {
209
- for (const bundleLinks of bundleGroups.values()) {
210
- if (String(filter.labelField || '').trim()) {
211
- const label = computeMapCustomLabelForLinkBundle(bundleLinks, filter)
212
- for (const link of bundleLinks) link._cm_link_label = label
213
- }
214
- if (String(filter.widthField || '').trim()) {
215
- const width = computeMapCustomWidthForLinkBundle(bundleLinks, filter)
216
- for (const link of bundleLinks) link._cm_link_width = Number.isFinite(Number(width)) ? Number(width) : null
217
- }
218
- if (String(filter.colourField || '').trim()) {
219
- const colour = computeMapCustomColourForLinkBundle(bundleLinks, stripped, filter)
220
- for (const link of bundleLinks) link._cm_link_colour = colour || ''
221
- }
222
- }
223
- }
224
- }
225
- return stripped
226
- }
227
-
228
- function labelByGroupSourcesDisplay(params) {
229
- const { bundleLinks, allLinks, field, mode, factorMode } = params
230
- const valueSets = new Map()
231
- bundleLinks.forEach(link => {
232
- const value = getLinkCustomColumnValue(link, field)
233
- const sourceId = String(link?.source_id || '').trim()
234
- if (value === undefined || value === null || value === '' || !sourceId) return
235
- if (!valueSets.has(value)) valueSets.set(value, new Set())
236
- valueSets.get(value).add(sourceId)
237
- })
238
- if (!valueSets.size) return ''
239
- if (mode === 'tally') return sortedSetCounts(valueSets).map(([value, count]) => `${value}: ${count}`).join(' ')
240
-
241
- const totalSets = new Map()
242
- allLinks.forEach(link => {
243
- const value = getLinkCustomColumnValue(link, field)
244
- const sourceId = String(link?.source_id || '').trim()
245
- if (value === undefined || value === null || value === '' || !sourceId) return
246
- if (!totalSets.has(value)) totalSets.set(value, new Set())
247
- totalSets.get(value).add(sourceId)
248
- })
249
- if (mode === 'percentage') {
250
- return sortedSetCounts(valueSets).map(([value, count]) => {
251
- const total = totalSets.get(value)?.size ?? count
252
- const pct = total > 0 ? Math.round((count / total) * 100) : 0
253
- return `${value}: ${pct}%`
254
- }).join(' ')
255
- }
256
- if (isChiSquareMode(mode)) {
257
- return chiSquareDisplay({
258
- ...params,
259
- observedEntries: sortedSetCounts(valueSets),
260
- totalCounts: new Map(Array.from(totalSets.entries()).map(([value, set]) => [value, set.size])),
261
- bundleSize: new Set(bundleLinks.map(link => link?.source_id).filter(Boolean)).size,
262
- grandTotal: new Set(allLinks.map(link => link?.source_id).filter(Boolean)).size,
263
- factorMode
264
- })
265
- }
266
- return ''
267
- }
268
-
269
- function labelByGroupCitationsDisplay(params) {
270
- const { bundleLinks, allLinks, field, mode, factorMode } = params
271
- const valueCounts = countValues(bundleLinks, field)
272
- if (!valueCounts.size) return ''
273
- if (mode === 'tally') return sortedCounts(valueCounts).map(([value, count]) => `${value}: ${count}`).join(' ')
274
-
275
- const totalCounts = countValues(allLinks, field)
276
- if (mode === 'percentage') {
277
- return sortedCounts(valueCounts).map(([value, count]) => {
278
- const total = totalCounts.get(value) || count
279
- const pct = total > 0 ? Math.round((count / total) * 100) : 0
280
- return `${value}: ${pct}%`
281
- }).join(' ')
282
- }
283
- if (isChiSquareMode(mode)) {
284
- return chiSquareDisplay({
285
- ...params,
286
- observedEntries: sortedCounts(valueCounts),
287
- totalCounts,
288
- bundleSize: bundleLinks.length,
289
- grandTotal: allLinks.length,
290
- factorMode
291
- })
292
- }
293
- return ''
294
- }
295
-
296
- function chiSquareDisplay(params) {
297
- const { observedEntries, totalCounts, bundleSize, grandTotal, mode, chiSqCritical1df, factorMode } = params
298
- const ordinal = ordinalTrendDisplay(params)
299
- if (ordinal !== null) {
300
- if (!ordinal) return factorMode ? '' : String(bundleSize)
301
- return factorMode ? ordinal : `${bundleSize} (${ordinal.replace(/^⭐/, '')})`
302
- }
303
-
304
- const significantValues = []
305
- observedEntries.forEach(([value, observed]) => {
306
- const valueTotal = totalCounts.get(value) || 0
307
- if (valueTotal === 0 || grandTotal === 0) return
308
- const expected = (bundleSize * valueTotal) / grandTotal
309
- const chisq = expected > 0 ? Math.pow(observed - expected, 2) / expected : 0
310
- if (chisq > chiSqCritical1df) {
311
- const arrow = observed > expected ? '▲' : '▼'
312
- if (mode === 'chisq-counts') significantValues.push(`${value}: ${observed}${arrow}`)
313
- else if (mode === 'chisq-counts-totals') significantValues.push(`${value}: ${observed}/${valueTotal}${arrow}`)
314
- else significantValues.push(`${value}${arrow}`)
315
- }
316
- })
317
- if (factorMode) return significantValues.length ? `⭐${significantValues.join(', ')}` : ''
318
- return significantValues.length ? `${bundleSize} (${significantValues.join(', ')})` : String(bundleSize)
319
- }
320
-
321
- function ordinalTrendDisplay(params) {
322
- const { ordinalCorrection, trendTest, totalCounts, observedEntries, sigLevel } = params
323
- if (!ordinalCorrection || !trendTest) return null
324
- const scored = Array.from(totalCounts.keys()).map(value => ({ value, score: parseLeadingInt(value) }))
325
- const numericLike = scored.length >= 2 && scored.every(item => item.score !== null)
326
- if (!numericLike) return null
327
- const observedMap = new Map(observedEntries)
328
- scored.sort((a, b) => (a.score - b.score) || String(a.value).localeCompare(String(b.value)))
329
- const observedCounts = scored.map(item => observedMap.get(item.value) || 0)
330
- const totals = scored.map(item => totalCounts.get(item.value) || 0)
331
- const scores = scored.map(item => item.score)
332
- const result = trendTest(observedCounts, totals, scores)
333
- if (result?.isValid && result.pValue < sigLevel && (result.direction === 'up' || result.direction === 'down')) {
334
- return result.direction === 'up' ? '⭐▲' : '⭐▼'
335
- }
336
- return ''
337
- }
338
-
339
- function getMapCustomTextValues(edgeLinks, fieldName) {
340
- return (Array.isArray(edgeLinks) ? edgeLinks : [])
341
- .map(link => getLinkCustomColumnValue(link, fieldName))
342
- .map(value => String(value ?? '').trim())
343
- .filter(Boolean)
344
- }
345
-
346
- function getMapCustomNumericValues(edgeLinks, fieldName) {
347
- return getMapCustomTextValues(edgeLinks, fieldName)
348
- .map(value => Number(value))
349
- .filter(value => Number.isFinite(value))
350
- }
351
-
352
- function countValues(links, field) {
353
- const counts = new Map()
354
- links.forEach(link => {
355
- const value = getLinkCustomColumnValue(link, field)
356
- if (value !== undefined && value !== null && value !== '') counts.set(value, (counts.get(value) || 0) + 1)
357
- })
358
- return counts
359
- }
360
-
361
- function sortedCounts(counts) {
362
- return Array.from(counts.entries()).sort((a, b) => String(a[0]).localeCompare(String(b[0])))
363
- }
364
-
365
- function sortedSetCounts(sets) {
366
- return Array.from(sets.entries())
367
- .map(([value, set]) => [value, set.size])
368
- .sort((a, b) => String(a[0]).localeCompare(String(b[0])))
369
- }
370
-
371
- function isChiSquareMode(mode) {
372
- return mode === 'chisq' || mode === 'chisq-counts' || mode === 'chisq-counts-totals'
373
- }
374
-
375
- function chiSquareCritical1df(sigLevel) {
376
- if (sigLevel === 0.1) return 2.71
377
- if (sigLevel === 0.01) return 6.63
378
- if (sigLevel === 0.005) return 7.88
379
- return 3.84
380
- }
381
-
382
- function parseLeadingInt(value) {
383
- const match = String(value ?? '').trim().match(/^([+-]?\d+)/)
384
- if (!match) return null
385
- const n = parseInt(match[1], 10)
386
- return Number.isFinite(n) ? n : null
387
- }
388
-
389
- function formatMapCustomNumericValue(value) {
390
- const n = Number(value)
391
- if (!Number.isFinite(n)) return ''
392
- if (Number.isInteger(n)) return String(n)
393
- return n.toFixed(2).replace(/\.?0+$/, '')
394
- }
395
-
396
- function computeNumericMode(values) {
397
- const counts = new Map()
398
- values.forEach(value => counts.set(value, (counts.get(value) || 0) + 1))
399
- return Array.from(counts.entries()).sort((a, b) => (b[1] - a[1]) || (b[0] - a[0]))[0]?.[0] ?? null
400
- }
401
-
402
- function mapNumericValueToMutedDivergingColour(value, minValue, maxValue) {
403
- const valueNum = Number(value)
404
- const minNum = Number(minValue)
405
- const maxNum = Number(maxValue)
406
- const whiteish = { r: 245, g: 245, b: 245 }
407
- const mutedGreen = { r: 120, g: 155, b: 120 }
408
- const mutedBlue = { r: 107, g: 140, b: 174 }
409
-
410
- if (!Number.isFinite(valueNum) || !Number.isFinite(minNum) || !Number.isFinite(maxNum)) return interpolateRgbColor(whiteish, whiteish, 0)
411
- if (minNum === maxNum) {
412
- if (valueNum > 0) return interpolateRgbColor(mutedGreen, mutedGreen, 0)
413
- if (valueNum < 0) return interpolateRgbColor(mutedBlue, mutedBlue, 0)
414
- return interpolateRgbColor(whiteish, whiteish, 0)
415
- }
416
- if (minNum < 0 && maxNum > 0) {
417
- if (valueNum >= 0) return interpolateRgbColor(whiteish, mutedGreen, maxNum === 0 ? 0 : (valueNum / maxNum))
418
- return interpolateRgbColor(whiteish, mutedBlue, minNum === 0 ? 0 : (valueNum / minNum))
419
- }
420
- if (maxNum <= 0) return interpolateRgbColor(whiteish, mutedBlue, (maxNum === minNum) ? 0 : ((valueNum - maxNum) / (minNum - maxNum)))
421
- return interpolateRgbColor(whiteish, mutedGreen, (maxNum === minNum) ? 0 : ((valueNum - minNum) / (maxNum - minNum)))
422
- }
423
-
424
- function interpolateRgbColor(fromRgb, toRgb, t) {
425
- const tt = Math.max(0, Math.min(1, Number(t) || 0))
426
- const r = Math.round(fromRgb.r + (toRgb.r - fromRgb.r) * tt)
427
- const g = Math.round(fromRgb.g + (toRgb.g - fromRgb.g) * tt)
428
- const b = Math.round(fromRgb.b + (toRgb.b - fromRgb.b) * tt)
429
- return `rgb(${r}, ${g}, ${b})`
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
+ }