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
|
---
// 賛否一覧(/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;
}
// 市が提出した議案か(請願・意見書・決議・条例制定・動議などの議員提出議案を除く)。
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();
content.querySelectorAll("table").forEach((table) => {
const headers = [...table.querySelectorAll("thead th")].map((th) => th.textContent.trim());
table.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 = groupLabel(headers[i] || "");
if (!group) return;
const vote = voteChar(cell);
if (!vote) return;
const s = stats.get(group) || { yes: 0, no: 0 };
if (vote === "〇") s.yes += 1;
else s.no += 1;
stats.set(group, s);
});
});
});
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) => {
if (a.group === ME_NAME) return -1;
if (b.group === ME_NAME) return 1;
return b.rate - a.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>
|