Horizon / src /pages /ContrastingGroupsView.jsx
NKessler's picture
Upload 6270 files
4083225 verified
Raw
History Blame Contribute Delete
3.78 kB
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>
);
}