diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-19 10:23:59 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-19 10:23:59 +0900 |
| commit | 2f4f3817adaa9722dabe8448105b83d6ce566c8a (patch) | |
| tree | 2fc06032d793106c23c71e70e51aee25c4665fb7 /src/components | |
| parent | ef71d78c5a30557c29577deecb669742c09578d0 (diff) | |
parks: 表をPNG画像で保存できるボタンを追加
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/ParksTable.astro | 222 |
1 files changed, 222 insertions, 0 deletions
diff --git a/src/components/ParksTable.astro b/src/components/ParksTable.astro index 6f8fdbd..e5def82 100644 --- a/src/components/ParksTable.astro +++ b/src/components/ParksTable.astro @@ -204,9 +204,11 @@ const total = parks.length; <label><input type="checkbox" data-fac-filter value="ball" />ボール遊び</label> <label><input type="checkbox" data-fac-filter value="cm" />指定管理</label> </fieldset> + <button type="button" class="parks-save" data-parks-save-image>画像で保存</button> <span class="parks-count" data-parks-count aria-live="polite">{total}件</span> </div> <p class="parks-hint">設備の情報は、市の資料などに基づきます。設備で絞ると、この情報がある公園だけが対象になります。</p> + <p class="parks-save-status" data-parks-save-status aria-live="polite"></p> <p class="parks-none" data-parks-none hidden>条件に合う公園が見つかりませんでした。</p> <div class="parks-scroll"> @@ -345,6 +347,85 @@ const total = parks.length; font-size: 0.9rem; opacity: 0.75; } + .parks-save { + padding: 0.35rem 0.7rem; + border: 1px solid var(--sl-color-hairline, #ccc); + border-radius: 0.35rem; + background: var(--sl-color-bg, #fff); + color: var(--sl-color-text, inherit); + font: inherit; + font-size: 0.85rem; + cursor: pointer; + } + .parks-save:hover { + border-color: var(--sl-color-text-accent, #4c8); + } + .parks-save:disabled { + opacity: 0.6; + cursor: wait; + } + .parks-save-status { + margin: 0 0 0.6rem; + font-size: 0.8rem; + opacity: 0.8; + } + .parks-save-status:empty { + display: none; + } + /* 画像保存用のオフスクリーン領域。サイトの表スタイルをそのまま使う。 */ + .parks-capture { + position: fixed; + left: 0; + top: 0; + z-index: -1; + pointer-events: none; + width: max-content; + padding: 24px 28px; + background: #fff; + color: #1a1a1a; + color-scheme: light; + --sl-color-text: #1a1a1a; + --sl-color-bg: #ffffff; + --sl-color-hairline: #dddddd; + --sl-color-gray-4: #888888; + --sl-color-gray-6: #eef1f3; + --sl-color-white: #1a1a1a; + --sl-color-text-accent: #0b7a4b; + } + .parks-capture-head { + display: flex; + align-items: baseline; + gap: 0.8rem; + margin-bottom: 0.2rem; + } + .parks-capture-head strong { + font-size: 1.35rem; + } + .parks-capture-head span { + font-size: 0.9rem; + opacity: 0.75; + } + .parks-capture-meta { + margin-bottom: 0.7rem; + font-size: 0.8rem; + opacity: 0.75; + } + .parks-capture table { + width: max-content; + } + .parks-capture thead th { + position: static; + background: #eef1f3; + color: #1a1a1a; + } + .parks-capture tbody td { + color: #1a1a1a; + } + .parks-capture-foot { + margin-top: 0.7rem; + font-size: 0.72rem; + opacity: 0.65; + } .parks-hint, .parks-none { margin: 0 0 0.6rem; @@ -593,6 +674,8 @@ const total = parks.length; </style> <script> + import { toPng } from "html-to-image"; + document.querySelectorAll("[data-parks]").forEach((root) => { const select = root.querySelector<HTMLSelectElement>("[data-parks-town]"); const search = root.querySelector<HTMLInputElement>("[data-parks-search]"); @@ -613,6 +696,145 @@ const total = parks.length; const allRows = () => Array.from(tbody.querySelectorAll<HTMLTableRowElement>("tr")); const total = allRows().length; + const saveButton = root.querySelector<HTMLButtonElement>("[data-parks-save-image]"); + const saveStatus = root.querySelector<HTMLElement>("[data-parks-save-status]"); + const MAX_IMAGE_ROWS = 30; + // AstroのスコープCSSを動的生成要素にも当てる(ダークテーマの色を上書きするため)。 + const scopeClass = Array.from(root.classList).find((cls) => cls.startsWith("astro-")); + const withScope = <T extends HTMLElement>(el: T): T => { + if (scopeClass) el.classList.add(scopeClass); + return el; + }; + + const saveTableImage = async () => { + if (!saveButton) return; + const visible = allRows().filter((row) => !row.classList.contains("is-hidden")); + if (!visible.length) { + if (saveStatus) saveStatus.textContent = "表示中の公園がありません。"; + return; + } + const table = root.querySelector("table"); + if (!table) return; + + saveButton.disabled = true; + if (saveStatus) saveStatus.textContent = "画像を作成中…"; + let wrap: HTMLDivElement | null = null; + try { + const limited = visible.slice(0, MAX_IMAGE_ROWS); + const clone = table.cloneNode(true) as HTMLTableElement; + const cloneBody = clone.querySelector("tbody"); + if (cloneBody) { + cloneBody.innerHTML = ""; + limited.forEach((row) => cloneBody.appendChild(row.cloneNode(true))); + } + + // ボタン・リンクは画像内では文字だけにする。 + clone.querySelectorAll("button").forEach((btn) => { + const span = document.createElement("span"); + span.textContent = (btn.textContent || "").trim(); + btn.replaceWith(span); + }); + clone.querySelectorAll("a").forEach((link) => { + const span = document.createElement("span"); + span.textContent = (link.textContent || "").trim(); + link.replaceWith(span); + }); + + // 設備アイコンはCSSの擬似要素なので、画像では絵文字を直接入れる。 + const icons: Record<string, string> = { + toilet: "🚻", + water: "🚰", + toy: "🎠", + ball: "⚽", + cm: "🏛", + pergola: "🌿", + azumaya: "🛖", + }; + clone.querySelectorAll<HTMLElement>(".fac-badge").forEach((badge) => { + for (const [kind, icon] of Object.entries(icons)) { + if (badge.classList.contains(`fac-${kind}`)) { + badge.textContent = icon; + break; + } + } + }); + + wrap = document.createElement("div"); + wrap.className = "parks parks-capture"; + withScope(wrap); + const head = document.createElement("div"); + head.className = "parks-capture-head"; + withScope(head); + const title = document.createElement("strong"); + title.textContent = "小平市の公園一覧"; + withScope(title); + const countLabel = document.createElement("span"); + countLabel.textContent = `${limited.length}件${ + visible.length > MAX_IMAGE_ROWS + ? `(表示中${visible.length}件の先頭${MAX_IMAGE_ROWS}件)` + : "" + }`; + withScope(countLabel); + head.append(title, countLabel); + + const meta = document.createElement("div"); + meta.className = "parks-capture-meta"; + withScope(meta); + const parts: string[] = []; + if (select?.value) parts.push(`町名: ${select.value}`); + if (search?.value.trim()) parts.push(`キーワード: ${search.value.trim()}`); + const facLabels = checks + .filter((c) => c.checked) + .map((c) => (c.parentElement?.textContent || "").trim()) + .filter(Boolean); + if (facLabels.length) parts.push(`設備: ${facLabels.join("・")}`); + if (sortKey) { + parts.push( + `並べ替え: ${sortKey === "area" ? "面積" : "公園名"} ${ + sortDir === 1 ? "昇順" : "降順" + }`, + ); + } + meta.textContent = parts.length ? parts.join(" / ") : "すべての公園"; + + const foot = document.createElement("div"); + foot.className = "parks-capture-foot"; + withScope(foot); + foot.textContent = `yasutakeyohei.com/kodaira-data/parks/ ・ ${new Date().toLocaleDateString( + "ja-JP", + )}時点`; + + wrap.append(head, meta, clone, foot); + document.body.appendChild(wrap); + + const dataUrl = await toPng(wrap, { + pixelRatio: 2, + backgroundColor: "#ffffff", + cacheBust: true, + }); + + const link = document.createElement("a"); + link.download = `kodaira-parks-${new Date().toISOString().slice(0, 10)}.png`; + link.href = dataUrl; + link.click(); + if (saveStatus) + saveStatus.textContent = + visible.length > MAX_IMAGE_ROWS + ? `画像を保存しました(表示中${visible.length}件の先頭${MAX_IMAGE_ROWS}件)。` + : "画像を保存しました。"; + } catch (error) { + console.error(error); + if (saveStatus) saveStatus.textContent = "画像の作成に失敗しました。"; + } finally { + wrap?.remove(); + saveButton.disabled = false; + window.setTimeout(() => { + if (saveStatus) saveStatus.textContent = ""; + }, 5000); + } + }; + + saveButton?.addEventListener("click", saveTableImage); let sortKey: string | null = null; let sortDir = 1; |
