Download src/pages/ContrastingGroupsView.jsx from NKessler/Horizon: direct link, hf CLI and curl.
- Browser
- Download file 3.78 kB
-
https://huggingface.co/NKessler/Horizon/resolve/main/src/pages/ContrastingGroupsView.jsx
- Command line
-
hf download hf://NKessler/Horizon/src/pages/ContrastingGroupsView.jsx
-
curl -L -o ContrastingGroupsView.jsx https://huggingface.co/NKessler/Horizon/resolve/main/src/pages/ContrastingGroupsView.jsx
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> | |
| ); | |
| } | |