<template> <div class="absolute border-0 border-l-2 border-solid border-gray-500 w-1/4 min-w-80 flex flex-col top-0 right-0 z-10 h-dvh bg-gray-800" v-if="isOpen"> <div class="relative z-10 p-2.5 border-solid border-0 border-b border-gray-500 text-right"> <h3 class="text-lg text-white">Tiles</h3> </div> <div class="overflow-hidden grow relative"> <div class="absolute top-0 left-0 h-full w-full"> <div class="relative z-10 h-full"> <div class="h-full" v-if="!selectedGroup"> <div class="flex pt-4 pl-4"> <div class="w-full flex gap-1.5 flex-row"> <div> <label class="mb-1.5 font-titles hidden" for="search">Search...</label> <input @mousedown.stop class="input-field" type="text" name="search" placeholder="Search" v-model="searchQuery" /> </div> </div> </div> <div class="flex flex-col h-[calc(100%_-_170px)] p-4 pb-24"> <div class="mb-4 flex flex-wrap gap-2"> <button v-for="tag in uniqueTags" :key="tag" @click="toggleTag(tag)" class="btn-cyan" :class="{ 'opacity-50': !selectedTags.includes(tag) }"> {{ tag }} </button> </div> <div class="h-full flex-grow overflow-y-auto"> <div class="grid grid-cols-4 gap-2 justify-items-center"> <div v-for="group in groupedTiles" :key="group.parent.id" class="flex flex-col items-center justify-center relative"> <img class="max-w-full max-h-full border-2 border-solid cursor-pointer transition-all duration-300" :src="`${config.server_endpoint}/textures/tiles/${group.parent.id}.png`" :alt="group.parent.name" @click="openGroup(group)" @load="() => processTile(group.parent)" :class="{ 'border-cyan shadow-lg scale-105': isActiveTile(group.parent), 'border-transparent hover:border-gray-300': !isActiveTile(group.parent) }" /> <span class="text-xs mt-1">{{ getTileCategory(group.parent) }}</span> <span v-if="group.children.length > 0" class="absolute top-0 right-0 bg-cyan text-white rounded-full w-5 h-5 flex items-center justify-center text-xs"> {{ group.children.length + 1 }} </span> </div> </div> </div> </div> </div> <div v-else class="h-full overflow-auto"> <div class="p-4"> <button @click="closeGroup" class="btn-cyan mb-4">Back to All Tiles</button> <h4 class="text-lg mb-4">{{ selectedGroup.parent.name }} Group</h4> <div class="grid grid-cols-8 gap-2 justify-items-center"> <div class="flex flex-col items-center justify-center"> <img class="max-w-full max-h-full border-2 border-solid cursor-pointer transition-all duration-300" :src="`${config.server_endpoint}/textures/tiles/${selectedGroup.parent.id}.png`" :alt="selectedGroup.parent.name" @click="selectTile(selectedGroup.parent.id)" :class="{ 'border-cyan shadow-lg scale-105': isActiveTile(selectedGroup.parent), 'border-transparent hover:border-gray-300': !isActiveTile(selectedGroup.parent) }" /> <span class="text-xs mt-1">{{ getTileCategory(selectedGroup.parent) }}</span> </div> <div v-for="childTile in selectedGroup.children" :key="childTile.id" class="flex flex-col items-center justify-center"> <img class="max-w-full max-h-full border-2 border-solid cursor-pointer transition-all duration-300" :src="`${config.server_endpoint}/textures/tiles/${childTile.id}.png`" :alt="childTile.name" @click="selectTile(childTile.id)" :class="{ 'border-cyan shadow-lg scale-105': isActiveTile(childTile), 'border-transparent hover:border-gray-300': !isActiveTile(childTile) }" /> <span class="text-xs mt-1">{{ getTileCategory(childTile) }}</span> </div> </div> </div> </div> </div> </div> </div> </div> </template> <script setup lang="ts"> import config from '@/application/config' import type { Tile } from '@/application/types' import { useMapEditorComposable } from '@/composables/useMapEditorComposable' import { TileStorage } from '@/storage/storages' import { liveQuery } from 'dexie' import { computed, onMounted, onUnmounted, ref } from 'vue' const isOpen = ref(false) const tileStorage = new TileStorage() const mapEditor = useMapEditorComposable() const searchQuery = ref('') const selectedTags = ref<string[]>([]) const tileCategories = ref<Map<string, string>>(new Map()) const selectedGroup = ref<{ parent: Tile; children: Tile[] } | null>(null) const tiles = ref<Tile[]>([]) defineExpose({ open: () => (isOpen.value = true), close: () => (isOpen.value = false), toggle: () => (isOpen.value = !isOpen.value) }) const uniqueTags = computed(() => { const allTags = tiles.value.flatMap((tile) => tile.tags || []) return Array.from(new Set(allTags)) }) const groupedTiles = computed(() => { const groups: { parent: Tile; children: Tile[] }[] = [] const filteredTiles = tiles.value.filter((tile) => { const matchesSearch = !searchQuery.value || tile.name.toLowerCase().includes(searchQuery.value.toLowerCase()) const matchesTags = selectedTags.value.length === 0 || (tile.tags && selectedTags.value.some((tag) => tile.tags.includes(tag))) return matchesSearch && matchesTags }) filteredTiles.forEach((tile) => { const parentGroup = groups.find((group) => areTilesRelated(group.parent, tile)) if (parentGroup && parentGroup.parent.id !== tile.id) { parentGroup.children.push(tile) } else { groups.push({ parent: tile, children: [] }) } }) return groups }) const tileColorData = ref<Map<string, { r: number; g: number; b: number }>>(new Map()) const tileEdgeData = ref<Map<string, number>>(new Map()) function areTilesRelated(tile1: Tile, tile2: Tile): boolean { const colorSimilarityThreshold = 30 // Adjust this value as needed const edgeComplexitySimilarityThreshold = 20 // Adjust this value as needed const color1 = tileColorData.value.get(tile1.id) const color2 = tileColorData.value.get(tile2.id) const edge1 = tileEdgeData.value.get(tile1.id) const edge2 = tileEdgeData.value.get(tile2.id) if (!color1 || !color2 || edge1 === undefined || edge2 === undefined) { return false } const colorDifference = Math.sqrt(Math.pow(color1.r - color2.r, 2) + Math.pow(color1.g - color2.g, 2) + Math.pow(color1.b - color2.b, 2)) const edgeComplexityDifference = Math.abs(edge1 - edge2) const namePrefix1 = tile1.name.split('_')[0] const namePrefix2 = tile2.name.split('_')[0] return colorDifference <= colorSimilarityThreshold && edgeComplexityDifference <= edgeComplexitySimilarityThreshold && namePrefix1 === namePrefix2 } const toggleTag = (tag: string) => { if (selectedTags.value.includes(tag)) { selectedTags.value = selectedTags.value.filter((t) => t !== tag) } else { selectedTags.value.push(tag) } } function processTile(tile: Tile) { const img = new Image() img.crossOrigin = 'Anonymous' img.onload = () => { const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') canvas.width = img.width canvas.height = img.height ctx!.drawImage(img, 0, 0, img.width, img.height) const imageData = ctx!.getImageData(0, 0, canvas.width, canvas.height) tileColorData.value.set(tile.id, getDominantColor(imageData)) tileEdgeData.value.set(tile.id, getEdgeComplexity(imageData)) } img.src = `${config.server_endpoint}/textures/tiles/${tile.id}.png` } function getDominantColor(imageData: ImageData) { let r = 0, g = 0, b = 0, total = 0 for (let i = 0; i < imageData.data.length; i += 4) { if (imageData.data[i + 3] > 0) { // Only consider non-transparent pixels r += imageData.data[i] g += imageData.data[i + 1] b += imageData.data[i + 2] total++ } } return { r: Math.round(r / total), g: Math.round(g / total), b: Math.round(b / total) } } function getEdgeComplexity(imageData: ImageData) { let edgePixels = 0 for (let y = 0; y < imageData.height; y++) { for (let x = 0; x < imageData.width; x++) { const i = (y * imageData.width + x) * 4 if (imageData.data[i + 3] > 0 && (x === 0 || y === 0 || x === imageData.width - 1 || y === imageData.height - 1 || imageData.data[i - 1] === 0 || imageData.data[i + 7] === 0)) { edgePixels++ } } } return edgePixels } function getTileCategory(tile: Tile): string { return tileCategories.value.get(tile.id) || '' } function openGroup(group: { parent: Tile; children: Tile[] }) { selectedGroup.value = group } function closeGroup() { selectedGroup.value = null } function selectTile(tile: string) { mapEditor.setSelectedTile(tile) } function isActiveTile(tile: Tile): boolean { return mapEditor.selectedTile.value === tile.id } let subscription: any = null onMounted(() => { subscription = liveQuery(() => tileStorage.liveQuery()).subscribe({ next: (result) => { tiles.value = result }, error: (error) => { console.error('Failed to fetch tiles:', error) } }) }) onUnmounted(() => { if (subscription) { subscription.unsubscribe() } }) </script>