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 (
Contrasting Groups

How sources are positioned across the dominant fault lines.

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.

{loading || !data ? (
identifying contrasting frames
) : (
{data.axes.map((axis) => ( ))}
)}
); } 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 (
Axis

{axis.label}

← {axis.leftLabel} {axis.rightLabel} →
{placed.map((s) => { const pctLeft = ((s.position + 1) / 2) * 100; const size = 14 + (s.articles / maxArticles) * 28; const aboveAxis = s.lane === 1; return (
{s.name}
{s.country} · {s.articles} art.
); })}
); }