// 議案の賛否一覧(/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 (name === "佐藤徹" && label === "公明") return ""; // 個人の(政和)は政和会に合算する(外山議員は常に、石津議員も一人でないときは政和会)。 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; } export type BillType = "city" | "member" | "petition"; // 議案の種別。グラフの集計と全期間一覧の分類で共通に使う。 export function billType(cell: string): BillType { if (cell.includes("sanpi-shugan")) return "petition"; const text = stripTags(cell); if (/意見書|附帯決議|付帯決議|決議|動議|制定/.test(text)) return "member"; return "city"; } // 市が提出した議案か(請願・意見書・決議・条例制定・動議などの議員提出議案を除く)。 function isCityBill(cell: string): boolean { return billType(cell) === "city"; } 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 || untilKey == 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, }; }); } export interface SanpiBillRow { /** 年度(例: 令和8年度)。 */ year: string; /** 年度別ページへのリンク。 */ yearHref: string; /** 定例会(例: 令和8年9月定例会)。 */ session: string; /** 議案名(HTMLを除いたもの)。 */ title: string; /** 議決結果(例: 可決、否決、採択、不採択)。 */ result: string; /** 安竹洋平の賛否。—は表決に加わらない(議長・欠席・退場)。 */ me: "〇" | "×" | "—"; /** 市側の議案・議員提出の議案・請願の別。 */ type: BillType; } const YEAR_LABELS: Record = { r1d: "令和元年度", r2d: "令和2年度", r3d: "令和3年度", r4d: "令和4年度", r5d: "令和5年度", r6d: "令和6年度", r7d: "令和7年度", r8d: "令和8年度", }; /** * 全期間の議案を、市側の議案・議員提出の議案・請願に分類して返す。 * 年度別ページの表をそのまま読み、安竹洋平の列だけを取り出す。 */ export function computeSanpiBills(): SanpiBillRow[] { const sources = import.meta.glob("/src/content/docs/sanpi/r*.mdx", { query: "?raw", import: "default", eager: true, }) as Record; const rows: { key: string; row: SanpiBillRow }[] = []; for (const [path, source] of Object.entries(sources)) { const key = path.match(/(r\d+d)\.mdx$/)?.[1] ?? ""; const year = YEAR_LABELS[key] ?? ""; const yearHref = key ? `/sanpi/${key}/` : ""; let session = ""; let header: string[] | null = null; let meIndex = -1; for (const line of source.split(/\r?\n/)) { if (line.startsWith("## ")) { session = line.slice(3).trim(); header = null; meIndex = -1; continue; } if (!line.startsWith("|")) { header = null; meIndex = -1; continue; } const cells = splitRow(line); if (isSeparatorRow(cells)) continue; if (header === null) { header = cells; meIndex = header.findIndex((h) => groupLabel(h) === ME_NAME); continue; } if (meIndex < 0) continue; const me = voteChar(cells[meIndex] ?? "") ?? "—"; rows.push({ key, row: { year, yearHref, session, title: stripTags(cells[0] ?? "").trim(), result: stripTags(cells[1] ?? "").trim(), me, type: billType(cells[0] ?? ""), }, }); } } rows.sort((a, b) => a.key.localeCompare(b.key)); return rows.map(({ row }) => row); }