lithop 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/Cargo.lock +284 -0
  4. data/Cargo.toml +6 -0
  5. data/README.md +31 -7
  6. data/RELEASING.md +24 -6
  7. data/docs/README.md +4 -1
  8. data/docs/artifacts.md +26 -2
  9. data/docs/bambu-studio.md +7 -1
  10. data/docs/color-model.md +2 -0
  11. data/docs/command.md +48 -7
  12. data/docs/config.md +31 -4
  13. data/docs/getting-started.md +10 -5
  14. data/docs/images/cmyw-lithophane-preview.png +0 -0
  15. data/docs/local-ui.md +114 -0
  16. data/docs/tiling.md +134 -0
  17. data/docs/troubleshooting.md +22 -0
  18. data/ext/lithop_native/Cargo.toml +17 -0
  19. data/ext/lithop_native/build.rs +4 -0
  20. data/ext/lithop_native/extconf.rb +6 -0
  21. data/ext/lithop_native/src/column_mesh.rs +388 -0
  22. data/ext/lithop_native/src/lib.rs +37 -0
  23. data/ext/lithop_native/src/mesh.rs +187 -0
  24. data/ext/lithop_native/src/palette.rs +67 -0
  25. data/ext/lithop_native/src/xml.rs +134 -0
  26. data/lib/lithop/calibration/export.rb +21 -8
  27. data/lib/lithop/cli/calibration.rb +3 -1
  28. data/lib/lithop/cli/image_options.rb +41 -2
  29. data/lib/lithop/cli/options.rb +13 -0
  30. data/lib/lithop/cli.rb +36 -1
  31. data/lib/lithop/export/color_metadata.rb +55 -0
  32. data/lib/lithop/export/generation.rb +131 -21
  33. data/lib/lithop/export/job.rb +30 -16
  34. data/lib/lithop/export/metadata.rb +54 -4
  35. data/lib/lithop/export/planning.rb +97 -0
  36. data/lib/lithop/export/previews.rb +25 -1
  37. data/lib/lithop/export/print_readme.rb +22 -4
  38. data/lib/lithop/export/result.rb +26 -4
  39. data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
  40. data/lib/lithop/formats/three_mf/mesh.rb +72 -3
  41. data/lib/lithop/formats/three_mf/model_writer.rb +249 -24
  42. data/lib/lithop/formats/three_mf/part_writer.rb +51 -6
  43. data/lib/lithop/formats/three_mf.rb +9 -6
  44. data/lib/lithop/image/pipeline.rb +66 -10
  45. data/lib/lithop/image/svg_loader.rb +5 -5
  46. data/lib/lithop/job_directory.rb +41 -0
  47. data/lib/lithop/job_execution.rb +378 -0
  48. data/lib/lithop/layer_structure.rb +11 -0
  49. data/lib/lithop/mesh/box_geometry.rb +41 -0
  50. data/lib/lithop/mesh/column_geometry.rb +10 -2
  51. data/lib/lithop/mesh/column_topology.rb +2 -0
  52. data/lib/lithop/mesh/geometry.rb +21 -13
  53. data/lib/lithop/model/layer_region_model.rb +24 -0
  54. data/lib/lithop/model/material_model.rb +17 -5
  55. data/lib/lithop/model/palette.rb +21 -3
  56. data/lib/lithop/native.rb +39 -0
  57. data/lib/lithop/reference_three_mf.rb +17 -1
  58. data/lib/lithop/settings.rb +116 -1
  59. data/lib/lithop/settings_file.rb +10 -4
  60. data/lib/lithop/tiling.rb +127 -0
  61. data/lib/lithop/version.rb +1 -1
  62. data/lib/lithop/web/app.rb +513 -0
  63. data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
  64. data/lib/lithop/web/job_launcher.rb +214 -0
  65. data/lib/lithop/web/job_store.rb +390 -0
  66. data/lib/lithop/web/preview_store.rb +182 -0
  67. data/lib/lithop/web/public/app.css +319 -0
  68. data/lib/lithop/web/public/app.js +1847 -0
  69. data/lib/lithop/web/server.rb +21 -0
  70. data/lib/lithop/web/settings_form.rb +64 -0
  71. data/lib/lithop/web/settings_library.rb +154 -0
  72. data/lib/lithop/web/views/index.erb +364 -0
  73. data/lib/lithop/web/worker.rb +27 -0
  74. data/lib/lithop.rb +5 -0
  75. data/sig/lithop.rbs +102 -2
  76. metadata +89 -2
