Multiple changes
- backend: add summoner spells - backend: add build variants - backend: builds are now storing full tree with runes (keystones) - backend: build trees are split on starter items and merged on runes - frontend: computing core tree now - frontend: variant selectors
This commit is contained in:
@@ -7,7 +7,15 @@ function sameArrays(array1: Array<number>, array2: Array<number>) {
|
||||
}
|
||||
|
||||
import { MongoClient } from 'mongodb'
|
||||
import { ItemTree, treeInit, treeMerge, treeCutBranches, treeSort } from './item_tree'
|
||||
import {
|
||||
ItemTree,
|
||||
treeInit,
|
||||
treeMerge,
|
||||
treeCutBranches,
|
||||
treeSort,
|
||||
treeMergeTree,
|
||||
areTreeSimilars
|
||||
} from './item_tree'
|
||||
const itemDict = new Map()
|
||||
|
||||
async function itemList() {
|
||||
@@ -41,14 +49,32 @@ type Rune = {
|
||||
selections: Array<number>
|
||||
pickrate?: number
|
||||
}
|
||||
type Builds = {
|
||||
tree: ItemTree
|
||||
start: Array<{ data: number; count: number }>
|
||||
bootsFirst: number
|
||||
type Build = {
|
||||
runeKeystone: number
|
||||
runes: Array<Rune>
|
||||
items: ItemTree
|
||||
bootsFirstCount: number
|
||||
bootsFirst?: number
|
||||
count: number
|
||||
suppItems: Array<{ data: number; count: number }>
|
||||
boots: Array<{ data: number; count: number }>
|
||||
lateGame: Array<{ data: number; count: number }>
|
||||
suppItems?: Array<{ data: number; count: number }>
|
||||
pickrate?: number
|
||||
}
|
||||
|
||||
type BuildWithStartItems = {
|
||||
runeKeystone: number
|
||||
runes: Array<Rune>
|
||||
items: ItemTree
|
||||
bootsFirst?: number
|
||||
bootsFirstCount: number
|
||||
count: number
|
||||
startItems: Array<{ data: number; count: number }>
|
||||
suppItems: Array<{ data: number; count: number }>
|
||||
boots: Array<{ data: number; count: number }>
|
||||
pickrate?: number
|
||||
}
|
||||
|
||||
type Builds = Build[]
|
||||
type Champion = {
|
||||
id: number
|
||||
name: string
|
||||
@@ -69,9 +95,9 @@ type LaneData = {
|
||||
losingMatches: number
|
||||
winrate: number
|
||||
pickrate: number
|
||||
runes: Array<Rune>
|
||||
builds: Builds
|
||||
matchups?: Array<MatchupData>
|
||||
summonerSpells: Array<{ id: number; count: number; pickrate?: number }>
|
||||
}
|
||||
type ChampionData = {
|
||||
champion: Champion
|
||||
@@ -80,8 +106,9 @@ type ChampionData = {
|
||||
lanes: Array<LaneData>
|
||||
}
|
||||
|
||||
// Helper function to create rune configuration from participant
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function handleParticipantRunes(participant: any, runes: Array<Rune>) {
|
||||
function createRuneConfiguration(participant: any): Rune {
|
||||
const primaryStyle = participant.perks.styles[0].style
|
||||
const secondaryStyle = participant.perks.styles[1].style
|
||||
const selections: Array<number> = []
|
||||
@@ -90,28 +117,56 @@ function handleParticipantRunes(participant: any, runes: Array<Rune>) {
|
||||
selections.push(perk.perk)
|
||||
}
|
||||
}
|
||||
const gameRunes: Rune = {
|
||||
count: 1,
|
||||
return {
|
||||
count: 0, // Will be incremented when added to build
|
||||
primaryStyle: primaryStyle,
|
||||
secondaryStyle: secondaryStyle,
|
||||
selections: selections
|
||||
}
|
||||
let addRunes = true
|
||||
for (const rune of runes) {
|
||||
if (
|
||||
rune.primaryStyle == gameRunes.primaryStyle &&
|
||||
rune.secondaryStyle == gameRunes.secondaryStyle &&
|
||||
sameArrays(rune.selections, gameRunes.selections)
|
||||
) {
|
||||
rune.count++
|
||||
addRunes = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (addRunes) runes.push(gameRunes)
|
||||
}
|
||||
|
||||
function handleMatchItems(
|
||||
// Find or create a build for the given rune keystone
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function findOrCreateBuild(builds: Builds, participant: any): Build {
|
||||
const keystone = participant.perks.styles[0].selections[0].perk
|
||||
const runeConfig = createRuneConfiguration(participant)
|
||||
|
||||
// Try to find existing build with matching keystone
|
||||
const existingBuild = builds.find(
|
||||
build =>
|
||||
build.runes[0].primaryStyle === runeConfig.primaryStyle && build.runeKeystone === keystone
|
||||
)
|
||||
|
||||
if (existingBuild) {
|
||||
// Check if this rune configuration already exists in the build
|
||||
const existingRune = existingBuild.runes.find(rune =>
|
||||
sameArrays(rune.selections, runeConfig.selections)
|
||||
)
|
||||
|
||||
if (existingRune) {
|
||||
existingRune.count++
|
||||
} else {
|
||||
existingBuild.runes.push({ ...runeConfig, count: 1 })
|
||||
}
|
||||
|
||||
return existingBuild
|
||||
}
|
||||
|
||||
// Create new build for this keystone
|
||||
const newBuild: Build = {
|
||||
runeKeystone: keystone,
|
||||
runes: [{ ...runeConfig, count: 1 }],
|
||||
items: treeInit(),
|
||||
bootsFirstCount: 0,
|
||||
count: 0,
|
||||
suppItems: [],
|
||||
boots: []
|
||||
}
|
||||
builds.push(newBuild)
|
||||
return newBuild
|
||||
}
|
||||
|
||||
function handleMatchBuilds(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
timeline: any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -119,6 +174,10 @@ function handleMatchItems(
|
||||
participantIndex: number,
|
||||
builds: Builds
|
||||
) {
|
||||
// Find or create the build for this participant's rune configuration
|
||||
const build = findOrCreateBuild(builds, participant)
|
||||
build.count += 1
|
||||
|
||||
const items: Array<number> = []
|
||||
for (const frame of timeline.info.frames) {
|
||||
for (const event of frame.events) {
|
||||
@@ -145,8 +204,8 @@ function handleMatchItems(
|
||||
x == participant.item6
|
||||
)
|
||||
if (suppItem != undefined) {
|
||||
const already = builds.suppItems.find(x => x.data == suppItem)
|
||||
if (already == undefined) builds.suppItems.push({ count: 1, data: suppItem })
|
||||
const already = build.suppItems.find(x => x.data == suppItem)
|
||||
if (already == undefined) build.suppItems.push({ count: 1, data: suppItem })
|
||||
else already.count += 1
|
||||
}
|
||||
}
|
||||
@@ -166,12 +225,12 @@ function handleMatchItems(
|
||||
if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) {
|
||||
// Check for bootsFirst
|
||||
if (items.length < 2) {
|
||||
builds.bootsFirst += 1
|
||||
build.bootsFirstCount += 1
|
||||
}
|
||||
|
||||
// Add to boots
|
||||
const already = builds.boots.find(x => x.data == event.itemId)
|
||||
if (already == undefined) builds.boots.push({ count: 1, data: event.itemId })
|
||||
// Add to boots array
|
||||
const already = build.boots.find(x => x.data == event.itemId)
|
||||
if (already == undefined) build.boots.push({ count: 1, data: event.itemId })
|
||||
else already.count += 1
|
||||
}
|
||||
|
||||
@@ -188,28 +247,18 @@ function handleMatchItems(
|
||||
// Ignore Cull as not-first item
|
||||
if (event.itemId == 1083 && items.length >= 1) continue
|
||||
|
||||
// Ignore non-final items, except when first item bought
|
||||
if (itemInfo.to.length != 0 && items.length >= 1) continue
|
||||
// Ignore non-final items, except when first item bought or support role
|
||||
if (itemInfo.to.length != 0 && (items.length >= 1 || participant.teamPosition === 'UTILITY'))
|
||||
continue
|
||||
|
||||
items.push(event.itemId)
|
||||
}
|
||||
}
|
||||
|
||||
// Core items
|
||||
treeMerge(builds.tree, items.slice(1, 4))
|
||||
|
||||
// Start items
|
||||
if (items.length >= 1) {
|
||||
const already = builds.start.find(x => x.data == items[0])
|
||||
if (already == undefined) builds.start.push({ count: 1, data: items[0] })
|
||||
else already.count += 1
|
||||
}
|
||||
|
||||
// Late game items
|
||||
for (const item of items.slice(3)) {
|
||||
const already = builds.lateGame.find(x => x.data == item)
|
||||
if (already == undefined) builds.lateGame.push({ count: 1, data: item })
|
||||
else already.count += 1
|
||||
// Merge the full item path into the build's item tree
|
||||
// This tree includes start item as the root, then branching paths
|
||||
if (items.length > 0) {
|
||||
treeMerge(build.items, items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,23 +273,15 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
|
||||
// Lanes
|
||||
let lane = champion.lanes.find(x => x.data == participant.teamPosition)
|
||||
if (lane == undefined) {
|
||||
const builds: Builds = {
|
||||
tree: treeInit(),
|
||||
start: [],
|
||||
bootsFirst: 0,
|
||||
boots: [],
|
||||
lateGame: [],
|
||||
suppItems: []
|
||||
}
|
||||
lane = {
|
||||
count: 1,
|
||||
data: participant.teamPosition,
|
||||
runes: [],
|
||||
builds: builds,
|
||||
builds: [],
|
||||
winningMatches: 0,
|
||||
losingMatches: 0,
|
||||
winrate: 0,
|
||||
pickrate: 0,
|
||||
summonerSpells: [],
|
||||
matchups: []
|
||||
}
|
||||
champion.lanes.push(lane)
|
||||
@@ -260,6 +301,18 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
|
||||
lane.losingMatches++
|
||||
}
|
||||
|
||||
// Summoner spells
|
||||
let spell1 = lane.summonerSpells.find(x => x.id == participant.summoner1Id)
|
||||
if (spell1 == undefined) {
|
||||
spell1 = { id: participant.summoner1Id, count: 1, pickrate: undefined }
|
||||
lane.summonerSpells.push(spell1)
|
||||
} else spell1.count += 1
|
||||
let spell2 = lane.summonerSpells.find(x => x.id == participant.summoner2Id)
|
||||
if (spell2 == undefined) {
|
||||
spell2 = { id: participant.summoner2Id, count: 1, pickrate: undefined }
|
||||
lane.summonerSpells.push(spell2)
|
||||
} else spell2.count += 1
|
||||
|
||||
// Track counter matchups - find opponent in same lane
|
||||
const opponentTeam = participant.teamId === 100 ? 200 : 100
|
||||
const opponent = match.info.participants.find(
|
||||
@@ -292,11 +345,8 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Runes
|
||||
handleParticipantRunes(participant, lane.runes)
|
||||
|
||||
// Items
|
||||
handleMatchItems(match.timeline, participant, participantIndex, lane.builds)
|
||||
// Items and runes (builds)
|
||||
handleMatchBuilds(match.timeline, participant, participantIndex, lane.builds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,50 +372,223 @@ async function handleMatchList(
|
||||
return totalMatches
|
||||
}
|
||||
|
||||
// Split or merge a build/buildtree on starter items
|
||||
// If starter items have a rest-of-tree that is too different, we split
|
||||
// into two variants.
|
||||
// Otherwise, we merge into a ProcessedBuild that has a tree without starters
|
||||
function splitMergeOnStarterItem(build: Build, championName: string): BuildWithStartItems[] {
|
||||
if (build.items.children.length > 2) {
|
||||
console.log(
|
||||
`Warning: We have more than 2 starter items for champion ${championName}. Current algorithm won't work.`
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
build.items.children.length <= 1 ||
|
||||
areTreeSimilars(build.items.children[0], build.items.children[1]) >= 0.5
|
||||
) {
|
||||
const startItems = []
|
||||
let items = build.items.children[0]
|
||||
startItems.push({ data: build.items.children[0].data, count: build.items.children[0].count })
|
||||
build.items.children[0].data = undefined
|
||||
if (build.items.children.length > 1) {
|
||||
startItems.push({ data: build.items.children[1].data, count: build.items.children[1].count })
|
||||
build.items.children[1].data = undefined
|
||||
items = treeMergeTree(build.items.children[0], build.items.children[1])
|
||||
}
|
||||
return [
|
||||
{
|
||||
runeKeystone: build.runeKeystone,
|
||||
runes: build.runes,
|
||||
items,
|
||||
bootsFirstCount: build.bootsFirstCount,
|
||||
count: build.count,
|
||||
startItems,
|
||||
suppItems: build.suppItems,
|
||||
boots: build.boots,
|
||||
pickrate: build.pickrate
|
||||
}
|
||||
]
|
||||
} else {
|
||||
// Trees are different. We separate into two build variants
|
||||
console.log(`Warning: for champion ${championName}, start item splits build variant.`)
|
||||
const builds = []
|
||||
for (const c of build.items.children) {
|
||||
builds.push({
|
||||
runeKeystone: build.runeKeystone,
|
||||
runes: build.runes,
|
||||
items: c,
|
||||
bootsFirstCount: build.bootsFirstCount,
|
||||
count: c.count,
|
||||
startItems: [{ data: c.data, count: c.count }],
|
||||
suppItems: build.suppItems,
|
||||
boots: build.boots
|
||||
})
|
||||
c.data = undefined
|
||||
}
|
||||
return builds
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to merge item counts with same data
|
||||
function mergeItemCounts(
|
||||
builds: BuildWithStartItems[],
|
||||
itemsGetter: (build: BuildWithStartItems) => Array<{ data: number; count: number }>
|
||||
): Array<{ data: number; count: number }> {
|
||||
const countsMap = new Map<number, number>()
|
||||
|
||||
for (const build of builds) {
|
||||
const items = itemsGetter(build)
|
||||
if (!items) continue
|
||||
|
||||
for (const item of items) {
|
||||
const existing = countsMap.get(item.data)
|
||||
if (existing !== undefined) {
|
||||
countsMap.set(item.data, existing + item.count)
|
||||
} else {
|
||||
countsMap.set(item.data, item.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(countsMap.entries()).map(([data, count]) => ({ data, count }))
|
||||
}
|
||||
|
||||
// Merge different builds that have the same items (item trees similar) but different
|
||||
// runes (primary style and keystones)
|
||||
function mergeBuildsForRunes(builds: BuildWithStartItems[]): BuildWithStartItems[] {
|
||||
const merged: BuildWithStartItems[] = []
|
||||
const processed = new Set<number>()
|
||||
const sortedBuilds = [...builds].sort((a, b) => b.count - a.count)
|
||||
|
||||
for (let i = 0; i < sortedBuilds.length; i++) {
|
||||
if (processed.has(i)) continue
|
||||
|
||||
const currentBuild = sortedBuilds[i]
|
||||
processed.add(i)
|
||||
|
||||
// Find all builds with similar item trees
|
||||
const similarBuildsIndices: number[] = []
|
||||
for (let j = i + 1; j < sortedBuilds.length; j++) {
|
||||
if (processed.has(j)) continue
|
||||
|
||||
const otherBuild = sortedBuilds[j]
|
||||
if (areTreeSimilars(currentBuild.items, otherBuild.items) >= 0.5) {
|
||||
similarBuildsIndices.push(j)
|
||||
processed.add(j)
|
||||
}
|
||||
}
|
||||
|
||||
// If no similar builds found, just add the current build as-is
|
||||
if (similarBuildsIndices.length === 0) {
|
||||
merged.push(currentBuild)
|
||||
continue
|
||||
}
|
||||
|
||||
// Merge all similar builds
|
||||
const allSimilarBuilds = [currentBuild, ...similarBuildsIndices.map(idx => sortedBuilds[idx])]
|
||||
const totalCount = allSimilarBuilds.reduce((sum, b) => sum + b.count, 0)
|
||||
|
||||
// Merge runes - combine all unique rune configurations
|
||||
const runesMap = new Map<string, Rune>()
|
||||
for (const build of allSimilarBuilds) {
|
||||
for (const rune of build.runes) {
|
||||
const key = `${rune.primaryStyle}-${rune.selections.join('-')}`
|
||||
const existing = runesMap.get(key)
|
||||
if (existing) {
|
||||
existing.count += rune.count
|
||||
} else {
|
||||
runesMap.set(key, { ...rune })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runes = Array.from(runesMap.values())
|
||||
runes.sort((a, b) => b.count - a.count)
|
||||
|
||||
merged.push({
|
||||
runeKeystone: runes[0].selections[0],
|
||||
runes: runes,
|
||||
items: currentBuild.items,
|
||||
bootsFirstCount: allSimilarBuilds.reduce((sum, b) => sum + b.bootsFirstCount, 0),
|
||||
count: totalCount,
|
||||
startItems: mergeItemCounts(allSimilarBuilds, b => b.startItems),
|
||||
suppItems: mergeItemCounts(allSimilarBuilds, b => b.suppItems),
|
||||
boots: mergeItemCounts(allSimilarBuilds, b => b.boots)
|
||||
})
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function cleanupLaneBuilds(lane: LaneData) {
|
||||
// Filter builds to remove variants that are not played enough
|
||||
lane.builds = lane.builds.filter(build => build.count / lane.count >= 0.05)
|
||||
|
||||
const builds = lane.builds
|
||||
|
||||
// Sort builds by count
|
||||
builds.sort((a, b) => b.count - a.count)
|
||||
|
||||
// For each build: prune item tree, clean up boots, calculate percentages
|
||||
for (const build of builds) {
|
||||
// Cut item tree branches to keep only 4 branches every time and with percentage threshold
|
||||
build.items.count = build.count
|
||||
treeCutBranches(build.items, 4, 0.05)
|
||||
treeSort(build.items)
|
||||
|
||||
// Remove boots that are not within percentage threshold
|
||||
arrayRemovePercentage(build.boots, build.count, 0.05)
|
||||
build.boots.sort((a, b) => b.count - a.count)
|
||||
|
||||
// Remove support items that are not within percentage threshold
|
||||
arrayRemovePercentage(build.suppItems, build.count, 0.05)
|
||||
build.suppItems.sort((a, b) => b.count - a.count)
|
||||
|
||||
// Calculate bootsFirst percentage
|
||||
build.bootsFirst = build.bootsFirstCount / build.count
|
||||
|
||||
// Compute runes pickrate, and filter out to keep only top 3
|
||||
build.runes.forEach(rune => (rune.pickrate = rune.count / build.count))
|
||||
build.runes.sort((a, b) => b.count - a.count)
|
||||
if (build.runes.length > 3) build.runes.splice(3, build.runes.length - 3)
|
||||
|
||||
build.pickrate = build.count / lane.count
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeChampionStats(champion: ChampionData, totalMatches: number) {
|
||||
const totalChampionMatches = champion.winningMatches + champion.losingMatches
|
||||
|
||||
arrayRemovePercentage(champion.lanes, totalChampionMatches, 0.2)
|
||||
champion.lanes.sort((a, b) => b.count - a.count)
|
||||
|
||||
// Filter runes to keep 3 most played
|
||||
for (const lane of champion.lanes) {
|
||||
const runes = lane.runes
|
||||
// Summoner spells
|
||||
lane.summonerSpells.forEach(x => (x.pickrate = x.count / lane.count))
|
||||
lane.summonerSpells.sort((a, b) => b.count - a.count)
|
||||
lane.summonerSpells = lane.summonerSpells.filter(x => x.pickrate >= 0.05)
|
||||
|
||||
runes.sort((a, b) => b.count - a.count)
|
||||
if (runes.length > 3) runes.splice(3, runes.length - 3)
|
||||
// Compute runes pickrate
|
||||
for (const rune of runes) rune.pickrate = rune.count / lane.count
|
||||
}
|
||||
// Cleaning up builds
|
||||
cleanupLaneBuilds(lane)
|
||||
|
||||
for (const lane of champion.lanes) {
|
||||
const builds = lane.builds
|
||||
// Now, second stage: clustering and de-clustering
|
||||
// First, we split the builds on starter items, to obtain a BuildWithStartItems.
|
||||
if (lane.data != 'UTILITY') {
|
||||
const newBuilds: BuildWithStartItems[] = []
|
||||
for (const build of lane.builds) {
|
||||
newBuilds.push(...splitMergeOnStarterItem(build, champion.champion.name))
|
||||
}
|
||||
lane.builds = newBuilds
|
||||
cleanupLaneBuilds(lane)
|
||||
}
|
||||
|
||||
// Cut item tree branches to keep only 4 branches every time and with percentage threshold
|
||||
builds.tree.count = lane.count
|
||||
treeCutBranches(builds.tree, 4, 0.05)
|
||||
treeSort(builds.tree)
|
||||
|
||||
// Cut item start, to only 4 and with percentage threshold
|
||||
arrayRemovePercentage(builds.start, lane.count, 0.05)
|
||||
builds.start.sort((a, b) => b.count - a.count)
|
||||
if (builds.start.length > 4) builds.start.splice(4, builds.start.length - 4)
|
||||
|
||||
// Remove boots that are not within percentage threshold
|
||||
arrayRemovePercentage(builds.boots, lane.count, 0.05)
|
||||
builds.boots.sort((a, b) => b.count - a.count)
|
||||
|
||||
builds.bootsFirst /= lane.count
|
||||
|
||||
// Cut supp items below 2 and percentage threshold
|
||||
arrayRemovePercentage(builds.suppItems, lane.count, 0.05)
|
||||
builds.suppItems.sort((a, b) => b.count - a.count)
|
||||
if (builds.suppItems.length > 2) builds.suppItems.splice(2, builds.suppItems.length - 2)
|
||||
|
||||
// Delete supp items if empty
|
||||
if (builds.suppItems.length == 0) delete builds.suppItems
|
||||
|
||||
builds.lateGame.sort((a, b) => b.count - a.count)
|
||||
// Finally, we merge the builds that are similar but have different keystones.
|
||||
// Now that we split everything that needed to be split, we are sure that we don't need
|
||||
// to have the data per-keystone. We can just merge them back, as it was the same build
|
||||
// all along.
|
||||
lane.builds = mergeBuildsForRunes(lane.builds as BuildWithStartItems[])
|
||||
cleanupLaneBuilds(lane)
|
||||
}
|
||||
|
||||
for (const lane of champion.lanes) {
|
||||
|
||||
@@ -81,4 +81,97 @@ function treeSort(itemtree: ItemTree) {
|
||||
}
|
||||
}
|
||||
|
||||
export { ItemTree, treeMerge, treeInit, treeCutBranches, treeSort }
|
||||
/*
|
||||
* Deep clone an ItemTree
|
||||
*/
|
||||
function treeClone(tree: ItemTree): ItemTree {
|
||||
return {
|
||||
data: tree.data,
|
||||
count: tree.count,
|
||||
children: tree.children.map(child => treeClone(child))
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Merge two ItemTrees into one
|
||||
*/
|
||||
function treeMergeTree(t1: ItemTree, t2: ItemTree): ItemTree {
|
||||
// Merge counts for the root
|
||||
t1.count += t2.count
|
||||
|
||||
// Merge children from t2 into t1
|
||||
for (const child2 of t2.children) {
|
||||
// Find matching child in t1 (same data value)
|
||||
const matchingChild = t1.children.find(child1 => child1.data === child2.data)
|
||||
|
||||
if (matchingChild) {
|
||||
// Recursively merge matching children
|
||||
treeMergeTree(matchingChild, child2)
|
||||
} else {
|
||||
// Add a deep copy of child2 to t1
|
||||
t1.children.push(treeClone(child2))
|
||||
}
|
||||
}
|
||||
|
||||
return t1
|
||||
}
|
||||
|
||||
/*
|
||||
* Flatten an ItemTree into a Set of item numbers
|
||||
*/
|
||||
function treeToSet(itemtree: ItemTree): Set<number> {
|
||||
const items: Set<number> = new Set()
|
||||
|
||||
function traverse(node: ItemTree) {
|
||||
if (node.data !== undefined) {
|
||||
items.add(node.data)
|
||||
}
|
||||
for (const child of node.children) {
|
||||
traverse(child)
|
||||
}
|
||||
}
|
||||
|
||||
traverse(itemtree)
|
||||
return items
|
||||
}
|
||||
|
||||
/*
|
||||
* Calculate similarity between two trees as item sets.
|
||||
* Returns a number between 0 and 1, where 1 means identical and 0 means completely different.
|
||||
* Uses Jaccard similarity: |A ∩ B| / |A ∪ B|
|
||||
* Sets included in one another will have similarity close to 1.
|
||||
*/
|
||||
function areTreeSimilars(t1: ItemTree, t2: ItemTree): number {
|
||||
const set1 = treeToSet(t1)
|
||||
const set2 = treeToSet(t2)
|
||||
|
||||
// Handle empty sets
|
||||
if (set1.size === 0 && set2.size === 0) {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// Calculate intersection
|
||||
const intersection = new Set<number>()
|
||||
for (const item of Array.from(set1)) {
|
||||
if (set2.has(item)) {
|
||||
intersection.add(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate union
|
||||
const union = new Set<number>()
|
||||
for (const item of Array.from(set1)) {
|
||||
union.add(item)
|
||||
}
|
||||
for (const item of Array.from(set2)) {
|
||||
union.add(item)
|
||||
}
|
||||
|
||||
// Jaccard similarity: |intersection| / |union|
|
||||
const similarity = intersection.size / Math.min(set1.size, set2.size)
|
||||
|
||||
// Ensure result is between 0 and 1
|
||||
return Math.max(0, Math.min(1, similarity))
|
||||
}
|
||||
|
||||
export { ItemTree, treeMerge, treeInit, treeCutBranches, treeSort, treeMergeTree, areTreeSimilars }
|
||||
|
||||
Reference in New Issue
Block a user