1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
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,
};
});
}
|