@@ -0,0 +1,1847 @@
1
+ const workspace = document.querySelector(".workspace");
2
+ const form = document.querySelector("#conversion-form");
3
+ const fileInput = document.querySelector("#image-input");
4
+ const filePicker = document.querySelector("#file-picker");
5
+ const fileName = document.querySelector("#file-name");
6
+ const previewIdInput = document.querySelector("#preview-id");
7
+ const sourceImage = document.querySelector("#source-image");
8
+ const compareSourceImage = document.querySelector("#compare-source-image");
9
+ const sourcePlaceholder = document.querySelector("#source-placeholder");
10
+ const tileLayout = document.querySelector("#tile-layout");
11
+ const separateZParts = document.querySelector("#separate-z-parts");
12
+ const tileOverlay = document.querySelector("#tile-overlay");
13
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
14
+ const stateLabels = {
15
+ queued: "待機中", running: "変換中", cancelling: "停止要求中",
16
+ completed: "完了", failed: "失敗", cancelled: "停止済み"
17
+ };
18
+ const previewKinds = [
19
+ { name: "preview.png", label: "CMYW", description: "CMYW仕上がり" },
20
+ { name: "cyan-mask.png", label: "Cyan", description: "Cyan材料マスク" },
21
+ { name: "magenta-mask.png", label: "Magenta", description: "Magenta材料マスク" },
22
+ { name: "yellow-mask.png", label: "Yellow", description: "Yellow材料マスク" },
23
+ { name: "white-mask.png", label: "White", description: "White材料マスク" },
24
+ { name: "transmission.png", label: "透過明度", description: "White厚みによる相対透過明度" },
25
+ { name: "thickness.png", label: "厚み", description: "White厚みマップ" },
26
+ { name: "tile-layout.svg", label: "タイル", description: "タイル配置" }
27
+ ];
28
+ const previewLabels = Object.fromEntries(previewKinds.map(({ name, label }) => [name, label]));
29
+ const settingLabels = {
30
+ width: "幅", height: "高さ", pixel_size: "XYセル", color_pixel_size: "色セル",
31
+ image_fit: "配置", crop_position_x: "横位置", crop_position_y: "縦位置", crop_scale: "クロップ倍率",
32
+ brightness: "明るさ", contrast: "コントラスト", saturation: "彩度", tile_count: "タイル数",
33
+ tile_layout: "タイル配置", tile_rows: "行数", tile_columns: "列数", separate_z_parts: "Z分割",
34
+ color_region_parts: "色領域数", minimum_thickness: "最小厚み", maximum_thickness: "最大厚み",
35
+ nozzle_diameter: "ノズル", margin_top: "上余白", margin_right: "右余白",
36
+ margin_bottom: "下余白", margin_left: "左余白"
37
+ };
38
+
39
+ let sourceUrl;
40
+ let previewId;
41
+ let stagePromise;
42
+ let selectionSequence = 0;
43
+ let renderSequence = 0;
44
+ let renderTimer;
45
+ let renderController;
46
+ let tileOverlayUrl;
47
+ let tileOverlayTimer;
48
+ let summaryTimer;
49
+ let summaryController;
50
+ let resolvedSummary;
51
+ let submitting = false;
52
+ let currentJobId = workspace?.dataset.selectedJob || undefined;
53
+ let activeJobId;
54
+ let activeJob;
55
+ let pollTimer;
56
+ let historyOffset = 0;
57
+ let historyTotal = 0;
58
+ const historyPageSize = 12;
59
+ const historyJobs = new Map();
60
+ const selectedJobIds = new Set();
61
+ let historySearchTimer;
62
+ let historyNotice;
63
+ let settingsLibrary = { schema_version: 1, presets: {}, draft: null };
64
+ let comparedJobs = [];
65
+ let aspectRatio = Number(document.querySelector("#width")?.value) / Number(document.querySelector("#height")?.value);
66
+ let submittedSignature;
67
+ let submittedSettings;
68
+ let displayedJob;
69
+ let dialogReturnFocus;
70
+ const appViewLabels = { create: "作成", jobs: "ジョブ", presets: "プリセット" };
71
+ let currentView = requestedView();
72
+ let currentJobDetailTab = requestedJobTab();
73
+
74
+ function requestedView() {
75
+ const parameters = new URL(window.location.href).searchParams;
76
+ const view = parameters.get("view");
77
+ if (Object.hasOwn(appViewLabels, view)) return view;
78
+ return parameters.has("job") ? "jobs" : "create";
79
+ }
80
+
81
+ function requestedJobTab() {
82
+ const tab = new URL(window.location.href).searchParams.get("tab");
83
+ return ["preview", "artifacts", "settings"].includes(tab) ? tab : "preview";
84
+ }
85
+
86
+ function viewUrl(view) {
87
+ const parameters = new URLSearchParams();
88
+ if (currentJobId) parameters.set("job", currentJobId);
89
+ parameters.set("view", view);
90
+ if (view === "jobs" && currentJobDetailTab !== "preview") parameters.set("tab", currentJobDetailTab);
91
+ return `/?${parameters}`;
92
+ }
93
+
94
+ function closeNavigation() {
95
+ document.body.classList.remove("navigation-open");
96
+ document.querySelector("#menu-toggle")?.setAttribute("aria-expanded", "false");
97
+ }
98
+
99
+ function showAppView(view, { push = false, focus = false } = {}) {
100
+ if (!Object.hasOwn(appViewLabels, view)) view = "create";
101
+ currentView = view;
102
+ workspace.dataset.view = view;
103
+ document.querySelectorAll("[data-app-view]").forEach((candidate) => {
104
+ candidate.hidden = candidate.dataset.appView !== view;
105
+ });
106
+ document.querySelectorAll("#primary-navigation [data-view-target]").forEach((button) => {
107
+ if (button.dataset.viewTarget === view) button.setAttribute("aria-current", "page");
108
+ else button.removeAttribute("aria-current");
109
+ });
110
+ document.querySelector("#current-view-label").textContent = appViewLabels[view];
111
+ document.title = `${appViewLabels[view]} · Lithop Local UI`;
112
+ closeNavigation();
113
+ if (push) window.history.pushState({ jobId: currentJobId, view }, "", viewUrl(view));
114
+ if (focus) document.querySelector(`[data-app-view="${view}"] .view-title`)?.focus({ preventScroll: true });
115
+ window.requestAnimationFrame(() => viewerStates?.forEach?.((_state, id) => applyViewer(id)));
116
+ }
117
+
118
+ document.querySelectorAll("[data-view-target]").forEach((control) => {
119
+ control.addEventListener("click", (event) => {
120
+ event.preventDefault();
121
+ showAppView(control.dataset.viewTarget, { push: currentView !== control.dataset.viewTarget, focus: true });
122
+ });
123
+ });
124
+ document.querySelector("#menu-toggle")?.addEventListener("click", () => {
125
+ const open = !document.body.classList.contains("navigation-open");
126
+ document.body.classList.toggle("navigation-open", open);
127
+ document.querySelector("#menu-toggle").setAttribute("aria-expanded", String(open));
128
+ if (open) document.querySelector("#primary-navigation [aria-current='page']")?.focus();
129
+ });
130
+ document.querySelector("#navigation-scrim")?.addEventListener("click", closeNavigation);
131
+ document.addEventListener("keydown", (event) => {
132
+ if (event.key === "Escape" && document.body.classList.contains("navigation-open")) {
133
+ closeNavigation();
134
+ document.querySelector("#menu-toggle")?.focus();
135
+ }
136
+ });
137
+
138
+ function selectJobDetailTab(name, { focus = false, push = false } = {}) {
139
+ const button = document.querySelector(`[data-job-tab="${name}"]`);
140
+ if (!button) return;
141
+ currentJobDetailTab = name;
142
+ document.querySelectorAll("[data-job-tab]").forEach((candidate) => {
143
+ candidate.setAttribute("aria-selected", String(candidate === button));
144
+ candidate.tabIndex = candidate === button ? 0 : -1;
145
+ });
146
+ document.querySelectorAll("[data-job-panel]").forEach((panel) => {
147
+ panel.hidden = panel.dataset.jobPanel !== name;
148
+ });
149
+ if (focus) button.focus();
150
+ if (push) window.history.pushState({ jobId: currentJobId, view: "jobs", tab: name }, "", viewUrl("jobs"));
151
+ window.requestAnimationFrame(() => {
152
+ if (name === "preview") applyViewer("result-preview");
153
+ });
154
+ }
155
+
156
+ document.querySelector("#job-detail-tabs")?.addEventListener("click", (event) => {
157
+ const button = event.target.closest("[data-job-tab]");
158
+ if (button) selectJobDetailTab(button.dataset.jobTab, { push: true });
159
+ });
160
+ document.querySelector("#job-detail-tabs")?.addEventListener("keydown", (event) => {
161
+ if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
162
+ const tabs = [...event.currentTarget.querySelectorAll("[data-job-tab]")];
163
+ const offset = event.key === "ArrowRight" ? 1 : -1;
164
+ const next = tabs[(tabs.indexOf(event.target) + offset + tabs.length) % tabs.length];
165
+ event.preventDefault();
166
+ selectJobDetailTab(next.dataset.jobTab, { focus: true, push: true });
167
+ });
168
+
169
+ document.querySelector("#help-button")?.addEventListener("click", () => {
170
+ dialogReturnFocus = document.querySelector("#help-button");
171
+ document.querySelector("#help-dialog").showModal();
172
+ });
173
+ document.querySelector("#close-help-dialog")?.addEventListener("click", () => document.querySelector("#help-dialog").close());
174
+ document.querySelector("#help-dialog")?.addEventListener("close", () => dialogReturnFocus?.focus());
175
+
176
+ function showSourceStatus(message) {
177
+ sourcePlaceholder.textContent = message;
178
+ sourcePlaceholder.hidden = false;
179
+ sourceImage.hidden = true;
180
+ document.querySelector('[data-expand-viewer="source-preview"]').disabled = true;
181
+ }
182
+
183
+ async function showSourceBlob(blob) {
184
+ if (sourceUrl) URL.revokeObjectURL(sourceUrl);
185
+ sourceUrl = URL.createObjectURL(blob);
186
+ sourceImage.src = sourceUrl;
187
+ try {
188
+ await sourceImage.decode();
189
+ } catch (_error) {
190
+ // The image load/error handlers still provide the visible result.
191
+ }
192
+ sourceImage.hidden = false;
193
+ sourcePlaceholder.hidden = true;
194
+ document.querySelector('[data-expand-viewer="source-preview"]').disabled = false;
195
+ resetViewer("source-preview");
196
+ }
197
+
198
+ async function responseError(response, fallback) {
199
+ try {
200
+ const payload = await response.json();
201
+ return payload.error || fallback;
202
+ } catch (_error) {
203
+ return fallback;
204
+ }
205
+ }
206
+
207
+ function settingParameters() {
208
+ const values = new URLSearchParams();
209
+ new FormData(form).forEach((value, key) => {
210
+ if (key !== "image" && key !== "preview_id" && typeof value === "string") values.append(key, value);
211
+ });
212
+ return values;
213
+ }
214
+
215
+ function settingsSignature() {
216
+ return settingParameters().toString();
217
+ }
218
+
219
+ function editableSettings() {
220
+ return Object.fromEntries([...settingParameters()].filter(([key]) => key !== "csrf_token"));
221
+ }
222
+
223
+ function formatSetting(value, suffix = "") {
224
+ return value === undefined || value === null || value === "" ? "自動" : `${value}${suffix}`;
225
+ }
226
+
227
+ function updateDraftSummary() {
228
+ const values = editableSettings();
229
+ const summary = document.querySelector("#draft-summary-values");
230
+ const layoutLabels = { auto: "自動", columns: "横方向", rows: "縦方向", grid: "行列指定" };
231
+ const resolvedLayout = resolvedSummary
232
+ ? `${resolvedSummary.tile_rows}行 × ${resolvedSummary.tile_columns}列`
233
+ : layoutLabels[values.tile_layout] || values.tile_layout;
234
+ const z = separateZParts?.checked
235
+ ? `色領域${values.color_region_parts} + Top White(${resolvedSummary?.z_plate_count || Number(values.color_region_parts) + 1}プレート)`
236
+ : "一体出力";
237
+ const entries = [
238
+ ["入力", fileInput.files?.[0]?.name || "未選択"],
239
+ ["完成寸法", `${formatSetting(values.width)} × ${formatSetting(values.height)} mm`],
240
+ ["セル", `XY ${formatSetting(values.pixel_size, " mm")} / 色 ${formatSetting(values.color_pixel_size, " mm")}`],
241
+ ["画像", `${values.image_fit === "contain" ? "全体を収める" : "全面に配置"} / 明るさ ${values.brightness}% / コントラスト ${values.contrast}% / 彩度 ${values.saturation}%`],
242
+ ["分割", `${values.tile_count}タイル・${resolvedLayout}(${layoutLabels[values.tile_layout] || values.tile_layout}) / ${z}`]
243
+ ];
244
+ summary.replaceChildren();
245
+ entries.forEach(([term, description]) => {
246
+ const dt = document.createElement("dt");
247
+ const dd = document.createElement("dd");
248
+ dt.textContent = term;
249
+ dd.textContent = description;
250
+ summary.append(dt, dd);
251
+ });
252
+ const viewedSettings = displayedJob
253
+ ? Object.fromEntries(Object.keys(values).filter((key) => key in displayedJob).map((key) => [key, displayedJob[key]]))
254
+ : undefined;
255
+ const baseline = submittedSettings || viewedSettings;
256
+ const changed = baseline
257
+ ? Object.keys(baseline).filter((key) => {
258
+ const baselineValue = typeof baseline[key] === "boolean" ? (baseline[key] ? "1" : "") : String(baseline[key] ?? "");
259
+ return baselineValue !== String(values[key] ?? "");
260
+ })
261
+ : [];
262
+ const sourceChanged = Boolean(
263
+ displayedJob?.input_name && fileInput.files?.[0] && fileInput.files[0].name !== displayedJob.input_name
264
+ );
265
+ const changeCount = changed.length + (sourceChanged ? 1 : 0);
266
+ const state = document.querySelector("#draft-summary-change");
267
+ state.textContent = baseline
268
+ ? (changeCount ? `${changeCount}項目を編集中` : `${submittedSettings ? "実行時" : "表示中ジョブ"}の設定と一致`)
269
+ : "未実行";
270
+ state.dataset.changed = String(changeCount > 0);
271
+ }
272
+
273
+ function updateDraftState(message) {
274
+ const state = document.querySelector("#draft-state");
275
+ if (message) state.textContent = message;
276
+ else if (!fileInput.files?.[0]) state.textContent = "画像を選択してください";
277
+ else if (submittedSignature && submittedSignature !== settingsSignature()) state.textContent = "実行した設定から変更されています";
278
+ else state.textContent = "入力プレビューに反映済み";
279
+ updateDraftSummary();
280
+ if (displayedJob) updateJobContext(displayedJob);
281
+ }
282
+
283
+ function showFormError(message) {
284
+ const error = document.querySelector("#form-error");
285
+ error.textContent = message;
286
+ error.hidden = !message;
287
+ if (message) error.scrollIntoView({ block: "nearest" });
288
+ }
289
+
290
+ async function discardPreview(id, keepalive = false) {
291
+ if (!id) return;
292
+ const values = new URLSearchParams({ csrf_token: csrfToken });
293
+ try {
294
+ await fetch(`/api/previews/${encodeURIComponent(id)}/discard`, { method: "POST", body: values, keepalive });
295
+ } catch (_error) {
296
+ // Expired preview inputs are recovered on the next local UI start.
297
+ }
298
+ }
299
+
300
+ async function renderPreview(expectedId) {
301
+ if (!expectedId || expectedId !== previewId) return;
302
+ const requestNumber = ++renderSequence;
303
+ renderController?.abort();
304
+ renderController = new AbortController();
305
+ showSourceStatus("プレビューを処理しています…");
306
+ updateDraftState("プレビューを更新しています…");
307
+ const startedAt = performance.now();
308
+ try {
309
+ const response = await fetch(`/api/previews/${encodeURIComponent(expectedId)}/render`, {
310
+ method: "POST",
311
+ headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
312
+ body: settingParameters(),
313
+ signal: renderController.signal
314
+ });
315
+ const responseAt = performance.now();
316
+ if (!response.ok) throw new Error(await responseError(response, "プレビューを生成できませんでした"));
317
+ const blob = await response.blob();
318
+ const transferredAt = performance.now();
319
+ if (requestNumber !== renderSequence || expectedId !== previewId) return;
320
+ await showSourceBlob(blob);
321
+ const paintedAt = await new Promise((resolve) => window.requestAnimationFrame(() => resolve(performance.now())));
322
+ window.dispatchEvent(new CustomEvent("lithop:preview-timing", { detail: {
323
+ request_ms: responseAt - startedAt,
324
+ transfer_ms: transferredAt - responseAt,
325
+ decode_paint_ms: paintedAt - transferredAt,
326
+ total_ms: paintedAt - startedAt,
327
+ server_timing: response.headers.get("Server-Timing"),
328
+ cache: response.headers.get("X-Lithop-Preview-Cache")
329
+ } }));
330
+ updateDraftState();
331
+ } catch (error) {
332
+ if (error.name !== "AbortError" && requestNumber === renderSequence) {
333
+ showSourceStatus(`読み込み失敗: ${error.message}`);
334
+ updateDraftState("プレビューを確認してください");
335
+ }
336
+ }
337
+ }
338
+
339
+ function schedulePreview(delay = 250) {
340
+ window.clearTimeout(renderTimer);
341
+ scheduleResolvedSummary(delay);
342
+ if (!previewId) return;
343
+ renderTimer = window.setTimeout(() => renderPreview(previewId), delay);
344
+ }
345
+
346
+ function scheduleResolvedSummary(delay = 250) {
347
+ window.clearTimeout(summaryTimer);
348
+ summaryTimer = window.setTimeout(refreshResolvedSummary, delay);
349
+ }
350
+
351
+ async function refreshResolvedSummary() {
352
+ summaryController?.abort();
353
+ summaryController = new AbortController();
354
+ try {
355
+ const response = await fetch("/api/settings-summary", {
356
+ method: "POST",
357
+ headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
358
+ body: settingParameters(),
359
+ signal: summaryController.signal
360
+ });
361
+ if (!response.ok) return;
362
+ resolvedSummary = await response.json();
363
+ updateDraftSummary();
364
+ } catch (error) {
365
+ if (error.name !== "AbortError") resolvedSummary = undefined;
366
+ }
367
+ }
368
+
369
+ function scheduleTileOverlay(delay = 250) {
370
+ window.clearTimeout(tileOverlayTimer);
371
+ tileOverlayTimer = window.setTimeout(renderTileOverlay, delay);
372
+ }
373
+
374
+ async function renderTileOverlay() {
375
+ const enabled = document.querySelector("#toggle-tile-overlay")?.checked;
376
+ const count = Number(form.elements.tile_count.value);
377
+ if (!enabled || count <= 1) {
378
+ tileOverlay.hidden = true;
379
+ return;
380
+ }
381
+ try {
382
+ const response = await fetch("/api/tile-layout", {
383
+ method: "POST",
384
+ headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
385
+ body: settingParameters()
386
+ });
387
+ if (!response.ok) throw new Error(await responseError(response, "タイル境界を表示できませんでした"));
388
+ const blob = await response.blob();
389
+ if (tileOverlayUrl) URL.revokeObjectURL(tileOverlayUrl);
390
+ tileOverlayUrl = URL.createObjectURL(blob);
391
+ tileOverlay.src = tileOverlayUrl;
392
+ tileOverlay.hidden = false;
393
+ applyViewer("source-preview");
394
+ } catch (error) {
395
+ tileOverlay.hidden = true;
396
+ showFormError(error.message);
397
+ }
398
+ }
399
+
400
+ function stageFile(file, { render = true } = {}) {
401
+ const requestNumber = selectionSequence;
402
+ const body = new FormData();
403
+ body.append("csrf_token", csrfToken);
404
+ body.append("image", file, file.name);
405
+ const promise = fetch("/api/previews", { method: "POST", body })
406
+ .then(async (response) => {
407
+ if (!response.ok) throw new Error(await responseError(response, "画像を読み込めませんでした"));
408
+ const payload = await response.json();
409
+ if (requestNumber !== selectionSequence) {
410
+ discardPreview(payload.id);
411
+ return;
412
+ }
413
+ previewId = payload.id;
414
+ previewIdInput.value = previewId;
415
+ if (render) await renderPreview(previewId);
416
+ })
417
+ .catch((error) => {
418
+ if (requestNumber === selectionSequence) {
419
+ showSourceStatus(`読み込み失敗: ${error.message}`);
420
+ showFormError(error.message);
421
+ }
422
+ })
423
+ .finally(() => {
424
+ if (stagePromise === promise) stagePromise = undefined;
425
+ });
426
+ stagePromise = promise;
427
+ return promise;
428
+ }
429
+
430
+ function selectFile(file) {
431
+ const oldPreviewId = previewId;
432
+ selectionSequence += 1;
433
+ previewId = undefined;
434
+ previewIdInput.value = "";
435
+ renderController?.abort();
436
+ window.clearTimeout(renderTimer);
437
+ discardPreview(oldPreviewId);
438
+ showFormError("");
439
+ if (!file) {
440
+ fileName.textContent = "ファイルをここへ選択";
441
+ showSourceStatus("画像未選択");
442
+ updateDraftState();
443
+ return;
444
+ }
445
+
446
+ fileName.textContent = file.name;
447
+ const raster = file.type === "image/jpeg" || file.type === "image/png" || /\.(jpe?g|png)$/i.test(file.name);
448
+ if (raster) showSourceBlob(file);
449
+ else showSourceStatus("SVGを検証しています…");
450
+ updateDraftState("画像を検証しています…");
451
+ stageFile(file);
452
+ }
453
+
454
+ fileInput?.addEventListener("change", () => selectFile(fileInput.files?.[0]));
455
+ filePicker?.addEventListener("keydown", (event) => {
456
+ if (event.key === "Enter" || event.key === " ") {
457
+ event.preventDefault();
458
+ fileInput.click();
459
+ }
460
+ });
461
+ ["dragenter", "dragover"].forEach((name) => filePicker?.addEventListener(name, (event) => {
462
+ event.preventDefault();
463
+ filePicker.classList.add("is-dragging");
464
+ }));
465
+ ["dragleave", "drop"].forEach((name) => filePicker?.addEventListener(name, (event) => {
466
+ event.preventDefault();
467
+ filePicker.classList.remove("is-dragging");
468
+ }));
469
+ filePicker?.addEventListener("drop", (event) => {
470
+ const file = event.dataTransfer?.files?.[0];
471
+ if (!file) return;
472
+ const transfer = new DataTransfer();
473
+ transfer.items.add(file);
474
+ fileInput.files = transfer.files;
475
+ selectFile(file);
476
+ });
477
+
478
+ function syncRanges() {
479
+ document.querySelectorAll(".range-control").forEach((range) => {
480
+ const number = document.getElementById(range.dataset.sync);
481
+ range.value = number.value;
482
+ range.addEventListener("input", () => {
483
+ number.value = range.value;
484
+ number.dispatchEvent(new Event("input", { bubbles: true }));
485
+ });
486
+ number.addEventListener("input", () => { range.value = number.value; });
487
+ });
488
+ }
489
+
490
+ function updateGridFields() {
491
+ const visible = tileLayout?.value === "grid";
492
+ document.querySelectorAll(".grid-only").forEach((element) => {
493
+ element.hidden = !visible;
494
+ const input = element.querySelector("input");
495
+ input.disabled = !visible;
496
+ input.required = visible;
497
+ });
498
+ }
499
+
500
+ function updateZFields() {
501
+ const visible = separateZParts?.checked;
502
+ const field = document.querySelector("#color-region-field");
503
+ const input = document.querySelector("#color-region-parts");
504
+ field.hidden = !visible;
505
+ input.disabled = !visible;
506
+ input.required = visible;
507
+ document.querySelector("#z-parts-summary").textContent = visible
508
+ ? `Base White付き色領域 ${input.value}プレート + Top White 1プレート`
509
+ : "Z方向は一体のCMYWモデルとして出力します";
510
+ const diagram = document.querySelector("#z-parts-diagram");
511
+ diagram.replaceChildren();
512
+ if (visible) {
513
+ const partCount = Number(input.value);
514
+ const size = Math.floor(7 / partCount);
515
+ let remainder = 7 % partCount;
516
+ let layer = 0;
517
+ const labels = Array.from({ length: partCount }, (_value, index) => {
518
+ const count = size + (remainder-- > 0 ? 1 : 0);
519
+ const start = 0.2 + (layer * 0.1);
520
+ layer += count;
521
+ const finish = 0.2 + (layer * 0.1);
522
+ return `色領域 ${index + 1}\n${start.toFixed(1)}–${finish.toFixed(1)} mm`;
523
+ });
524
+ labels.concat("Top White\n0.9 mm–可変上面").forEach((label) => {
525
+ const part = document.createElement("span");
526
+ part.textContent = label;
527
+ diagram.append(part);
528
+ });
529
+ }
530
+ diagram.hidden = !visible;
531
+ }
532
+
533
+ function updateDimensions() {
534
+ const width = Number(document.querySelector("#width").value);
535
+ const height = Number(document.querySelector("#height").value);
536
+ document.querySelector("#preview-dimensions").textContent = `${width || "—"} × ${height || "—"} mm`;
537
+ if (width > 0 && height > 0) document.querySelector("#source-preview").style.aspectRatio = `${width} / ${height}`;
538
+ }
539
+
540
+ tileLayout?.addEventListener("change", updateGridFields);
541
+ separateZParts?.addEventListener("change", updateZFields);
542
+ document.querySelector("#color-region-parts")?.addEventListener("input", updateZFields);
543
+ document.querySelector("#toggle-tile-overlay")?.addEventListener("change", () => scheduleTileOverlay(0));
544
+ syncRanges();
545
+ updateGridFields();
546
+ updateZFields();
547
+ updateDimensions();
548
+ updateDraftSummary();
549
+ refreshResolvedSummary();
550
+
551
+ const widthInput = document.querySelector("#width");
552
+ const heightInput = document.querySelector("#height");
553
+ const aspectLock = document.querySelector("#aspect-lock");
554
+ aspectLock?.addEventListener("change", () => {
555
+ if (aspectLock.checked) aspectRatio = Number(widthInput.value) / Number(heightInput.value);
556
+ });
557
+ widthInput?.addEventListener("input", () => {
558
+ if (aspectLock.checked && aspectRatio > 0 && document.activeElement === widthInput) heightInput.value = (Number(widthInput.value) / aspectRatio).toFixed(2);
559
+ updateDimensions();
560
+ });
561
+ heightInput?.addEventListener("input", () => {
562
+ if (aspectLock.checked && aspectRatio > 0 && document.activeElement === heightInput) widthInput.value = (Number(heightInput.value) * aspectRatio).toFixed(2);
563
+ updateDimensions();
564
+ });
565
+ document.querySelector("#swap-dimensions")?.addEventListener("click", () => {
566
+ const width = widthInput.value;
567
+ widthInput.value = heightInput.value;
568
+ heightInput.value = width;
569
+ aspectRatio = Number(widthInput.value) / Number(heightInput.value);
570
+ updateDimensions();
571
+ schedulePreview(0);
572
+ updateDraftState();
573
+ });
574
+
575
+ document.querySelectorAll(".section-reset").forEach((button) => {
576
+ button.addEventListener("click", () => {
577
+ button.closest("details").querySelectorAll("[data-default]").forEach((control) => {
578
+ if (control.type === "checkbox") control.checked = control.dataset.default === "1";
579
+ else control.value = control.dataset.default;
580
+ control.dispatchEvent(new Event("change", { bubbles: true }));
581
+ });
582
+ syncRangesValues();
583
+ updateDimensions();
584
+ schedulePreview(0);
585
+ });
586
+ });
587
+
588
+ function syncRangesValues() {
589
+ document.querySelectorAll(".range-control").forEach((range) => {
590
+ range.value = document.getElementById(range.dataset.sync).value;
591
+ });
592
+ }
593
+
594
+ form?.addEventListener("input", (event) => {
595
+ if (event.target !== fileInput) {
596
+ schedulePreview();
597
+ scheduleTileOverlay();
598
+ updateDraftState();
599
+ }
600
+ });
601
+ form?.addEventListener("change", (event) => {
602
+ if (event.target !== fileInput) {
603
+ schedulePreview();
604
+ scheduleTileOverlay();
605
+ updateDraftState();
606
+ }
607
+ });
608
+ form?.addEventListener("invalid", (event) => {
609
+ event.target.closest("details")?.setAttribute("open", "");
610
+ const label = event.target.closest("label")?.childNodes?.[0]?.textContent?.trim();
611
+ showFormError(`${label || "入力値"}を確認してください`);
612
+ }, true);
613
+
614
+ function setSubmitting(value) {
615
+ submitting = value;
616
+ const button = document.querySelector("#submit-button");
617
+ button.disabled = value;
618
+ button.textContent = value ? "変換を準備しています…" : "変換を開始";
619
+ }
620
+
621
+ form?.addEventListener("submit", async (event) => {
622
+ event.preventDefault();
623
+ if (submitting || !form.reportValidity()) return;
624
+ setSubmitting(true);
625
+ showFormError("");
626
+ try {
627
+ if (stagePromise) await stagePromise;
628
+ const data = new FormData(form);
629
+ if (previewId) {
630
+ data.delete("image");
631
+ data.set("preview_id", previewId);
632
+ } else {
633
+ data.delete("preview_id");
634
+ }
635
+ const snapshot = settingsSignature();
636
+ const snapshotValues = editableSettings();
637
+ const response = await fetch("/jobs", { method: "POST", headers: { Accept: "application/json" }, body: data });
638
+ if (!response.ok) throw new Error(await responseError(response, "変換を開始できませんでした"));
639
+ const payload = await response.json();
640
+ submittedSignature = snapshot;
641
+ submittedSettings = snapshotValues;
642
+ currentJobId = payload.job_id;
643
+ activeJobId = payload.job_id;
644
+ activeJob = undefined;
645
+ currentJobDetailTab = "preview";
646
+ workspace.dataset.selectedJob = currentJobId;
647
+ showAppView("jobs", { focus: true });
648
+ window.history.pushState({ jobId: currentJobId, view: "jobs" }, "", viewUrl("jobs"));
649
+ previewId = undefined;
650
+ previewIdInput.value = "";
651
+ showJobPanel(currentJobId);
652
+ updateDraftState("この設定で変換を開始しました");
653
+ refreshHistory();
654
+ pollJob(currentJobId);
655
+ const selectedFile = fileInput.files?.[0];
656
+ if (selectedFile) stageFile(selectedFile, { render: false });
657
+ } catch (error) {
658
+ showFormError(error.message);
659
+ setSubmitting(false);
660
+ }
661
+ });
662
+
663
+ function settingsDifferences(values) {
664
+ const current = editableSettings();
665
+ return Object.entries(settingLabels).filter(([key]) => key in values).filter(([key]) => {
666
+ const next = typeof values[key] === "boolean" ? (values[key] ? "1" : "") : String(values[key] ?? "");
667
+ return next !== String(current[key] ?? "");
668
+ }).map(([key, label]) => ({ key, label, before: current[key] ?? "自動", after: values[key] ?? "自動" }));
669
+ }
670
+
671
+ function applySettings(values, sourceLabel) {
672
+ const differences = settingsDifferences(values);
673
+ const detail = differences.length
674
+ ? differences.map((difference) => `${difference.label}: ${difference.before} → ${difference.after}`).join("\n")
675
+ : "現在の設定との差はありません。";
676
+ if (!window.confirm(`${sourceLabel}を編集へ読み込みます。\n\n${detail}`)) return false;
677
+ Object.entries(settingLabels).forEach(([key]) => {
678
+ if (!(key in values)) return;
679
+ const control = form.elements.namedItem(key);
680
+ if (!control) return;
681
+ if (control.type === "checkbox") control.checked = values[key] === true || values[key] === "1";
682
+ else control.value = values[key] === null ? "" : String(values[key]);
683
+ });
684
+ updateGridFields();
685
+ updateZFields();
686
+ syncRangesValues();
687
+ updateDimensions();
688
+ schedulePreview(0);
689
+ scheduleTileOverlay(0);
690
+ refreshResolvedSummary();
691
+ updateDraftState(fileInput.files?.[0]
692
+ ? `${sourceLabel}を反映しました。入力画像は保持されています`
693
+ : `${sourceLabel}を反映しました。画像を選択してください`);
694
+ document.querySelector("#settings-library-status").textContent = fileInput.files?.[0]
695
+ ? `${sourceLabel}を反映しました。入力画像は変更していません。`
696
+ : `${sourceLabel}を反映しました。画像は保存されないため、選択してください。`;
697
+ showAppView("create", { push: currentView !== "create", focus: true });
698
+ return true;
699
+ }
700
+
701
+ async function settingsLibraryRequest(path, values = new URLSearchParams()) {
702
+ values.set("csrf_token", csrfToken);
703
+ const response = await fetch(path, {
704
+ method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" }, body: values
705
+ });
706
+ if (!response.ok) throw new Error(await responseError(response, "設定を保存できませんでした"));
707
+ settingsLibrary = await response.json();
708
+ renderSettingsLibrary();
709
+ return settingsLibrary;
710
+ }
711
+
712
+ function renderSettingsLibrary() {
713
+ const select = document.querySelector("#preset-select");
714
+ const selected = select.value;
715
+ select.replaceChildren(new Option("プリセットを選択", ""));
716
+ Object.keys(settingsLibrary.presets || {}).forEach((name) => select.add(new Option(name, name)));
717
+ if (settingsLibrary.presets?.[selected]) select.value = selected;
718
+ document.querySelector("#draft-restore").disabled = !settingsLibrary.draft;
719
+ document.querySelector("#draft-delete").disabled = !settingsLibrary.draft;
720
+ }
721
+
722
+ async function loadSettingsLibrary() {
723
+ try {
724
+ const response = await fetch("/api/settings-library", { cache: "no-store" });
725
+ if (!response.ok) throw new Error(await responseError(response, "保存設定を読み込めませんでした"));
726
+ settingsLibrary = await response.json();
727
+ renderSettingsLibrary();
728
+ } catch (error) {
729
+ document.querySelector("#settings-library-status").textContent = error.message;
730
+ }
731
+ }
732
+
733
+ document.querySelector("#preset-select")?.addEventListener("change", (event) => {
734
+ document.querySelector("#preset-name").value = event.target.value;
735
+ });
736
+ document.querySelector("#preset-save")?.addEventListener("click", async () => {
737
+ try {
738
+ const values = settingParameters();
739
+ const name = document.querySelector("#preset-name").value.trim();
740
+ values.set("name", name);
741
+ await settingsLibraryRequest("/api/settings-library/presets", values);
742
+ document.querySelector("#preset-select").value = name;
743
+ document.querySelector("#settings-library-status").textContent = `「${name}」へ現在設定を保存しました。画像は保存していません。`;
744
+ } catch (error) { showFormError(error.message); }
745
+ });
746
+ document.querySelector("#preset-load")?.addEventListener("click", () => {
747
+ const name = document.querySelector("#preset-select").value;
748
+ const preset = settingsLibrary.presets?.[name];
749
+ if (!preset) return showFormError("読み込むプリセットを選択してください");
750
+ applySettings(preset.settings, `プリセット「${name}」`);
751
+ });
752
+ document.querySelector("#preset-rename")?.addEventListener("click", async () => {
753
+ const name = document.querySelector("#preset-select").value;
754
+ const newName = document.querySelector("#preset-name").value.trim();
755
+ if (!name) return showFormError("名前を変更するプリセットを選択してください");
756
+ try {
757
+ await settingsLibraryRequest("/api/settings-library/presets/rename", new URLSearchParams({ name, new_name: newName }));
758
+ document.querySelector("#preset-select").value = newName;
759
+ document.querySelector("#settings-library-status").textContent = `「${name}」を「${newName}」へ変更しました。`;
760
+ } catch (error) { showFormError(error.message); }
761
+ });
762
+ document.querySelector("#preset-delete")?.addEventListener("click", async () => {
763
+ const name = document.querySelector("#preset-select").value;
764
+ if (!name) return showFormError("削除するプリセットを選択してください");
765
+ if (!window.confirm(`プリセット「${name}」を削除します。`)) return;
766
+ try {
767
+ await settingsLibraryRequest("/api/settings-library/presets/delete", new URLSearchParams({ name }));
768
+ document.querySelector("#preset-name").value = "";
769
+ document.querySelector("#settings-library-status").textContent = `「${name}」を削除しました。`;
770
+ } catch (error) { showFormError(error.message); }
771
+ });
772
+ document.querySelector("#draft-save")?.addEventListener("click", async () => {
773
+ try {
774
+ await settingsLibraryRequest("/api/settings-library/draft", settingParameters());
775
+ document.querySelector("#settings-library-status").textContent = "下書き設定を保存しました。画像は保存していません。";
776
+ } catch (error) { showFormError(error.message); }
777
+ });
778
+ document.querySelector("#draft-restore")?.addEventListener("click", () => {
779
+ if (settingsLibrary.draft) applySettings(settingsLibrary.draft.settings, "下書き設定");
780
+ });
781
+ document.querySelector("#draft-delete")?.addEventListener("click", async () => {
782
+ try {
783
+ await settingsLibraryRequest("/api/settings-library/draft/delete");
784
+ document.querySelector("#settings-library-status").textContent = "下書き設定を削除しました。";
785
+ } catch (error) { showFormError(error.message); }
786
+ });
787
+ document.querySelector("#load-job-settings")?.addEventListener("click", () => {
788
+ if (displayedJob) applySettings(displayedJob, `ジョブ ${displayedJob.job_id} の設定`);
789
+ });
790
+
791
+ function formatBytes(value) {
792
+ const bytes = Number(value) || 0;
793
+ if (bytes < 1024) return `${bytes} B`;
794
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
795
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
796
+ }
797
+
798
+ function jobSnapshot(job) {
799
+ const values = [];
800
+ if (job.input_name) values.push(job.input_name);
801
+ if (job.created_at) values.push(new Date(job.created_at).toLocaleString("ja-JP"));
802
+ if (job.width && job.height) values.push(`${job.width} × ${job.height} mm`);
803
+ if (job.tile_count) values.push(`${job.tile_count}タイル`);
804
+ if (job.separate_z_parts) values.push("Z分割");
805
+ return values.join(" · ");
806
+ }
807
+
808
+ function jobSettingDifferences(job) {
809
+ const current = editableSettings();
810
+ return Object.entries(settingLabels).filter(([key]) => {
811
+ if (!(key in job)) return false;
812
+ const jobValue = typeof job[key] === "boolean" ? (job[key] ? "1" : "") : String(job[key] ?? "");
813
+ return jobValue !== String(current[key] ?? "");
814
+ }).map(([, label]) => label);
815
+ }
816
+
817
+ function updateJobContext(job) {
818
+ document.querySelector("#job-view-name").textContent = job.input_name || "入力名の記録がないジョブ";
819
+ const differences = jobSettingDifferences(job);
820
+ const snapshot = jobSnapshot(job);
821
+ document.querySelector("#job-settings-snapshot").textContent = differences.length
822
+ ? `${snapshot} · 編集中と異なる項目: ${differences.join("、")}`
823
+ : `${snapshot} · 編集中の設定と一致`;
824
+ }
825
+
826
+ function updateGlobalJob(job) {
827
+ const control = document.querySelector("#global-job-control");
828
+ const representedJob = job || activeJob;
829
+ const representedId = representedJob?.job_id || activeJobId || currentJobId;
830
+ if (!representedId) {
831
+ control.hidden = true;
832
+ return;
833
+ }
834
+ const state = representedJob?.state || "queued";
835
+ const percent = Math.round(Number(representedJob?.percent) || 0);
836
+ control.hidden = false;
837
+ const inputName = representedJob?.input_name;
838
+ document.querySelector("#global-job-label").textContent = `${inputName ? `${inputName} · ` : ""}${representedId} · ${stateLabels[state] || state}`;
839
+ document.querySelector("#global-job-percent").textContent = `${percent}%`;
840
+ const cancel = document.querySelector("#global-cancel-button");
841
+ cancel.hidden = !["queued", "running", "cancelling"].includes(state);
842
+ cancel.disabled = state === "cancelling";
843
+ cancel.textContent = state === "cancelling" ? "停止要求中" : "停止";
844
+ }
845
+
846
+ function showJobPanel(jobId) {
847
+ const panel = document.querySelector("#job-panel");
848
+ displayedJob = undefined;
849
+ workspace.classList.add("has-job");
850
+ panel.hidden = false;
851
+ document.querySelector("#job-id").textContent = jobId;
852
+ document.querySelector("#job-view-name").textContent = "ジョブを読み込んでいます";
853
+ document.querySelector("#job-state").textContent = "待機中";
854
+ document.querySelector("#job-percent").textContent = "0%";
855
+ document.querySelector("#job-message").textContent = "変換を開始しています";
856
+ document.querySelector("#progress-bar").style.width = "0%";
857
+ document.querySelector("#result-layout").hidden = true;
858
+ document.querySelector("#job-error").hidden = true;
859
+ document.querySelector("#delete-button").hidden = true;
860
+ document.querySelector("#cancel-button").hidden = false;
861
+ document.querySelector("#job-connection").textContent = "状態を取得しています";
862
+ document.querySelector("#job-connection").dataset.connection = "loading";
863
+ document.querySelector("#job-updated-at").textContent = "";
864
+ document.querySelector("#retry-status-button").hidden = true;
865
+ document.querySelector("#job-detail-tabs").hidden = true;
866
+ selectJobDetailTab(currentJobDetailTab);
867
+ workspace.classList.remove("show-job-list");
868
+ updateGlobalJob();
869
+ panel.scrollIntoView({ block: "start" });
870
+ }
871
+
872
+ function formatElapsed(start) {
873
+ if (!start) return "";
874
+ const seconds = Math.max(0, Math.round((Date.now() - new Date(start).getTime()) / 1000));
875
+ if (seconds < 60) return `${seconds}秒経過`;
876
+ const minutes = Math.floor(seconds / 60);
877
+ return `${minutes}分${seconds % 60}秒経過`;
878
+ }
879
+
880
+ function showConnection(state, message) {
881
+ const connection = document.querySelector("#job-connection");
882
+ connection.dataset.connection = state;
883
+ connection.textContent = message;
884
+ document.querySelector("#retry-status-button").hidden = state !== "offline";
885
+ }
886
+
887
+ function resultPreviews(job) {
888
+ const artifacts = new Map(job.artifacts.map((artifact) => [artifact.name, artifact]));
889
+ return previewKinds.map(({ name }) => artifacts.get(name)).filter(Boolean);
890
+ }
891
+
892
+ function selectResultPreview(artifact, button) {
893
+ document.querySelectorAll("#result-tabs button").forEach((candidate) => candidate.setAttribute("aria-selected", String(candidate === button)));
894
+ document.querySelector("#result-image").src = `${artifact.url}?v=${encodeURIComponent(currentJobId || "result")}`;
895
+ document.querySelector("#result-caption").textContent = previewLabels[artifact.name];
896
+ resetViewer("result-preview");
897
+ }
898
+
899
+ function setBambuOpenStatus(message, state = "") {
900
+ const status = document.querySelector("#bambu-open-status");
901
+ status.textContent = message;
902
+ status.dataset.state = state;
903
+ }
904
+
905
+ async function openInBambuStudio(jobId, artifact, button) {
906
+ const initialLabel = button.textContent;
907
+ button.disabled = true;
908
+ button.textContent = "起動を要求中…";
909
+ if (currentJobId === jobId) setBambuOpenStatus(`${artifact.name} をBambu Studioへ渡しています`);
910
+ try {
911
+ const body = new URLSearchParams({ name: artifact.name });
912
+ const response = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/open-in-bambu-studio`, {
913
+ method: "POST", headers: { "X-CSRF-Token": csrfToken }, body
914
+ });
915
+ const payload = await response.json().catch(() => ({}));
916
+ if (!response.ok) throw new Error(payload.error || "Bambu Studioを開けませんでした");
917
+ if (currentJobId === jobId) {
918
+ setBambuOpenStatus(`${artifact.name} をBambu Studioに渡しました。表示されない場合は3MFを保存して開いてください。`, "success");
919
+ }
920
+ } catch (error) {
921
+ if (currentJobId === jobId) setBambuOpenStatus(error.message, "error");
922
+ } finally {
923
+ if (button.isConnected) {
924
+ button.disabled = false;
925
+ button.textContent = initialLabel;
926
+ }
927
+ }
928
+ }
929
+
930
+ function bambuOpenButton(job, artifact) {
931
+ const button = document.createElement("button");
932
+ button.type = "button";
933
+ button.className = "secondary-button bambu-open-button";
934
+ button.textContent = "Bambu Studioで開く";
935
+ button.setAttribute("aria-label", `${artifact.name}をBambu Studioで開く`);
936
+ button.addEventListener("click", (event) => {
937
+ event.stopPropagation();
938
+ openInBambuStudio(job.job_id, artifact, button);
939
+ });
940
+ return button;
941
+ }
942
+
943
+ function renderTileModels(job) {
944
+ const section = document.querySelector("#tile-results");
945
+ const list = document.querySelector("#tile-result-list");
946
+ const models = job.artifacts.filter((artifact) => /(?:^|\/)tile_[^/]+\.3mf$/i.test(artifact.name));
947
+ const previews = new Map(job.artifacts.map((artifact) => [artifact.name, artifact]));
948
+ const metadata = new Map((job.tiles || []).map((tile) => [tile.file, tile]));
949
+ const plateCount = job.z_part_separation?.enabled
950
+ ? (job.z_part_separation.color_regions?.length || 0) + 1
951
+ : 1;
952
+ list.replaceChildren();
953
+ section.querySelector(".tile-map")?.remove();
954
+ const selectTile = (tileId, { move = false } = {}) => {
955
+ section.querySelectorAll("[data-tile-id]").forEach((element) => {
956
+ const selected = element.dataset.tileId === tileId;
957
+ element.classList.toggle("is-selected", selected);
958
+ if (element.matches("button")) element.setAttribute("aria-pressed", String(selected));
959
+ });
960
+ if (move) document.getElementById(`tile-card-${tileId}`)?.scrollIntoView({ block: "nearest" });
961
+ };
962
+ if (models.length > 1) {
963
+ const map = document.createElement("div");
964
+ map.className = "tile-map";
965
+ map.setAttribute("aria-label", "タイル全体図");
966
+ const rows = new Map();
967
+ models.forEach((model) => {
968
+ const tile = metadata.get(model.name);
969
+ const tileId = tile?.id || model.name.split("/").pop().replace(/\.3mf$/i, "");
970
+ const button = document.createElement("button");
971
+ button.type = "button";
972
+ button.dataset.tileId = tileId;
973
+ button.textContent = tileId.replace("tile_", "");
974
+ button.setAttribute("aria-label", `${tileId}を表示`);
975
+ button.addEventListener("click", () => selectTile(tileId, { move: true }));
976
+ const rowNumber = tile?.row || 1;
977
+ if (!rows.has(rowNumber)) {
978
+ const row = document.createElement("div");
979
+ row.className = "tile-map-row";
980
+ rows.set(rowNumber, row);
981
+ map.append(row);
982
+ }
983
+ rows.get(rowNumber).append(button);
984
+ });
985
+ list.before(map);
986
+ }
987
+ models.forEach((model) => {
988
+ const previewName = model.name.replace(/\.3mf$/i, "-preview.png");
989
+ const preview = previews.get(previewName);
990
+ const tile = metadata.get(model.name);
991
+ const tileId = tile?.id || model.name.split("/").pop().replace(/\.3mf$/i, "");
992
+ const card = document.createElement("figure");
993
+ card.className = "tile-result-card";
994
+ card.id = `tile-card-${tileId}`;
995
+ card.dataset.tileId = tileId;
996
+ card.tabIndex = 0;
997
+ card.addEventListener("click", () => selectTile(tileId));
998
+ card.addEventListener("keydown", (event) => {
999
+ if (event.target.closest("button, a")) return;
1000
+ if (event.key === "Enter" || event.key === " ") {
1001
+ event.preventDefault();
1002
+ selectTile(tileId);
1003
+ }
1004
+ });
1005
+ if (preview) {
1006
+ const image = document.createElement("img");
1007
+ image.src = `${preview.url}?v=${encodeURIComponent(currentJobId || "result")}`;
1008
+ image.alt = `${model.name} のプレビュー`;
1009
+ image.loading = "lazy";
1010
+ card.append(image);
1011
+ } else {
1012
+ const missing = document.createElement("div");
1013
+ missing.className = "tile-preview-missing";
1014
+ missing.textContent = "プレビューなし";
1015
+ card.append(missing);
1016
+ }
1017
+ const caption = document.createElement("figcaption");
1018
+ const name = document.createElement("strong");
1019
+ name.textContent = tileId;
1020
+ const link = document.createElement("a");
1021
+ link.href = model.url;
1022
+ link.download = model.name.split("/").pop();
1023
+ link.textContent = "3MFを保存";
1024
+ const actions = document.createElement("div");
1025
+ actions.className = "tile-model-actions";
1026
+ actions.append(link);
1027
+ if (job.can_open_in_bambu_studio) actions.append(bambuOpenButton(job, model));
1028
+ caption.append(name, actions);
1029
+ card.append(caption);
1030
+ if (tile) {
1031
+ const details = document.createElement("small");
1032
+ const dimensions = tile.dimensions_mm || {};
1033
+ const offset = tile.offset_mm || {};
1034
+ details.textContent = `行${tile.row}・列${tile.column} · ${dimensions.width} × ${dimensions.height} mm · 配置 (${offset.x}, ${offset.y}) mm · ${plateCount}プレート`;
1035
+ card.append(details);
1036
+ }
1037
+ list.append(card);
1038
+ });
1039
+ section.hidden = models.length < 2;
1040
+ const first = section.querySelector("[data-tile-id]");
1041
+ if (first) selectTile(first.dataset.tileId);
1042
+ }
1043
+
1044
+ function renderArtifacts(job) {
1045
+ const layout = document.querySelector("#result-layout");
1046
+ const list = document.querySelector("#artifact-list");
1047
+ const tabs = document.querySelector("#result-tabs");
1048
+ list.replaceChildren();
1049
+ tabs.replaceChildren();
1050
+ setBambuOpenStatus(job.can_open_in_bambu_studio ? "3MFは保存するか、Bambu Studioで直接開けます。" : "");
1051
+ renderTileModels(job);
1052
+ const groups = { "モデル": [], "画像・配置": [], "設定・記録": [], "印刷説明": [] };
1053
+ job.artifacts.forEach((artifact) => {
1054
+ if (/\.(3mf|stl)$/i.test(artifact.name)) groups["モデル"].push(artifact);
1055
+ else if (/\.(png|svg)$/i.test(artifact.name)) groups["画像・配置"].push(artifact);
1056
+ else if (/PRINT_README/i.test(artifact.name)) groups["印刷説明"].push(artifact);
1057
+ else groups["設定・記録"].push(artifact);
1058
+ });
1059
+ document.querySelector("#download-models").disabled = !job.artifacts.some((artifact) => /\.3mf$/i.test(artifact.name));
1060
+ document.querySelector("#download-all").disabled = job.artifacts.length === 0;
1061
+ Object.entries(groups).forEach(([label, artifacts]) => {
1062
+ if (!artifacts.length) return;
1063
+ const group = document.createElement("li");
1064
+ const title = document.createElement("strong");
1065
+ title.textContent = label;
1066
+ const entries = document.createElement("ul");
1067
+ artifacts.forEach((artifact) => {
1068
+ const item = document.createElement("li");
1069
+ const link = document.createElement("a");
1070
+ link.href = artifact.url;
1071
+ link.textContent = artifact.name;
1072
+ if (artifact.name.endsWith(".3mf") || artifact.name.endsWith(".stl")) link.download = artifact.name;
1073
+ if (/\.3mf$/i.test(artifact.name) && job.can_open_in_bambu_studio) {
1074
+ const actions = document.createElement("div");
1075
+ actions.className = "artifact-actions";
1076
+ actions.append(link, bambuOpenButton(job, artifact));
1077
+ item.append(actions);
1078
+ } else {
1079
+ item.append(link);
1080
+ }
1081
+ entries.append(item);
1082
+ });
1083
+ group.append(title, entries);
1084
+ list.append(group);
1085
+ });
1086
+ const previews = resultPreviews(job);
1087
+ previews.forEach((artifact, index) => {
1088
+ const kind = previewKinds.find(({ name }) => name === artifact.name);
1089
+ const button = document.createElement("button");
1090
+ button.type = "button";
1091
+ button.role = "tab";
1092
+ button.textContent = kind.label;
1093
+ button.title = kind.description;
1094
+ button.setAttribute("aria-selected", String(index === 0));
1095
+ button.addEventListener("click", () => selectResultPreview(artifact, button));
1096
+ tabs.append(button);
1097
+ });
1098
+ if (previews[0]) selectResultPreview(previews[0], tabs.firstElementChild);
1099
+ tabs.hidden = previews.length < 2;
1100
+ const correctedFigure = document.querySelector(".comparison-source").closest("figure");
1101
+ const unavailable = document.querySelector("#corrected-input-unavailable");
1102
+ correctedFigure.hidden = !job.corrected_input_url;
1103
+ unavailable.hidden = Boolean(job.corrected_input_url);
1104
+ if (job.corrected_input_url) {
1105
+ compareSourceImage.src = `${job.corrected_input_url}?v=${encodeURIComponent(currentJobId || "result")}`;
1106
+ } else {
1107
+ compareSourceImage.removeAttribute("src");
1108
+ }
1109
+ layout.hidden = false;
1110
+ }
1111
+
1112
+ async function downloadArchive(scope) {
1113
+ if (!currentJobId) return;
1114
+ const button = document.querySelector(scope === "models" ? "#download-models" : "#download-all");
1115
+ button.disabled = true;
1116
+ try {
1117
+ const response = await fetch(`/api/jobs/${encodeURIComponent(currentJobId)}/archive-summary?scope=${scope}`, { cache: "no-store" });
1118
+ if (!response.ok) throw new Error(await responseError(response, "ZIPの対象を確認できませんでした"));
1119
+ const summary = await response.json();
1120
+ const files = summary.files.map((file) => `${file.name}(${formatBytes(file.size_bytes)})`).join("\n");
1121
+ if (!window.confirm(`${summary.file_count}ファイル(${formatBytes(summary.size_bytes)})をZIPで取得します。\n\n${files}`)) return;
1122
+ const link = document.createElement("a");
1123
+ link.href = summary.download_url;
1124
+ link.download = `${currentJobId}-${scope}.zip`;
1125
+ document.body.append(link);
1126
+ link.click();
1127
+ link.remove();
1128
+ } catch (error) {
1129
+ showFormError(error.message);
1130
+ } finally {
1131
+ button.disabled = false;
1132
+ }
1133
+ }
1134
+
1135
+ document.querySelector("#download-models")?.addEventListener("click", () => downloadArchive("models"));
1136
+ document.querySelector("#download-all")?.addEventListener("click", () => downloadArchive("all"));
1137
+
1138
+ function updateResultAspect(job) {
1139
+ const width = Number(job.width);
1140
+ const height = Number(job.height);
1141
+ if (!(width > 0 && height > 0)) return;
1142
+ document.querySelectorAll(".comparison-grid .result-preview").forEach((preview) => {
1143
+ preview.style.aspectRatio = `${width} / ${height}`;
1144
+ });
1145
+ }
1146
+
1147
+ function renderJobSettingDetails(job) {
1148
+ const details = document.querySelector("#job-setting-details");
1149
+ details.replaceChildren();
1150
+ const entries = [
1151
+ ["入力", job.input_name || "記録なし"],
1152
+ ["ジョブID", job.job_id],
1153
+ ["作成日時", job.created_at ? new Date(job.created_at).toLocaleString("ja-JP") : "記録なし"],
1154
+ ...Object.entries(settingLabels).filter(([key]) => key in job).map(([key, label]) => [label, formatSetting(job[key])])
1155
+ ];
1156
+ entries.forEach(([label, value]) => {
1157
+ const term = document.createElement("dt");
1158
+ const description = document.createElement("dd");
1159
+ term.textContent = label;
1160
+ description.textContent = value;
1161
+ details.append(term, description);
1162
+ });
1163
+ const log = document.querySelector("#job-detail-log-link");
1164
+ log.hidden = !job.log_url;
1165
+ if (job.log_url) log.href = job.log_url;
1166
+ }
1167
+
1168
+ function renderJob(job) {
1169
+ const percent = Math.round(Number(job.percent) || 0);
1170
+ document.querySelector("#job-state").textContent = stateLabels[job.state] || job.state;
1171
+ document.querySelector("#job-state").dataset.state = job.state;
1172
+ document.querySelector("#job-percent").textContent = `${percent}%`;
1173
+ document.querySelector("#job-message").textContent = job.message || job.phase || "";
1174
+ displayedJob = job;
1175
+ const active = ["queued", "running", "cancelling"].includes(job.state);
1176
+ if (active && !activeJobId) activeJobId = job.job_id;
1177
+ if (job.job_id === activeJobId) {
1178
+ activeJob = job;
1179
+ updateGlobalJob(job);
1180
+ if (!active) activeJobId = undefined;
1181
+ } else if (!activeJobId) {
1182
+ updateGlobalJob(job);
1183
+ }
1184
+ updateJobContext(job);
1185
+ renderJobSettingDetails(job);
1186
+ updateDraftSummary();
1187
+ updateResultAspect(job);
1188
+ const thickness = job.minimum_thickness && job.maximum_thickness
1189
+ ? `厚み画像: ${job.minimum_thickness}–${job.maximum_thickness} mm。`
1190
+ : "";
1191
+ document.querySelector("#result-legend").textContent = `${thickness}材料マスクは白が0層、濃い部分ほどその材料の層数が多い領域です。`;
1192
+ document.querySelector("#progress-bar").style.width = `${percent}%`;
1193
+ document.querySelector(".progress-track").setAttribute("aria-valuenow", String(percent));
1194
+ const updated = job.updated_at ? new Date(job.updated_at).toLocaleString("ja-JP") : "時刻の記録なし";
1195
+ const elapsed = active ? formatElapsed(job.started_at || job.created_at) : "";
1196
+ document.querySelector("#job-updated-at").textContent = `最終更新 ${updated}${elapsed ? ` · ${elapsed}` : ""}`;
1197
+ showConnection("online", active ? "接続中・状態を自動更新" : "最新状態を取得済み");
1198
+ const cancel = document.querySelector("#cancel-button");
1199
+ cancel.hidden = !active;
1200
+ cancel.disabled = job.state === "cancelling";
1201
+ cancel.textContent = job.state === "cancelling" ? "停止要求中…" : "停止";
1202
+ const final = ["completed", "failed", "cancelled"].includes(job.state);
1203
+ document.querySelector("#job-detail-tabs").hidden = job.state !== "completed";
1204
+ document.querySelector("#load-job-settings").hidden = !Object.keys(settingLabels).some((key) => key in job);
1205
+ const remove = document.querySelector("#delete-button");
1206
+ remove.hidden = !final;
1207
+ remove.dataset.fileCount = job.file_count || 0;
1208
+ remove.dataset.sizeBytes = job.size_bytes || 0;
1209
+ const summary = document.querySelector("#delete-summary");
1210
+ summary.textContent = `${job.file_count || 0}ファイル・${formatBytes(job.size_bytes)}`;
1211
+ summary.hidden = !final;
1212
+ if (job.state === "completed") renderArtifacts(job);
1213
+ const error = document.querySelector("#job-error");
1214
+ const logLink = document.querySelector("#job-log-link");
1215
+ if (job.state === "failed" || job.state === "cancelled") {
1216
+ const guidance = job.state === "failed" ? "画像または設定を確認して、もう一度変換できます。" : "編集内容は保持されています。";
1217
+ document.querySelector("#job-error-message").textContent = `${job.error?.message || job.message} ${guidance}`;
1218
+ logLink.hidden = !job.log_url;
1219
+ if (job.log_url) logLink.href = job.log_url;
1220
+ error.hidden = false;
1221
+ } else {
1222
+ logLink.hidden = true;
1223
+ error.hidden = true;
1224
+ }
1225
+ if (final) setSubmitting(false);
1226
+ return active;
1227
+ }
1228
+
1229
+ async function fetchJob(jobId) {
1230
+ const response = await fetch(`/api/jobs/${encodeURIComponent(jobId)}`, { cache: "no-store" });
1231
+ if (response.status === 404) return null;
1232
+ if (!response.ok) throw new Error("ジョブ状態を取得できませんでした");
1233
+ return response.json();
1234
+ }
1235
+
1236
+ async function pollJob(jobId) {
1237
+ window.clearTimeout(pollTimer);
1238
+ try {
1239
+ const job = await fetchJob(jobId);
1240
+ if (!job) {
1241
+ if (jobId === currentJobId) {
1242
+ document.querySelector("#job-message").textContent = "ジョブは削除されました";
1243
+ showConnection("online", "ジョブは別の画面で削除されました");
1244
+ }
1245
+ return;
1246
+ }
1247
+ const isActiveJob = jobId === activeJobId;
1248
+ const active = ["queued", "running", "cancelling"].includes(job.state);
1249
+ if (isActiveJob) {
1250
+ activeJob = job;
1251
+ updateGlobalJob(job);
1252
+ if (!active) activeJobId = undefined;
1253
+ }
1254
+ if (jobId === currentJobId) renderJob(job);
1255
+ if (active && (isActiveJob || jobId === currentJobId)) pollTimer = window.setTimeout(() => pollJob(jobId), 900);
1256
+ refreshHistory();
1257
+ } catch (error) {
1258
+ if (jobId === currentJobId) showConnection("offline", `通信できません: ${error.message}`);
1259
+ if (jobId === activeJobId || jobId === currentJobId) pollTimer = window.setTimeout(() => pollJob(jobId), 1800);
1260
+ }
1261
+ }
1262
+
1263
+ async function selectJob(jobId, { push = true, navigate = true } = {}) {
1264
+ if (push) currentJobDetailTab = "preview";
1265
+ currentJobId = jobId;
1266
+ workspace.dataset.selectedJob = jobId;
1267
+ if (navigate) showAppView("jobs", { focus: push });
1268
+ showJobPanel(jobId);
1269
+ try {
1270
+ const job = await fetchJob(jobId);
1271
+ if (!job) {
1272
+ document.querySelector("#job-message").textContent = "ジョブは見つかりません";
1273
+ showConnection("online", "ジョブは削除された可能性があります");
1274
+ return;
1275
+ }
1276
+ if (["queued", "running", "cancelling"].includes(job.state) && !activeJobId) {
1277
+ activeJobId = job.job_id;
1278
+ activeJob = job;
1279
+ }
1280
+ renderJob(job);
1281
+ if (push) window.history.pushState({ jobId, view: "jobs" }, "", viewUrl("jobs"));
1282
+ if (["queued", "running", "cancelling"].includes(job.state)) pollJob(jobId);
1283
+ } catch (error) {
1284
+ showConnection("offline", `通信できません: ${error.message}`);
1285
+ }
1286
+ }
1287
+
1288
+ async function deleteJob(jobId, button) {
1289
+ const count = button.dataset.fileCount || "0";
1290
+ const size = formatBytes(button.dataset.sizeBytes);
1291
+ if (!window.confirm(`${jobId} の${count}ファイル(${size})を削除します。元に戻せません。`)) return;
1292
+ button.disabled = true;
1293
+ try {
1294
+ const response = await fetch(`/jobs/${encodeURIComponent(jobId)}/delete`, { method: "POST", headers: { "X-CSRF-Token": csrfToken } });
1295
+ if (!response.ok) throw new Error(await responseError(response, "ジョブを削除できませんでした"));
1296
+ if (jobId === activeJobId || jobId === activeJob?.job_id) {
1297
+ activeJobId = undefined;
1298
+ activeJob = undefined;
1299
+ }
1300
+ if (jobId === currentJobId) {
1301
+ window.clearTimeout(pollTimer);
1302
+ currentJobId = undefined;
1303
+ displayedJob = undefined;
1304
+ workspace.dataset.selectedJob = "";
1305
+ workspace.classList.remove("has-job");
1306
+ document.querySelector("#job-panel").hidden = true;
1307
+ updateGlobalJob();
1308
+ window.history.pushState({ view: "jobs" }, "", "/?view=jobs");
1309
+ }
1310
+ await refreshHistory();
1311
+ } catch (error) {
1312
+ button.disabled = false;
1313
+ showFormError(error.message);
1314
+ }
1315
+ }
1316
+
1317
+ function updateHistorySelection() {
1318
+ const selected = [...selectedJobIds].map((id) => historyJobs.get(id)).filter(Boolean);
1319
+ const completed = selected.filter((job) => job.state === "completed");
1320
+ const final = selected.filter((job) => ["completed", "failed", "cancelled"].includes(job.state));
1321
+ const size = selected.reduce((sum, job) => sum + Number(job.size_bytes || 0), 0);
1322
+ document.querySelector("#history-selection-status").textContent = selected.length
1323
+ ? `${selected.length}件選択・${formatBytes(size)}` : (historyNotice || "未選択");
1324
+ document.querySelector("#compare-jobs").disabled = selected.length !== 2 || completed.length !== 2;
1325
+ document.querySelector("#delete-selected-jobs").disabled = selected.length === 0 || final.length !== selected.length;
1326
+ }
1327
+
1328
+ function renderHistory() {
1329
+ const list = document.querySelector("#job-list");
1330
+ list.replaceChildren();
1331
+ historyJobs.forEach((job) => {
1332
+ const item = document.createElement("li");
1333
+ item.dataset.state = job.state;
1334
+ if (["completed", "failed", "cancelled"].includes(job.state)) {
1335
+ const checkbox = document.createElement("input");
1336
+ checkbox.type = "checkbox";
1337
+ checkbox.className = "history-select";
1338
+ checkbox.dataset.jobId = job.job_id;
1339
+ checkbox.checked = selectedJobIds.has(job.job_id);
1340
+ checkbox.setAttribute("aria-label", `${job.input_name || job.job_id}を選択`);
1341
+ item.append(checkbox);
1342
+ }
1343
+ const link = document.createElement("a");
1344
+ link.href = `/?job=${encodeURIComponent(job.job_id)}&view=jobs`;
1345
+ link.dataset.jobId = job.job_id;
1346
+ const title = document.createElement("strong");
1347
+ title.textContent = job.input_name || job.job_id;
1348
+ const id = document.createElement("span");
1349
+ id.textContent = job.job_id;
1350
+ const details = document.createElement("small");
1351
+ const size = job.width && job.height ? ` · ${job.width} × ${job.height} mm` : "";
1352
+ const tiles = job.tile_count ? ` · ${job.tile_count}タイル` : "";
1353
+ const date = job.created_at ? ` · ${new Date(job.created_at).toLocaleString("ja-JP")}` : "";
1354
+ details.textContent = `${stateLabels[job.state] || job.state} · ${Math.round(Number(job.percent) || 0)}%${size}${tiles}${date}`;
1355
+ if (job.preview_url) {
1356
+ const thumbnail = document.createElement("img");
1357
+ thumbnail.className = "history-thumbnail";
1358
+ thumbnail.src = job.preview_url;
1359
+ thumbnail.alt = "";
1360
+ thumbnail.loading = "lazy";
1361
+ link.append(thumbnail);
1362
+ }
1363
+ link.append(title, id, details);
1364
+ item.append(link);
1365
+ if (["completed", "failed", "cancelled"].includes(job.state)) {
1366
+ const remove = document.createElement("button");
1367
+ remove.type = "button";
1368
+ remove.className = "history-delete";
1369
+ remove.textContent = "削除";
1370
+ remove.dataset.jobId = job.job_id;
1371
+ remove.dataset.fileCount = job.file_count || 0;
1372
+ remove.dataset.sizeBytes = job.size_bytes || 0;
1373
+ item.append(remove);
1374
+ }
1375
+ list.append(item);
1376
+ });
1377
+ document.querySelector("#history-empty").hidden = historyJobs.size > 0;
1378
+ document.querySelector("#history-empty").textContent = historyTotal ? "条件に一致するジョブはありません。" : "まだジョブはありません。";
1379
+ document.querySelector("#load-more-jobs").hidden = historyJobs.size >= historyTotal;
1380
+ updateHistorySelection();
1381
+ }
1382
+
1383
+ function historyRequestParameters() {
1384
+ return new URLSearchParams({
1385
+ limit: String(historyPageSize), offset: String(historyOffset),
1386
+ q: document.querySelector("#history-query").value,
1387
+ state: document.querySelector("#history-filter").value,
1388
+ date_from: document.querySelector("#history-date-from").value,
1389
+ date_to: document.querySelector("#history-date-to").value,
1390
+ sort: document.querySelector("#history-sort").value
1391
+ });
1392
+ }
1393
+
1394
+ async function refreshHistory({ append = false } = {}) {
1395
+ try {
1396
+ if (!append) historyOffset = 0;
1397
+ const response = await fetch(`/api/jobs?${historyRequestParameters()}`, { cache: "no-store" });
1398
+ if (!response.ok) return;
1399
+ const payload = await response.json();
1400
+ if (!append) historyJobs.clear();
1401
+ payload.jobs.forEach((job) => historyJobs.set(job.job_id, job));
1402
+ const active = payload.jobs.find((job) => ["queued", "running", "cancelling"].includes(job.state));
1403
+ if (active && !activeJobId) {
1404
+ activeJobId = active.job_id;
1405
+ activeJob = active;
1406
+ updateGlobalJob(active);
1407
+ pollJob(active.job_id);
1408
+ }
1409
+ historyTotal = payload.total;
1410
+ historyOffset = historyJobs.size;
1411
+ [...selectedJobIds].forEach((id) => {
1412
+ if (!historyJobs.has(id) && !append) selectedJobIds.delete(id);
1413
+ });
1414
+ renderHistory();
1415
+ } catch (_error) {
1416
+ // The selected job poll reports connection failures.
1417
+ }
1418
+ }
1419
+
1420
+ document.querySelector("#job-list")?.addEventListener("click", (event) => {
1421
+ const checkbox = event.target.closest(".history-select");
1422
+ if (checkbox) {
1423
+ historyNotice = undefined;
1424
+ if (checkbox.checked) selectedJobIds.add(checkbox.dataset.jobId);
1425
+ else selectedJobIds.delete(checkbox.dataset.jobId);
1426
+ updateHistorySelection();
1427
+ return;
1428
+ }
1429
+ const remove = event.target.closest(".history-delete");
1430
+ if (remove) {
1431
+ event.preventDefault();
1432
+ deleteJob(remove.dataset.jobId, remove);
1433
+ return;
1434
+ }
1435
+ const link = event.target.closest("a[data-job-id]");
1436
+ if (link) {
1437
+ event.preventDefault();
1438
+ selectJob(link.dataset.jobId);
1439
+ }
1440
+ });
1441
+ document.querySelectorAll("#history-filter, #history-date-from, #history-date-to, #history-sort").forEach((control) => {
1442
+ control.addEventListener("change", () => refreshHistory());
1443
+ });
1444
+ document.querySelector("#history-query")?.addEventListener("input", () => {
1445
+ window.clearTimeout(historySearchTimer);
1446
+ historySearchTimer = window.setTimeout(() => refreshHistory(), 250);
1447
+ });
1448
+ document.querySelector("#load-more-jobs")?.addEventListener("click", () => refreshHistory({ append: true }));
1449
+
1450
+ document.querySelector("#delete-selected-jobs")?.addEventListener("click", async () => {
1451
+ const jobs = [...selectedJobIds].map((id) => historyJobs.get(id)).filter(Boolean);
1452
+ const size = jobs.reduce((sum, job) => sum + Number(job.size_bytes || 0), 0);
1453
+ if (!window.confirm(`${jobs.length}件(${formatBytes(size)})を削除します。元に戻せません。\n\n${jobs.map((job) => job.job_id).join("\n")}`)) return;
1454
+ const values = new URLSearchParams({ csrf_token: csrfToken });
1455
+ jobs.forEach((job) => values.append("job_ids[]", job.job_id));
1456
+ try {
1457
+ const response = await fetch("/api/jobs/delete", {
1458
+ method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" }, body: values
1459
+ });
1460
+ if (!response.ok) throw new Error(await responseError(response, "複数削除に失敗しました"));
1461
+ const payload = await response.json();
1462
+ payload.deleted.forEach((entry) => selectedJobIds.delete(entry.job_id));
1463
+ historyNotice = payload.errors.length
1464
+ ? `${payload.deleted.length}件削除、未削除: ${payload.errors.map((entry) => `${entry.job_id}(${entry.error})`).join("、")}`
1465
+ : `${payload.deleted.length}件を削除しました`;
1466
+ if (payload.deleted.some((entry) => entry.job_id === currentJobId)) {
1467
+ currentJobId = undefined;
1468
+ displayedJob = undefined;
1469
+ workspace.classList.remove("has-job");
1470
+ document.querySelector("#job-panel").hidden = true;
1471
+ updateGlobalJob();
1472
+ window.history.pushState({ view: "jobs" }, "", "/?view=jobs");
1473
+ }
1474
+ if (payload.deleted.some((entry) => entry.job_id === activeJobId || entry.job_id === activeJob?.job_id)) {
1475
+ activeJobId = undefined;
1476
+ activeJob = undefined;
1477
+ updateGlobalJob();
1478
+ }
1479
+ await refreshHistory();
1480
+ } catch (error) { showFormError(error.message); }
1481
+ });
1482
+
1483
+ function compareArtifact(job, name) {
1484
+ return job?.artifacts?.find((artifact) => artifact.name === name);
1485
+ }
1486
+
1487
+ function renderComparedImages() {
1488
+ const kind = document.querySelector("#job-compare-kind").value;
1489
+ comparedJobs.forEach((job, index) => {
1490
+ const side = index === 0 ? "a" : "b";
1491
+ const image = document.querySelector(`#compare-${side}-image`);
1492
+ const missing = document.querySelector(`#compare-${side}-missing`);
1493
+ const artifact = compareArtifact(job, kind);
1494
+ image.hidden = !artifact;
1495
+ missing.hidden = Boolean(artifact);
1496
+ image.onerror = () => {
1497
+ image.hidden = true;
1498
+ missing.hidden = false;
1499
+ };
1500
+ if (artifact) image.src = `${artifact.url}?compare=${encodeURIComponent(job.job_id)}`;
1501
+ else image.removeAttribute("src");
1502
+ });
1503
+ resetViewer("job-compare-preview");
1504
+ }
1505
+
1506
+ function renderJobComparison() {
1507
+ const kinds = new Set();
1508
+ comparedJobs.forEach((job) => job?.artifacts?.forEach((artifact) => {
1509
+ if (previewLabels[artifact.name]) kinds.add(artifact.name);
1510
+ }));
1511
+ const kindSelect = document.querySelector("#job-compare-kind");
1512
+ kindSelect.replaceChildren();
1513
+ previewKinds.filter(({ name }) => kinds.has(name)).forEach(({ name, label }) => {
1514
+ const availability = comparedJobs.map((job) => Boolean(compareArtifact(job, name)));
1515
+ const suffix = availability.every(Boolean) ? "" : `(${availability[0] ? "Aのみ" : "Bのみ"})`;
1516
+ kindSelect.add(new Option(`${label}${suffix}`, name));
1517
+ });
1518
+ comparedJobs.forEach((job, index) => {
1519
+ const side = index === 0 ? "a" : "b";
1520
+ document.querySelector(`#compare-${side}-name`).textContent = job
1521
+ ? (job.input_name || "入力名の記録なし") : "削除されたジョブ";
1522
+ document.querySelector(`#compare-${side}-meta`).textContent = job
1523
+ ? `${job.job_id} · ${job.created_at ? new Date(job.created_at).toLocaleString("ja-JP") : "日時なし"} · ${job.width || "—"} × ${job.height || "—"} mm`
1524
+ : "取得できません";
1525
+ document.querySelector(`#compare-${side}-use`).disabled = !job;
1526
+ const preview = document.querySelector(`#compare-${side}-preview`);
1527
+ if (job?.width && job?.height) preview.style.aspectRatio = `${job.width} / ${job.height}`;
1528
+ });
1529
+ const differences = document.querySelector("#job-compare-differences");
1530
+ differences.replaceChildren();
1531
+ Object.entries(settingLabels).forEach(([key, label]) => {
1532
+ const left = comparedJobs[0]?.[key];
1533
+ const right = comparedJobs[1]?.[key];
1534
+ const normalizedLeft = typeof left === "boolean" ? (left ? "有効" : "無効") : String(left ?? "自動");
1535
+ const normalizedRight = typeof right === "boolean" ? (right ? "有効" : "無効") : String(right ?? "自動");
1536
+ if (normalizedLeft === normalizedRight) return;
1537
+ const row = document.createElement("tr");
1538
+ [label, normalizedLeft, normalizedRight].forEach((value) => {
1539
+ const cell = document.createElement("td");
1540
+ cell.textContent = value;
1541
+ row.append(cell);
1542
+ });
1543
+ differences.append(row);
1544
+ });
1545
+ if (!differences.childElementCount) {
1546
+ const row = document.createElement("tr");
1547
+ const cell = document.createElement("td");
1548
+ cell.colSpan = 3;
1549
+ cell.textContent = "仕上がりに関係する設定差はありません。";
1550
+ row.append(cell);
1551
+ differences.append(row);
1552
+ }
1553
+ const missing = comparedJobs.filter((job) => !job).length;
1554
+ const status = document.querySelector("#job-compare-status");
1555
+ status.hidden = missing === 0;
1556
+ status.textContent = missing ? "比較対象の一方が削除されたため、その側の画像と設定は表示しません。" : "";
1557
+ if (kindSelect.options.length) renderComparedImages();
1558
+ }
1559
+
1560
+ document.querySelector("#compare-jobs")?.addEventListener("click", async () => {
1561
+ const ids = [...selectedJobIds].filter((id) => historyJobs.get(id)?.state === "completed");
1562
+ if (ids.length !== 2) return;
1563
+ comparedJobs = await Promise.all(ids.map((id) => fetchJob(id).catch(() => null)));
1564
+ renderJobComparison();
1565
+ document.querySelector("#job-compare-dialog").showModal();
1566
+ });
1567
+ document.querySelector("#job-compare-kind")?.addEventListener("change", renderComparedImages);
1568
+ document.querySelector("#close-job-compare")?.addEventListener("click", () => document.querySelector("#job-compare-dialog").close());
1569
+ ["a", "b"].forEach((side, index) => {
1570
+ document.querySelector(`#compare-${side}-use`)?.addEventListener("click", () => {
1571
+ const job = comparedJobs[index];
1572
+ if (job && applySettings(job, `ジョブ ${job.job_id} の設定`)) {
1573
+ document.querySelector("#job-compare-dialog").close();
1574
+ document.querySelector("#conversion-form").scrollIntoView({ block: "start" });
1575
+ }
1576
+ });
1577
+ });
1578
+
1579
+ async function cancelJob(jobId) {
1580
+ if (!jobId) return;
1581
+ const detailButton = jobId === currentJobId ? document.querySelector("#cancel-button") : undefined;
1582
+ const globalButton = jobId === activeJobId ? document.querySelector("#global-cancel-button") : undefined;
1583
+ [detailButton, globalButton].filter(Boolean).forEach((button) => {
1584
+ button.disabled = true;
1585
+ button.textContent = "停止要求中…";
1586
+ });
1587
+ try {
1588
+ const response = await fetch(`/jobs/${encodeURIComponent(jobId)}/cancel`, { method: "POST", headers: { "X-CSRF-Token": csrfToken } });
1589
+ if (!response.ok) throw new Error(await responseError(response, "停止要求に失敗しました"));
1590
+ } catch (error) {
1591
+ [detailButton, globalButton].filter(Boolean).forEach((button) => {
1592
+ button.disabled = false;
1593
+ button.textContent = "停止を再試行";
1594
+ });
1595
+ if (jobId === currentJobId) document.querySelector("#job-message").textContent = error.message;
1596
+ if (jobId === activeJobId) document.querySelector("#global-job-label").textContent = `停止要求に失敗: ${error.message}`;
1597
+ }
1598
+ }
1599
+ document.querySelector("#cancel-button")?.addEventListener("click", () => {
1600
+ cancelJob(currentJobId);
1601
+ });
1602
+ document.querySelector("#global-cancel-button")?.addEventListener("click", () => {
1603
+ cancelJob(activeJobId || currentJobId);
1604
+ });
1605
+ document.querySelector("#global-job-link")?.addEventListener("click", () => {
1606
+ workspace.classList.remove("show-job-list");
1607
+ const jobId = activeJobId || activeJob?.job_id || currentJobId;
1608
+ if (jobId) selectJob(jobId);
1609
+ else showAppView("jobs", { push: currentView !== "jobs", focus: true });
1610
+ });
1611
+ document.querySelector("#back-to-job-list")?.addEventListener("click", () => {
1612
+ workspace.classList.add("show-job-list");
1613
+ document.querySelector("#history-query")?.focus({ preventScroll: true });
1614
+ });
1615
+ document.querySelector("#delete-button")?.addEventListener("click", () => {
1616
+ if (currentJobId) deleteJob(currentJobId, document.querySelector("#delete-button"));
1617
+ });
1618
+ document.querySelector("#choose-another-image")?.addEventListener("click", () => {
1619
+ showAppView("create", { push: currentView !== "create" });
1620
+ filePicker.scrollIntoView({ block: "center" });
1621
+ filePicker.focus();
1622
+ });
1623
+ document.querySelector("#review-settings")?.addEventListener("click", () => {
1624
+ showAppView("create", { push: currentView !== "create" });
1625
+ const settings = document.querySelector(".settings-section");
1626
+ settings.open = true;
1627
+ settings.scrollIntoView({ block: "start" });
1628
+ document.querySelector("#width")?.focus();
1629
+ });
1630
+ document.querySelector("#retry-status-button")?.addEventListener("click", () => {
1631
+ if (!currentJobId) return;
1632
+ showConnection("loading", "状態を再取得しています");
1633
+ pollJob(currentJobId);
1634
+ });
1635
+ window.addEventListener("popstate", () => {
1636
+ const parameters = new URL(window.location.href).searchParams;
1637
+ const jobId = parameters.get("job");
1638
+ const view = requestedView();
1639
+ currentJobDetailTab = requestedJobTab();
1640
+ showAppView(view, { focus: true });
1641
+ if (jobId && jobId !== currentJobId) selectJob(jobId, { push: false });
1642
+ else if (jobId) selectJobDetailTab(currentJobDetailTab);
1643
+ else if (!jobId) {
1644
+ currentJobId = undefined;
1645
+ displayedJob = undefined;
1646
+ workspace.classList.remove("has-job");
1647
+ document.querySelector("#job-panel").hidden = true;
1648
+ updateGlobalJob();
1649
+ }
1650
+ });
1651
+
1652
+ const viewerStates = new Map();
1653
+ function viewerState(id) {
1654
+ if (!viewerStates.has(id)) viewerStates.set(id, { scale: 1, x: 0, y: 0, actual: false });
1655
+ return viewerStates.get(id);
1656
+ }
1657
+
1658
+ function viewerViewports(id) {
1659
+ const viewport = document.getElementById(id);
1660
+ const grouped = [...document.querySelectorAll(`[data-viewer-group="${id}"]`)]
1661
+ .filter((candidate) => !candidate.closest("figure")?.hidden);
1662
+ return [...new Set([...(viewport ? [viewport] : []), ...grouped])];
1663
+ }
1664
+
1665
+ function viewerImages(id) {
1666
+ return viewerViewports(id).flatMap((viewport) => [...viewport.querySelectorAll("img")]);
1667
+ }
1668
+
1669
+ function viewerPanBounds(id) {
1670
+ const viewport = viewerViewports(id)[0];
1671
+ const state = viewerState(id);
1672
+ const image = viewport?.querySelector("img:not(.tile-overlay)");
1673
+ if (!viewport || !image) return { x: 0, y: 0 };
1674
+ const width = state.actual ? image.naturalWidth : viewport.clientWidth;
1675
+ const height = state.actual ? image.naturalHeight : viewport.clientHeight;
1676
+ return {
1677
+ x: Math.max(0, ((width * state.scale) - viewport.clientWidth) / 2),
1678
+ y: Math.max(0, ((height * state.scale) - viewport.clientHeight) / 2)
1679
+ };
1680
+ }
1681
+
1682
+ function constrainViewerPan(id) {
1683
+ const state = viewerState(id);
1684
+ const bounds = viewerPanBounds(id);
1685
+ state.x = Math.max(-bounds.x, Math.min(bounds.x, state.x));
1686
+ state.y = Math.max(-bounds.y, Math.min(bounds.y, state.y));
1687
+ const pannable = bounds.x > 0 || bounds.y > 0;
1688
+ viewerViewports(id).forEach((viewport) => viewport.classList.toggle("is-pannable", pannable));
1689
+ const hint = document.querySelector(`[data-view-hint="${id}"]`);
1690
+ if (hint) hint.textContent = pannable
1691
+ ? "ドラッグまたは矢印キーで表示位置を移動できます。"
1692
+ : "拡大すると表示位置を移動できます。";
1693
+ }
1694
+
1695
+ function applyViewer(id) {
1696
+ const state = viewerState(id);
1697
+ constrainViewerPan(id);
1698
+ const images = viewerImages(id);
1699
+ const reference = images.find((image) => !image.classList.contains("tile-overlay"));
1700
+ images.forEach((image) => {
1701
+ const naturalWidth = image.classList.contains("tile-overlay") ? reference?.naturalWidth : image.naturalWidth;
1702
+ const naturalHeight = image.classList.contains("tile-overlay") ? reference?.naturalHeight : image.naturalHeight;
1703
+ image.style.width = state.actual ? `${naturalWidth}px` : "100%";
1704
+ image.style.height = state.actual ? `${naturalHeight}px` : "100%";
1705
+ image.style.transform = `translate(${state.x}px, ${state.y}px) scale(${state.scale})`;
1706
+ });
1707
+ const scale = document.querySelector(`[data-viewer="${id}"] [data-view-scale]`);
1708
+ if (scale) scale.textContent = state.actual ? "100%" : (state.scale !== 1 ? `${Math.round(state.scale * 100)}%` : "全体");
1709
+ }
1710
+
1711
+ function resetViewer(id) {
1712
+ viewerStates.set(id, { scale: 1, x: 0, y: 0, actual: false });
1713
+ applyViewer(id);
1714
+ }
1715
+
1716
+ document.querySelectorAll(".preview-toolbar").forEach((toolbar) => {
1717
+ const id = toolbar.dataset.viewer;
1718
+ toolbar.addEventListener("click", (event) => {
1719
+ const action = event.target.dataset.viewAction;
1720
+ if (!action) return;
1721
+ const state = viewerState(id);
1722
+ if (action === "in") state.scale = Math.min(4, state.scale + 0.25);
1723
+ if (action === "out") state.scale = Math.max(0.5, state.scale - 0.25);
1724
+ if (action === "actual") Object.assign(state, { scale: 1, x: 0, y: 0, actual: true });
1725
+ if (action === "reset") resetViewer(id);
1726
+ else applyViewer(id);
1727
+ });
1728
+ toolbar.querySelector("[data-view-background]")?.addEventListener("change", (event) => {
1729
+ viewerViewports(id).forEach((viewport) => { viewport.dataset.background = event.target.value; });
1730
+ });
1731
+ });
1732
+
1733
+ document.querySelectorAll(".preview-viewport").forEach((viewport) => {
1734
+ let drag;
1735
+ viewport.addEventListener("pointerdown", (event) => {
1736
+ const viewerId = viewport.dataset.viewerGroup || viewport.id;
1737
+ const bounds = viewerPanBounds(viewerId);
1738
+ if ((!bounds.x && !bounds.y) || (event.pointerType === "mouse" && event.button !== 0)) return;
1739
+ event.preventDefault();
1740
+ drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
1741
+ viewport.setPointerCapture(event.pointerId);
1742
+ viewport.classList.add("is-panning");
1743
+ });
1744
+ viewport.addEventListener("pointermove", (event) => {
1745
+ if (!drag || drag.pointerId !== event.pointerId) return;
1746
+ event.preventDefault();
1747
+ const viewerId = viewport.dataset.viewerGroup || viewport.id;
1748
+ const state = viewerState(viewerId);
1749
+ state.x += event.clientX - drag.x;
1750
+ state.y += event.clientY - drag.y;
1751
+ drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
1752
+ applyViewer(viewerId);
1753
+ });
1754
+ const stop = (event) => {
1755
+ if (drag && event.pointerId !== drag.pointerId) return;
1756
+ drag = undefined;
1757
+ viewport.classList.remove("is-panning");
1758
+ };
1759
+ viewport.addEventListener("pointerup", stop);
1760
+ viewport.addEventListener("pointercancel", stop);
1761
+ viewport.addEventListener("lostpointercapture", stop);
1762
+ });
1763
+ document.querySelectorAll(".preview-viewport img, #compare-source-image").forEach((image) => {
1764
+ image.addEventListener("load", () => {
1765
+ const viewport = image.closest(".preview-viewport");
1766
+ applyViewer(viewport?.dataset.viewerGroup || viewport?.id || "result-preview");
1767
+ });
1768
+ });
1769
+ window.addEventListener("resize", () => viewerStates.forEach((_state, id) => applyViewer(id)));
1770
+
1771
+ function openPreviewDialog(viewerId, trigger) {
1772
+ const dialog = document.querySelector("#preview-dialog");
1773
+ const sourceFigure = document.querySelector("#dialog-source-figure");
1774
+ const resultFigure = document.querySelector("#dialog-result-figure");
1775
+ const dialogSource = document.querySelector("#dialog-source-image");
1776
+ const dialogResult = document.querySelector("#dialog-result-image");
1777
+ const legend = document.querySelector("#dialog-legend");
1778
+ dialogReturnFocus = trigger;
1779
+ if (viewerId === "source-preview") {
1780
+ document.querySelector("#preview-dialog-title").textContent = "編集中の入力を大きく表示";
1781
+ sourceFigure.hidden = false;
1782
+ resultFigure.hidden = true;
1783
+ document.querySelector("#dialog-preview-grid").classList.add("is-single");
1784
+ dialogSource.src = sourceImage.src;
1785
+ document.querySelector("#dialog-source-caption").textContent = fileInput.files?.[0]?.name || "編集中の入力";
1786
+ document.querySelector("#dialog-source-preview").style.aspectRatio = document.querySelector("#source-preview").style.aspectRatio;
1787
+ legend.hidden = true;
1788
+ } else {
1789
+ document.querySelector("#preview-dialog-title").textContent = "変換結果を大きく比較";
1790
+ sourceFigure.hidden = !compareSourceImage.getAttribute("src");
1791
+ resultFigure.hidden = false;
1792
+ document.querySelector("#dialog-preview-grid").classList.toggle("is-single", sourceFigure.hidden);
1793
+ dialogSource.src = compareSourceImage.src;
1794
+ dialogResult.src = document.querySelector("#result-image").src;
1795
+ document.querySelector("#dialog-source-caption").textContent = "変換時の補正後入力";
1796
+ document.querySelector("#dialog-result-caption").textContent = document.querySelector("#result-caption").textContent;
1797
+ const ratio = document.querySelector("#result-preview").style.aspectRatio;
1798
+ document.querySelector("#dialog-source-preview").style.aspectRatio = ratio;
1799
+ document.querySelector("#dialog-result-preview").style.aspectRatio = ratio;
1800
+ legend.textContent = document.querySelector("#result-legend").textContent;
1801
+ legend.hidden = false;
1802
+ }
1803
+ dialog.showModal();
1804
+ window.requestAnimationFrame(() => resetViewer("dialog-preview"));
1805
+ }
1806
+
1807
+ document.querySelectorAll("[data-expand-viewer]").forEach((button) => {
1808
+ button.addEventListener("click", () => openPreviewDialog(button.dataset.expandViewer, button));
1809
+ });
1810
+ document.querySelector("#close-preview-dialog")?.addEventListener("click", () => document.querySelector("#preview-dialog").close());
1811
+ document.querySelector("#preview-dialog")?.addEventListener("close", () => dialogReturnFocus?.focus());
1812
+ document.querySelector("#preview-dialog")?.addEventListener("click", (event) => {
1813
+ if (event.target === event.currentTarget) event.currentTarget.close();
1814
+ });
1815
+ document.querySelector("#preview-dialog")?.addEventListener("keydown", (event) => {
1816
+ if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "-", "0"].includes(event.key)) return;
1817
+ if (["INPUT", "SELECT"].includes(event.target.tagName)) return;
1818
+ event.preventDefault();
1819
+ const state = viewerState("dialog-preview");
1820
+ if (event.key === "ArrowLeft") state.x -= 30;
1821
+ if (event.key === "ArrowRight") state.x += 30;
1822
+ if (event.key === "ArrowUp") state.y -= 30;
1823
+ if (event.key === "ArrowDown") state.y += 30;
1824
+ if (event.key === "+") state.scale = Math.min(4, state.scale + 0.25);
1825
+ if (event.key === "-") state.scale = Math.max(0.5, state.scale - 0.25);
1826
+ if (event.key === "0") return resetViewer("dialog-preview");
1827
+ applyViewer("dialog-preview");
1828
+ });
1829
+
1830
+ showAppView(currentView);
1831
+ if (currentJobId) {
1832
+ workspace.classList.add("has-job");
1833
+ selectJob(currentJobId, { push: false, navigate: currentView === "jobs" });
1834
+ }
1835
+ loadSettingsLibrary();
1836
+ refreshHistory();
1837
+ window.setInterval(() => {
1838
+ if (historyJobs.size <= historyPageSize) refreshHistory();
1839
+ }, 4000);
1840
+
1841
+ window.addEventListener("beforeunload", () => {
1842
+ if (previewId) {
1843
+ navigator.sendBeacon(`/api/previews/${encodeURIComponent(previewId)}/discard`, new URLSearchParams({ csrf_token: csrfToken }));
1844
+ }
1845
+ if (sourceUrl) URL.revokeObjectURL(sourceUrl);
1846
+ if (tileOverlayUrl) URL.revokeObjectURL(tileOverlayUrl);
1847
+ });