aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/components/SanpiTable.astro
blob: 1040bb43243e315e405f6e82ad03630f5b4e3cfe (plain)
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
---
// 賛否一覧(/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 REFERENCE = "フォ";
  // グラフの先頭に固定する議員(安竹洋平)。
  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 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());
      const refIdx = headers.findIndex((h) => groupLabel(h) === REFERENCE);
      if (refIdx < 0) return;

      table.querySelectorAll("tbody tr").forEach((tr) => {
        const cells = [...tr.children];
        const refVote = voteChar(cells[refIdx]);
        if (!refVote) return;
        cells.forEach((cell, i) => {
          if (i < 2 || i === refIdx) return;
          const group = groupLabel(headers[i] || "");
          if (!group || group === REFERENCE) return;
          const vote = voteChar(cell);
          if (!vote) return;
          const s = stats.get(group) || { match: 0, total: 0 };
          s.total += 1;
          if (vote === refVote) s.match += 1;
          stats.set(group, s);
        });
      });
    });

    const rows = [...stats.entries()]
      .map(([group, s]) => ({ group, rate: s.total ? s.match / s.total : 0, total: s.total }))
      .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 || 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 =
      "フォーラム小平と賛否が一致した割合です(〇・×が同じ議案のみを数え、—は除く)。高いほど与党寄り、低いほど野党寄りです。件数が少ない会派は参考程度にご覧ください。";

    const list = document.createElement("ul");
    list.className = "sanpi-chart-list";
    rows.forEach(({ group, rate, total }) => {
      const percent = Math.round(rate * 100);
      const hue = Math.round(210 * rate);
      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";
      const fill = document.createElement("span");
      fill.className = "sanpi-chart-fill";
      fill.style.width = `${percent}%`;
      fill.style.background = `hsl(${hue} 70% 45%)`;
      bar.appendChild(fill);

      const val = document.createElement("span");
      val.className = "sanpi-chart-val";
      val.innerHTML = `${percent}%<small>(${total}件)</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>