From e86d83d135e8b8379d03e9ff59690b73c1088e40 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:10:48 +0900 Subject: sanpi/: 議案の賛否一覧を年度別ページに分割 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 概要ページ(/sanpi/)は累計の傾向グラフと年度別リンクのみに - 表と賛否理由は /sanpi/r1d/〜/sanpi/r8d/ の8ページに分割 (3月定例会は前年度に属する) - 傾向グラフは年度別ページの表をビルド時に集計して描画 (src/utils/sanpiStats.ts / src/components/SanpiChart.astro) - SanpiTable.astro は表のツールチップ・全文表示のみに整理 --- src/utils/sanpiStats.ts | 176 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 src/utils/sanpiStats.ts (limited to 'src/utils/sanpiStats.ts') diff --git a/src/utils/sanpiStats.ts b/src/utils/sanpiStats.ts new file mode 100644 index 0000000..b2bfed3 --- /dev/null +++ b/src/utils/sanpiStats.ts @@ -0,0 +1,176 @@ +// 議案の賛否一覧(/sanpi/)の「会派ごとの賛否の傾向」を、年度別ページ +// (/src/content/docs/sanpi/*.mdx)の表からビルド時に集計する。 +// +// 以前はブラウザ上でページ内の表(DOM)を走査して集計していたが、一覧を +// 年度ごとの別ページに分割したため、ここで全年度を横断して集計し、 +// 概要ページにサーバー側で描画する(SEO・初期表示の面でも有利)。 + +/** 議案の賛否を集計した1行分。 */ +export interface SanpiChartRow { + /** 会派・議員の表示名(例: 安竹洋平、政和)。 */ + label: string; + yes: number; + no: number; + total: number; + /** 賛成の割合(0〜1)。 */ + rate: number; + /** 賛成の割合(整数%)。 */ + yesPct: number; + /** 反対の割合(整数%)。 */ + noPct: number; + /** 安竹洋平本人かどうか(グラフで強調する)。 */ + me: boolean; +} + +/** グラフの説明文。 */ +export const SANPI_CHART_CAPTION = + "市が提出した議案(請願・意見書・決議・条例制定・動議などの議員提出議案を除く)に対する、会派ごとの議員の賛成・反対の割合です。安竹洋平が議員になってからの累計で、反対の割合が高い順に並べています。会派が分かれた場合は表の各列の票を1件として数えています。会派に合流している期間(中江美和議員のフォーラム、石津はるか議員の政和会)は、会派の票を個人に充当しています。件数が少ない会派は参考程度にご覧ください。"; + +// グラフの表示名(議員名を添える)。 +const DISPLAY_NAME: Record = { + れ新: "れ新(中倉)", + 市民: "市民(水口)", +}; + +const ME_NAME = "安竹洋平"; + +// 表に個人名が出ない期間は、所属会派の票をその議員に充当する。 +// from/until は定例会(令和X年Y月)を基準にする。until を省略すると現在まで。 +const MEMBER_RULES: { name: string; group: string; from: string; until?: string }[] = [ + // 中江美和議員は令和3年6月からフォーラム小平。 + { name: "中江美和", group: "フォ", from: "令和3年6月" }, + // 石津はるか議員は新議員として政和会に入り、後に一人会派となった。 + { name: "石津はるか", group: "政和", from: "令和3年6月", until: "令和7年3月" }, +]; + +const stripTags = (text: string): string => text.replace(/<[^>]+>/g, ""); + +// 表の見出しセルから会派・議員の集計単位を取り出す。 +function groupLabel(headerText: string): string { + const text = stripTags(headerText).replace(/\s+/g, "").trim(); + const m = text.match(/^(.+?)((.+?))$/); + if (!m) return text; + const name = m[1]; + const label = m[2]; + // 会派(N人)は会派そのもの。 + if (/^\d+人$/.test(label)) return name; + // 個人の(政和)は政和会に合算する(外山議員は常に、石津議員も一人でないときは政和会)。 + if (label === "政和") return "政和"; + // (一人)などは議員ごとに分ける。 + return name; +} + +function voteChar(cell: string): "〇" | "×" | null { + const text = stripTags(cell).replace(/\s+/g, ""); + if (/^[〇○◯]$/.test(text)) return "〇"; + if (/^[×✕✖]$/.test(text)) return "×"; + return null; +} + +// 市が提出した議案か(請願・意見書・決議・条例制定・動議などの議員提出議案を除く)。 +function isCityBill(cell: string): boolean { + if (cell.includes("sanpi-shugan")) return false; + const text = stripTags(cell); + return !/意見書|決議|動議|制定/.test(text); +} + +function eraKey(text: string): number | null { + const m = text.match(/令和(\d+)年(\d+)月/); + return m ? Number(m[1]) * 100 + Number(m[2]) : null; +} + +// Markdownの表の行をセルの配列にする。 +function splitRow(line: string): string[] { + const parts = line.split("|"); + parts.shift(); + parts.pop(); + return parts.map((part) => part.trim()); +} + +const isSeparatorRow = (cells: string[]): boolean => + cells.length > 0 && cells.every((cell) => /^:?-{2,}:?$/.test(cell) || cell === ""); + +/** 年度別ページの表を横断して、会派ごとの賛否を集計する。 */ +export function computeSanpiStats(): SanpiChartRow[] { + const sources = import.meta.glob("/src/content/docs/sanpi/*.mdx", { + query: "?raw", + import: "default", + eager: true, + }) as Record; + + const stats = new Map(); + const bump = (name: string, vote: "〇" | "×") => { + const s = stats.get(name) || { yes: 0, no: 0 }; + if (vote === "〇") s.yes += 1; + else s.no += 1; + stats.set(name, s); + }; + + for (const source of Object.values(sources)) { + let sessionKey: number | null = null; + let header: string[] | null = null; + + for (const line of source.split(/\r?\n/)) { + if (line.startsWith("## ")) { + sessionKey = eraKey(line); + header = null; + continue; + } + if (!line.startsWith("|")) { + header = null; + continue; + } + const cells = splitRow(line); + if (isSeparatorRow(cells)) continue; + if (header === null) { + header = cells; + continue; + } + + const groups = header.map(groupLabel); + if (!isCityBill(cells[0] ?? "")) continue; + + for (let i = 2; i < cells.length; i += 1) { + const group = groups[i]; + if (!group) continue; + const vote = voteChar(cells[i] ?? ""); + if (vote) bump(group, vote); + } + + if (sessionKey == null) continue; + for (const rule of MEMBER_RULES) { + const fromKey = eraKey(rule.from); + const untilKey = rule.until ? eraKey(rule.until) : Infinity; + if (fromKey == null || sessionKey < fromKey || sessionKey > untilKey) continue; + if (groups.includes(rule.name)) continue; + const gi = groups.indexOf(rule.group); + if (gi < 0) continue; + const vote = voteChar(cells[gi] ?? ""); + if (vote) bump(rule.name, vote); + } + } + } + + const rows = [...stats.entries()] + .map(([group, s]) => { + const total = s.yes + s.no; + return { group, yes: s.yes, no: s.no, total, rate: total ? s.yes / total : 0 }; + }) + .filter((row) => row.total > 0) + // 反対の割合が高い順(=賛成率の低い順)。 + .sort((a, b) => a.rate - b.rate || b.total - a.total || a.group.localeCompare(b.group, "ja")); + + return rows.map(({ group, yes, no, total, rate }) => { + const yesPct = Math.round(rate * 100); + return { + label: DISPLAY_NAME[group] || group, + yes, + no, + total, + rate, + yesPct, + noPct: 100 - yesPct, + me: group === ME_NAME, + }; + }); +} -- cgit v1.3.1