Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ python -m http.server

- **コード**:**MIT License**([`LICENSE`](./LICENSE) を参照)
- **メディア(デモ用画像・動画/GIF)**:**CC BY-SA 4.0**
詳細は [`CREDITS.md`](./CREDITS.md) および [`docs/assets/licenses/CC-BY-SA-4.0.txt`](./docs/assets/licenses/CC-BY-SA-4.0.txt)を参照
詳細は [`CREDITS.md`](./CREDITS.md) および [`docs/assets/licenses/LICENSE-CC-BY-SA-4.0.txt`](./docs/assets/licenses/LICENSE-CC-BY-SA-4.0.txt)を参照

### メディア配置
- 画像(デモ用画像):`web/assets/images/`
Expand All @@ -88,3 +88,6 @@ python -m http.server

- **Box Fitting — Jared Tarbell**
http://www.complexification.net/gallery/machines/boxFitting

## 免責事項
本アプリの利用に関連して発生した損害について、開発者は一切の責任を負いません。
30 changes: 28 additions & 2 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ <h3 id="settings-heading" class="section-title mb-3">操作</h3>
<i class="bi bi-1-circle fs-5 text-primary" aria-hidden="true"></i>
<h4 class="mb-1">画像選択</h4>
</div>
<div id="notice" class="d-none" role="status" aria-live="polite" aria-atomic="true"></div>
<div class="d-flex flex-column">
<label for="fileInput" class="form-label mb-1 small text-muted">ファイル</label>
<input type="file" id="fileInput" class="form-control form-control-sm" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml" aria-describedby="fileHelp">
Expand Down Expand Up @@ -84,7 +85,7 @@ <h4 class="mb-1">アルゴリズム選択</h4>
</div>
<div>
<label for="numRectangles" class="form-label mb-1 small text-muted">長方形の数</label>
<input type="number" id="numRectangles" class="form-control form-control-sm" value="1000" min="1" step="1" inputmode="numeric" aria-describedby="rectHelp" autocomplete="off">
<input type="number" id="numRectangles" class="form-control form-control-sm" value="500" min="1" max="5000" step="1" inputmode="numeric" aria-describedby="rectHelp" autocomplete="off">
</div>
</div>
</fieldset>
Expand Down Expand Up @@ -161,9 +162,34 @@ <h3 id="preview-heading" class="section-title mb-0">再生・保存</h3>
</main>

<footer class="container-xxl my-5 text-center text-muted small">
<span>© 2025 Rectangle Fitting by <a href="https://github.com/Shimo-1999" target="_blank" rel="noopener">Shimo-1999</a></span>
<span>© 2025 Rectangle Fitting by
<a href="https://github.com/Shimo-1999" target="_blank" rel="noopener">Shimo-1999</a>
</span>
·
<a href="#" data-bs-toggle="modal" data-bs-target="#tosModal">注意 / 免責事項</a>
</footer>

<div class="modal fade" id="tosModal" tabindex="-1" aria-labelledby="tosLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 id="tosLabel" class="modal-title">注意 / 免責事項</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="閉じる"></button>
</div>
<div class="modal-body small text-start">
<p><strong>注意</strong><br>
画像サイズや解像度が大きい場合、ブラウザや端末のメモリ使用量が増加し、フリーズやクラッシュが発生することがあります。作業中のデータは事前に保存し、十分な電源と余裕のある環境でご利用ください。</p>
<p><strong>免責事項</strong><br>
本アプリの利用に関連して発生した損害について、開発者は一切の責任を負いません。</p>
</div>
<div class="modal-footer">
<button class="btn btn-primary" data-bs-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.bundle.min.js"></script>
<script src="./vendor/gif.js"></script>
<script src="./js/script.js" type="module"></script>
<script type="module">
Expand Down
119 changes: 116 additions & 3 deletions web/js/script.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import init, { Store } from "./../wasm/pkg/wasm.js";

// ---------- limits & notice helpers ----------
const LIMITS = {
IMG_MAX_BYTES: 15 * 1024 * 1024, // 15MB
RECT_MAX_INPUT: 5000,
IMG_MAX_PIXELS: 20_000_000,
IMG_MAX_SIDE: 8192,
};

