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
|
---
// 賛否一覧(/sanpi/)の表をスマートフォンでも読みやすくする。
// 表そのものは Markdown で書かれているため、ここでは目印の要素と、
// 実行時に表を加工するスクリプトだけを提供する(見た目は custom.css)。
---
<div class="sanpi-marker" hidden aria-hidden="true"></div>
<script>
// マウスがある環境でのみホバー表示を有効にする(タップ端末では
// タップがホバーとして扱われ、ツールチップが残るのを避けるため)。
const CAN_HOVER = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
// グラフの先頭に固定する議員(安竹洋平)。
const ME_NAME = "安竹洋平";
// 一人会派のグラフ表示名(議員名を添える)。
const DISPLAY_NAME = { "れ新": "れ新(中倉)", "市民": "市民(水口)" };
function groupLabel(headerText) {
const text = 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) {
const text = (cell.textContent || "").replace(/\s+/g, "");
if (/^[〇○◯]$/.test(text)) return "〇";
if (/^[×✕✖]$/.test(text)) return "×";
return null;
}
// 表に個人名が出ない期間は、所属会派の票をその議員に充当する。
// from/until は定例会(令和X年Y月)を基準にする。until を省略すると現在まで。
const MEMBER_RULES = [
// 中江美和議員は令和3年6月からフォーラム小平。
{ name: "中江美和", group: "フォ", from: "令和3年6月" },
// 石津はるか議員は新議員として政和会に入り、後に一人会派となった。
{ name: "石津はるか", group: "政和", from: "令和3年6月", until: "令和7年3月" },
];
function eraKey(text) {
const m = (text || "").match(/令和(\d+)年(\d+)月/);
return m ? Number(m[1]) * 100 + Number(m[2]) : null;
}
// 市が提出した議案か(請願・意見書・決議・条例制定・動議などの議員提出議案を除く)。
function isCityBill(cell) {
if (cell.querySelector(".sanpi-shugan")) return false;
const text = cell.textContent || "";
return !/意見書|決議|動議|制定/.test(text);
}
// 各会派が市提出議案にどれだけ賛成したか(賛成と反対の割合)をグラフにする。
function buildChart(content) {
const host = content.querySelector(".sanpi-chart");
if (!host) return;
const stats = new Map();
const bump = (name, vote) => {
const s = stats.get(name) || { yes: 0, no: 0 };
if (vote === "〇") s.yes += 1;
else s.no += 1;
stats.set(name, s);
};
// 会派に合流している期間の票を個人に充当する。
const applyRules = (groups, cells, sessionKey) => {
if (sessionKey == null) return;
MEMBER_RULES.forEach((rule) => {
const fromKey = eraKey(rule.from);
const untilKey = rule.until ? eraKey(rule.until) : Infinity;
if (sessionKey < fromKey || sessionKey > untilKey) return;
if (groups.includes(rule.name)) return;
const gi = groups.indexOf(rule.group);
if (gi < 0) return;
const vote = voteChar(cells[gi]);
if (!vote) return;
bump(rule.name, vote);
});
};
// 見出し(令和X年Y月定例会)を追いながら、文書順に表を処理する。
let sessionKey = null;
content.querySelectorAll("h2, table").forEach((el) => {
if (el.tagName === "H2") {
sessionKey = eraKey(el.textContent);
return;
}
const headers = [...el.querySelectorAll("thead th")].map((th) => th.textContent.trim());
const groups = headers.map(groupLabel);
el.querySelectorAll("tbody tr").forEach((tr) => {
const cells = [...tr.children];
if (!cells.length || !isCityBill(cells[0])) return;
cells.forEach((cell, i) => {
if (i < 2) return;
const group = groups[i];
if (!group) return;
const vote = voteChar(cell);
if (!vote) return;
bump(group, vote);
});
applyRules(groups, cells, sessionKey);
});
});
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"));
if (!rows.length) {
host.hidden = true;
return;
}
const caption = document.createElement("p");
caption.className = "sanpi-chart-caption";
caption.textContent =
"市が提出した議案(請願・意見書・決議・条例制定・動議などの議員提出議案を除く)に対する、会派ごとの議員の賛成・反対の割合です。反対の割合が高い順に並べています。会派が分かれた場合は表の各列の票を1件として数えています。会派に合流している期間(中江美和議員のフォーラム、石津はるか議員の政和会)は、会派の票を個人に充当しています。件数が少ない会派は参考程度にご覧ください。";
const list = document.createElement("ul");
list.className = "sanpi-chart-list";
rows.forEach(({ group, yes, no, total, rate }) => {
const yesPct = Math.round(rate * 100);
const noPct = 100 - yesPct;
const item = document.createElement("li");
item.className = "sanpi-chart-item";
const label = document.createElement("span");
label.className = "sanpi-chart-label";
label.textContent = DISPLAY_NAME[group] || group;
if (group === ME_NAME) label.style.fontWeight = "700";
const bar = document.createElement("span");
bar.className = "sanpi-chart-bar";
bar.setAttribute("title", `賛成${yes}件/反対${no}件`);
const fill = document.createElement("span");
fill.className = "sanpi-chart-fill";
fill.style.width = `${yesPct}%`;
bar.appendChild(fill);
const val = document.createElement("span");
val.className = "sanpi-chart-val";
val.innerHTML = `賛成${yesPct}%<small>(${yes})</small>/反対${noPct}%<small>(${no})</small>`;
item.append(label, bar, val);
list.appendChild(item);
});
host.append(caption, list);
}
function enhanceSanpiTables() {
const marker = document.querySelector(".sanpi-marker");
if (!marker) return;
const content = marker.closest(".sl-markdown-content");
if (!content) return;
let tip = null;
const hideTip = () => {
if (tip) tip.hidden = true;
};
const showTip = (el) => {
if (!tip) {
tip = document.createElement("div");
tip.className = "sanpi-tip";
tip.setAttribute("role", "tooltip");
document.body.appendChild(tip);
}
tip.textContent = el.dataset.full || el.textContent.trim();
tip.hidden = false;
tip.style.left = "0px";
tip.style.top = "0px";
const r = el.getBoundingClientRect();
const tr = tip.getBoundingClientRect();
const left = Math.min(
Math.max(8, r.left),
Math.max(8, window.innerWidth - tr.width - 8),
);
let top = r.top - tr.height - 8;
if (top < 8) top = r.bottom + 8;
tip.style.left = `${left}px`;
tip.style.top = `${top}px`;
};
content.querySelectorAll("table").forEach((table) => {
// 各行の先頭セル(議案名)を、省略表示できるようにくるむ。
table.querySelectorAll("tbody tr").forEach((tr) => {
const cell = tr.querySelector("td");
if (!cell || cell.querySelector(".sanpi-title")) return;
const span = document.createElement("span");
span.className = "sanpi-title";
// 「請願」バッジは省略の対象外にして、つねに表示する。
const badge = cell.querySelector(":scope > .sanpi-shugan");
Array.from(cell.childNodes).forEach((node) => {
if (node !== badge) span.appendChild(node);
});
if (badge) {
cell.insertBefore(span, badge.nextSibling);
} else {
cell.appendChild(span);
}
const full = span.textContent.trim();
span.dataset.full = full;
span.tabIndex = 0;
span.setAttribute("role", "button");
span.setAttribute("aria-expanded", "false");
span.setAttribute("aria-label", `${full}(タップで全文を表示)`);
const toggle = () => {
hideTip();
const open = cell.classList.toggle("sanpi-open");
span.setAttribute("aria-expanded", String(open));
};
if (CAN_HOVER) {
span.addEventListener("mouseenter", () => showTip(span));
span.addEventListener("mouseleave", hideTip);
span.addEventListener("focus", () => showTip(span));
span.addEventListener("blur", hideTip);
}
span.addEventListener("click", toggle);
span.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggle();
}
});
});
});
window.addEventListener("scroll", hideTip, { passive: true });
window.addEventListener("resize", hideTip);
buildChart(content);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", enhanceSanpiTables);
} else {
enhanceSanpiTables();
}
</script>
|