Files
buildpath/frontend/components/item/Tree.vue

289 lines
6.2 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<Item>>('/api/cdragon/items', {
lazy: true, // Don't block rendering
server: false // Client-side only
})
// Track image loading state
const imagesLoaded = ref(false)
// Create item map reactively
const itemMap = reactive(new Map<number, Item>())
watch(
items,
newItems => {
if (newItems) {
itemMap.clear()
for (const item of newItems) {
itemMap.set(item.id, item)
}
}
},
{ immediate: true }
)
const startTreeItem = useTemplateRef('start')
const arrows: Array<svgdomarrowsLinePath> = []
const pendingChildMounts: Array<Element> = []
// Get the actual icon element for arrow drawing
const startElement = computed(() => startTreeItem.value?.iconElement ?? null)
// 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 (startElement.value) {
// Wait for the ItemIcon to load its image
const imgElement = startElement.value.querySelector('img')
if (imgElement) {
await waitForImageLoad(imgElement as HTMLImageElement)
}
// 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(startElement.value!, childEnd)
}
pendingChildMounts.length = 0
}
// Use multiple requestAnimationFrame to ensure rendering is complete
requestAnimationFrame(() => {
requestAnimationFrame(() => {
refreshArrows()
emit('mount', startElement.value!)
})
})
}
})
onBeforeUpdate(() => {
for (const arrow of arrows) {
arrow.release()
}
arrows.splice(0, arrows.length)
})
onUpdated(async () => {
await nextTick()
if (startElement.value && imagesLoaded.value) {
// Redraw arrows after DOM update
requestAnimationFrame(() => {
requestAnimationFrame(() => {
refreshArrows()
emit('mount', startElement.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;pointer-events:none;',
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 (startElement.value) {
if (imagesLoaded.value) {
// Parent is ready, draw arrow immediately
requestAnimationFrame(() => {
requestAnimationFrame(() => {
drawArrow(startElement.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 (startElement.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">
<ItemIcon
v-if="itemMap.get(tree.data)"
ref="start"
:item="itemMap.get(tree.data)!"
:show-pickrate="true"
:pickrate="parentCount ? tree.count / parentCount : 0"
:size="48"
class="item-tree-img"
/>
</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;
position: relative;
z-index: 10;
}
.item-tree-children {
margin-left: 32px;
position: relative;
z-index: 1;
}
.item-tree-child {
width: fit-content;
position: relative;
}
/* Mobile responsive */
@media only screen and (max-width: 900px) {
.item-tree-children {
margin-left: 20px;
}
}
</style>