// 議案の賛否一覧(/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, }; }); }