140 lines
3.7 KiB
JavaScript
140 lines
3.7 KiB
JavaScript
/**
|
|
* Rank related charts for investigation (context/family + optional series correlation).
|
|
*/
|
|
|
|
/**
|
|
* @param {string} seedId
|
|
* @param {Record<string, object>} catalog
|
|
* @param {Map<string, { dims: Map<string, number[]> }>|null} [loaded]
|
|
* @param {{ limit?: number, weights?: Array<{ id: string, weight: number }>|Record<string, number>|null }} [opts]
|
|
* @returns {Array<{ id: string, score: number, reason: string }>}
|
|
*/
|
|
export function rankRelatedCharts(seedId, catalog, loaded = null, opts = {}) {
|
|
const seed = catalog?.[seedId]
|
|
if (!seed) return []
|
|
const limit = opts.limit ?? 12
|
|
/** @type {Map<string, number>} */
|
|
const weightMap = new Map()
|
|
if (Array.isArray(opts.weights)) {
|
|
for (const w of opts.weights) {
|
|
if (w?.id) weightMap.set(w.id, Number(w.weight) || 0)
|
|
}
|
|
} else if (opts.weights && typeof opts.weights === 'object') {
|
|
for (const [id, w] of Object.entries(opts.weights)) weightMap.set(id, Number(w) || 0)
|
|
}
|
|
|
|
/** @type {Array<{ id: string, score: number, reason: string }>} */
|
|
const out = []
|
|
|
|
for (const [id, meta] of Object.entries(catalog || {})) {
|
|
if (id === seedId) continue
|
|
let score = 0
|
|
/** @type {string[]} */
|
|
const reasons = []
|
|
|
|
if (meta.context && seed.context) {
|
|
if (meta.context === seed.context) {
|
|
score += 4
|
|
reasons.push('same context')
|
|
} else if (sharePrefix(String(meta.context), String(seed.context))) {
|
|
score += 2
|
|
reasons.push('related context')
|
|
}
|
|
}
|
|
if (meta.family && seed.family && meta.family === seed.family) {
|
|
score += 2
|
|
reasons.push('same family')
|
|
}
|
|
if (meta.plugin && seed.plugin && meta.plugin === seed.plugin) {
|
|
score += 1
|
|
reasons.push('same plugin')
|
|
}
|
|
if (meta.units && seed.units && meta.units === seed.units) {
|
|
score += 0.5
|
|
reasons.push('same units')
|
|
}
|
|
|
|
const seedCard = loaded?.get(seedId)
|
|
const other = loaded?.get(id)
|
|
if (seedCard?.dims?.size && other?.dims?.size) {
|
|
const corr = maxAbsCorrelation(seedCard.dims, other.dims)
|
|
if (corr >= 0.75) {
|
|
score += corr * 3
|
|
reasons.push(`corr ${corr.toFixed(2)}`)
|
|
} else if (corr >= 0.55) {
|
|
score += corr
|
|
reasons.push(`corr ${corr.toFixed(2)}`)
|
|
}
|
|
}
|
|
|
|
const aw = weightMap.get(id) || 0
|
|
if (aw > 0) {
|
|
score += aw * 2.5
|
|
reasons.push(`weight ${aw.toFixed(2)}`)
|
|
}
|
|
|
|
if (score > 0) out.push({ id, score, reason: reasons.join(', ') })
|
|
}
|
|
|
|
out.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
|
|
return out.slice(0, limit)
|
|
}
|
|
|
|
/**
|
|
* @param {string} a
|
|
* @param {string} b
|
|
*/
|
|
function sharePrefix(a, b) {
|
|
const aa = a.split('.')
|
|
const bb = b.split('.')
|
|
if (aa.length < 2 || bb.length < 2) return false
|
|
return aa[0] === bb[0] && (aa[1] === bb[1] || aa.length === 1 || bb.length === 1)
|
|
}
|
|
|
|
/**
|
|
* @param {Map<string, number[]>} a
|
|
* @param {Map<string, number[]>} b
|
|
*/
|
|
function maxAbsCorrelation(a, dimsB) {
|
|
let best = 0
|
|
for (const seriesA of a.values()) {
|
|
for (const seriesB of dimsB.values()) {
|
|
const c = Math.abs(pearson(seriesA, seriesB))
|
|
if (c > best) best = c
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
/**
|
|
* @param {number[]} x
|
|
* @param {number[]} y
|
|
*/
|
|
export function pearson(x, y) {
|
|
const n = Math.min(x.length, y.length)
|
|
if (n < 8) return 0
|
|
const xs = x.slice(-n)
|
|
const ys = y.slice(-n)
|
|
let sx = 0
|
|
let sy = 0
|
|
for (let i = 0; i < n; i++) {
|
|
sx += xs[i]
|
|
sy += ys[i]
|
|
}
|
|
const mx = sx / n
|
|
const my = sy / n
|
|
let num = 0
|
|
let dx = 0
|
|
let dy = 0
|
|
for (let i = 0; i < n; i++) {
|
|
const a = xs[i] - mx
|
|
const b = ys[i] - my
|
|
num += a * b
|
|
dx += a * a
|
|
dy += b * b
|
|
}
|
|
const den = Math.sqrt(dx * dy)
|
|
if (!den) return 0
|
|
return num / den
|
|
}
|