feat: tag items depending on region and gold state when bought
All checks were successful
pipeline / lint-and-format (push) Successful in 4m29s
pipeline / build-and-push-images (push) Successful in 1m28s

This commit is contained in:
2026-04-18 21:08:58 +02:00
parent 17024f91a8
commit a5728a147f
7 changed files with 231 additions and 10 deletions

View File

@@ -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<Props>(), {
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"
/>
</div>
</template>

View File

@@ -6,9 +6,41 @@ interface Props {
show: boolean
x: number
y: number
tags?: ItemTag[]
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
tags: () => []
})
// Tag display helpers
function getTagLabel(tag: ItemTag): string {
const labels: Record<ItemTag, string> = {
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<ItemTag, string> = {
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 }}
</div>
<!-- Item Tags -->
<div v-if="tags && tags.length > 0" class="tooltip-tags">
<span
v-for="tag in tags"
:key="tag"
:class="['item-tag', getTagClass(tag)]"
:title="getTagTooltip(tag)"
>
{{ getTagLabel(tag) }}
</span>
</div>
<!-- eslint-disable vue/no-v-html -->
<div
v-if="formattedDescription"
@@ -337,6 +381,58 @@ const formattedDescription = computed(() =>
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;

View File

@@ -234,6 +234,7 @@ function handleRefresh() {
:show-pickrate="true"
:pickrate="parentCount ? tree.count / parentCount : 0"
:size="48"
:tags="tree.tags"
class="item-tree-img"
/>
</div>

View File

@@ -22,6 +22,7 @@ export default withNuxt([
LaneData: 'readonly',
ChampionData: 'readonly',
ItemTree: 'readonly',
ItemTag: 'readonly',
Builds: 'readonly',
PerkStyle: 'readonly',
PerksResponse: 'readonly',

View File

@@ -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[]
}
/**

View File

@@ -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<MatchupData>
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<number, ChampionData>, 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)

View File

@@ -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<ItemTag>
}
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<ItemTag> = []
// 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
}