@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.
- package/LICENSE.md +110 -0
- package/README.md +121 -0
- package/index.js +38 -0
- package/package.json +34 -0
- package/src/apply-filters.js +557 -0
- package/src/link-display-transforms.js +430 -0
- package/src/link-traversal.js +144 -0
- package/src/path-tracing.js +177 -0
|
@@ -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
|
+
}
|