@causalmap/filter-engine 0.1.0

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.
@@ -0,0 +1,430 @@
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
+ }
@@ -0,0 +1,144 @@
1
+ // Pure causal-link traversal shared by the UI filter pipeline and app API.
2
+
3
+ export function normalizeTraversalSteps(value, maxSteps = 8) {
4
+ const n = Number(value)
5
+ if (!Number.isFinite(n)) return 0
6
+ return Math.max(0, Math.min(maxSteps, Math.trunc(n)))
7
+ }
8
+
9
+ export function getLinkSourceIdsForTracing(link) {
10
+ if (!link || typeof link !== 'object') return []
11
+ const assessedSourceIds = link.assessed === true && Array.isArray(link.metadata?.assessment_source_ids)
12
+ ? link.metadata.assessment_source_ids
13
+ .map(sourceId => String(sourceId || '').trim())
14
+ .filter(Boolean)
15
+ : []
16
+ if (assessedSourceIds.length > 0) return Array.from(new Set(assessedSourceIds))
17
+
18
+ const sourceId = String(link.source_id || '').trim()
19
+ return sourceId ? [sourceId] : []
20
+ }
21
+
22
+ export function findLinksWithinSteps(links, targetFactors, stepsUp, stepsDown) {
23
+ const validLinks = new Set()
24
+ const linksByEffect = new Map()
25
+ const linksByCause = new Map()
26
+
27
+ for (const link of Array.isArray(links) ? links : []) {
28
+ const cause = String(link?.cause || '').trim()
29
+ const effect = String(link?.effect || '').trim()
30
+ if (!cause || !effect) continue
31
+
32
+ if (!linksByEffect.has(effect)) linksByEffect.set(effect, [])
33
+ linksByEffect.get(effect).push(link)
34
+
35
+ if (!linksByCause.has(cause)) linksByCause.set(cause, [])
36
+ linksByCause.get(cause).push(link)
37
+ }
38
+
39
+ const up = normalizeTraversalSteps(stepsUp)
40
+ const down = normalizeTraversalSteps(stepsDown)
41
+
42
+ // Upstream means causes of the target factor.
43
+ if (up > 0) {
44
+ let currentFactors = new Set(targetFactors || [])
45
+ for (let step = 0; step < up; step += 1) {
46
+ const nextFactors = new Set()
47
+ for (const factor of currentFactors) {
48
+ for (const link of linksByEffect.get(factor) || []) {
49
+ validLinks.add(link)
50
+ const next = String(link?.cause || '').trim()
51
+ if (next) nextFactors.add(next)
52
+ }
53
+ }
54
+ currentFactors = nextFactors
55
+ if (!currentFactors.size) break
56
+ }
57
+ }
58
+
59
+ // Downstream means effects of the target factor.
60
+ if (down > 0) {
61
+ let currentFactors = new Set(targetFactors || [])
62
+ for (let step = 0; step < down; step += 1) {
63
+ const nextFactors = new Set()
64
+ for (const factor of currentFactors) {
65
+ for (const link of linksByCause.get(factor) || []) {
66
+ validLinks.add(link)
67
+ const next = String(link?.effect || '').trim()
68
+ if (next) nextFactors.add(next)
69
+ }
70
+ }
71
+ currentFactors = nextFactors
72
+ if (!currentFactors.size) break
73
+ }
74
+ }
75
+
76
+ return Array.from(validLinks)
77
+ }
78
+
79
+ export function findLinksWithinStepsWithThreads(links, targetFactors, stepsUp, stepsDown, getSourceIds = getLinkSourceIdsForTracing) {
80
+ const validLinks = new Set()
81
+ const linksByEffectAndSource = new Map()
82
+ const linksByCauseAndSource = new Map()
83
+
84
+ for (const link of Array.isArray(links) ? links : []) {
85
+ const cause = String(link?.cause || '').trim()
86
+ const effect = String(link?.effect || '').trim()
87
+ const sourceIds = getSourceIds(link)
88
+ if (!cause || !effect || sourceIds.length === 0) continue
89
+
90
+ for (const sourceId of sourceIds) {
91
+ if (!linksByEffectAndSource.has(effect)) linksByEffectAndSource.set(effect, new Map())
92
+ if (!linksByEffectAndSource.get(effect).has(sourceId)) linksByEffectAndSource.get(effect).set(sourceId, [])
93
+ linksByEffectAndSource.get(effect).get(sourceId).push(link)
94
+
95
+ if (!linksByCauseAndSource.has(cause)) linksByCauseAndSource.set(cause, new Map())
96
+ if (!linksByCauseAndSource.get(cause).has(sourceId)) linksByCauseAndSource.get(cause).set(sourceId, [])
97
+ linksByCauseAndSource.get(cause).get(sourceId).push(link)
98
+ }
99
+ }
100
+
101
+ const up = normalizeTraversalSteps(stepsUp)
102
+ const down = normalizeTraversalSteps(stepsDown)
103
+ const targets = new Set(targetFactors || [])
104
+
105
+ if (up > 0) {
106
+ for (const targetFactor of targets) {
107
+ const sourceMap = linksByEffectAndSource.get(targetFactor)
108
+ if (!sourceMap) continue
109
+ for (const sourceId of sourceMap.keys()) {
110
+ traverseSameSource(linksByEffectAndSource, targetFactor, sourceId, up, 'cause', validLinks)
111
+ }
112
+ }
113
+ }
114
+
115
+ if (down > 0) {
116
+ for (const targetFactor of targets) {
117
+ const sourceMap = linksByCauseAndSource.get(targetFactor)
118
+ if (!sourceMap) continue
119
+ for (const sourceId of sourceMap.keys()) {
120
+ traverseSameSource(linksByCauseAndSource, targetFactor, sourceId, down, 'effect', validLinks)
121
+ }
122
+ }
123
+ }
124
+
125
+ return Array.from(validLinks)
126
+ }
127
+
128
+ function traverseSameSource(index, startFactor, sourceId, steps, nextField, validLinks) {
129
+ let currentFactors = new Set([startFactor])
130
+ for (let step = 0; step < steps; step += 1) {
131
+ const nextFactors = new Set()
132
+ for (const factor of currentFactors) {
133
+ const factorSourceMap = index.get(factor)
134
+ const stepLinks = factorSourceMap?.get(sourceId) || []
135
+ for (const link of stepLinks) {
136
+ validLinks.add(link)
137
+ const next = String(link?.[nextField] || '').trim()
138
+ if (next) nextFactors.add(next)
139
+ }
140
+ }
141
+ currentFactors = nextFactors
142
+ if (!currentFactors.size) break
143
+ }
144
+ }