| 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)); |
|
|
| |
| |
| const sorted = [...axis.sources].sort((a, b) => a.position - b.position); |
| const placed = sorted.map((s, i) => ({ |
| ...s, |
| lane: i % 2 === 0 ? 1 : -1, |
| })); |
|
|
| 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> |
| ); |
| } |
|
|