From a5728a147fd8503ab1036cf0d9e7a5a18b73c765 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 18 Apr 2026 21:08:58 +0200 Subject: [PATCH] feat: tag items depending on region and gold state when bought --- frontend/components/item/ItemIcon.vue | 5 +- frontend/components/item/ItemTooltip.vue | 98 +++++++++++++++++++- frontend/components/item/Tree.vue | 1 + frontend/eslint.config.mjs | 1 + frontend/types/api.ts | 6 ++ match_collector/champion_stat.ts | 21 ++++- match_collector/item_tree.ts | 109 +++++++++++++++++++++-- 7 files changed, 231 insertions(+), 10 deletions(-) diff --git a/frontend/components/item/ItemIcon.vue b/frontend/components/item/ItemIcon.vue index 80af69d..b5c7764 100644 --- a/frontend/components/item/ItemIcon.vue +++ b/frontend/components/item/ItemIcon.vue @@ -7,6 +7,7 @@ interface Props { showPickrate?: boolean pickrate?: number class?: string + tags?: ItemTag[] } // Expose the icon element for external use (e.g., arrow drawing) @@ -20,7 +21,8 @@ const props = withDefaults(defineProps(), { size: 48, showPickrate: false, pickrate: 0, - class: '' + class: '', + tags: () => [] }) // Tooltip state - encapsulated in this component @@ -100,6 +102,7 @@ const itemIconPath = computed(() => CDRAGON_BASE + mapPath(props.item.iconPath)) :item="tooltipState.item" :x="tooltipState.x" :y="tooltipState.y" + :tags="tags" /> diff --git a/frontend/components/item/ItemTooltip.vue b/frontend/components/item/ItemTooltip.vue index 2183d51..fecd89d 100644 --- a/frontend/components/item/ItemTooltip.vue +++ b/frontend/components/item/ItemTooltip.vue @@ -6,9 +6,41 @@ interface Props { show: boolean x: number y: number + tags?: ItemTag[] } -const props = defineProps() +const props = withDefaults(defineProps(), { + tags: () => [] +}) + +// Tag display helpers +function getTagLabel(tag: ItemTag): string { + const labels: Record = { + ahead: 'Ahead', + behind: 'Behind', + region_euw: 'EUW', + region_eun: 'EUN', + region_na: 'NA', + region_kr: 'KR' + } + return labels[tag] || tag +} + +function getTagTooltip(tag: ItemTag): string { + const tooltips: Record = { + ahead: 'This item is typically bought when ahead in gold', + behind: 'This item is typically bought when behind in gold', + region_euw: 'Popular in EU West region', + region_eun: 'Popular in EU Nordic & East region', + region_na: 'Popular in North America region', + region_kr: 'Popular in Korea region' + } + return tooltips[tag] || tag +} + +function getTagClass(tag: ItemTag): string { + return `tag-${tag}` +} // Parse description and convert to styled HTML const formatDescription = (description?: string) => { @@ -108,6 +140,18 @@ const formattedDescription = computed(() => {{ item.plaintext }} + +
+ + {{ getTagLabel(tag) }} + +
+
color: var(--color-on-surface); } +/* Item tags in tooltip */ +.tooltip-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-on-surface-dim); +} + +.tooltip-tags .item-tag { + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 3px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + white-space: nowrap; +} + +/* Gold situation tags */ +.tooltip-tags .tag-ahead { + background-color: #22c55e; + color: white; +} + +.tooltip-tags .tag-behind { + background-color: #ef4444; + color: white; +} + +/* Region tags */ +.tooltip-tags .tag-region_euw { + background-color: #3b82f6; + color: white; +} + +.tooltip-tags .tag-region_eun { + background-color: #8b5cf6; + color: white; +} + +.tooltip-tags .tag-region_na { + background-color: #f59e0b; + color: white; +} + +.tooltip-tags .tag-region_kr { + background-color: #ec4899; + color: white; +} + .fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; diff --git a/frontend/components/item/Tree.vue b/frontend/components/item/Tree.vue index 8a471b2..5547593 100644 --- a/frontend/components/item/Tree.vue +++ b/frontend/components/item/Tree.vue @@ -234,6 +234,7 @@ function handleRefresh() { :show-pickrate="true" :pickrate="parentCount ? tree.count / parentCount : 0" :size="48" + :tags="tree.tags" class="item-tree-img" />
diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 3997750..c113c5a 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -22,6 +22,7 @@ export default withNuxt([ LaneData: 'readonly', ChampionData: 'readonly', ItemTree: 'readonly', + ItemTag: 'readonly', Builds: 'readonly', PerkStyle: 'readonly', PerksResponse: 'readonly', diff --git a/frontend/types/api.ts b/frontend/types/api.ts index eb5d0aa..74811ca 100644 --- a/frontend/types/api.ts +++ b/frontend/types/api.ts @@ -1,4 +1,9 @@ declare global { + /** + * Item tags derived from purchase patterns + */ + type ItemTag = 'ahead' | 'behind' | 'region_euw' | 'region_eun' | 'region_na' | 'region_kr' + /** * Represents an item in the build tree */ @@ -6,6 +11,7 @@ declare global { count: number data: number children: ItemTree[] + tags: ItemTag[] } /** diff --git a/match_collector/champion_stat.ts b/match_collector/champion_stat.ts index fac2a39..a7e2afb 100644 --- a/match_collector/champion_stat.ts +++ b/match_collector/champion_stat.ts @@ -2,12 +2,14 @@ import { MongoClient } from 'mongodb' import { ItemTree, GoldAdvantageTag, + PlatformCounts, treeInit, treeMerge, treeCutBranches, treeSort, treeMergeTree, - areTreeSimilars + areTreeSimilars, + treeDeriveTags } from './item_tree' import { Match, Timeline, Participant, Frame } from './api' @@ -101,6 +103,8 @@ type LaneData = { builds: Builds matchups?: Array summonerSpells: Array<{ id: number; count: number; pickrate?: number }> + // Region distribution for this lane (used for tag derivation) + regionDistribution?: PlatformCounts } type ChampionData = { champion: Champion @@ -332,11 +336,21 @@ function handleMatch(match: Match, champions: Map, platfor winrate: 0, pickrate: 0, summonerSpells: [], - matchups: [] + matchups: [], + regionDistribution: { euw: 0, eun: 0, na: 0, kr: 0 } } champion.lanes.push(lane) } else lane.count += 1 + // Track region distribution for this lane + if (lane.regionDistribution && platform) { + const platformKey = platform.toLowerCase() + if (platformKey === 'euw1') lane.regionDistribution.euw++ + else if (platformKey === 'eun1') lane.regionDistribution.eun++ + else if (platformKey === 'na1') lane.regionDistribution.na++ + else if (platformKey === 'kr') lane.regionDistribution.kr++ + } + // Initialize matchups if not present if (!lane.matchups) { lane.matchups = [] @@ -589,6 +603,9 @@ function cleanupLaneBuilds(lane: LaneData) { treeCutBranches(build.items, 4, 0.05) treeSort(build.items) + // Derive tags from purchase patterns (gold advantage, region) + treeDeriveTags(build.items, lane.regionDistribution) + // Remove boots that are not within percentage threshold arrayRemovePercentage(build.boots, build.count, 0.05) build.boots.sort((a, b) => b.count - a.count) diff --git a/match_collector/item_tree.ts b/match_collector/item_tree.ts index 8747e6e..7caef09 100644 --- a/match_collector/item_tree.ts +++ b/match_collector/item_tree.ts @@ -7,6 +7,9 @@ type PlatformCounts = { kr: number } +// Item tags that can be derived from purchase patterns +type ItemTag = 'ahead' | 'behind' | 'region_euw' | 'region_eun' | 'region_na' | 'region_kr' + type ItemTree = { data: number | undefined count: number @@ -22,6 +25,9 @@ type ItemTree = { // Platform tracking platformCount: PlatformCounts + + // Derived tags for display + tags: Array } function initPlatformCounts(): PlatformCounts { @@ -34,7 +40,8 @@ function treeInit(): ItemTree { count: 0, children: [], boughtWhen: { aheadCount: 0, behindCount: 0, evenCount: 0, meanGold: 0 }, - platformCount: initPlatformCounts() + platformCount: initPlatformCounts(), + tags: [] } } @@ -73,7 +80,8 @@ function nodeMerge(itemtree: ItemTree, node: ItemTree) { count: count, children: [], boughtWhen: { ...node.boughtWhen }, - platformCount: { ...node.platformCount } + platformCount: { ...node.platformCount }, + tags: [] } itemtree.children.push(next) } @@ -107,7 +115,8 @@ function treeMerge( eun: platformKey === 'eun1' ? 1 : 0, na: platformKey === 'na1' ? 1 : 0, kr: platformKey === 'kr' ? 1 : 0 - } + }, + tags: [] }) } } @@ -122,7 +131,8 @@ function treeCutBranches(itemtree: ItemTree, thresholdCount: number, thresholdPe count: +Infinity, children: [], boughtWhen: { aheadCount: 0, behindCount: 0, evenCount: 0, meanGold: 0 }, - platformCount: initPlatformCounts() + platformCount: initPlatformCounts(), + tags: [] } ) itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1) @@ -169,7 +179,8 @@ function treeClone(tree: ItemTree): ItemTree { eun: tree.platformCount.eun, na: tree.platformCount.na, kr: tree.platformCount.kr - } + }, + tags: [...tree.tags] } } @@ -266,14 +277,100 @@ function areTreeSimilars(t1: ItemTree, t2: ItemTree): number { return Math.max(0, Math.min(1, similarity)) } +/* + * Derive tags for an item based on purchase patterns + * Tags are derived when a specific condition is dominant (>= 60% threshold) + * For region tags, we compare against expected distribution to find items that are + * significantly more popular in a region than expected + */ +function deriveTags(node: ItemTree, expectedRegionDistribution?: PlatformCounts): void { + const tags: Array = [] + + // Derive gold situation tags + const totalGoldSituations = + node.boughtWhen.aheadCount + node.boughtWhen.behindCount + node.boughtWhen.evenCount + if (totalGoldSituations > 0) { + const aheadPct = node.boughtWhen.aheadCount / totalGoldSituations + const behindPct = node.boughtWhen.behindCount / totalGoldSituations + + // Only tag if there's a dominant pattern (>= 60%) + if (aheadPct >= 0.6) { + tags.push('ahead') + } else if (behindPct >= 0.6) { + tags.push('behind') + } + } + + // Derive region tags by comparing against expected distribution + const totalRegionCount = + node.platformCount.euw + node.platformCount.eun + node.platformCount.na + node.platformCount.kr + if (totalRegionCount > 0 && expectedRegionDistribution) { + const totalExpected = + expectedRegionDistribution.euw + + expectedRegionDistribution.eun + + expectedRegionDistribution.na + + expectedRegionDistribution.kr + + if (totalExpected > 0) { + // Calculate expected percentages + const expectedEuwPct = expectedRegionDistribution.euw / totalExpected + const expectedEunPct = expectedRegionDistribution.eun / totalExpected + const expectedNaPct = expectedRegionDistribution.na / totalExpected + const expectedKrPct = expectedRegionDistribution.kr / totalExpected + + // Calculate actual percentages for this item + const actualEuwPct = node.platformCount.euw / totalRegionCount + const actualEunPct = node.platformCount.eun / totalRegionCount + const actualNaPct = node.platformCount.na / totalRegionCount + const actualKrPct = node.platformCount.kr / totalRegionCount + + // Tag if the item is significantly more popular in a region (>= 1.5x expected rate) + // and has a minimum absolute percentage (>= 10%) + const SIGNIFICANCE_THRESHOLD = 1.5 + const MINIMUM_PCT = 0.1 + + if (actualEuwPct >= expectedEuwPct * SIGNIFICANCE_THRESHOLD && actualEuwPct >= MINIMUM_PCT) { + tags.push('region_euw') + } + if (actualEunPct >= expectedEunPct * SIGNIFICANCE_THRESHOLD && actualEunPct >= MINIMUM_PCT) { + tags.push('region_eun') + } + if (actualNaPct >= expectedNaPct * SIGNIFICANCE_THRESHOLD && actualNaPct >= MINIMUM_PCT) { + tags.push('region_na') + } + if (actualKrPct >= expectedKrPct * SIGNIFICANCE_THRESHOLD && actualKrPct >= MINIMUM_PCT) { + tags.push('region_kr') + } + } + } + + node.tags = tags + + // Recursively derive tags for children + for (const child of node.children) { + deriveTags(child, expectedRegionDistribution) + } +} + +/* + * Apply tag derivation to an entire tree + * expectedRegionDistribution: the total region distribution for the champion/lane, + * used to detect items that are region-specific + */ +function treeDeriveTags(itemtree: ItemTree, expectedRegionDistribution?: PlatformCounts): void { + deriveTags(itemtree, expectedRegionDistribution) +} + export { ItemTree, PlatformCounts, GoldAdvantageTag, + ItemTag, treeMerge, treeInit, treeCutBranches, treeSort, treeMergeTree, - areTreeSimilars + areTreeSimilars, + treeDeriveTags }