Files

1093 lines
54 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
buildGraph,
cloneBook,
makeIndex,
primaryRatePerMinute,
summarizeBook,
traceIngredientClosure,
validateBook,
} from "./core.mjs";
const $ = (selector) => document.querySelector(selector);
const elements = {
app: $("#app"),
statRecipes: $("#statRecipes"),
statItems: $("#statItems"),
statMachines: $("#statMachines"),
tabRecipes: $("#tabRecipes"),
tabItems: $("#tabItems"),
tabMachines: $("#tabMachines"),
saveState: $("#saveState"),
undoButton: $("#undoButton"),
redoButton: $("#redoButton"),
importButton: $("#importButton"),
exportButton: $("#exportButton"),
issuesButton: $("#issuesButton"),
issueCount: $("#issueCount"),
saveButton: $("#saveButton"),
addEntityButton: $("#addEntityButton"),
typeTabs: $("#typeTabs"),
catalogSearch: $("#catalogSearch"),
stageFilter: $("#stageFilter"),
biomeFilter: $("#biomeFilter"),
catalogSummary: $("#catalogSummary"),
entityList: $("#entityList"),
graphTitle: $("#graphTitle"),
graphSubtitle: $("#graphSubtitle"),
graphMode: $("#graphMode"),
depthControl: $("#depthControl"),
graphDepth: $("#graphDepth"),
fitGraphButton: $("#fitGraphButton"),
graphStage: $("#graphStage"),
graph: $("#recipeGraph"),
graphViewport: $("#graphViewport"),
graphBackdrop: $("#graphBackdrop"),
graphEdges: $("#graphEdges"),
graphNodes: $("#graphNodes"),
graphEmpty: $("#graphEmpty"),
zoomOutButton: $("#zoomOutButton"),
zoomInButton: $("#zoomInButton"),
zoomOutput: $("#zoomOutput"),
routeSummary: $("#routeSummary"),
inspectorTitle: $("#inspectorTitle"),
inspectorKind: $("#inspectorKind"),
inspectorContent: $("#inspectorContent"),
issuesDialog: $("#issuesDialog"),
issuesSummary: $("#issuesSummary"),
issueFilter: $("#issueFilter"),
issueList: $("#issueList"),
helpDialog: $("#helpDialog"),
importFile: $("#importFile"),
toastStack: $("#toastStack"),
};
const TYPE_META = {
recipes: { label: "配方", glyph: "✦", singular: "recipe" },
items: { label: "物品", glyph: "◆", singular: "item" },
machines: { label: "设施", glyph: "▣", singular: "machine" },
};
const RECIPE_KINDS = [
["craft", "制造"], ["inscription", "刻印"], ["growth", "生长"],
["decay", "腐朽"], ["state", "状态转化"], ["separation", "分离"],
["ritual", "仪式"], ["result_bag", "结果袋"], ["recovery", "回收"],
];
const INPUT_MODES = [["consumed", "消耗"], ["catalyst", "催化"], ["fluid", "流体"], ["state", "状态"]];
const ITEM_CATEGORIES = ["raw", "material", "component", "fluid", "proof", "state", "anchor", "offering", "construct", "record", "relic", "waste"];
const DRAFT_KEY = "magic-foundry-recipe-book-draft-v1";
const MAX_HISTORY = 100;
const state = {
book: null,
selected: { type: "recipes", id: "perform_first_warp" },
activeType: "recipes",
search: "",
stage: "all",
biome: "all",
graphMode: "focus",
graphDepth: 1,
graphTransform: { x: 0, y: 0, scale: 1 },
graphBounds: null,
graphNeedsFit: true,
issues: [],
issueSeverity: "all",
history: [],
future: [],
dirty: false,
serverOnline: false,
saving: false,
savedBookJson: "",
technologyIds: null,
draftTimer: null,
pendingEdit: null,
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function short(value, length = 22) {
const text = String(value ?? "");
return text.length > length ? `${text.slice(0, length - 1)}…` : text;
}
function slugTime() {
return new Date().toISOString().replaceAll(":", "-").replace("T", "_").slice(0, 19);
}
function parseLines(value) {
return String(value || "").split(/[\n,]/).map((entry) => entry.trim()).filter(Boolean);
}
function numberFromInput(input, nullable = false) {
if (nullable && input.value.trim() === "") return null;
const value = Number(input.value);
return Number.isFinite(value) ? value : 0;
}
function selectedEntity() {
return state.book?.[state.selected.type]?.find((entry) => entry.id === state.selected.id) || null;
}
function ensureSelection() {
if (selectedEntity()) return;
const list = state.book?.[state.activeType] || [];
if (list.length) state.selected = { type: state.activeType, id: list[0].id };
}
function optionHtml(entries, selected, getId = (entry) => entry.id, getLabel = (entry) => entry.name) {
return entries.map((entry) => {
const id = getId(entry);
return `<option value="${escapeHtml(id)}"${id === selected ? " selected" : ""}>${escapeHtml(getLabel(entry))}</option>`;
}).join("");
}
function toast(message, severity = "success", timeout = 2800) {
const node = document.createElement("div");
node.className = `toast ${severity}`;
node.textContent = message;
elements.toastStack.append(node);
window.setTimeout(() => node.remove(), timeout);
}
function scheduleDraft() {
window.clearTimeout(state.draftTimer);
state.draftTimer = window.setTimeout(() => {
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ savedAt: new Date().toISOString(), book: state.book }));
} catch {
toast("浏览器草稿空间不足,请尽快保存或导出。", "warning", 5000);
}
}, 500);
}
function updateDirty() {
state.dirty = JSON.stringify(state.book) !== state.savedBookJson;
if (state.dirty) scheduleDraft();
else localStorage.removeItem(DRAFT_KEY);
}
function mutate(label, operation, { fitGraph = false } = {}) {
state.pendingEdit = null;
state.history.push({ label, book: cloneBook(state.book), selected: { ...state.selected } });
if (state.history.length > MAX_HISTORY) state.history.shift();
state.future.length = 0;
operation(state.book);
state.book.metadata ||= {};
state.book.metadata.updatedAt = new Date().toISOString();
updateDirty();
state.graphNeedsFit ||= fitGraph;
refreshAll();
}
function undo() {
state.pendingEdit = null;
const snapshot = state.history.pop();
if (!snapshot) return;
state.future.push({ label: snapshot.label, book: cloneBook(state.book), selected: { ...state.selected } });
state.book = snapshot.book;
state.selected = snapshot.selected;
updateDirty();
state.graphNeedsFit = true;
refreshAll();
toast(`已撤销:${snapshot.label}`);
}
function redo() {
state.pendingEdit = null;
const snapshot = state.future.pop();
if (!snapshot) return;
state.history.push({ label: snapshot.label, book: cloneBook(state.book), selected: { ...state.selected } });
state.book = snapshot.book;
state.selected = snapshot.selected;
updateDirty();
state.graphNeedsFit = true;
refreshAll();
toast(`已重做:${snapshot.label}`);
}
function validate() {
state.issues = validateBook(state.book, { technologyIds: state.technologyIds });
return state.issues;
}
function renderStatus() {
const summary = summarizeBook(state.book, state.issues);
elements.statRecipes.textContent = summary.recipes;
elements.statItems.textContent = summary.items;
elements.statMachines.textContent = summary.machines;
elements.tabRecipes.textContent = summary.recipes;
elements.tabItems.textContent = summary.items;
elements.tabMachines.textContent = summary.machines;
elements.undoButton.disabled = state.history.length === 0;
elements.undoButton.dataset.history = String(state.history.length);
elements.undoButton.dataset.pendingEdit = state.pendingEdit?.key || "";
elements.redoButton.disabled = state.future.length === 0;
elements.saveButton.disabled = state.saving;
elements.saveButton.textContent = state.saving ? "保存中…" : "保存数据";
const errors = state.issues.filter((entry) => entry.severity === "error").length;
const warnings = state.issues.filter((entry) => entry.severity === "warning").length;
elements.issueCount.textContent = errors ? `${errors} 个错误` : warnings ? `${warnings} 个警告` : "校验通过";
elements.issuesButton.classList.toggle("has-errors", errors > 0);
elements.issuesButton.classList.toggle("has-warnings", warnings > 0);
let className = "save-state ";
let text;
if (state.saving) {
className += "loading";
text = "正在写入";
} else if (state.dirty) {
className += state.serverOnline ? "dirty" : "offline";
text = state.serverOnline ? "有未保存修改" : "草稿尚未写回";
} else if (state.serverOnline) {
className += "saved";
text = "已保存到项目";
} else {
className += "offline";
text = "仅导入导出模式";
}
elements.saveState.className = className;
elements.saveState.querySelector("span").textContent = text;
}
function renderFilters() {
const currentStage = state.stage;
const currentBiome = state.biome;
elements.stageFilter.innerHTML = `<option value="all">全部阶段</option>${optionHtml(state.book.stages, currentStage)}`;
elements.biomeFilter.innerHTML = `<option value="all">全部生态区</option>${optionHtml(state.book.biomes, currentBiome)}`;
elements.stageFilter.value = state.book.stages.some((entry) => entry.id === currentStage) ? currentStage : "all";
elements.biomeFilter.value = state.book.biomes.some((entry) => entry.id === currentBiome) ? currentBiome : "all";
}
function renderCatalog() {
const query = state.search.trim().toLocaleLowerCase("zh-CN");
const stageOrder = new Map(state.book.stages.map((entry) => [entry.id, entry.order]));
const list = (state.book[state.activeType] || []).filter((entity) => {
if (state.stage !== "all" && entity.stage !== state.stage) return false;
if (state.biome !== "all" && entity.biome !== state.biome) return false;
if (!query) return true;
const haystack = [entity.name, entity.id, entity.category, entity.kind, entity.description, entity.notes, ...(entity.tags || [])].join(" ").toLocaleLowerCase("zh-CN");
return haystack.includes(query);
}).sort((a, b) => (stageOrder.get(a.stage) ?? 99) - (stageOrder.get(b.stage) ?? 99) || a.name.localeCompare(b.name, "zh-CN"));
elements.typeTabs.querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.type === state.activeType));
elements.catalogSummary.textContent = `显示 ${list.length} / ${state.book[state.activeType].length}${TYPE_META[state.activeType].label}`;
if (!list.length) {
elements.entityList.innerHTML = `<div class="list-empty">没有符合条件的条目。<br>可以清除搜索或筛选。</div>`;
return;
}
let lastStage = null;
const stageNames = new Map(state.book.stages.map((entry) => [entry.id, entry.name]));
elements.entityList.innerHTML = list.map((entity) => {
const stageLabel = entity.stage !== lastStage ? `<div class="stage-group-label">${escapeHtml(stageNames.get(entity.stage) || entity.stage)}</div>` : "";
lastStage = entity.stage;
const meta = state.activeType === "recipes" ? `${entity.duration}s · ${entity.kind}` : state.activeType === "items" ? entity.category : `速度 ×${entity.baseSpeed}`;
return `${stageLabel}<button class="entity-row${state.selected.type === state.activeType && state.selected.id === entity.id ? " active" : ""}" type="button" data-type="${state.activeType}" data-id="${escapeHtml(entity.id)}">
<span class="entity-icon">${TYPE_META[state.activeType].glyph}</span>
<span class="entity-copy"><strong>${escapeHtml(entity.name)}</strong><small>${escapeHtml(entity.id)}</small></span>
<span class="entity-meta">${escapeHtml(meta)}</span>
</button>`;
}).join("");
}
function commonFields(entity) {
return `<div class="inspector-section">
<h3 class="section-title">基本信息</h3>
<div class="form-grid">
<label class="form-field full"><span>显示名称</span><input type="text" data-field="name" value="${escapeHtml(entity.name)}"></label>
<label class="form-field full"><span>稳定 ID</span><input class="id-field" type="text" data-action="rename-id" value="${escapeHtml(entity.id)}"><small class="field-help">改名会同步更新所有引用;运行时接入后不建议频繁修改。</small></label>
<label class="form-field"><span>阶段</span><select data-field="stage">${optionHtml(state.book.stages, entity.stage)}</select></label>
<label class="form-field"><span>生态区</span><select data-field="biome">${optionHtml(state.book.biomes, entity.biome)}</select></label>
</div>
</div>`;
}
function linkButtons(type, entities) {
if (!entities.length) return `<span class="field-help">暂无</span>`;
return `<div class="dependency-links">${entities.map((entry) => `<button type="button" data-action="navigate" data-type="${type}" data-id="${escapeHtml(entry.id)}">${escapeHtml(entry.name)}</button>`).join("")}</div>`;
}
function renderItemInspector(item) {
const index = makeIndex(state.book);
const producers = index.producers.get(item.id) || [];
const consumers = index.consumers.get(item.id) || [];
const categories = [...new Set([...ITEM_CATEGORIES, ...state.book.items.map((entry) => entry.category).filter(Boolean)])];
return `${commonFields(item)}
<div class="inspector-section">
<h3 class="section-title">物料属性</h3>
<div class="form-grid">
<label class="form-field"><span>类别</span><select data-field="category">${optionHtml(categories, item.category, (entry) => entry, (entry) => entry)}</select></label>
<label class="checkbox-field"><input type="checkbox" data-field="isRaw"${item.isRaw ? " checked" : ""}> 世界原料</label>
<label class="form-field"><span>单件质量</span><input type="number" min="0.001" step="0.1" data-field="mass" value="${escapeHtml(item.mass)}"></label>
<label class="form-field"><span>堆叠上限</span><input type="number" min="1" step="1" data-field="stackSize" value="${escapeHtml(item.stackSize)}"></label>
<label class="form-field full"><span>法则标签(逗号分隔)</span><input type="text" data-field="tags" data-array="comma" value="${escapeHtml((item.tags || []).join(", "))}"></label>
<label class="form-field full"><span>策划备注</span><textarea data-field="notes">${escapeHtml(item.notes || "")}</textarea></label>
</div>
</div>
<div class="inspector-section">
<h3 class="section-title">依赖关系</h3>
<div class="inline-stats"><div><strong>${producers.length}</strong><span>生产配方</span></div><div><strong>${consumers.length}</strong><span>消费配方</span></div><div><strong>${item.mass}</strong><span>质量</span></div></div>
<div class="form-field" style="margin-top:10px"><span>由这些配方产出</span>${linkButtons("recipes", producers)}</div>
<div class="form-field" style="margin-top:10px"><span>用于这些配方</span>${linkButtons("recipes", consumers)}</div>
</div>
${entityActions("items")}`;
}
function renderInputRows(recipe) {
const itemOptions = [...state.book.items].sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
return (recipe.inputs || []).map((entry, index) => `<div class="io-row input">
<div class="io-main">
<select aria-label="输入物品" data-io="inputs" data-index="${index}" data-key="itemId">${optionHtml(itemOptions, entry.itemId)}</select>
<input aria-label="输入数量" type="number" min="0.001" step="0.1" data-io="inputs" data-index="${index}" data-key="amount" value="${escapeHtml(entry.amount)}">
<select aria-label="输入方式" data-io="inputs" data-index="${index}" data-key="mode">${optionHtml(INPUT_MODES, entry.mode || "consumed", (item) => item[0], (item) => item[1])}</select>
<button class="row-delete" type="button" aria-label="删除输入" data-action="delete-io" data-side="inputs" data-index="${index}">×</button>
</div>
</div>`).join("") || `<p class="field-help">没有物品输入;这通常表示世界采集或环境生成。</p>`;
}
function renderOutputRows(recipe) {
const itemOptions = [...state.book.items].sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
return (recipe.outputs || []).map((entry, index) => `<div class="io-row output">
<div class="io-main">
<select aria-label="产出物品" data-io="outputs" data-index="${index}" data-key="itemId">${optionHtml(itemOptions, entry.itemId)}</select>
<input aria-label="期望数量" type="number" min="0" step="0.1" data-io="outputs" data-index="${index}" data-key="amount" value="${escapeHtml(entry.amount)}">
<button class="row-delete" type="button" aria-label="删除产出" data-action="delete-io" data-side="outputs" data-index="${index}">×</button>
</div>
<div class="io-extra">
<label>最小<input type="number" min="0" step="0.1" placeholder="—" data-nullable data-io="outputs" data-index="${index}" data-key="min" value="${entry.min ?? ""}"></label>
<label>最大<input type="number" min="0" step="0.1" placeholder="—" data-nullable data-io="outputs" data-index="${index}" data-key="max" value="${entry.max ?? ""}"></label>
<label>概率<input type="number" min="0" max="1" step="0.01" placeholder="—" data-nullable data-io="outputs" data-index="${index}" data-key="chance" value="${entry.chance ?? ""}"></label>
<label>权重<input type="number" min="0" step="1" placeholder="—" data-nullable data-io="outputs" data-index="${index}" data-key="weight" value="${entry.weight ?? ""}"></label>
</div>
</div>`).join("") || `<p class="field-help">至少需要一项产出,校验会阻止空产出配方被保存。</p>`;
}
function renderRecipeInspector(recipe) {
const machine = state.book.machines.find((entry) => entry.id === recipe.machineId);
const rate = primaryRatePerMinute(recipe, machine);
const consumed = (recipe.inputs || []).filter((entry) => entry.mode !== "catalyst").reduce((sum, entry) => sum + Number(entry.amount || 0), 0);
return `${commonFields(recipe)}
<div class="inspector-section">
<h3 class="section-title">制作规则</h3>
<div class="form-grid">
<label class="form-field"><span>制作设施</span><select data-field="machineId">${optionHtml(state.book.machines, recipe.machineId)}</select></label>
<label class="form-field"><span>法则类型</span><select data-field="kind">${optionHtml(RECIPE_KINDS, recipe.kind, (item) => item[0], (item) => item[1])}</select></label>
<label class="form-field"><span>单次耗时(秒)</span><input type="number" min="0.01" step="0.1" data-field="duration" value="${escapeHtml(recipe.duration)}"></label>
<label class="form-field"><span>解锁科技 ID</span><input class="id-field" type="text" data-field="unlockedBy" value="${escapeHtml(recipe.unlockedBy || "")}"></label>
<label class="form-field"><span>魔力 / 秒</span><input type="number" min="0" step="0.1" data-field="manaPerSecond" value="${escapeHtml(recipe.manaPerSecond || 0)}"></label>
<label class="form-field"><span>单次熵增</span><input type="number" min="0" step="0.1" data-field="entropy" value="${escapeHtml(recipe.entropy || 0)}"></label>
<label class="checkbox-field full"><input type="checkbox" data-field="enabled"${recipe.enabled !== false ? " checked" : ""}> 启用此配方</label>
</div>
</div>
<div class="inspector-section">
<h3 class="section-title"><span>输入 <small>(${(recipe.inputs || []).length})</small></span><button type="button" data-action="add-io" data-side="inputs"> 添加输入</button></h3>
${renderInputRows(recipe)}
</div>
<div class="inspector-section">
<h3 class="section-title"><span>产出 <small>(${(recipe.outputs || []).length})</small></span><button type="button" data-action="add-io" data-side="outputs"> 添加产出</button></h3>
${renderOutputRows(recipe)}
</div>
<div class="inspector-section">
<h3 class="section-title">仪式与策划说明</h3>
<div class="form-grid">
<label class="form-field full"><span>环境 / 同步条件(每行一项)</span><textarea data-field="conditions" data-array="lines">${escapeHtml((recipe.conditions || []).join("\n"))}</textarea></label>
<label class="form-field full"><span>法则标签(逗号分隔)</span><input type="text" data-field="tags" data-array="comma" value="${escapeHtml((recipe.tags || []).join(", "))}"></label>
<label class="form-field full"><span>策划备注</span><textarea data-field="notes">${escapeHtml(recipe.notes || "")}</textarea></label>
</div>
</div>
<div class="inspector-section">
<h3 class="section-title">即时估算</h3>
<div class="inline-stats"><div><strong>${rate.toFixed(1)}</strong><span>主产物 / 分</span></div><div><strong>${consumed.toFixed(1)}</strong><span>每批消耗量</span></div><div><strong>${Number(recipe.entropy || 0).toFixed(1)}</strong><span>每批熵增</span></div></div>
</div>
<button type="button" class="help-link" data-action="help">催化、状态、权重和概率分别是什么意思?</button>
${entityActions("recipes", true)}`;
}
function renderMachineInspector(machine) {
const recipes = state.book.recipes.filter((entry) => entry.machineId === machine.id);
return `${commonFields(machine)}
<div class="inspector-section">
<h3 class="section-title">设施属性</h3>
<div class="form-grid">
<label class="form-field"><span>基础速度倍率</span><input type="number" min="0.01" step="0.05" data-field="baseSpeed" value="${escapeHtml(machine.baseSpeed)}"></label>
<label class="form-field full"><span>法则标签(逗号分隔)</span><input type="text" data-field="tags" data-array="comma" value="${escapeHtml((machine.tags || []).join(", "))}"></label>
<label class="form-field full"><span>设施说明</span><textarea data-field="description">${escapeHtml(machine.description || "")}</textarea></label>
</div>
</div>
<div class="inspector-section">
<h3 class="section-title">承载配方 <small>(${recipes.length})</small></h3>
${linkButtons("recipes", recipes)}
</div>
${entityActions("machines")}`;
}
function entityActions(type, canDuplicate = false) {
return `<div class="inspector-actions">
${canDuplicate ? `<button class="button ghost" type="button" data-action="duplicate">复制配方</button>` : ""}
<button class="button ghost danger" type="button" data-action="delete-entity">删除${TYPE_META[type].label}</button>
</div>`;
}
function renderInspector() {
const entity = selectedEntity();
if (!entity) {
elements.inspectorTitle.textContent = "选择一个条目";
elements.inspectorKind.textContent = "—";
elements.inspectorContent.innerHTML = `<div class="empty-inspector"><span>✦</span><p>从左侧目录或中央配方树选择一个条目。</p></div>`;
return;
}
elements.inspectorTitle.textContent = entity.name;
elements.inspectorKind.textContent = TYPE_META[state.selected.type].label;
if (state.selected.type === "items") elements.inspectorContent.innerHTML = renderItemInspector(entity);
else if (state.selected.type === "recipes") elements.inspectorContent.innerHTML = renderRecipeInspector(entity);
else elements.inspectorContent.innerHTML = renderMachineInspector(entity);
}
function focusGraphLayout(graph) {
const nodeMap = new Map(graph.nodes.map((node) => [node.key, node]));
const adjacency = new Map(graph.nodes.map((node) => [node.key, []]));
for (const edge of graph.edges) {
adjacency.get(edge.source)?.push({ key: edge.target, delta: 1 });
adjacency.get(edge.target)?.push({ key: edge.source, delta: -1 });
}
const selectedKey = `${state.selected.type}:${state.selected.id}`;
const ranks = new Map([[selectedKey, 0]]);
const queue = [selectedKey];
while (queue.length) {
const key = queue.shift();
for (const neighbor of adjacency.get(key) || []) {
if (!ranks.has(neighbor.key)) {
ranks.set(neighbor.key, ranks.get(key) + neighbor.delta);
queue.push(neighbor.key);
}
}
}
for (const node of graph.nodes) if (!ranks.has(node.key)) ranks.set(node.key, 0);
const rankValues = [...new Set(ranks.values())].sort((a, b) => a - b);
const columns = new Map(rankValues.map((rank) => [rank, graph.nodes.filter((node) => ranks.get(node.key) === rank)]));
const stageOrder = new Map(state.book.stages.map((entry) => [entry.id, entry.order]));
for (const column of columns.values()) column.sort((a, b) => (stageOrder.get(a.stage) ?? 99) - (stageOrder.get(b.stage) ?? 99) || a.label.localeCompare(b.label, "zh-CN"));
const maxRows = Math.max(1, ...[...columns.values()].map((column) => column.length));
const columnWidth = 236;
const rowHeight = 76;
const positions = new Map();
const bands = [];
rankValues.forEach((rank, columnIndex) => {
const column = columns.get(rank);
const x = columnIndex * columnWidth;
const yOffset = (maxRows - column.length) * rowHeight / 2;
column.forEach((node, rowIndex) => {
const isRecipe = node.type === "recipes";
positions.set(node.key, { x: x + 22, y: 54 + yOffset + rowIndex * rowHeight, width: isRecipe ? 188 : 174, height: isRecipe ? 62 : 54 });
});
bands.push({
x,
width: columnWidth - 10,
height: maxRows * rowHeight + 100,
label: rank < 0 ? `上游 ${Math.abs(rank)}` : rank > 0 ? `下游 ${rank}` : "当前选择",
});
});
return { positions, bands, width: Math.max(rankValues.length * columnWidth, 400), height: maxRows * rowHeight + 100 };
}
function stagedGraphLayout(graph) {
const stages = new Map(state.book.stages.map((entry) => [entry.id, entry]));
const stageIds = [...new Set(graph.nodes.map((node) => node.stage))].sort((a, b) => (stages.get(a)?.order ?? 99) - (stages.get(b)?.order ?? 99));
const positions = new Map();
const bands = [];
const bandWidth = 670;
let maxHeight = 280;
stageIds.forEach((stageId, stageIndex) => {
const stageNodes = graph.nodes.filter((node) => node.stage === stageId);
const recipes = stageNodes.filter((node) => node.type === "recipes");
const machines = stageNodes.filter((node) => node.type === "machines");
const items = stageNodes.filter((node) => node.type === "items");
const itemConnections = new Map(items.map((item) => [item.key, { in: 0, out: 0 }]));
graph.edges.forEach((edge) => {
if (itemConnections.has(edge.source)) itemConnections.get(edge.source).out += 1;
if (itemConnections.has(edge.target)) itemConnections.get(edge.target).in += 1;
});
const leftItems = items.filter((item) => itemConnections.get(item.key)?.out > 0);
const rightItems = items.filter((item) => !leftItems.includes(item));
const x = stageIndex * bandWidth;
leftItems.forEach((node, index) => positions.set(node.key, { x: x + 28, y: 60 + index * 72, width: 174, height: 54 }));
recipes.forEach((node, index) => positions.set(node.key, { x: x + 240, y: 60 + index * 82, width: 188, height: 62 }));
rightItems.forEach((node, index) => positions.set(node.key, { x: x + 466, y: 60 + index * 72, width: 174, height: 54 }));
machines.forEach((node, index) => positions.set(node.key, { x: x + 240, y: 74 + recipes.length * 82 + index * 70, width: 188, height: 54 }));
const rows = Math.max(leftItems.length * 72, recipes.length * 82 + machines.length * 70, rightItems.length * 72, 190);
const height = rows + 110;
maxHeight = Math.max(maxHeight, height);
bands.push({ x, width: bandWidth - 16, height, label: stages.get(stageId)?.name || stageId });
});
return { positions, bands, width: Math.max(stageIds.length * bandWidth, 400), height: maxHeight };
}
function graphLayout(graph) {
return state.graphMode === "focus" ? focusGraphLayout(graph) : stagedGraphLayout(graph);
}
function edgePath(source, target) {
const sourceCenter = source.x + source.width / 2;
const targetCenter = target.x + target.width / 2;
const forward = sourceCenter <= targetCenter;
const sx = forward ? source.x + source.width : source.x;
const tx = forward ? target.x : target.x + target.width;
const sy = source.y + source.height / 2;
const ty = target.y + target.height / 2;
const bend = Math.max(55, Math.abs(tx - sx) * .44);
const c1 = forward ? sx + bend : sx - bend;
const c2 = forward ? tx - bend : tx + bend;
return { d: `M ${sx} ${sy} C ${c1} ${sy}, ${c2} ${ty}, ${tx} ${ty}`, mx: (sx + tx) / 2, my: (sy + ty) / 2 };
}
function renderGraph() {
const entity = selectedEntity();
const graph = buildGraph(state.book, {
mode: state.graphMode,
depth: state.graphDepth,
selection: state.selected,
stage: state.stage,
biome: state.biome,
});
const layout = graphLayout(graph);
state.graphBounds = { x: 0, y: 0, width: layout.width, height: layout.height };
elements.graphEmpty.hidden = graph.nodes.length > 0;
elements.depthControl.classList.toggle("is-disabled", state.graphMode === "full");
elements.graphMode.querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.mode === state.graphMode));
elements.graphTitle.textContent = entity ? entity.name : "配方树";
elements.graphSubtitle.textContent = state.graphMode === "focus" ? `显示选中条目前后 ${state.graphDepth} 层关系` : `显示当前筛选下的全部关系(${graph.nodes.length} 个节点)`;
elements.graphBackdrop.innerHTML = layout.bands.map((band) => `<g class="stage-band"><rect x="${band.x + 4}" y="8" width="${band.width}" height="${Math.max(layout.height - 16, band.height)}" rx="16"></rect><text x="${band.x + 24}" y="35">${escapeHtml(band.label)}</text></g>`).join("");
elements.graphEdges.innerHTML = graph.edges.map((edge) => {
const source = layout.positions.get(edge.source);
const target = layout.positions.get(edge.target);
if (!source || !target) return "";
const path = edgePath(source, target);
const amount = Number(edge.amount);
return `<g><path class="graph-edge ${escapeHtml(edge.kind)}" d="${path.d}"></path>${Number.isFinite(amount) ? `<text class="edge-label" x="${path.mx}" y="${path.my - 4}">×${amount}</text>` : ""}</g>`;
}).join("");
elements.graphNodes.innerHTML = graph.nodes.map((node) => {
const position = layout.positions.get(node.key);
if (!position) return "";
const singular = TYPE_META[node.type]?.singular || "item";
const selected = state.selected.type === node.type && state.selected.id === node.id;
return `<g class="graph-node ${singular}${selected ? " selected" : ""}" data-type="${node.type}" data-id="${escapeHtml(node.id)}" transform="translate(${position.x} ${position.y})">
<rect width="${position.width}" height="${position.height}" rx="10"></rect>
<text class="node-glyph" x="13" y="22">${TYPE_META[node.type]?.glyph || "◆"}</text>
<text class="node-label" x="31" y="22">${escapeHtml(short(node.label, 20))}</text>
<text class="node-subtitle" x="13" y="${position.height - 12}">${escapeHtml(short(node.subtitle, 29))}</text>
</g>`;
}).join("");
const critical = traceIngredientClosure(state.book, "first_warp_record");
const selectedSummary = entity ? `${TYPE_META[state.selected.type].label}${entity.name}”` : "未选择条目";
elements.routeSummary.textContent = `${selectedSummary} · 首次折跃主线 ${critical.items.length} 种物品 / ${critical.recipes.length} 道配方 / ${critical.missing.length} 处断点`;
updateGraphTransform();
if (state.graphNeedsFit) {
state.graphNeedsFit = false;
requestAnimationFrame(fitGraph);
}
}
function updateGraphTransform() {
const { x, y, scale } = state.graphTransform;
elements.graphViewport.setAttribute("transform", `translate(${x} ${y}) scale(${scale})`);
elements.zoomOutput.value = `${Math.round(scale * 100)}%`;
elements.zoomOutput.textContent = `${Math.round(scale * 100)}%`;
}
function fitGraph() {
const bounds = state.graphBounds;
const rect = elements.graph.getBoundingClientRect();
if (!bounds || rect.width < 20 || rect.height < 20) return;
const padding = 44;
const scale = Math.min(1.1, Math.max(.12, Math.min((rect.width - padding * 2) / bounds.width, (rect.height - padding * 2) / bounds.height)));
state.graphTransform = {
scale,
x: (rect.width - bounds.width * scale) / 2 - bounds.x * scale,
y: (rect.height - bounds.height * scale) / 2 - bounds.y * scale,
};
updateGraphTransform();
}
function zoomGraph(factor, clientX, clientY) {
const rect = elements.graph.getBoundingClientRect();
const px = clientX == null ? rect.width / 2 : clientX - rect.left;
const py = clientY == null ? rect.height / 2 : clientY - rect.top;
const previous = state.graphTransform.scale;
const next = Math.min(2.2, Math.max(.12, previous * factor));
const worldX = (px - state.graphTransform.x) / previous;
const worldY = (py - state.graphTransform.y) / previous;
state.graphTransform.x = px - worldX * next;
state.graphTransform.y = py - worldY * next;
state.graphTransform.scale = next;
updateGraphTransform();
}
function renderIssues() {
const errors = state.issues.filter((entry) => entry.severity === "error").length;
const warnings = state.issues.filter((entry) => entry.severity === "warning").length;
const info = state.issues.filter((entry) => entry.severity === "info").length;
elements.issuesSummary.textContent = `${errors} 个错误 · ${warnings} 个警告 · ${info} 条提示`;
elements.issueFilter.querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.severity === state.issueSeverity));
const issues = state.issues.filter((entry) => state.issueSeverity === "all" || entry.severity === state.issueSeverity);
elements.issueList.innerHTML = issues.length ? issues.map((entry) => `<button class="issue-row ${entry.severity}" type="button" data-type="${escapeHtml(entry.entityType)}" data-id="${escapeHtml(entry.entityId)}">
<span class="issue-dot"></span><span class="issue-copy"><strong>${escapeHtml(entry.message)}</strong><small>${escapeHtml(entry.entityType)}${entry.entityId ? ` / ${escapeHtml(entry.entityId)}` : ""}</small></span><span class="issue-code">${escapeHtml(entry.code)}</span>
</button>`).join("") : `<div class="no-issues">✓ 当前筛选下没有问题</div>`;
}
function refreshAll() {
ensureSelection();
validate();
renderStatus();
renderCatalog();
renderInspector();
renderGraph();
renderIssues();
}
function selectEntity(type, id, { switchTab = false, fitGraph = true } = {}) {
if (!state.book?.[type]?.some((entry) => entry.id === id)) return;
state.selected = { type, id };
if (switchTab) state.activeType = type;
state.graphNeedsFit ||= fitGraph;
renderCatalog();
renderInspector();
renderGraph();
}
function uniqueId(prefix, collection) {
let suffix = 1;
let candidate = `${prefix}_${suffix}`;
const ids = new Set(collection.map((entry) => entry.id));
while (ids.has(candidate)) candidate = `${prefix}_${++suffix}`;
return candidate;
}
function addEntity() {
const type = state.activeType;
const stage = state.stage !== "all" ? state.stage : state.book.stages[0]?.id;
const biome = state.biome !== "all" ? state.biome : state.book.biomes[0]?.id;
let entity;
if (type === "items") {
entity = { id: uniqueId("new_item", state.book.items), name: "新物品", category: "material", stage, biome, mass: 1, stackSize: 100, isRaw: false, tags: [], notes: "" };
} else if (type === "machines") {
entity = { id: uniqueId("new_machine", state.book.machines), name: "新设施", stage, biome, baseSpeed: 1, tags: [], description: "" };
} else {
entity = { id: uniqueId("new_recipe", state.book.recipes), name: "新配方", stage, biome, machineId: state.book.machines[0]?.id || "", kind: "craft", duration: 1, unlockedBy: "", inputs: [], outputs: state.book.items[0] ? [{ itemId: state.book.items[0].id, amount: 1 }] : [], manaPerSecond: 0, entropy: 0, conditions: [], tags: [], notes: "", enabled: true };
}
mutate(`新增${TYPE_META[type].label}`, (book) => book[type].push(entity), { fitGraph: true });
selectEntity(type, entity.id, { switchTab: true });
toast(`已新增${TYPE_META[type].label},请填写右侧属性。`);
}
function renameSelected(nextId) {
const entity = selectedEntity();
const type = state.selected.type;
const previousId = entity.id;
const clean = nextId.trim();
if (clean === previousId) return;
if (!/^[a-z][a-z0-9_]*$/.test(clean)) {
toast("ID 只能使用小写字母、数字和下划线,并以字母开头。", "error", 4500);
renderInspector();
return;
}
if (state.book[type].some((entry) => entry.id === clean)) {
toast(`ID “${clean}” 已存在。`, "error");
renderInspector();
return;
}
const affected = type === "items"
? state.book.recipes.reduce((count, recipe) => count + [...(recipe.inputs || []), ...(recipe.outputs || [])].filter((entry) => entry.itemId === previousId).length, 0)
: type === "machines" ? state.book.recipes.filter((recipe) => recipe.machineId === previousId).length : 0;
if (affected && !window.confirm(`这会同步修改 ${affected} 处配方引用。继续将 ${previousId} 改为 ${clean}`)) {
renderInspector();
return;
}
mutate(`重命名 ${previousId}`, (book) => {
book[type].find((entry) => entry.id === previousId).id = clean;
if (type === "items") {
for (const recipe of book.recipes) for (const entry of [...(recipe.inputs || []), ...(recipe.outputs || [])]) if (entry.itemId === previousId) entry.itemId = clean;
}
if (type === "machines") for (const recipe of book.recipes) if (recipe.machineId === previousId) recipe.machineId = clean;
state.selected.id = clean;
}, { fitGraph: true });
toast(`已更新 ID,并同步 ${affected} 处引用。`);
}
function duplicateRecipe() {
const recipe = selectedEntity();
if (!recipe || state.selected.type !== "recipes") return;
const copy = cloneBook(recipe);
copy.id = uniqueId(`${recipe.id}_copy`, state.book.recipes);
copy.name = `${recipe.name}(副本)`;
mutate(`复制配方 ${recipe.name}`, (book) => book.recipes.push(copy), { fitGraph: true });
selectEntity("recipes", copy.id, { switchTab: true });
}
function deleteSelected() {
const entity = selectedEntity();
const type = state.selected.type;
if (!entity) return;
const index = makeIndex(state.book);
if (type === "items") {
const references = (index.producers.get(entity.id)?.length || 0) + (index.consumers.get(entity.id)?.length || 0);
if (references) return toast(`不能删除:仍有 ${references} 道配方引用这个物品。请先改接配方。`, "error", 5000);
}
if (type === "machines") {
const references = state.book.recipes.filter((recipe) => recipe.machineId === entity.id).length;
if (references) return toast(`不能删除:仍有 ${references} 道配方使用这个设施。请先更换设施。`, "error", 5000);
}
if (!window.confirm(`确认删除${TYPE_META[type].label}${entity.name}”?可以用撤销恢复。`)) return;
mutate(`删除${TYPE_META[type].label} ${entity.name}`, (book) => {
const position = book[type].findIndex((entry) => entry.id === entity.id);
book[type].splice(position, 1);
const fallback = book[type][Math.min(position, book[type].length - 1)];
state.selected = fallback ? { type, id: fallback.id } : { type: "recipes", id: book.recipes[0]?.id || "" };
}, { fitGraph: true });
}
function changeEntityField(target) {
const entity = selectedEntity();
if (!entity) return;
const field = target.dataset.field;
let value;
if (target.type === "checkbox") value = target.checked;
else if (target.dataset.array) value = parseLines(target.value);
else if (target.type === "number") value = numberFromInput(target);
else value = target.value;
if (JSON.stringify(entity[field]) === JSON.stringify(value)) return;
mutate(`修改 ${entity.name} / ${field}`, () => { selectedEntity()[field] = value; });
}
function changeIoField(target) {
const recipe = selectedEntity();
if (!recipe || state.selected.type !== "recipes") return;
const side = target.dataset.io;
const index = Number(target.dataset.index);
const key = target.dataset.key;
const entry = recipe[side]?.[index];
if (!entry) return;
let value = target.type === "number" ? numberFromInput(target, target.hasAttribute("data-nullable")) : target.value;
mutate(`修改 ${recipe.name} / ${side}`, () => {
const current = selectedEntity()[side][index];
if (value == null) delete current[key];
else current[key] = value;
});
}
function isLiveInput(target) {
return target.matches('input[type="text"], input[type="number"], textarea') && !target.matches('[data-action="rename-id"]');
}
function beginLiveEdit(target) {
if (!isLiveInput(target)) return;
const editKey = [state.selected.type, state.selected.id, target.dataset.field, target.dataset.io, target.dataset.index, target.dataset.key].join(":");
if (state.pendingEdit?.key === editKey) return;
state.pendingEdit = null;
const entity = selectedEntity();
if (!entity) return;
const label = `修改 ${entity.name} / ${target.dataset.field || target.dataset.io || "字段"}`;
state.history.push({ label, book: cloneBook(state.book), selected: { ...state.selected } });
if (state.history.length > MAX_HISTORY) state.history.shift();
state.future.length = 0;
state.pendingEdit = { key: editKey, label };
}
function applyLiveInput(target) {
if (!isLiveInput(target)) return;
const entity = selectedEntity();
if (!entity) return;
if (target.dataset.io) {
const entry = entity[target.dataset.io]?.[Number(target.dataset.index)];
if (!entry) return;
const value = target.type === "number" ? numberFromInput(target, target.hasAttribute("data-nullable")) : target.value;
if ((value == null && entry[target.dataset.key] == null) || JSON.stringify(entry[target.dataset.key]) === JSON.stringify(value)) return;
beginLiveEdit(target);
if (value == null) delete entry[target.dataset.key];
else entry[target.dataset.key] = value;
} else if (target.dataset.field) {
let value;
if (target.dataset.array) value = parseLines(target.value);
else if (target.type === "number") value = numberFromInput(target);
else value = target.value;
if (JSON.stringify(entity[target.dataset.field]) === JSON.stringify(value)) return;
beginLiveEdit(target);
entity[target.dataset.field] = value;
}
state.book.metadata ||= {};
state.book.metadata.updatedAt = new Date().toISOString();
updateDirty();
validate();
renderStatus();
if (["name", "tags"].includes(target.dataset.field)) renderCatalog();
renderGraph();
renderIssues();
}
function finalizeLiveEdit() {
if (!state.pendingEdit) return;
state.pendingEdit = null;
renderStatus();
}
function addIo(side) {
const recipe = selectedEntity();
const firstItem = state.book.items[0];
if (!recipe || state.selected.type !== "recipes" || !firstItem) return;
mutate(`为 ${recipe.name} 添加${side === "inputs" ? "输入" : "产出"}`, () => {
if (side === "inputs") selectedEntity().inputs.push({ itemId: firstItem.id, amount: 1, mode: "consumed" });
else selectedEntity().outputs.push({ itemId: firstItem.id, amount: 1 });
}, { fitGraph: true });
}
function deleteIo(side, index) {
const recipe = selectedEntity();
if (!recipe || state.selected.type !== "recipes") return;
mutate(`删除 ${recipe.name}${side === "inputs" ? "输入" : "产出"}`, () => selectedEntity()[side].splice(index, 1), { fitGraph: true });
}
function downloadBook() {
const blob = new Blob([`${JSON.stringify(state.book, null, 2)}\n`], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `recipe-book_${slugTime()}.json`;
anchor.click();
URL.revokeObjectURL(url);
toast("已导出 JSON;导出不会清除“未保存”状态。", "success", 4000);
}
async function saveBook() {
finalizeLiveEdit();
const issues = validate();
const errors = issues.filter((entry) => entry.severity === "error");
if (errors.length) {
renderIssues();
elements.issuesDialog.showModal();
return toast(`有 ${errors.length} 个错误,修复后才能保存。`, "error", 5000);
}
if (!state.serverOnline) {
downloadBook();
return toast("本地保存服务未连接,已改为导出。请用启动器打开工具以直接写回项目。", "warning", 6500);
}
state.saving = true;
renderStatus();
try {
const response = await fetch("/api/book", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(state.book) });
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.message || `保存失败(HTTP ${response.status}`);
state.savedBookJson = JSON.stringify(state.book);
state.dirty = false;
state.history.length = 0;
state.future.length = 0;
localStorage.removeItem(DRAFT_KEY);
toast(`已写回项目,并生成备份 ${result.backup || ""}`.trim(), "success", 4500);
} catch (error) {
state.serverOnline = false;
toast(`${error.message}。数据仍保留在浏览器草稿中。`, "error", 6000);
} finally {
state.saving = false;
renderStatus();
}
}
async function importBook(file) {
try {
const imported = JSON.parse(await file.text());
const issues = validateBook(imported, { technologyIds: state.technologyIds });
const errors = issues.filter((entry) => entry.severity === "error");
if (errors.length) throw new Error(`文件有 ${errors.length} 个结构错误:${errors[0].message}`);
state.history.push({ label: "导入数据", book: cloneBook(state.book), selected: { ...state.selected } });
state.future.length = 0;
state.book = imported;
updateDirty();
state.selected = imported.recipes.some((entry) => entry.id === "perform_first_warp") ? { type: "recipes", id: "perform_first_warp" } : { type: "recipes", id: imported.recipes[0]?.id || "" };
state.activeType = state.selected.type;
state.graphNeedsFit = true;
renderFilters();
refreshAll();
toast(`已导入 ${file.name},请确认后点击“保存数据”。`, "success", 4500);
} catch (error) {
toast(`导入失败:${error.message}`, "error", 6500);
} finally {
elements.importFile.value = "";
}
}
async function loadTechnologyIds() {
try {
const response = await fetch("../../docs/tech-tree-nodes-v0.2.csv", { cache: "no-store" });
if (!response.ok) return;
const text = await response.text();
state.technologyIds = new Set(text.split(/\r?\n/).slice(1).map((line) => line.split(",", 1)[0].trim()).filter(Boolean));
} catch {
state.technologyIds = null;
}
}
async function loadInitialBook() {
let book;
try {
const response = await fetch("/api/book", { cache: "no-store" });
if (!response.ok) throw new Error("API unavailable");
book = await response.json();
state.serverOnline = true;
} catch {
try {
const response = await fetch("../../data/recipe-book.json", { cache: "no-store" });
if (!response.ok) throw new Error("file unavailable");
book = await response.json();
} catch {
elements.app.setAttribute("aria-busy", "false");
elements.saveState.className = "save-state offline";
elements.saveState.querySelector("span").textContent = "等待导入数据";
elements.importFile.click();
toast("无法自动读取配方书。请选择 data/recipe-book.json。", "warning", 8000);
return;
}
}
await loadTechnologyIds();
state.book = book;
state.savedBookJson = JSON.stringify(book);
try {
const draft = JSON.parse(localStorage.getItem(DRAFT_KEY) || "null");
const draftTime = Date.parse(draft?.book?.metadata?.updatedAt || draft?.savedAt || 0);
const bookTime = Date.parse(book?.metadata?.updatedAt || 0);
if (draft?.book && draftTime > bookTime && window.confirm("检测到比项目文件更新的浏览器草稿。要恢复尚未保存的修改吗?")) {
state.book = draft.book;
updateDirty();
toast("已恢复浏览器草稿;确认无误后请保存到项目。", "warning", 5500);
}
} catch {
localStorage.removeItem(DRAFT_KEY);
}
renderFilters();
refreshAll();
elements.app.setAttribute("aria-busy", "false");
}
elements.typeTabs.addEventListener("click", (event) => {
const button = event.target.closest("button[data-type]");
if (!button) return;
state.activeType = button.dataset.type;
const current = state.book[state.activeType].find((entry) => entry.id === state.selected.id);
if (!current) {
const first = state.book[state.activeType][0];
if (first) state.selected = { type: state.activeType, id: first.id };
}
state.graphNeedsFit = true;
refreshAll();
});
elements.entityList.addEventListener("click", (event) => {
const row = event.target.closest(".entity-row");
if (row) selectEntity(row.dataset.type, row.dataset.id);
});
elements.catalogSearch.addEventListener("input", (event) => { state.search = event.target.value; renderCatalog(); });
elements.stageFilter.addEventListener("change", (event) => { state.stage = event.target.value; state.graphNeedsFit = true; renderCatalog(); renderGraph(); });
elements.biomeFilter.addEventListener("change", (event) => { state.biome = event.target.value; state.graphNeedsFit = true; renderCatalog(); renderGraph(); });
elements.addEntityButton.addEventListener("click", addEntity);
elements.inspectorContent.addEventListener("change", (event) => {
const target = event.target;
if (target.matches('[data-action="rename-id"]')) renameSelected(target.value);
else if (isLiveInput(target)) finalizeLiveEdit();
else if (target.dataset.io) changeIoField(target);
else if (target.dataset.field) changeEntityField(target);
});
elements.inspectorContent.addEventListener("input", (event) => applyLiveInput(event.target));
elements.inspectorContent.addEventListener("focusout", (event) => { if (isLiveInput(event.target)) finalizeLiveEdit(); });
elements.inspectorContent.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const action = button.dataset.action;
if (action === "navigate") selectEntity(button.dataset.type, button.dataset.id, { switchTab: true });
else if (action === "add-io") addIo(button.dataset.side);
else if (action === "delete-io") deleteIo(button.dataset.side, Number(button.dataset.index));
else if (action === "duplicate") duplicateRecipe();
else if (action === "delete-entity") deleteSelected();
else if (action === "help") elements.helpDialog.showModal();
});
elements.graphMode.addEventListener("click", (event) => {
const button = event.target.closest("button[data-mode]");
if (!button) return;
state.graphMode = button.dataset.mode;
state.graphNeedsFit = true;
renderGraph();
});
elements.graphDepth.addEventListener("change", (event) => { state.graphDepth = Number(event.target.value); state.graphNeedsFit = true; renderGraph(); });
elements.fitGraphButton.addEventListener("click", fitGraph);
elements.zoomInButton.addEventListener("click", () => zoomGraph(1.2));
elements.zoomOutButton.addEventListener("click", () => zoomGraph(1 / 1.2));
let drag = null;
elements.graph.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: state.graphTransform.x, originY: state.graphTransform.y, moved: false, target: event.target.closest(".graph-node") };
elements.graph.setPointerCapture(event.pointerId);
elements.graph.classList.add("dragging");
});
elements.graph.addEventListener("pointermove", (event) => {
if (!drag || drag.pointerId !== event.pointerId) return;
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (Math.abs(dx) + Math.abs(dy) > 5) drag.moved = true;
if (drag.moved) {
state.graphTransform.x = drag.originX + dx;
state.graphTransform.y = drag.originY + dy;
updateGraphTransform();
}
});
elements.graph.addEventListener("pointerup", (event) => {
if (!drag || drag.pointerId !== event.pointerId) return;
if (!drag.moved && drag.target) selectEntity(drag.target.dataset.type, drag.target.dataset.id, { switchTab: true, fitGraph: false });
drag = null;
elements.graph.classList.remove("dragging");
});
elements.graph.addEventListener("wheel", (event) => { event.preventDefault(); zoomGraph(event.deltaY < 0 ? 1.12 : 1 / 1.12, event.clientX, event.clientY); }, { passive: false });
elements.undoButton.addEventListener("click", () => { finalizeLiveEdit(); undo(); });
elements.redoButton.addEventListener("click", () => { finalizeLiveEdit(); redo(); });
elements.importButton.addEventListener("click", () => elements.importFile.click());
elements.importFile.addEventListener("change", () => { if (elements.importFile.files[0]) importBook(elements.importFile.files[0]); });
elements.exportButton.addEventListener("click", downloadBook);
elements.saveButton.addEventListener("click", saveBook);
elements.issuesButton.addEventListener("click", () => { renderIssues(); elements.issuesDialog.showModal(); });
elements.issueFilter.addEventListener("click", (event) => {
const button = event.target.closest("button[data-severity]");
if (!button) return;
state.issueSeverity = button.dataset.severity;
renderIssues();
});
elements.issueList.addEventListener("click", (event) => {
const row = event.target.closest(".issue-row");
if (!row || !TYPE_META[row.dataset.type] || !row.dataset.id) return;
elements.issuesDialog.close();
selectEntity(row.dataset.type, row.dataset.id, { switchTab: true });
});
window.addEventListener("keydown", (event) => {
const modifier = event.ctrlKey || event.metaKey;
if (modifier && event.key.toLowerCase() === "s") { event.preventDefault(); finalizeLiveEdit(); saveBook(); }
else if (modifier && event.key.toLowerCase() === "z" && !event.shiftKey) { event.preventDefault(); finalizeLiveEdit(); undo(); }
else if (modifier && (event.key.toLowerCase() === "y" || (event.key.toLowerCase() === "z" && event.shiftKey))) { event.preventDefault(); finalizeLiveEdit(); redo(); }
else if (!modifier && event.key === "/" && !["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName)) { event.preventDefault(); elements.catalogSearch.focus(); }
});
window.addEventListener("beforeunload", (event) => { if (state.dirty) { event.preventDefault(); event.returnValue = ""; } });
window.addEventListener("resize", () => { if (state.graphBounds) fitGraph(); });
loadInitialBook();