@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,557 @@
|
|
|
1
|
+
// Shared, pure filter engine: links + sources tables in, filtered links out.
|
|
2
|
+
// No window / DataService / DOM. Imported by BOTH the browser webapp and the Deno edge function,
|
|
3
|
+
// so it must stay plain .js (no TypeScript, no build step) and import only pure helpers.
|
|
4
|
+
//
|
|
5
|
+
// This is the single source of truth for ordered link filtering. The webapp pipeline and the
|
|
6
|
+
// app-api edge function both call it; do not reintroduce a parallel copy.
|
|
7
|
+
import {
|
|
8
|
+
applyLabelByGroupToFactors,
|
|
9
|
+
attachReservedCmLinkFields,
|
|
10
|
+
} from './link-display-transforms.js'
|
|
11
|
+
import { findLinksWithinSteps, findLinksWithinStepsWithThreads, getLinkSourceIdsForTracing } from './link-traversal.js'
|
|
12
|
+
import { applyPathTracingFilter } from './path-tracing.js'
|
|
13
|
+
|
|
14
|
+
function norm(value) {
|
|
15
|
+
return String(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function arr(value) {
|
|
19
|
+
return Array.isArray(value)
|
|
20
|
+
? value.map((v) => String(v ?? '').trim()).filter(Boolean)
|
|
21
|
+
: String(value ?? '').split(',').map((v) => v.trim()).filter(Boolean)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Canonical (webapp) default is 'start' when no matchMode is given.
|
|
25
|
+
function checkTextMatch(value, term, mode) {
|
|
26
|
+
const text = String(value ?? '').toLowerCase()
|
|
27
|
+
const search = String(term ?? '').toLowerCase()
|
|
28
|
+
if (!search) return false
|
|
29
|
+
if (mode === 'anywhere') return text.includes(search)
|
|
30
|
+
if (mode === 'exact') return text === search
|
|
31
|
+
return text.startsWith(search) // 'start' and default
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Webapp tag list: array as-is, or comma-split a string (trim + drop empties).
|
|
35
|
+
function linkTagsList(link) {
|
|
36
|
+
return Array.isArray(link.tags)
|
|
37
|
+
? link.tags
|
|
38
|
+
: (link.tags || '').split(',').map((t) => t.trim()).filter((t) => t)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Match DataService.applySourceJoinInPlace: expose source custom_columns/metadata on each row for label-by-group + map bundles. */
|
|
42
|
+
function mergeSourceMetadataOntoLinks(links, sourceById) {
|
|
43
|
+
return links.map((link) => {
|
|
44
|
+
const sid = String(link?.source_id ?? '').trim()
|
|
45
|
+
const source = sourceById.get(sid)
|
|
46
|
+
if (!source || typeof link !== 'object') return link
|
|
47
|
+
const out = { ...link }
|
|
48
|
+
const md = source.metadata && typeof source.metadata === 'object' ? source.metadata : {}
|
|
49
|
+
const cc = md.custom_columns && typeof md.custom_columns === 'object' ? md.custom_columns : {}
|
|
50
|
+
for (const key of Object.keys(cc)) {
|
|
51
|
+
const safeKey = String(key).replace(/[\s/]/g, '_')
|
|
52
|
+
out[`s_${safeKey}`] = cc[key]
|
|
53
|
+
}
|
|
54
|
+
for (const key of Object.keys(md)) {
|
|
55
|
+
if (key === 'custom_columns') continue
|
|
56
|
+
const value = md[key]
|
|
57
|
+
if (typeof value !== 'object' || value === null) {
|
|
58
|
+
out[`source_${key}`] = value
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Factors whose cause/effect matches the filter's selected labels. Exposed so the webapp can reuse
|
|
66
|
+
// the exact same matching for its highlight outputs.
|
|
67
|
+
export function matchLabelFactors(links, filter) {
|
|
68
|
+
const selected = arr(filter.selectedLabels)
|
|
69
|
+
const mode = filter.matchMode
|
|
70
|
+
const targetFactors = new Set()
|
|
71
|
+
if (!selected.length) return targetFactors
|
|
72
|
+
for (const link of links) {
|
|
73
|
+
const cause = String(link?.cause || '')
|
|
74
|
+
const effect = String(link?.effect || '')
|
|
75
|
+
for (const label of selected) {
|
|
76
|
+
if (checkTextMatch(cause, label, mode)) targetFactors.add(cause)
|
|
77
|
+
if (checkTextMatch(effect, label, mode)) targetFactors.add(effect)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return targetFactors
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function applyLabelFilter(links, filter) {
|
|
84
|
+
const selected = arr(filter.selectedLabels)
|
|
85
|
+
if (!selected.length) return links
|
|
86
|
+
const targetFactors = matchLabelFactors(links, filter)
|
|
87
|
+
if (!targetFactors.size) return []
|
|
88
|
+
if (Number(filter.stepsUp) === 0 && Number(filter.stepsDown) === 0) return []
|
|
89
|
+
// Same-source thread tracing uses getLinkSourceIdsForTracing, which is what the webapp uses.
|
|
90
|
+
if (filter.traceThreads) {
|
|
91
|
+
return findLinksWithinStepsWithThreads(links, targetFactors, filter.stepsUp ?? 1, filter.stepsDown ?? 1)
|
|
92
|
+
}
|
|
93
|
+
return findLinksWithinSteps(links, targetFactors, filter.stepsUp ?? 1, filter.stepsDown ?? 1)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Keep links whose tags match ANY selected tag (OR). Links with no tags are dropped.
|
|
97
|
+
export function applyTagsFilter(links, filter) {
|
|
98
|
+
const selected = Array.isArray(filter.selectedTags) ? filter.selectedTags : []
|
|
99
|
+
if (!selected.length) return links
|
|
100
|
+
return links.filter((link) => {
|
|
101
|
+
if (!link.tags) return false
|
|
102
|
+
const linkTags = linkTagsList(link)
|
|
103
|
+
return selected.some((selectedTag) =>
|
|
104
|
+
linkTags.some((linkTag) => checkTextMatch(linkTag, selectedTag, filter.matchMode)))
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Exclude links whose tags match the selected tags. Default ALL (every); excludeAny uses OR (some).
|
|
109
|
+
// Links with no tags are kept.
|
|
110
|
+
export function applyExcludeTagsFilter(links, filter) {
|
|
111
|
+
const selected = Array.isArray(filter.selectedTags) ? filter.selectedTags : []
|
|
112
|
+
if (!selected.length) return links
|
|
113
|
+
const excludeAny = filter.excludeAny === true
|
|
114
|
+
return links.filter((link) => {
|
|
115
|
+
if (!link.tags) return true
|
|
116
|
+
const linkTags = linkTagsList(link)
|
|
117
|
+
const tagsMatch = excludeAny
|
|
118
|
+
? selected.some((et) => linkTags.some((lt) => checkTextMatch(lt, et, filter.matchMode)))
|
|
119
|
+
: selected.every((et) => linkTags.some((lt) => checkTextMatch(lt, et, filter.matchMode)))
|
|
120
|
+
return !tagsMatch
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Membership over a resolved link column (the caller resolves filter.selectedField first). No trim.
|
|
125
|
+
export function applyEverythingFilter(links, filter) {
|
|
126
|
+
const field = filter.selectedField
|
|
127
|
+
const values = Array.isArray(filter.selectedValues) ? filter.selectedValues : []
|
|
128
|
+
if (!field || !values.length) return links
|
|
129
|
+
return links.filter((link) => {
|
|
130
|
+
const fieldValue = link[field]
|
|
131
|
+
if (fieldValue !== null && fieldValue !== undefined && fieldValue !== '') {
|
|
132
|
+
if (Array.isArray(fieldValue)) return fieldValue.some((v) => values.includes(String(v)))
|
|
133
|
+
return values.includes(String(fieldValue))
|
|
134
|
+
}
|
|
135
|
+
return false
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Exclude links whose cause/effect match the selected labels. Default ALL (every); excludeAny uses OR (some).
|
|
140
|
+
export function applyExcludeLabelFilter(links, filter) {
|
|
141
|
+
const selected = Array.isArray(filter.selectedLabels) ? filter.selectedLabels : []
|
|
142
|
+
if (!selected.length) return links
|
|
143
|
+
const excludeAny = filter.excludeAny === true
|
|
144
|
+
return links.filter((link) => {
|
|
145
|
+
const cause = link.cause || ''
|
|
146
|
+
const effect = link.effect || ''
|
|
147
|
+
const labelsMatch = excludeAny
|
|
148
|
+
? selected.some((el) => checkTextMatch(cause, el, filter.matchMode) || checkTextMatch(effect, el, filter.matchMode))
|
|
149
|
+
: selected.every((el) => checkTextMatch(cause, el, filter.matchMode) || checkTextMatch(effect, el, filter.matchMode))
|
|
150
|
+
return !labelsMatch
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function applySourcesFilter(links, filter) {
|
|
155
|
+
const sourceIds = new Set(arr(filter.sourceIds).map((id) => norm(id)))
|
|
156
|
+
if (!sourceIds.size) return links
|
|
157
|
+
return links.filter((link) => sourceIds.has(norm(link?.source_id)))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Threshold/mode selection shared by both frequency filters (faithful to the webapp).
|
|
161
|
+
// mode: 'minimum' keeps metrics >= threshold; anything else is 'top' (default) keeping the
|
|
162
|
+
// top-`threshold` by value, ties at the cutoff included. which: 'sources' counts unique sources,
|
|
163
|
+
// anything else counts appearances (default).
|
|
164
|
+
function frequencyAllowed(metrics, filter) {
|
|
165
|
+
const threshold = filter.threshold || 1
|
|
166
|
+
const allowed = new Set()
|
|
167
|
+
if (filter.mode === 'minimum') {
|
|
168
|
+
metrics.forEach((count, key) => {
|
|
169
|
+
if (count >= threshold) allowed.add(key)
|
|
170
|
+
})
|
|
171
|
+
} else {
|
|
172
|
+
const sorted = Array.from(metrics.entries()).sort((a, b) => b[1] - a[1])
|
|
173
|
+
if (sorted.length > 0) {
|
|
174
|
+
const idx = Math.min(threshold - 1, sorted.length - 1)
|
|
175
|
+
const cutoff = sorted[idx][1]
|
|
176
|
+
sorted.forEach(([key, cnt]) => {
|
|
177
|
+
if (cnt >= cutoff) allowed.add(key)
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return allowed
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Link frequency filter. Bundles links by cause|||effect, optionally separating flipped opposite
|
|
186
|
+
* pairs. `options.separateFlippedBundles` is the resolved combine-opposites bundle decision
|
|
187
|
+
* (strategy === 'separate'); the caller computes it from the active combine-opposites filter and
|
|
188
|
+
* passes it in, so the engine stays free of pipeline-state lookups. The per-link flip flags
|
|
189
|
+
* (flipped_cause / flipped_effect) are already on the link, set upstream by combine-opposites.
|
|
190
|
+
*/
|
|
191
|
+
export function applyLinkFrequencyFilter(links, filter, options = {}) {
|
|
192
|
+
const separateFlippedBundles = options.separateFlippedBundles === true
|
|
193
|
+
const getBundleKey = (link) => {
|
|
194
|
+
const c = `${(link.cause || '').trim()}`
|
|
195
|
+
const e = `${(link.effect || '').trim()}`
|
|
196
|
+
const hasFlip = (typeof link.flipped_cause === 'boolean') || (typeof link.flipped_effect === 'boolean')
|
|
197
|
+
if (!hasFlip || !separateFlippedBundles) return `${c}|||${e}`
|
|
198
|
+
const fc = link.flipped_cause === true ? 'F' : '-'
|
|
199
|
+
const fe = link.flipped_effect === true ? 'F' : '-'
|
|
200
|
+
return `${c}|||${e}|||${fc}${fe}`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const linkMetrics = new Map()
|
|
204
|
+
if (filter.which === 'sources') {
|
|
205
|
+
const sourceSets = new Map()
|
|
206
|
+
links.forEach((link) => {
|
|
207
|
+
const key = getBundleKey(link)
|
|
208
|
+
if (!sourceSets.has(key)) sourceSets.set(key, new Set())
|
|
209
|
+
getLinkSourceIdsForTracing(link).forEach((sourceId) => sourceSets.get(key).add(sourceId))
|
|
210
|
+
})
|
|
211
|
+
sourceSets.forEach((set, key) => linkMetrics.set(key, set.size))
|
|
212
|
+
} else {
|
|
213
|
+
links.forEach((link) => {
|
|
214
|
+
const key = getBundleKey(link)
|
|
215
|
+
linkMetrics.set(key, (linkMetrics.get(key) || 0) + 1)
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const allowedKeys = frequencyAllowed(linkMetrics, filter)
|
|
220
|
+
return links.filter((link) => allowedKeys.has(getBundleKey(link)))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Factor frequency filter: count factors (cause/effect) and keep links where BOTH ends pass.
|
|
224
|
+
// No flip bundling (factors, not link pairs).
|
|
225
|
+
export function applyFactorFrequencyFilter(links, filter) {
|
|
226
|
+
const factorCounts = new Map()
|
|
227
|
+
if (filter.which === 'sources') {
|
|
228
|
+
const factorSourceSets = new Map()
|
|
229
|
+
links.forEach((link) => {
|
|
230
|
+
const sourceIds = getLinkSourceIdsForTracing(link)
|
|
231
|
+
if (sourceIds.length === 0) return
|
|
232
|
+
const cause = (link.cause || '').trim()
|
|
233
|
+
const effect = (link.effect || '').trim()
|
|
234
|
+
if (cause) {
|
|
235
|
+
if (!factorSourceSets.has(cause)) factorSourceSets.set(cause, new Set())
|
|
236
|
+
sourceIds.forEach((sourceId) => factorSourceSets.get(cause).add(sourceId))
|
|
237
|
+
}
|
|
238
|
+
if (effect) {
|
|
239
|
+
if (!factorSourceSets.has(effect)) factorSourceSets.set(effect, new Set())
|
|
240
|
+
sourceIds.forEach((sourceId) => factorSourceSets.get(effect).add(sourceId))
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
factorSourceSets.forEach((set, factor) => factorCounts.set(factor, set.size))
|
|
244
|
+
} else {
|
|
245
|
+
links.forEach((link) => {
|
|
246
|
+
const cause = (link.cause || '').trim()
|
|
247
|
+
const effect = (link.effect || '').trim()
|
|
248
|
+
if (cause) factorCounts.set(cause, (factorCounts.get(cause) || 0) + 1)
|
|
249
|
+
if (effect) factorCounts.set(effect, (factorCounts.get(effect) || 0) + 1)
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const allowedFactors = frequencyAllowed(factorCounts, filter)
|
|
254
|
+
return links.filter((link) => allowedFactors.has((link.cause || '').trim()) && allowedFactors.has((link.effect || '').trim()))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Membership over a resolved source-group column flattened onto each link (s_*/source_*).
|
|
258
|
+
// The caller resolves filter.selectedField to that key first. Exact value match, trimmed.
|
|
259
|
+
export function applySourceGroupsFilter(links, filter) {
|
|
260
|
+
const field = filter.selectedField
|
|
261
|
+
const values = Array.isArray(filter.selectedValues) ? filter.selectedValues : []
|
|
262
|
+
if (!field || !values.length) return links
|
|
263
|
+
return links.filter((link) => {
|
|
264
|
+
const fieldValue = link[field]
|
|
265
|
+
if (fieldValue === null || fieldValue === undefined || fieldValue === '') return false
|
|
266
|
+
if (Array.isArray(fieldValue)) return fieldValue.some((v) => values.includes(String(v).trim()))
|
|
267
|
+
return values.includes(String(fieldValue).trim())
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function applyExcludeSelfLoops(links) {
|
|
272
|
+
return links.filter((link) => (link.cause || '').trim() !== (link.effect || '').trim())
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Canonical per-label transforms (match the webapp's applyZoomToLabel / applyCollapseToLabel /
|
|
276
|
+
// applyBracketReplacement exactly).
|
|
277
|
+
export function applyZoomToLabel(label, level) {
|
|
278
|
+
if (!label || level === 0 || level === '0' || level === 'none') return label
|
|
279
|
+
const levelNum = parseInt(level)
|
|
280
|
+
if (isNaN(levelNum) || levelNum <= 0) return label
|
|
281
|
+
const parts = label.split(';').map((p) => p.trim())
|
|
282
|
+
if (parts.length <= levelNum) return label
|
|
283
|
+
return parts.slice(0, levelNum).join('; ')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function applyCollapseToLabel(label, selectedLabels, separate, matchMode) {
|
|
287
|
+
if (!label || !selectedLabels || selectedLabels.length === 0) return label
|
|
288
|
+
let result = label
|
|
289
|
+
if (separate) {
|
|
290
|
+
// Replace each matching search term with itself (re-checked against the running result).
|
|
291
|
+
selectedLabels.forEach((searchTerm) => {
|
|
292
|
+
if (checkTextMatch(result, searchTerm, matchMode)) result = searchTerm
|
|
293
|
+
})
|
|
294
|
+
} else {
|
|
295
|
+
// Replace any matching label with the first search term.
|
|
296
|
+
if (selectedLabels.some((searchTerm) => checkTextMatch(result, searchTerm, matchMode))) {
|
|
297
|
+
result = selectedLabels[0]
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return result
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function applyBracketReplacement(label, replaceRound, replaceSquare) {
|
|
304
|
+
if (!label || (!replaceRound && !replaceSquare)) return label
|
|
305
|
+
let result = label
|
|
306
|
+
if (replaceRound) result = result.replace(/\([^)]*\)/g, '').trim()
|
|
307
|
+
if (replaceSquare) result = result.replace(/\[[^\]]*\]/g, '').trim()
|
|
308
|
+
return result
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Shared recoding transform for the label-transform family (zoom / collapse / replace-brackets).
|
|
313
|
+
* Applies `transformLabel` to each cause/effect, tracks `_recoded` provenance (preserving the
|
|
314
|
+
* earliest original), and returns the set of changed (new) labels for highlights.
|
|
315
|
+
* @param {Array} links
|
|
316
|
+
* @param {(label:string)=>string} transformLabel
|
|
317
|
+
* @param {{ setFlags?: boolean }} options setFlags adds `_recoded_cause`/`_recoded_effect` (collapse,
|
|
318
|
+
* replace-brackets do; zoom does not).
|
|
319
|
+
* @returns {{ rows: Array, changedFactors: Set<string> }}
|
|
320
|
+
*/
|
|
321
|
+
export function applyRecodingTransform(links, transformLabel, options = {}) {
|
|
322
|
+
const setFlags = options.setFlags === true
|
|
323
|
+
const changedFactors = new Set()
|
|
324
|
+
const rows = links.map((link) => {
|
|
325
|
+
const originalCause = link.cause
|
|
326
|
+
const originalEffect = link.effect
|
|
327
|
+
const newCause = transformLabel(originalCause)
|
|
328
|
+
const newEffect = transformLabel(originalEffect)
|
|
329
|
+
const recodedCause = newCause !== originalCause
|
|
330
|
+
const recodedEffect = newEffect !== originalEffect
|
|
331
|
+
if (recodedCause) changedFactors.add(newCause)
|
|
332
|
+
if (recodedEffect) changedFactors.add(newEffect)
|
|
333
|
+
|
|
334
|
+
// Preserve original labels in _recoded for downstream operations (e.g. rename).
|
|
335
|
+
const recodedMeta = link._recoded || {
|
|
336
|
+
cause: { original: originalCause, recoded: false },
|
|
337
|
+
effect: { original: originalEffect, recoded: false },
|
|
338
|
+
}
|
|
339
|
+
if (recodedCause) {
|
|
340
|
+
if (!recodedMeta.cause) recodedMeta.cause = { original: originalCause, recoded: true }
|
|
341
|
+
else if (!recodedMeta.cause.original) recodedMeta.cause.original = originalCause
|
|
342
|
+
recodedMeta.cause.recoded = true
|
|
343
|
+
}
|
|
344
|
+
if (recodedEffect) {
|
|
345
|
+
if (!recodedMeta.effect) recodedMeta.effect = { original: originalEffect, recoded: true }
|
|
346
|
+
else if (!recodedMeta.effect.original) recodedMeta.effect.original = originalEffect
|
|
347
|
+
recodedMeta.effect.recoded = true
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const out = { ...link, cause: newCause, effect: newEffect, _recoded: recodedMeta }
|
|
351
|
+
if (setFlags) {
|
|
352
|
+
out._recoded_cause = recodedCause || link._recoded_cause === true
|
|
353
|
+
out._recoded_effect = recodedEffect || link._recoded_effect === true
|
|
354
|
+
}
|
|
355
|
+
return out
|
|
356
|
+
})
|
|
357
|
+
return { rows, changedFactors }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function applyZoomFilter(links, filter) {
|
|
361
|
+
if (!(filter.level > 0)) return links
|
|
362
|
+
return applyRecodingTransform(links, (label) => applyZoomToLabel(label, filter.level), { setFlags: false }).rows
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Returns { rows, changedFactors }: changedFactors feeds the webapp's collapse highlights.
|
|
366
|
+
export function applyCollapseFilter(links, filter) {
|
|
367
|
+
const selected = filter.selectedLabels && filter.selectedLabels.length ? filter.selectedLabels : []
|
|
368
|
+
if (!selected.length) return { rows: links, changedFactors: new Set() }
|
|
369
|
+
return applyRecodingTransform(
|
|
370
|
+
links,
|
|
371
|
+
(label) => applyCollapseToLabel(label, selected, filter.separate, filter.matchMode),
|
|
372
|
+
{ setFlags: true }
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function applyReplaceBracketsFilter(links, filter) {
|
|
377
|
+
const hasNew = (typeof filter?.replaceRound === 'boolean') || (typeof filter?.replaceSquare === 'boolean')
|
|
378
|
+
const replaceRound = hasNew ? !!filter.replaceRound : (filter?.bracketMode === 'round')
|
|
379
|
+
const replaceSquare = hasNew ? !!filter.replaceSquare : (filter?.bracketMode === 'square')
|
|
380
|
+
if (!replaceRound && !replaceSquare) return links
|
|
381
|
+
return applyRecodingTransform(
|
|
382
|
+
links,
|
|
383
|
+
(label) => applyBracketReplacement(label, replaceRound, replaceSquare),
|
|
384
|
+
{ setFlags: true }
|
|
385
|
+
).rows
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Webapp stripTags: remove bracketed [N] / [~N] tags, collapse whitespace.
|
|
389
|
+
function stripCombineTags(text) {
|
|
390
|
+
if (!text) return ''
|
|
391
|
+
return String(text).replace(/\[~\d+\]|\[\d+\]/g, '').replace(/\s+/g, ' ').trim()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function normalizeHierarchy(label) {
|
|
395
|
+
return String(label || '').split(';').map((p) => p.replace(/\s+/g, ' ').trim()).filter(Boolean).join('; ')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Faithful port of the webapp's combine-opposites inline branch. Rewrites negative variants
|
|
399
|
+
// ([~N] / legacy bareword ~N, and ~prefixed hierarchy labels) to their positive label and sets
|
|
400
|
+
// flipped_cause/flipped_effect. Numeric opposites default ON; tilde opposites default OFF.
|
|
401
|
+
export function applyCombineOppositesFilter(links, filter) {
|
|
402
|
+
const useNumericOpposites = (filter.useNumericOpposites !== false)
|
|
403
|
+
const useTildeOpposites = (filter.useTildeOpposites === true)
|
|
404
|
+
|
|
405
|
+
const labelsSet = new Set()
|
|
406
|
+
for (const link of links) {
|
|
407
|
+
if (link.cause) labelsSet.add(link.cause)
|
|
408
|
+
if (link.effect) labelsSet.add(link.effect)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// numericMapping: tagNum -> { negativeLabel, positiveLabel }
|
|
412
|
+
const numericMapping = new Map()
|
|
413
|
+
if (useNumericOpposites) {
|
|
414
|
+
const tagPairs = new Map()
|
|
415
|
+
labelsSet.forEach((label) => {
|
|
416
|
+
const negMatch = label.match(/\[~(\d+)\]|~(\d+)/)
|
|
417
|
+
if (negMatch) {
|
|
418
|
+
const num = negMatch[1] || negMatch[2]
|
|
419
|
+
if (!tagPairs.has(num)) tagPairs.set(num, {})
|
|
420
|
+
tagPairs.get(num).negative = label
|
|
421
|
+
}
|
|
422
|
+
const bracketMatch = label.match(/\[(\d+)\]/)
|
|
423
|
+
if (bracketMatch) {
|
|
424
|
+
const matchIndex = label.indexOf(bracketMatch[0])
|
|
425
|
+
if (matchIndex === 0 || label[matchIndex - 1] !== '~') {
|
|
426
|
+
const num = bracketMatch[1]
|
|
427
|
+
if (!tagPairs.has(num)) tagPairs.set(num, {})
|
|
428
|
+
tagPairs.get(num).positive = label
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
const allNumbers = label.matchAll(/\d+/g)
|
|
432
|
+
for (const match of allNumbers) {
|
|
433
|
+
const num = match[0]
|
|
434
|
+
const matchIndex = match.index
|
|
435
|
+
if (matchIndex === 0 || label[matchIndex - 1] !== '~') {
|
|
436
|
+
if (!tagPairs.has(num)) tagPairs.set(num, {})
|
|
437
|
+
tagPairs.get(num).positive = label
|
|
438
|
+
break
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
})
|
|
443
|
+
tagPairs.forEach((pair, num) => {
|
|
444
|
+
if (pair.negative && pair.positive) {
|
|
445
|
+
numericMapping.set(num, { negativeLabel: pair.negative, positiveLabel: pair.positive })
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// tildeMapping: normalized ~label -> preferred opposite (existing original spelling if present).
|
|
451
|
+
const tildeMapping = new Map()
|
|
452
|
+
if (useTildeOpposites) {
|
|
453
|
+
const toggleAllParts = (normalizedLabel) => normalizeHierarchy(normalizedLabel).split(';').map((part) => {
|
|
454
|
+
const s = part.replace(/\s+/g, ' ').trim()
|
|
455
|
+
return /^~\s*/.test(s) ? s.replace(/^~\s*/, '').trim() : `~${s}`
|
|
456
|
+
}).join('; ')
|
|
457
|
+
const normToOriginal = new Map()
|
|
458
|
+
labelsSet.forEach((label) => {
|
|
459
|
+
const orig = String(label || '')
|
|
460
|
+
const norm = normalizeHierarchy(orig)
|
|
461
|
+
if (!norm) return
|
|
462
|
+
if (!normToOriginal.has(norm)) normToOriginal.set(norm, orig)
|
|
463
|
+
})
|
|
464
|
+
labelsSet.forEach((label) => {
|
|
465
|
+
const orig = String(label || '').trim()
|
|
466
|
+
if (!/^\s*~/.test(orig)) return
|
|
467
|
+
const norm = normalizeHierarchy(orig)
|
|
468
|
+
const oppositeNorm = toggleAllParts(norm)
|
|
469
|
+
tildeMapping.set(norm, normToOriginal.get(oppositeNorm) || oppositeNorm)
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return links.map((link) => {
|
|
474
|
+
let newCause = link.cause
|
|
475
|
+
let newEffect = link.effect
|
|
476
|
+
let flippedCause = false
|
|
477
|
+
let flippedEffect = false
|
|
478
|
+
|
|
479
|
+
if (useNumericOpposites) {
|
|
480
|
+
if (link.effect) {
|
|
481
|
+
const negTagMatch = link.effect.match(/\[~(\d+)\]|~(\d+)/)
|
|
482
|
+
if (negTagMatch) {
|
|
483
|
+
const mapping = numericMapping.get(negTagMatch[1] || negTagMatch[2])
|
|
484
|
+
if (mapping) { newEffect = mapping.positiveLabel; flippedEffect = true }
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (link.cause) {
|
|
488
|
+
const negTagMatch = link.cause.match(/\[~(\d+)\]|~(\d+)/)
|
|
489
|
+
if (negTagMatch) {
|
|
490
|
+
const mapping = numericMapping.get(negTagMatch[1] || negTagMatch[2])
|
|
491
|
+
if (mapping) { newCause = mapping.positiveLabel; flippedCause = true }
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (useTildeOpposites) {
|
|
497
|
+
if (typeof newEffect === 'string' && /^\s*~/.test(newEffect)) {
|
|
498
|
+
const mapped = tildeMapping.get(normalizeHierarchy(newEffect))
|
|
499
|
+
if (mapped) { newEffect = mapped; flippedEffect = true }
|
|
500
|
+
}
|
|
501
|
+
if (typeof newCause === 'string' && /^\s*~/.test(newCause)) {
|
|
502
|
+
const mapped = tildeMapping.get(normalizeHierarchy(newCause))
|
|
503
|
+
if (mapped) { newCause = mapped; flippedCause = true }
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (filter.stripTags !== false) {
|
|
508
|
+
newCause = stripCombineTags(newCause)
|
|
509
|
+
newEffect = stripCombineTags(newEffect)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return { ...link, cause: newCause, effect: newEffect, flipped_cause: flippedCause, flipped_effect: flippedEffect }
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Apply an ordered list of link filters. Pure: data in, data out.
|
|
518
|
+
* @param {Array} links Enriched link rows.
|
|
519
|
+
* @param {Array} filters Ordered filter configs ({ id, type, enabled?, ...cfg }).
|
|
520
|
+
* @param {Array} sources Source rows (id, metadata.custom_columns); used by source-based filters.
|
|
521
|
+
* @returns {{ rows: Array, unsupported: string[] }}
|
|
522
|
+
*/
|
|
523
|
+
export function applyOrderedLinkFilters(links, filters, sources = []) {
|
|
524
|
+
const sourceById = new Map(sources.map((source) => [String(source?.id ?? ''), source]))
|
|
525
|
+
let rows = Array.isArray(links) ? mergeSourceMetadataOntoLinks(links.slice(), sourceById) : []
|
|
526
|
+
const list = Array.isArray(filters) ? filters : []
|
|
527
|
+
const unsupported = []
|
|
528
|
+
|
|
529
|
+
for (const raw of list) {
|
|
530
|
+
const filter = raw && typeof raw === 'object' ? raw : null
|
|
531
|
+
if (!filter || filter.enabled === false) continue
|
|
532
|
+
const type = String(filter.type || '').trim()
|
|
533
|
+
if (type === 'sources') rows = applySourcesFilter(rows, filter)
|
|
534
|
+
else if (type === 'everything') rows = applyEverythingFilter(rows, filter)
|
|
535
|
+
else if (type === 'label') rows = applyLabelFilter(rows, filter)
|
|
536
|
+
else if (type === 'exclude-label') rows = applyExcludeLabelFilter(rows, filter)
|
|
537
|
+
else if (type === 'path-tracing') rows = applyPathTracingFilter(rows, filter)
|
|
538
|
+
else if (type === 'tags') rows = applyTagsFilter(rows, filter)
|
|
539
|
+
else if (type === 'exclude-tags') rows = applyExcludeTagsFilter(rows, filter)
|
|
540
|
+
else if (type === 'source-groups') rows = applySourceGroupsFilter(rows, filter)
|
|
541
|
+
else if (type === 'link-frequency') rows = applyLinkFrequencyFilter(rows, filter)
|
|
542
|
+
else if (type === 'factor-frequency') rows = applyFactorFrequencyFilter(rows, filter)
|
|
543
|
+
else if (type === 'custom-links-label') rows = applyLabelByGroupToFactors(rows, filter)
|
|
544
|
+
else if (type === 'map-custom-columns') {
|
|
545
|
+
// Reserved map columns are attached after ordered row filters have run.
|
|
546
|
+
}
|
|
547
|
+
else if (type === 'exclude-self-loops') rows = applyExcludeSelfLoops(rows)
|
|
548
|
+
else if (type === 'zoom') rows = applyZoomFilter(rows, filter)
|
|
549
|
+
else if (type === 'collapse') rows = applyCollapseFilter(rows, filter).rows
|
|
550
|
+
else if (type === 'replace-brackets') rows = applyReplaceBracketsFilter(rows, filter)
|
|
551
|
+
else if (type === 'combine-opposites') rows = applyCombineOppositesFilter(rows, filter)
|
|
552
|
+
else unsupported.push(type || '(missing type)')
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
rows = attachReservedCmLinkFields(rows, list.filter((filter) => filter && typeof filter === 'object'))
|
|
556
|
+
return { rows, unsupported }
|
|
557
|
+
}
|