File size: 3,783 Bytes
4083225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { fetchContrastingGroups } from "../api/client.js";
import "./ContrastingGroupsView.css";

export default function ContrastingGroupsView({ query }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetchContrastingGroups(query)
      .then((d) => !cancelled && setData(d))
      .finally(() => !cancelled && setLoading(false));
    return () => {
      cancelled = true;
    };
  }, [query]);

  return (
    <div className="groups-view">
      <header className="groups-view__header">
        <div>
          <div className="label">Contrasting Groups</div>
          <h1 className="groups-view__title">
            How sources are positioned across the dominant fault lines.
          </h1>
        </div>
        <p className="groups-view__lede">
          The model identifies the contrasting frames most active in this
          investigation and places each source on a continuous spectrum based on
          its coverage. Marker size reflects article count.
        </p>
      </header>

      {loading || !data ? (
        <div className="groups-view__loading">
          <Loader2 size={14} className="spin" />
          <span>identifying contrasting frames</span>
        </div>
      ) : (
        <div className="groups-view__axes">
          {data.axes.map((axis) => (
            <SpectrumAxis key={axis.id} axis={axis} />
          ))}
        </div>
      )}
    </div>
  );
}

function SpectrumAxis({ axis }) {
  const maxArticles = Math.max(...axis.sources.map((s) => s.articles));

  // Simple vertical-jitter assignment so close-by markers don't overlap.
  // Sort by position, then alternate up/down within a small window.
  const sorted = [...axis.sources].sort((a, b) => a.position - b.position);
  const placed = sorted.map((s, i) => ({
    ...s,
    lane: i % 2 === 0 ? 1 : -1, // 1 = above axis, -1 = below
  }));

  return (
    <section className="spectrum">
      <header className="spectrum__header">
        <div>
          <div className="label">Axis</div>
          <h2 className="spectrum__title">{axis.label}</h2>
        </div>
        <div className="spectrum__poles">
          <span className="spectrum__pole spectrum__pole--left">
            ← {axis.leftLabel}
          </span>
          <span className="spectrum__pole spectrum__pole--right">
            {axis.rightLabel} →
          </span>
        </div>
      </header>

      <div className="spectrum__plot">
        <div className="spectrum__axis-line" />
        <div className="spectrum__center" />

        {placed.map((s) => {
          const pctLeft = ((s.position + 1) / 2) * 100;
          const size = 14 + (s.articles / maxArticles) * 28;
          const aboveAxis = s.lane === 1;
          return (
            <div
              key={s.name + axis.id}
              className={`spectrum__marker ${aboveAxis ? "above" : "below"}`}
              style={{ left: `${pctLeft}%` }}
              title={`${s.name} · ${s.articles} articles`}
            >
              <span
                className="spectrum__dot"
                style={{ width: size, height: size }}
              />
              <div className="spectrum__leader" />
              <div className="spectrum__label">
                <div className="spectrum__source">{s.name}</div>
                <div className="spectrum__meta">
                  <span>{s.country}</span>
                  <span className="faint">·</span>
                  <span>{s.articles} art.</span>
                </div>
              </div>
            </div>
          );
        })}
      </div>
    </section>
  );
}