// ---------- module-scope state ----------
const state = {
store: null, // wasm Store
Expand All @@ -16,6 +24,7 @@ window.addEventListener("DOMContentLoaded", async () => {

// cache DOM
els = {
notice: document.getElementById("notice"),
fileInput: document.getElementById("fileInput"),
algo: document.getElementById("algorithm"),
numRects: document.getElementById("numRectangles"),
Expand All @@ -36,6 +45,12 @@ window.addEventListener("DOMContentLoaded", async () => {
speed: document.getElementById("speed"),
};

els.numRects?.setAttribute("max", String(LIMITS.RECT_MAX_INPUT));
els.numRects?.setAttribute("min", "1");

els.numRects?.addEventListener("input", clampRectCount);
els.numRects?.addEventListener("wheel", (e) => e.preventDefault(), { passive: false });

// events
els.fileInput.addEventListener("change", onFileChange);
els.runBtn?.addEventListener("click", onRunClick);
Expand All @@ -56,6 +71,52 @@ window.addEventListener("DOMContentLoaded", async () => {
requestAnimationFrame(loop);
});

function hasActiveWarn() {
return !!els.notice?.querySelector(".alert");
}

function syncRunButtonWithWarn() {
if (els.runBtn) els.runBtn.disabled = hasActiveWarn();
}

function showWarn(msg, variant = "warning") {
const box = els.notice; if (!box) return;
box.classList.remove("d-none");
box.innerHTML = "";
box.insertAdjacentHTML("beforeend", `
<div class="alert alert-${variant} alert-dismissible fade show" role="alert">
<div>${msg}</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="閉じる"></button>
</div>
`);
syncRunButtonWithWarn();

const alertEl = box.querySelector(".alert:last-child");
alertEl?.addEventListener("closed.bs.alert", () => {
if (!hasActiveWarn()) box.classList.add("d-none");
syncRunButtonWithWarn();
});
}

function clearWarn() {
const box = els.notice; if (!box) return;
box.innerHTML = "";
box.classList.add("d-none");
syncRunButtonWithWarn();
}

// ---------- clamp helper ----------
function clampRectCount() {
if (!els.numRects) return;
const max = LIMITS.RECT_MAX_INPUT;
const min = 1;
let v = parseInt(els.numRects.value, 10);
if (!Number.isFinite(v)) v = min;
if (v < min) v = min;
if (v > max) v = max;
els.numRects.value = String(v);
}

// ---------- UI toggles ----------
function setResultReady(on) {
state.hasResult = on;
Expand Down Expand Up @@ -88,8 +149,62 @@ async function onFileChange() {
resetControls();
clearStage();
state.store.clear();

const file = els.fileInput.files?.[0];
if (!file) {
clearWarn();
return;
}

if (file.size > LIMITS.IMG_MAX_BYTES) {
els.fileInput.value = "";
showWarn(
`ファイルが大きすぎます(${(file.size/1024/1024).toFixed(1)}MB)。` +
`上限は ${(LIMITS.IMG_MAX_BYTES/1024/1024)}MB です。`,
"warning"
);
return;
}

try {
const bmp = await createImageBitmap(file);
const w = bmp.width, h = bmp.height;
bmp.close?.();

if (Number.isFinite(LIMITS.IMG_MAX_PIXELS)) {
const px = w * h;
if (px > LIMITS.IMG_MAX_PIXELS) {
els.fileInput.value = "";
showWarn(
`解像度が大きすぎます(${w}×${h} ≈ ${(px/1e6).toFixed(1)}MP)。` +
`上限は ${(LIMITS.IMG_MAX_PIXELS/1e6)}MP です。`,
"warning"
);
return;
}
}
if (Number.isFinite(LIMITS.IMG_MAX_SIDE)) {
const sideMax = LIMITS.IMG_MAX_SIDE;
if (Math.max(w, h) > sideMax) {
els.fileInput.value = "";
showWarn(
`一辺が大きすぎます(${w}×${h})。` +
`上限は ${sideMax}px です。`,
"warning"
);
return;
}
}
} catch (e) {
els.fileInput.value = "";
showWarn("画像の読み込みに失敗しました。別のファイルをお試しください。", "danger");
return;
}

clearWarn();
}


async function fetchDefaultAsFile() {
const resp = await fetch("./assets/images/Parrot.jpg");
const blob = await resp.blob();
Expand All @@ -115,7 +230,7 @@ async function runPipeline(file) {
const { width, height, rgba } = await decodeToRGBA(file);
state.srcSize = { width, height };

const numRects = Number(els.numRects?.value || 1000);
const numRects = Number(els.numRects?.value || 500);
const algoId = Number(els.algo?.value || 1);

const rgbaView = new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength);
Expand Down Expand Up @@ -221,7 +336,6 @@ function loop(now) {
}

// ========== Save (PNG / GIF) ==========

async function onSavePng() {
if (!state.store || !state.hasResult) return;
try {
Expand Down Expand Up @@ -351,7 +465,6 @@ async function drawSvgOntoCanvas(svgString, canvas, { background = null } = {})
}
}


function parseSvgSize(svgString) {
const m = svgString.match(/viewBox\s*=\s*["']\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*["']/i);
if (m) return { width: Number(m[1]), height: Number(m[2]) };
Expand Down