306 lines
6.4 KiB
Vue
306 lines
6.4 KiB
Vue
<script setup lang="ts">
|
|
import { LinePath as svgdomarrowsLinePath } from 'svg-dom-arrows'
|
|
|
|
defineProps<{
|
|
tree: ItemTree
|
|
parentCount?: number
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
mount: [end: Element]
|
|
refresh: []
|
|
parentReady: []
|
|
}>()
|
|
|
|
const { data: items, pending: itemsLoading } = useFetch<Array<{ id: number; iconPath: string }>>(
|
|
'/api/cdragon/items',
|
|
{
|
|
lazy: true, // Don't block rendering
|
|
server: false // Client-side only
|
|
}
|
|
)
|
|
|
|
// Track image loading state
|
|
const imagesLoaded = ref(false)
|
|
const imageElement: Ref<HTMLImageElement | null> = ref(null)
|
|
|
|
// Create item map reactively
|
|
const itemMap = reactive(new Map<number, { id: number; iconPath: string }>())
|
|
watch(
|
|
items,
|
|
newItems => {
|
|
if (newItems) {
|
|
itemMap.clear()
|
|
for (const item of newItems) {
|
|
itemMap.set(item.id, item)
|
|
}
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
function getItemIconPath(itemId: number): string {
|
|
const item = itemMap.get(itemId)
|
|
return item ? CDRAGON_BASE + mapPath(item.iconPath) : ''
|
|
}
|
|
|
|
const start: Ref<Element | null> = useTemplateRef('start')
|
|
const arrows: Array<svgdomarrowsLinePath> = []
|
|
const pendingChildMounts: Array<Element> = []
|
|
|
|
// Function to wait for an image to load
|
|
function waitForImageLoad(imgElement: HTMLImageElement): Promise<void> {
|
|
return new Promise(resolve => {
|
|
if (imgElement.complete) {
|
|
requestAnimationFrame(() => resolve())
|
|
return
|
|
}
|
|
|
|
imgElement.addEventListener(
|
|
'load',
|
|
() => {
|
|
requestAnimationFrame(() => resolve())
|
|
},
|
|
{ once: true }
|
|
)
|
|
imgElement.addEventListener(
|
|
'error',
|
|
() => {
|
|
requestAnimationFrame(() => resolve())
|
|
},
|
|
{ once: true }
|
|
)
|
|
})
|
|
}
|
|
|
|
onMounted(async () => {
|
|
// Wait for next tick to ensure DOM is ready
|
|
await nextTick()
|
|
|
|
// Wait for items to be loaded
|
|
await new Promise<void>(resolve => {
|
|
if (!itemsLoading.value) {
|
|
resolve()
|
|
} else {
|
|
const unwatch = watch(itemsLoading, loading => {
|
|
if (!loading) {
|
|
unwatch()
|
|
resolve()
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
if (start.value) {
|
|
const imgElement = start.value as HTMLImageElement
|
|
imageElement.value = imgElement
|
|
|
|
// Wait for own image to load
|
|
await waitForImageLoad(imgElement)
|
|
|
|
// Now that image is loaded and DOM is ready, draw arrows
|
|
imagesLoaded.value = true
|
|
|
|
// Notify children that parent is ready
|
|
emit('parentReady')
|
|
|
|
// Draw any pending arrows from children that mounted before we were ready
|
|
if (pendingChildMounts.length > 0) {
|
|
await nextTick()
|
|
for (const childEnd of pendingChildMounts) {
|
|
drawArrow(start.value!, childEnd)
|
|
}
|
|
pendingChildMounts.length = 0
|
|
}
|
|
|
|
// Use multiple requestAnimationFrame to ensure rendering is complete
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
refreshArrows()
|
|
emit('mount', start.value!)
|
|
})
|
|
})
|
|
}
|
|
})
|
|
|
|
onBeforeUpdate(() => {
|
|
for (const arrow of arrows) {
|
|
arrow.release()
|
|
}
|
|
arrows.splice(0, arrows.length)
|
|
})
|
|
|
|
onUpdated(async () => {
|
|
await nextTick()
|
|
|
|
if (start.value && imagesLoaded.value) {
|
|
// Redraw arrows after DOM update
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
refreshArrows()
|
|
emit('mount', start.value!)
|
|
})
|
|
})
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
for (const arrow of arrows) {
|
|
arrow.release()
|
|
}
|
|
})
|
|
|
|
function drawArrow(start: Element, end: Element) {
|
|
if (start == null || end == null) return
|
|
|
|
const arrow = new svgdomarrowsLinePath({
|
|
start: {
|
|
element: start,
|
|
position: {
|
|
top: 0.5,
|
|
left: 1
|
|
}
|
|
},
|
|
end: {
|
|
element: end,
|
|
position: {
|
|
top: 0.5,
|
|
left: 0
|
|
}
|
|
},
|
|
style: 'stroke:var(--color-on-surface);stroke-width:2;fill:transparent;',
|
|
appendTo: document.body
|
|
})
|
|
arrows.push(arrow)
|
|
}
|
|
|
|
function refreshArrows() {
|
|
// Double requestAnimationFrame to ensure layout is complete
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
for (const arrow of arrows) {
|
|
arrow.redraw()
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
// Redraw arrows on window resize
|
|
addEventListener('resize', _ => {
|
|
refreshArrows()
|
|
})
|
|
addEventListener('scroll', _ => {
|
|
refreshArrows()
|
|
})
|
|
|
|
function handleSubtreeMount(end: Element) {
|
|
if (start.value) {
|
|
if (imagesLoaded.value) {
|
|
// Parent is ready, draw arrow immediately
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
drawArrow(start.value!, end)
|
|
refreshArrows()
|
|
})
|
|
})
|
|
} else {
|
|
// Parent not ready yet, store for later
|
|
pendingChildMounts.push(end)
|
|
}
|
|
}
|
|
emit('refresh')
|
|
}
|
|
|
|
function handleParentReady() {
|
|
// Parent became ready, redraw all our arrows
|
|
if (start.value && imagesLoaded.value) {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
refreshArrows()
|
|
})
|
|
})
|
|
}
|
|
// Propagate to children
|
|
emit('parentReady')
|
|
}
|
|
|
|
function handleRefresh() {
|
|
refreshArrows()
|
|
emit('refresh')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="item-tree-container">
|
|
<div v-if="tree.data != undefined && tree.data != null" class="item-tree-node">
|
|
<img
|
|
ref="start"
|
|
class="item-tree-img"
|
|
:alt="tree.data.toString()"
|
|
:src="getItemIconPath(tree.data)"
|
|
/>
|
|
<span class="item-tree-pickrate">{{ ((tree.count / parentCount!!) * 100).toFixed(0) }}%</span>
|
|
</div>
|
|
|
|
<div class="item-tree-children">
|
|
<div v-for="child in tree.children" :key="child.data" class="item-tree-child">
|
|
<ItemTree
|
|
:tree="child"
|
|
:parent-count="tree.count"
|
|
@refresh="handleRefresh"
|
|
@mount="end => handleSubtreeMount(end)"
|
|
@parent-ready="handleParentReady"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style>
|
|
.item-tree-container {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.item-tree-node {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
width: fit-content;
|
|
}
|
|
|
|
.item-tree-img {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--color-on-surface);
|
|
}
|
|
|
|
.item-tree-pickrate {
|
|
font-size: 0.65rem;
|
|
color: var(--color-on-surface);
|
|
opacity: 0.6;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.item-tree-children {
|
|
margin-left: 32px;
|
|
}
|
|
|
|
.item-tree-child {
|
|
width: fit-content;
|
|
}
|
|
|
|
@media only screen and (max-width: 900px) {
|
|
.item-tree-img {
|
|
width: 40px;
|
|
height: 40px;
|
|
}
|
|
|
|
.item-tree-children {
|
|
margin-left: 20px;
|
|
}
|
|
}
|
|
</style>
|