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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
|
// 議案の賛否一覧(/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 (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<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 || 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<string, string> = {
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<string, string>;
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);
}
|