aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-19 10:23:59 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-19 10:23:59 +0900
commit2f4f3817adaa9722dabe8448105b83d6ce566c8a (patch)
tree2fc06032d793106c23c71e70e51aee25c4665fb7 /src
parentef71d78c5a30557c29577deecb669742c09578d0 (diff)
parks: 表をPNG画像で保存できるボタンを追加
Diffstat (limited to 'src')
-rw-r--r--src/components/ParksTable.astro222
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;