feat: tag items depending on region and gold state when bought
This commit is contained in:
@@ -7,6 +7,7 @@ interface Props {
|
|||||||
showPickrate?: boolean
|
showPickrate?: boolean
|
||||||
pickrate?: number
|
pickrate?: number
|
||||||
class?: string
|
class?: string
|
||||||
|
tags?: ItemTag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose the icon element for external use (e.g., arrow drawing)
|
// Expose the icon element for external use (e.g., arrow drawing)
|
||||||
@@ -20,7 +21,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
size: 48,
|
size: 48,
|
||||||
showPickrate: false,
|
showPickrate: false,
|
||||||
pickrate: 0,
|
pickrate: 0,
|
||||||
class: ''
|
class: '',
|
||||||
|
tags: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
// Tooltip state - encapsulated in this component
|
// Tooltip state - encapsulated in this component
|
||||||
@@ -100,6 +102,7 @@ const itemIconPath = computed(() => CDRAGON_BASE + mapPath(props.item.iconPath))
|
|||||||
:item="tooltipState.item"
|
:item="tooltipState.item"
|
||||||
:x="tooltipState.x"
|
:x="tooltipState.x"
|
||||||
:y="tooltipState.y"
|
:y="tooltipState.y"
|
||||||
|
:tags="tags"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,9 +6,41 @@ interface Props {
|
|||||||
show: boolean
|
show: boolean
|
||||||
x: number
|
x: number
|
||||||
y: 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
|
// Parse description and convert to styled HTML
|
||||||
const formatDescription = (description?: string) => {
|
const formatDescription = (description?: string) => {
|
||||||
@@ -108,6 +140,18 @@ const formattedDescription = computed(() =>
|
|||||||
{{ item.plaintext }}
|
{{ item.plaintext }}
|
||||||
</div>
|
</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 -->
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<div
|
<div
|
||||||
v-if="formattedDescription"
|
v-if="formattedDescription"
|
||||||
@@ -337,6 +381,58 @@ const formattedDescription = computed(() =>
|
|||||||
color: var(--color-on-surface);
|
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-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ function handleRefresh() {
|
|||||||
:show-pickrate="true"
|
:show-pickrate="true"
|
||||||
:pickrate="parentCount ? tree.count / parentCount : 0"
|
:pickrate="parentCount ? tree.count / parentCount : 0"
|
||||||
:size="48"
|
:size="48"
|
||||||
|
:tags="tree.tags"
|
||||||
class="item-tree-img"
|
class="item-tree-img"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default withNuxt([
|
|||||||
LaneData: 'readonly',
|
LaneData: 'readonly',
|
||||||
ChampionData: 'readonly',
|
ChampionData: 'readonly',
|
||||||
ItemTree: 'readonly',
|
ItemTree: 'readonly',
|
||||||
|
ItemTag: 'readonly',
|
||||||
Builds: 'readonly',
|
Builds: 'readonly',
|
||||||
PerkStyle: 'readonly',
|
PerkStyle: 'readonly',
|
||||||
PerksResponse: 'readonly',
|
PerksResponse: 'readonly',
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
declare global {
|
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
|
* Represents an item in the build tree
|
||||||
*/
|
*/
|
||||||
@@ -6,6 +11,7 @@ declare global {
|
|||||||
count: number
|
count: number
|
||||||
data: number
|
data: number
|
||||||
children: ItemTree[]
|
children: ItemTree[]
|
||||||
|
tags: ItemTag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { MongoClient } from 'mongodb'
|
|||||||
import {
|
import {
|
||||||
ItemTree,
|
ItemTree,
|
||||||
GoldAdvantageTag,
|
GoldAdvantageTag,
|
||||||
|
PlatformCounts,
|
||||||
treeInit,
|
treeInit,
|
||||||
treeMerge,
|
treeMerge,
|
||||||
treeCutBranches,
|
treeCutBranches,
|
||||||
treeSort,
|
treeSort,
|
||||||
treeMergeTree,
|
treeMergeTree,
|
||||||
areTreeSimilars
|
areTreeSimilars,
|
||||||
|
treeDeriveTags
|
||||||
} from './item_tree'
|
} from './item_tree'
|
||||||
|
|
||||||
import { Match, Timeline, Participant, Frame } from './api'
|
import { Match, Timeline, Participant, Frame } from './api'
|
||||||
@@ -101,6 +103,8 @@ type LaneData = {
|
|||||||
builds: Builds
|
builds: Builds
|
||||||
matchups?: Array<MatchupData>
|
matchups?: Array<MatchupData>
|
||||||
summonerSpells: Array<{ id: number; count: number; pickrate?: number }>
|
summonerSpells: Array<{ id: number; count: number; pickrate?: number }>
|
||||||
|
// Region distribution for this lane (used for tag derivation)
|
||||||
|
regionDistribution?: PlatformCounts
|
||||||
}
|
}
|
||||||
type ChampionData = {
|
type ChampionData = {
|
||||||
champion: Champion
|
champion: Champion
|
||||||
@@ -332,11 +336,21 @@ function handleMatch(match: Match, champions: Map<number, ChampionData>, platfor
|
|||||||
winrate: 0,
|
winrate: 0,
|
||||||
pickrate: 0,
|
pickrate: 0,
|
||||||
summonerSpells: [],
|
summonerSpells: [],
|
||||||
matchups: []
|
matchups: [],
|
||||||
|
regionDistribution: { euw: 0, eun: 0, na: 0, kr: 0 }
|
||||||
}
|
}
|
||||||
champion.lanes.push(lane)
|
champion.lanes.push(lane)
|
||||||
} else lane.count += 1
|
} 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
|
// Initialize matchups if not present
|
||||||
if (!lane.matchups) {
|
if (!lane.matchups) {
|
||||||
lane.matchups = []
|
lane.matchups = []
|
||||||
@@ -589,6 +603,9 @@ function cleanupLaneBuilds(lane: LaneData) {
|
|||||||
treeCutBranches(build.items, 4, 0.05)
|
treeCutBranches(build.items, 4, 0.05)
|
||||||
treeSort(build.items)
|
treeSort(build.items)
|
||||||
|
|
||||||
|
// Derive tags from purchase patterns (gold advantage, region)
|
||||||
|
treeDeriveTags(build.items, lane.regionDistribution)
|
||||||
|
|
||||||
// Remove boots that are not within percentage threshold
|
// Remove boots that are not within percentage threshold
|
||||||
arrayRemovePercentage(build.boots, build.count, 0.05)
|
arrayRemovePercentage(build.boots, build.count, 0.05)
|
||||||
build.boots.sort((a, b) => b.count - a.count)
|
build.boots.sort((a, b) => b.count - a.count)
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ type PlatformCounts = {
|
|||||||
kr: number
|
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 = {
|
type ItemTree = {
|
||||||
data: number | undefined
|
data: number | undefined
|
||||||
count: number
|
count: number
|
||||||
@@ -22,6 +25,9 @@ type ItemTree = {
|
|||||||
|
|
||||||
// Platform tracking
|
// Platform tracking
|
||||||
platformCount: PlatformCounts
|
platformCount: PlatformCounts
|
||||||
|
|
||||||
|
// Derived tags for display
|
||||||
|
tags: Array<ItemTag>
|
||||||
}
|
}
|
||||||
|
|
||||||
function initPlatformCounts(): PlatformCounts {
|
function initPlatformCounts(): PlatformCounts {
|
||||||
@@ -34,7 +40,8 @@ function treeInit(): ItemTree {
|
|||||||
count: 0,
|
count: 0,
|
||||||
children: [],
|
children: [],
|
||||||
boughtWhen: { aheadCount: 0, behindCount: 0, evenCount: 0, meanGold: 0 },
|
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,
|
count: count,
|
||||||
children: [],
|
children: [],
|
||||||
boughtWhen: { ...node.boughtWhen },
|
boughtWhen: { ...node.boughtWhen },
|
||||||
platformCount: { ...node.platformCount }
|
platformCount: { ...node.platformCount },
|
||||||
|
tags: []
|
||||||
}
|
}
|
||||||
itemtree.children.push(next)
|
itemtree.children.push(next)
|
||||||
}
|
}
|
||||||
@@ -107,7 +115,8 @@ function treeMerge(
|
|||||||
eun: platformKey === 'eun1' ? 1 : 0,
|
eun: platformKey === 'eun1' ? 1 : 0,
|
||||||
na: platformKey === 'na1' ? 1 : 0,
|
na: platformKey === 'na1' ? 1 : 0,
|
||||||
kr: platformKey === 'kr' ? 1 : 0
|
kr: platformKey === 'kr' ? 1 : 0
|
||||||
}
|
},
|
||||||
|
tags: []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +131,8 @@ function treeCutBranches(itemtree: ItemTree, thresholdCount: number, thresholdPe
|
|||||||
count: +Infinity,
|
count: +Infinity,
|
||||||
children: [],
|
children: [],
|
||||||
boughtWhen: { aheadCount: 0, behindCount: 0, evenCount: 0, meanGold: 0 },
|
boughtWhen: { aheadCount: 0, behindCount: 0, evenCount: 0, meanGold: 0 },
|
||||||
platformCount: initPlatformCounts()
|
platformCount: initPlatformCounts(),
|
||||||
|
tags: []
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1)
|
itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1)
|
||||||
@@ -169,7 +179,8 @@ function treeClone(tree: ItemTree): ItemTree {
|
|||||||
eun: tree.platformCount.eun,
|
eun: tree.platformCount.eun,
|
||||||
na: tree.platformCount.na,
|
na: tree.platformCount.na,
|
||||||
kr: tree.platformCount.kr
|
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))
|
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 {
|
export {
|
||||||
ItemTree,
|
ItemTree,
|
||||||
PlatformCounts,
|
PlatformCounts,
|
||||||
GoldAdvantageTag,
|
GoldAdvantageTag,
|
||||||
|
ItemTag,
|
||||||
treeMerge,
|
treeMerge,
|
||||||
treeInit,
|
treeInit,
|
||||||
treeCutBranches,
|
treeCutBranches,
|
||||||
treeSort,
|
treeSort,
|
||||||
treeMergeTree,
|
treeMergeTree,
|
||||||
areTreeSimilars
|
areTreeSimilars,
|
||||||
|
treeDeriveTags
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user