aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/utils/sanpiStats.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/utils/sanpiStats.ts')
-rw-r--r--src/utils/sanpiStats.ts176
1 files changed, 176 insertions, 0 deletions
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<string, string> = {
+ れ新: "れ新(中倉)",
+ 市民: "市民(水口)",
+};
+
+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<string, string>;
+
+ const stats = new Map<string, { yes: number; no: number }>();
+ 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,
+ };
+ });
+}