const DATA_URL = window.ZabaCrafting?.dataUrl || "./generated/crafting-recipes-release.json"; const ICON_BASE = window.ZabaCrafting?.iconBase || "./assets/icons/"; const ICON_DATA = window.ZabaCrafting?.iconData || {}; const QUICK_FILTERS = [ { id: "all", label: "ทั้งหมด", test: () => true }, { id: "popular", label: "ยอดนิยม", test: (recipe) => recipe.searchText.match(/crafting_table|stick|torch|chest|furnace|bed|boat|sword|pickaxe|axe|shovel|shield|bow|arrow|bucket|door|ladder|rail|piston|hopper|observer/) }, { id: "wood", label: "ไม้", test: (recipe) => recipe.searchText.match(/wood|log|plank|slab|stairs|fence|door|trapdoor|sign|boat|shelf|ไม้|แผ่นไม้|ซุง/) }, { id: "stone", label: "หิน", test: (recipe) => recipe.searchText.match(/stone|cobble|deepslate|granite|diorite|andesite|tuff|blackstone|หิน/) }, { id: "copper", label: "ทองแดง", test: (recipe) => recipe.searchText.match(/copper|ทองแดง/) }, { id: "color", label: "สี/ย้อม", test: (recipe) => recipe.searchText.match(/dye|banner|wool|carpet|bed|stained|terracotta|candle|shulker|bundle|harness|สี|ย้อม/) }, { id: "redstone", label: "เรดสโตน", test: (recipe) => recipe.category === "redstone" || recipe.searchText.match(/redstone|piston|hopper|observer|rail|comparator|repeater|เรดสโตน/) }, { id: "gear", label: "อุปกรณ์", test: (recipe) => ["tools", "weapons", "armor"].includes(recipe.category) }, { id: "nether", label: "เนเธอร์/เอนด์", test: (recipe) => recipe.category === "nether_end" || recipe.searchText.match(/nether|end_|ender|quartz|blaze|obsidian|เนเธอร์|เอนด์/) } ]; const TYPE_LABELS = { all: "ทุกประเภทสูตร", "minecraft:crafting_shaped": "ต้องวางตำแหน่ง", "minecraft:crafting_shapeless": "ไม่บังคับตำแหน่ง", "minecraft:crafting_transmute": "สูตรแปลงไอเทม" }; let categories = []; let items = {}; let recipes = []; let activeCategory = "all"; let activeQuickFilter = "all"; let activeType = "all"; let activeSort = "name_th"; let selectedRecipe = null; let visibleLimit = 180; const el = { recipeCount: document.getElementById("recipe-count"), selectedIcon: document.getElementById("selected-icon"), selectedTitle: document.getElementById("selected-title"), selectedSubtitle: document.getElementById("selected-subtitle"), selectedDescription: document.getElementById("selected-description"), selectedEditions: document.getElementById("selected-editions"), ingredientList: document.getElementById("ingredient-list"), craftingGrid: document.getElementById("crafting-grid"), resultSlot: document.getElementById("result-slot"), tabs: document.getElementById("category-tabs"), grid: document.getElementById("recipe-card-grid"), search: document.getElementById("recipe-search"), empty: document.getElementById("empty-state") }; function ensureFinderControls() { const finderPanel = document.querySelector(".finder-panel"); const searchBox = document.querySelector(".search-box"); if (searchBox && !document.getElementById("recipe-search")) { const searchInput = document.createElement("input"); searchInput.type = "search"; searchInput.id = "recipe-search"; searchInput.placeholder = "ค้นหา เช่น ดาบ โต๊ะคราฟ redstone"; searchInput.autocomplete = "off"; searchBox.appendChild(searchInput); } el.search = document.getElementById("recipe-search"); if (!finderPanel || document.getElementById("quick-filter-tabs")) return; const controls = document.createElement("div"); controls.className = "craft-filter-panel"; controls.innerHTML = `

ตัวกรองเร็ว

`; const categoryTabs = document.getElementById("category-tabs"); finderPanel.insertBefore(controls, categoryTabs); const footer = document.createElement("div"); footer.className = "recipe-results-footer"; footer.innerHTML = `

`; el.grid.insertAdjacentElement("afterend", footer); el.quickTabs = document.getElementById("quick-filter-tabs"); el.typeFilter = document.getElementById("type-filter"); el.sortFilter = document.getElementById("sort-filter"); el.clearFilter = document.getElementById("clear-filter-button"); el.visibleSummary = document.getElementById("visible-summary"); el.loadMore = document.getElementById("load-more-recipes"); } function normalizeRecipe(recipe) { const ingredients = (recipe.ingredients || []).map((ingredient) => ({ id: ingredient.id, count: Number(ingredient.count || 1), alternatives: ingredient.alternatives || (ingredient.id ? [ingredient.id] : []) })); const tags = [ recipe.id, recipe.result, recipe.nameTh, recipe.nameEn, recipe.thaiBucket, recipe.category, recipe.type, ...ingredients.flatMap((ingredient) => [ingredient.id, ...ingredient.alternatives]) ].filter(Boolean); return { ...recipe, outputQty: Number(recipe.outputQty || 1), grid: Array.from({ length: 9 }, (_, index) => recipe.grid?.[index] || null), ingredients, tags, searchText: tags.join(" ").toLowerCase() }; } function itemMeta(id) { if (!id) return { nameTh: "-", nameEn: "-", label: "-", color: "#cfd5cc" }; const meta = items[id] || {}; const nameTh = meta.nameTh || id.replace(/^#/, "").replace(/_/g, " "); const nameEn = meta.nameEn || nameTh; return { nameTh, nameEn, label: shortLabel(id), color: colorFromId(id) }; } function shortLabel(id) { return String(id) .replace(/^#/, "") .split("_") .filter(Boolean) .slice(0, 2) .map((part) => part.slice(0, 3)) .join(" ") .toUpperCase(); } function colorFromId(id) { let hash = 0; for (const ch of String(id)) hash = ((hash << 5) - hash + ch.charCodeAt(0)) | 0; const hue = Math.abs(hash) % 360; return `hsl(${hue} 34% 58%)`; } function icon(id, large = false) { const meta = itemMeta(id); const node = document.createElement("span"); node.className = `item-icon${large ? " item-icon-lg" : ""}`; node.style.setProperty("--item-color", meta.color); node.title = `${meta.nameTh} (${meta.nameEn})`; const mappedIcon = ICON_DATA[id]; if (!id || (id.startsWith("#") && !mappedIcon)) { node.textContent = meta.label; return node; } const img = document.createElement("img"); img.src = mappedIcon || `${ICON_BASE}${id}.png`; img.alt = ""; img.loading = "lazy"; img.decoding = "async"; img.addEventListener("error", () => { node.textContent = meta.label; }, { once: true }); node.appendChild(img); return node; } function recipeDescription(recipe) { if (recipe.notes) return recipe.notes; if (recipe.type === "minecraft:crafting_shapeless") { return "สูตรแบบไม่บังคับตำแหน่ง วางวัตถุดิบในช่องคราฟช่องใดก็ได้ตามจำนวนที่ระบุ"; } if (recipe.type === "minecraft:crafting_transmute") { return "สูตรแปลงไอเทม ใช้วัตถุดิบหลักร่วมกับวัสดุแปลงตามที่ระบุในช่องคราฟ"; } return "วางวัตถุดิบตามตำแหน่ง 3x3 ที่แสดง แล้วจะได้ผลลัพธ์ตามจำนวนที่ระบุ"; } function setSelected(recipeToShow, updateHash = true) { selectedRecipe = recipeToShow; if (updateHash) history.replaceState(null, "", `#${recipeToShow.id}`); el.selectedIcon.replaceChildren(icon(recipeToShow.result || recipeToShow.id, true)); el.selectedTitle.textContent = `${recipeToShow.nameTh} (${recipeToShow.nameEn})`; el.selectedSubtitle.textContent = `ผลลัพธ์: ${recipeToShow.nameTh} x${recipeToShow.outputQty}`; el.selectedDescription.textContent = recipeDescription(recipeToShow); el.selectedEditions.textContent = "Java และ Bedrock ส่วนใหญ่ใช้สูตรเดียวกัน; ตรวจเวอร์ชันเกมหากเป็นไอเทมใหม่"; renderCraftingGrid(recipeToShow); renderIngredients(recipeToShow); renderCards(); } function renderCraftingGrid(recipeToShow) { el.craftingGrid.replaceChildren(); recipeToShow.grid.forEach((itemId) => { const slot = document.createElement("div"); slot.className = "crafting-slot"; if (itemId) { slot.appendChild(icon(itemId)); } else { slot.textContent = "-"; slot.classList.add("empty-slot"); } el.craftingGrid.appendChild(slot); }); el.resultSlot.replaceChildren(icon(recipeToShow.result || recipeToShow.id)); } function renderIngredients(recipeToShow) { el.ingredientList.replaceChildren(); recipeToShow.ingredients.forEach((ingredient) => { const li = document.createElement("li"); const primary = ingredient.id || ingredient.alternatives[0]; li.appendChild(icon(primary)); const text = document.createElement("span"); const meta = itemMeta(primary); const alternatives = ingredient.alternatives.filter((id) => id && id !== primary); const altText = alternatives.length ? ` หรือ ${alternatives.map((id) => itemMeta(id).nameTh).join(", ")}` : ""; text.innerHTML = `${meta.nameTh} x${ingredient.count}${meta.nameEn}${altText}`; li.appendChild(text); el.ingredientList.appendChild(li); }); } function renderTabs() { el.tabs.replaceChildren(); categories.forEach((category) => { const button = document.createElement("button"); button.type = "button"; button.className = category.id === activeCategory ? "active" : ""; const count = category.id === "all" ? recipes.length : recipes.filter((recipeToCheck) => recipeToCheck.category === category.id).length; button.textContent = `${category.label} (${count})`; button.addEventListener("click", () => { activeCategory = category.id; visibleLimit = 180; renderTabs(); renderCards(); }); el.tabs.appendChild(button); }); } function renderQuickFilters() { el.quickTabs.replaceChildren(); QUICK_FILTERS.forEach((filter) => { const count = recipes.filter(filter.test).length; const button = document.createElement("button"); button.type = "button"; button.className = filter.id === activeQuickFilter ? "active" : ""; button.textContent = `${filter.label} (${count})`; button.addEventListener("click", () => { activeQuickFilter = filter.id; visibleLimit = 180; renderQuickFilters(); renderCards(); }); el.quickTabs.appendChild(button); }); } function renderTypeFilter() { const types = ["all", ...new Set(recipes.map((recipe) => recipe.type).filter(Boolean))]; el.typeFilter.innerHTML = types .map((type) => ``) .join(""); } function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch])); } function matches(recipeToCheck) { const query = (el.search?.value || "").trim().toLowerCase(); const quickFilter = QUICK_FILTERS.find((filter) => filter.id === activeQuickFilter) || QUICK_FILTERS[0]; if (activeCategory !== "all" && recipeToCheck.category !== activeCategory) return false; if (activeType !== "all" && recipeToCheck.type !== activeType) return false; if (!quickFilter.test(recipeToCheck)) return false; if (!query) return true; return recipeToCheck.searchText.includes(query); } function compareRecipeName(a, b) { if (activeSort === "name_en") return a.nameEn.localeCompare(b.nameEn, "en"); if (activeSort === "category") return `${a.category} ${a.nameTh}`.localeCompare(`${b.category} ${b.nameTh}`, "th"); return a.nameTh.localeCompare(b.nameTh, "th"); } function sortRecipes(list) { const categoryOrder = new Map(categories.map((category, index) => [category.id, index])); return [...list].sort((a, b) => { if (activeCategory === "all") { const orderA = categoryOrder.get(a.category) ?? 999; const orderB = categoryOrder.get(b.category) ?? 999; if (orderA !== orderB) return orderA - orderB; } return compareRecipeName(a, b); }); } function getCategoryLabel(categoryId) { const category = categories.find((categoryItem) => categoryItem.id === categoryId); return category?.label || categoryId || "อื่นๆ"; } function createCategoryDivider(categoryId, count) { const divider = document.createElement("div"); divider.className = "recipe-category-divider"; const label = document.createElement("span"); label.textContent = getCategoryLabel(categoryId); const amount = document.createElement("small"); amount.textContent = `${count.toLocaleString("th-TH")} สูตร`; divider.append(label, amount); return divider; } function renderCards() { const visibleRecipes = sortRecipes(recipes.filter(matches)); const recipesToRender = visibleRecipes; const fragment = document.createDocumentFragment(); const showCategoryDividers = activeCategory === "all" && visibleRecipes.length > 0; const categoryCounts = visibleRecipes.reduce((counts, recipeToCount) => { counts.set(recipeToCount.category, (counts.get(recipeToCount.category) || 0) + 1); return counts; }, new Map()); let currentCategory = null; el.grid.replaceChildren(); recipesToRender.forEach((recipeToShow) => { if (showCategoryDividers && recipeToShow.category !== currentCategory) { currentCategory = recipeToShow.category; fragment.appendChild(createCategoryDivider(currentCategory, categoryCounts.get(currentCategory) || 0)); } const button = document.createElement("button"); button.type = "button"; button.className = recipeToShow.id === selectedRecipe?.id ? "recipe-card active" : "recipe-card"; const label = `${recipeToShow.nameTh} (${recipeToShow.nameEn})`; button.setAttribute("aria-label", label); button.title = label; button.dataset.tooltip = label; button.appendChild(icon(recipeToShow.result || recipeToShow.id)); button.addEventListener("click", () => setSelected(recipeToShow)); fragment.appendChild(button); }); el.grid.appendChild(fragment); el.empty.hidden = visibleRecipes.length > 0; el.visibleSummary.textContent = visibleRecipes.length ? `แสดงทั้งหมด ${visibleRecipes.length.toLocaleString("th-TH")} สูตรที่ตรงเงื่อนไข` : "ไม่พบสูตรที่ตรงกับตัวกรอง"; el.loadMore.hidden = true; } function clearFilters() { activeCategory = "all"; activeQuickFilter = "all"; activeType = "all"; activeSort = "name_th"; visibleLimit = 180; el.search.value = ""; el.typeFilter.value = activeType; el.sortFilter.value = activeSort; renderTabs(); renderQuickFilters(); renderCards(); } async function loadDatabase() { ensureFinderControls(); if (window.ZabaCrafting?.iconMapUrl) { try { const iconResponse = await fetch(window.ZabaCrafting.iconMapUrl, { cache: "no-store" }); if (iconResponse.ok) Object.assign(ICON_DATA, await iconResponse.json()); } catch (error) { console.warn("Cannot load crafting icon map", error); } } const response = await fetch(DATA_URL, { cache: "no-store" }); if (!response.ok) throw new Error(`Cannot load crafting data: ${response.status}`); const data = await response.json(); categories = data.categories || []; items = data.items || {}; recipes = (data.recipes || []).map(normalizeRecipe); el.recipeCount.textContent = `${recipes.length.toLocaleString("th-TH")} สูตร`; const hashId = decodeURIComponent(location.hash.replace(/^#/, "")); const initialRecipe = recipes.find((recipe) => recipe.id === hashId || recipe.result === hashId) || recipes[0]; renderTabs(); renderQuickFilters(); renderTypeFilter(); setSelected(initialRecipe, Boolean(hashId)); el.search.addEventListener("input", () => { visibleLimit = 180; renderCards(); }); el.typeFilter.addEventListener("change", () => { activeType = el.typeFilter.value; visibleLimit = 180; renderCards(); }); el.sortFilter.addEventListener("change", () => { activeSort = el.sortFilter.value; renderCards(); }); el.clearFilter.addEventListener("click", clearFilters); el.loadMore.addEventListener("click", () => { visibleLimit += 180; renderCards(); }); } loadDatabase().catch((error) => { console.error(error); el.selectedTitle.textContent = "โหลดข้อมูลสูตรคราฟไม่สำเร็จ"; el.selectedSubtitle.textContent = "ตรวจไฟล์ JSON หรือ path ของฐานข้อมูล"; });