feat(frontend): dashboard & channels constellation scenes — publish bridge + floating overlays

This commit is contained in:
asepharyana
2026-08-24 15:22:27 +07:00
parent 1fafebb16d
commit 60ae1fb5c3
7 changed files with 423 additions and 330 deletions
@@ -1,8 +1,18 @@
"use client"; "use client";
import { ChannelCultureGlossary } from "@/components/ChannelCultureGlossary"; /**
* Channels scene — every channel is a star on the stage; clicking a star
* opens its floating culture dossier here. Search filters the sky.
*/
import { useEffect, useMemo, useState } from "react";
import { SkeletonPanel } from "@/components/shared"; import { SkeletonPanel } from "@/components/shared";
import {
useSceneFocusSetter,
useSceneGraph,
useScenePublish,
} from "@/components/shell/scene-graph-context";
import { useChannelCultures } from "@/hooks"; import { useChannelCultures } from "@/hooks";
import { culturesToGraph } from "@/lib/constellation/graph";
import type { ChannelCultureRow } from "@/lib/types"; import type { ChannelCultureRow } from "@/lib/types";
export function ChannelsView({ export function ChannelsView({
@@ -11,12 +21,92 @@ export function ChannelsView({
initialCultures?: ChannelCultureRow[]; initialCultures?: ChannelCultureRow[];
}) { }) {
const { data: cultures } = useChannelCultures(100, initialCultures); const { data: cultures } = useChannelCultures(100, initialCultures);
const publish = useScenePublish();
const setFocus = useSceneFocusSetter();
const { state } = useSceneGraph();
const [query, setQuery] = useState("");
const rows = cultures ?? [];
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) =>
(r.channel_name ?? r.channel_id).toLowerCase().includes(q) ||
(r.culture_summary ?? "").toLowerCase().includes(q),
);
}, [rows, query]);
const graph = useMemo(() => culturesToGraph(filtered), [filtered]);
useEffect(() => {
publish({ graph, focus: null });
}, [graph, publish]);
useEffect(() => () => setFocus(null), [setFocus]);
const selectedId = state?.focus ?? null;
const selectedRow = useMemo(
() =>
selectedId
? (rows.find((r) => `channel:${r.channel_id}` === selectedId) ?? null)
: null,
[rows, selectedId],
);
return ( return (
<div className="space-y-5"> <div className="min-h-full">
{cultures ? ( {/* Search whisper — top-left */}
<ChannelCultureGlossary cultures={cultures} /> <section
className="pointer-events-auto absolute left-5 top-16 w-64"
aria-label="Filter channels"
>
<p className="eyebrow mb-2">Channels · {filtered.length} mapped</p>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="filter the sky…"
className="w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/60 px-3.5 py-1.5 font-mono text-xs text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]"
/>
{!cultures ? (
<div className="mt-3">
<SkeletonPanel rows={3} />
</div>
) : null}
</section>
{/* Culture dossier for the selected star */}
{selectedRow ? (
<aside
className="pointer-events-auto absolute bottom-20 left-1/2 w-[min(34rem,92vw)] -translate-x-1/2 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/80 p-4 backdrop-blur-xl md:left-auto md:right-5 md:translate-x-0"
aria-label={`Culture of ${selectedRow.channel_name ?? selectedRow.channel_id}`}
>
<button
type="button"
className="absolute right-3 top-3 font-mono text-xs text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
onClick={() => setFocus(null)}
>
esc
</button>
<p className="eyebrow">channel culture</p>
<h3 className="display mt-1 text-xl leading-tight text-ink glow-signal">
{selectedRow.channel_name ?? selectedRow.channel_id}
</h3>
<p className="mt-2 max-h-40 overflow-y-auto text-sm text-pretty text-ink-soft">
{selectedRow.culture_summary ?? "Belum dianalisis."}
</p>
<p className="mt-2 font-mono text-[10px] uppercase tracking-wider text-[var(--color-ink-faint)]">
{selectedRow.last_analyzed_at
? `analyzed ${new Date(selectedRow.last_analyzed_at).toLocaleString()}`
: "never analyzed"}
</p>
</aside>
) : ( ) : (
<SkeletonPanel rows={6} /> <p className="pointer-events-none absolute inset-x-0 bottom-24 hidden justify-center font-mono text-xs text-[var(--color-ink-faint)] md:flex">
klik sebuah bintang untuk membuka culture dossier
</p>
)} )}
</div> </div>
); );
@@ -1,32 +1,18 @@
"use client"; "use client";
import { /**
Activity, * Dashboard scene — guild star + channel orbit live on the stage canvas;
Flag, * this overlay adds the metric whisper (top-left), activity ribbon
MessageSquare, * (bottom-left), and moderation gauge (right) as floating panels.
Mic, */
Radio, import { Activity, Flag, ShieldAlert } from "lucide-react";
ShieldAlert, import { useEffect, useMemo } from "react";
Users,
} from "lucide-react";
import { useEffect } from "react";
import { useAmbient } from "@/components/ambient/ambient-context"; import { useAmbient } from "@/components/ambient/ambient-context";
import { AreaActivity, RadialGauge } from "@/components/charts"; import { AreaActivity, RadialGauge } from "@/components/charts";
import { GlassPanel } from "@/components/primitives"; import { ErrorState, LoadingState } from "@/components/shared";
import { import { useScenePublish } from "@/components/shell/scene-graph-context";
ErrorState, import { useActivity, useStats } from "@/hooks";
LoadingState, import { statsToGraph } from "@/lib/constellation/graph";
SkeletonHero,
SkeletonMetricRow,
SkeletonPanel,
} from "@/components/shared";
import { MetricTile, SectionHeader } from "@/components/shared/section";
import {
useActivity,
useStats,
useTopReactions,
useTopReactors,
} from "@/hooks";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import type { DashboardStats } from "@/lib/types"; import type { DashboardStats } from "@/lib/types";
import { staggerDelay } from "@/lib/utils"; import { staggerDelay } from "@/lib/utils";
@@ -50,11 +36,19 @@ export function DashboardView({
initialStats?: DashboardStats; initialStats?: DashboardStats;
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"]; initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
}) { }) {
const { data: stats, isLoading, error } = useStats(initialStats); const { data: stats, error } = useStats(initialStats);
const { data: activity } = useActivity(14, initialActivity as never); const { data: activity } = useActivity(14, initialActivity as never);
const { data: reactors } = useTopReactors();
const { data: reactions } = useTopReactions();
const ambient = useAmbient(); const ambient = useAmbient();
const publish = useScenePublish();
const graph = useMemo(
() => (stats ? statsToGraph(stats) : { nodes: [], edges: [] }),
[stats],
);
useEffect(() => {
publish({ graph, focus: "guild" });
}, [graph, publish]);
useEffect(() => { useEffect(() => {
const s = deriveSignal(stats); const s = deriveSignal(stats);
@@ -66,303 +60,134 @@ export function DashboardView({
}, [stats, ambient]); }, [stats, ambient]);
if (error && !stats) return <ErrorState error={error} />; if (error && !stats) return <ErrorState error={error} />;
if (!stats && isLoading) if (!stats) return <LoadingState label="aligning constellation" />;
return (
<div className="space-y-5">
<SkeletonHero />
<SkeletonMetricRow cols={4} />
<SkeletonPanel rows={5} />
<div className="grid gap-5 lg:grid-cols-5">
<SkeletonPanel className="lg:col-span-3" rows={4} />
<SkeletonPanel className="lg:col-span-2" rows={4} />
</div>
</div>
);
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
const s = stats; const s = stats;
const total = s.total_flagged + s.total_clean || 1; const total = s.total_flagged + s.total_clean || 1;
const cleanRatio = s.total_clean / total; const cleanRatio = s.total_clean / total;
return ( return (
<div className="space-y-5"> <div className="min-h-full">
{/* Hero */} {/* Metric whisper — top-left under brand */}
<GlassPanel glow className="relative overflow-hidden"> <section
<div className="scan-line absolute inset-x-0 top-0" /> className="pointer-events-auto absolute left-5 top-16 w-64 animate-stagger"
<div className="flex flex-wrap items-end justify-between gap-4"> style={staggerDelay(0)}
<div> aria-label="Key metrics"
<div className="eyebrow mb-2">GMW · Operations Grid</div> >
<h2 className="display hero-clamp leading-none text-ink glow-signal"> <p className="eyebrow mb-2">Operations · {deriveSignal(s).label}</p>
Ambient Field <div className="space-y-1.5">
</h2> <WhisperRow
<p className="mt-2 max-w-md text-pretty text-sm text-ink-soft"> label="messages"
Real-time moderation, voice & media presence across the monitored
guild. {formatNumber(s.total_messages)} messages captured.
</p>
</div>
<div className="flex items-center gap-2 text-ink-soft">
<Radio className="size-4 text-signal animate-breathe" />
<span className="mono text-xs uppercase tracking-wider">
{deriveSignal(s).label}
</span>
</div>
</div>
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile
label="Messages"
value={formatNumber(s.total_messages)} value={formatNumber(s.total_messages)}
tone="signal" tone="ink"
icon={<MessageSquare className="size-3.5" />}
className="animate-stagger"
style={staggerDelay(0)}
/> />
<MetricTile <WhisperRow
label="Flagged" label="flagged"
value={formatNumber(s.total_flagged)} value={formatNumber(s.total_flagged)}
tone={s.total_flagged > 0 ? "vermilion" : "neutral"} tone={s.total_flagged > 0 ? "vermilion" : "ink"}
hint={`${s.today_flagged} today`} hint={`${s.today_flagged} today`}
className="animate-stagger"
style={staggerDelay(1)}
/> />
<MetricTile <WhisperRow
label="Active 24h" label="active 24h"
value={formatNumber(s.active_users_24h)} value={formatNumber(s.active_users_24h)}
tone="signal" tone="signal"
icon={<Users className="size-3.5" />}
className="animate-stagger"
style={staggerDelay(2)}
/> />
<MetricTile <WhisperRow
label="Voice clips" label="voice clips"
value={formatNumber(s.total_voice_recordings)} value={formatNumber(s.total_voice_recordings)}
icon={<Mic className="size-3.5" />} tone="ink"
className="animate-stagger"
style={staggerDelay(3)}
/> />
</div> </div>
</GlassPanel> </section>
{/* Activity */} {/* Activity ribbon — bottom-left */}
<GlassPanel> <section
<SectionHeader className="pointer-events-auto absolute bottom-20 left-5 hidden w-80 lg:block"
eyebrow="14-day signal" aria-label="Activity"
title={ >
<span className="flex items-center gap-2"> <p className="eyebrow mb-1 flex items-center gap-1.5">
<Activity className="size-4 text-signal" /> Activity & moderation <Activity className="size-3.5 text-signal" /> 14-day signal
</span> </p>
}
action={
<div className="flex items-center gap-3 text-xs text-ink-soft">
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full bg-signal" /> messages
</span>
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full bg-vermilion" /> flagged
</span>
</div>
}
/>
{activity ? ( {activity ? (
<AreaActivity daily={activity.daily} /> <AreaActivity daily={activity.daily} />
) : ( ) : (
<LoadingState label="streaming" /> <LoadingState label="streaming" />
)} )}
</GlassPanel> </section>
{/* Two-column: channels + moderation */} {/* Moderation gauge — right */}
<div className="grid gap-5 lg:grid-cols-5"> <section
<GlassPanel className="lg:col-span-3"> className="pointer-events-auto absolute right-5 top-16 hidden w-56 md:block"
<SectionHeader eyebrow="throughput" title="Top channels" /> aria-label="Moderation"
<div className="space-y-2.5"> >
{s.top_channels.slice(0, 7).map((c) => { <p className="eyebrow mb-2 flex items-center gap-1.5">
const pct = <ShieldAlert className="size-3.5 text-signal" /> Trust
(c.message_count / (s.top_channels[0]?.message_count || 1)) * </p>
100; <RadialGauge
return ( value={cleanRatio}
<div key={c.channel_id} className="flex items-center gap-3"> tone={
<span className="w-28 shrink-0 truncate text-sm text-ink-soft sm:w-40"> cleanRatio > 0.8
{c.channel_name ?? c.channel_id.slice(0, 8)} ? "signal"
</span> : cleanRatio > 0.6
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/8"> ? "amber"
<div : "vermilion"
className="h-full rounded-full bg-signal/70" }
style={{ width: `${pct}%` }} label={`${Math.round(cleanRatio * 100)}%`}
/> sublabel="clean"
</div> />
<span className="mono w-14 text-right text-xs text-ink-faint"> <div className="mt-3 space-y-1 font-mono text-xs text-[var(--color-ink-soft)]">
{formatNumber(c.message_count)} <p className="flex items-center justify-between">
</span> <span className="inline-flex items-center gap-1.5">
</div> <Flag className="size-3 text-vermilion" /> flagged
); </span>
})} <span>{formatNumber(s.total_flagged)}</span>
</div> </p>
</GlassPanel> <p className="flex items-center justify-between">
<span>warned</span>
<GlassPanel className="lg:col-span-2"> <span>{formatNumber(s.total_warned)}</span>
<SectionHeader eyebrow="trust" title="Moderation" /> </p>
<div className="flex items-center gap-5"> <p className="flex items-center justify-between">
<RadialGauge <span>pending</span>
value={cleanRatio} <span>{s.moderation_overview.pending}</span>
tone={ </p>
cleanRatio > 0.8 <p className="flex items-center justify-between">
? "signal" <span>processing</span>
: cleanRatio > 0.6 <span>{s.moderation_overview.processing}</span>
? "amber" </p>
: "vermilion" </div>
} </section>
label={`${Math.round(cleanRatio * 100)}%`}
sublabel="clean"
/>
<div className="flex-1 space-y-2 text-sm">
<Row
icon={<ShieldAlert className="size-4 text-signal" />}
label="Clean"
value={formatNumber(s.total_clean)}
/>
<Row
icon={<Flag className="size-4 text-vermilion" />}
label="Flagged"
value={formatNumber(s.total_flagged)}
/>
<Row
icon={<Activity className="size-4 text-amber" />}
label="Warned"
value={formatNumber(s.total_warned)}
/>
</div>
</div>
<div className="mt-4 flex items-center justify-around border-t border-hairline pt-3 text-center">
<Mini
label="pending"
value={s.moderation_overview.pending}
tone="amber"
/>
<Mini
label="processing"
value={s.moderation_overview.processing}
tone="signal"
/>
<Mini
label="errors"
value={s.moderation_overview.error}
tone="vermilion"
/>
</div>
</GlassPanel>
</div>
{/* Reactors + reactions */}
<div className="grid gap-5 lg:grid-cols-2">
<GlassPanel>
<SectionHeader eyebrow="engagement" title="Top reactors" />
<div className="space-y-2">
{(reactors ?? []).slice(0, 6).map((r, i) => {
const maxNet = reactors?.[0]?.net_count || 1;
const pct = Math.max(4, Math.round((r.net_count / maxNet) * 100));
return (
<div key={r.user_id} className="flex items-center gap-3">
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<span className="w-28 shrink-0 truncate text-sm text-ink-soft sm:w-40">
{r.username}
</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8">
<div
className="h-full rounded-full bg-signal/70"
style={{ width: `${pct}%` }}
/>
</div>
<span className="mono w-12 text-right text-xs text-signal">
+{formatNumber(r.net_count)}
</span>
</div>
);
})}
{(reactors ?? []).length === 0 && <EmptyHint />}
</div>
</GlassPanel>
<GlassPanel>
<SectionHeader eyebrow="culture" title="Top reactions" />
<div className="space-y-3">
{(reactions ?? []).slice(0, 5).map((m) => (
<div key={m.message_id} className="flex items-start gap-3">
<div className="flex flex-wrap gap-1 pt-0.5">
{m.top_emojis.slice(0, 3).map((e, i) => (
<span
key={`${m.message_id}-${i}`}
className="text-lg leading-none"
>
{e.emoji}
</span>
))}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-ink">
{m.content || "(no text)"}
</div>
<div className="mono text-[0.65rem] text-ink-faint">
{m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}
</div>
</div>
<span className="mono text-xs text-ink-soft">
{m.reaction_count}
</span>
</div>
))}
{(reactions ?? []).length === 0 && <EmptyHint />}
</div>
</GlassPanel>
</div>
</div> </div>
); );
} }
function Row({ function WhisperRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-center gap-2.5">
{icon}
<span className="flex-1 text-ink-soft">{label}</span>
<span className="mono text-ink">{value}</span>
</div>
);
}
function Mini({
label, label,
value, value,
tone, tone,
hint,
}: { }: {
label: string; label: string;
value: number; value: string;
tone: "signal" | "amber" | "vermilion"; tone: "ink" | "signal" | "vermilion";
hint?: string;
}) { }) {
const color = const color =
tone === "vermilion" tone === "vermilion"
? "text-vermilion" ? "text-vermilion"
: tone === "amber" : tone === "signal"
? "text-amber" ? "text-signal"
: "text-signal"; : "text-ink";
return ( return (
<div> <p className="flex items-baseline gap-2">
<div className={`display text-xl ${color}`}>{value}</div> <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-ink-faint)]">
<div className="eyebrow mt-0.5">{label}</div> {label}
</div> </span>
); <span className={`display text-lg leading-none ${color}`}>{value}</span>
} {hint ? (
<span className="font-mono text-[10px] text-[var(--color-ink-faint)]">
function EmptyHint() { {hint}
return ( </span>
<div className="py-6 text-center text-xs text-ink-faint"> ) : null}
Awaiting data </p>
</div>
); );
} }
@@ -3,7 +3,9 @@
/** /**
* ConstellationFrame — replaces the classic AppFrame chrome. * ConstellationFrame — replaces the classic AppFrame chrome.
* The stage canvas sits fixed behind everything; page content is an * The stage canvas sits fixed behind everything; page content is an
* overlay layer (absolute, no top bar / nav rail / scroll shell). * overlay layer (no top bar / nav rail / scroll shell). Views publish
* their live graph via SceneGraphProvider; the frame renders whatever
* the active view published (fallback: route-scenes default builder).
* Chatbot + CommandPalette keep mounting at the layout level. * Chatbot + CommandPalette keep mounting at the layout level.
*/ */
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@@ -11,39 +13,67 @@ import { type ReactNode, useMemo } from "react";
import { MiniPlayer } from "@/components/media/mini-player"; import { MiniPlayer } from "@/components/media/mini-player";
import { ConstellationStage } from "@/components/shell/constellation-stage"; import { ConstellationStage } from "@/components/shell/constellation-stage";
import { FloatingChrome } from "@/components/shell/floating-chrome"; import { FloatingChrome } from "@/components/shell/floating-chrome";
import { resolveScene, type SceneSeed } from "@/components/shell/route-scenes"; import {
buildDefaultGraph,
resolveScene,
type SceneSeed,
} from "@/components/shell/route-scenes";
import {
SceneGraphProvider,
useSceneGraph,
} from "@/components/shell/scene-graph-context";
export interface ConstellationFrameProps { function StageFromContext({ seed }: { seed?: SceneSeed }) {
children: ReactNode; const pathname = usePathname() ?? "/";
/** Typed SSR seed consumed by the active scene's graph builder. */ const { state, setFocus } = useSceneGraph();
sceneSeed?: SceneSeed; const onChannelsRoute = pathname.startsWith("/channels");
const graph = useMemo(() => {
if (state) return state.graph;
const scene = resolveScene(pathname);
return scene
? buildDefaultGraph(scene, seed ?? {})
: { nodes: [], edges: [] };
}, [state, pathname, seed]);
return (
<ConstellationStage
graph={graph}
selectedId={state?.focus ?? null}
onNodeClick={(id) => {
// On /channels/ a click selects the star (opens its dossier).
if (onChannelsRoute && id.startsWith("channel:")) {
setFocus((prev) => (prev === id ? null : id));
return;
}
const meta = state?.graph.nodes.find((n) => n.id === id);
if (meta?.href) window.location.assign(meta.href);
}}
/>
);
} }
export function ConstellationFrame({ export function ConstellationFrame({
children, children,
sceneSeed, sceneSeed,
}: ConstellationFrameProps) { }: ConstellationFrameProps_) {
const pathname = usePathname() ?? "/";
const scene = useMemo(() => resolveScene(pathname), [pathname]);
const graph = useMemo(
() => (scene ? scene.build(sceneSeed ?? {}) : { nodes: [], edges: [] }),
[scene, sceneSeed],
);
return ( return (
<div className="relative h-dvh w-full overflow-hidden"> <SceneGraphProvider>
<ConstellationStage <div className="relative h-dvh w-full overflow-hidden">
graph={graph} <StageFromContext seed={sceneSeed} />
onNodeClick={(id) => { <FloatingChrome />
if (id.startsWith("channel:")) window.location.assign("/channels/"); <MiniPlayer />
}} {/* Overlay content region — scenes place floating panels inside. */}
/> <main className="pointer-events-none absolute inset-0 z-10 overflow-y-auto overscroll-contain">
<FloatingChrome /> <div className="pointer-events-auto min-h-full">{children}</div>
<MiniPlayer /> </main>
{/* Overlay content region — scenes place floating panels inside. */} </div>
<main className="pointer-events-none absolute inset-0 z-10 overflow-y-auto overscroll-contain"> </SceneGraphProvider>
<div className="pointer-events-auto min-h-full">{children}</div>
</main>
</div>
); );
} }
interface ConstellationFrameProps_ {
children: ReactNode;
/** Typed SSR seed for the route-scenes fallback builder. */
sceneSeed?: SceneSeed;
}
@@ -17,6 +17,8 @@ import { readPalette, type StagePalette } from "@/lib/constellation/palette";
export interface ConstellationStageProps { export interface ConstellationStageProps {
graph: ConstellationGraph; graph: ConstellationGraph;
seed?: number; seed?: number;
/** Node id kept highlighted (scene selection). */
selectedId?: string | null;
onNodeClick?: (nodeId: string) => void; onNodeClick?: (nodeId: string) => void;
} }
@@ -26,6 +28,7 @@ const Z_MAX = 3;
export function ConstellationStage({ export function ConstellationStage({
graph, graph,
seed = 42, seed = 42,
selectedId = null,
onNodeClick, onNodeClick,
}: ConstellationStageProps) { }: ConstellationStageProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -34,7 +37,7 @@ export function ConstellationStage({
const layoutRef = useRef<LayoutNode[]>([]); const layoutRef = useRef<LayoutNode[]>([]);
const nodeByIdRef = useRef(new Map(graph.nodes.map((n) => [n.id, n]))); const nodeByIdRef = useRef(new Map(graph.nodes.map((n) => [n.id, n])));
const [size, setSize] = useState({ w: 0, h: 0 }); const [size, setSize] = useState({ w: 0, h: 0 });
const [hovered, setHovered] = useState<string | null>(null); const hoveredRef = useRef<string | null>(null);
const [reduced, setReduced] = useState(false); const [reduced, setReduced] = useState(false);
nodeByIdRef.current = new Map(graph.nodes.map((n) => [n.id, n])); nodeByIdRef.current = new Map(graph.nodes.map((n) => [n.id, n]));
@@ -222,7 +225,7 @@ export function ConstellationStage({
return; return;
} }
const hit = pickNode(ev.clientX, ev.clientY); const hit = pickNode(ev.clientX, ev.clientY);
setHovered(hit ? hit.id : null); hoveredRef.current = hit ? hit.id : null;
canvas.style.cursor = hit ? "pointer" : "grab"; canvas.style.cursor = hit ? "pointer" : "grab";
}; };
const onPointerUp = (ev: PointerEvent) => { const onPointerUp = (ev: PointerEvent) => {
@@ -318,7 +321,7 @@ export function ConstellationStage({
v.halo.material.opacity = v.halo.material.opacity =
(v.id === "guild" ? 0.14 : 0.08) * (0.7 + 0.6 * pulse); (v.id === "guild" ? 0.14 : 0.08) * (0.7 + 0.6 * pulse);
} }
const hov = v.id === hovered; const hov = v.id === hoveredRef.current || v.id === selectedId;
v.core.scale.setScalar(hov ? 1.25 : 1); v.core.scale.setScalar(hov ? 1.25 : 1);
i += 1; i += 1;
} }
@@ -334,7 +337,9 @@ export function ConstellationStage({
const sy = -(v.y - y) * z + size.h / 2 + v.r + 14; const sy = -(v.y - y) * z + size.h / 2 + v.r + 14;
el.style.transform = `translate(${sx}px, ${sy}px) translateX(-50%)`; el.style.transform = `translate(${sx}px, ${sy}px) translateX(-50%)`;
el.style.color = el.style.color =
id === hovered ? "var(--color-signal)" : "var(--color-ink-soft)"; id === hoveredRef.current || id === selectedId
? "var(--color-signal)"
: "var(--color-ink-soft)";
} }
} }
}; };
@@ -354,7 +359,7 @@ export function ConstellationStage({
scene.clear(); scene.clear();
renderer.dispose(); renderer.dispose();
}; };
}, [graph, size.w, size.h, reduced, hovered, onNodeClick]); }, [graph, size.w, size.h, reduced, selectedId, onNodeClick]);
const emptyGraph = graph.nodes.length === 0; const emptyGraph = graph.nodes.length === 0;
const hint = useMemo( const hint = useMemo(
@@ -4,7 +4,11 @@
*/ */
import type { ConstellationGraph } from "@/lib/constellation/graph"; import type { ConstellationGraph } from "@/lib/constellation/graph";
import { channelsToGraph, statsToGraph } from "@/lib/constellation/graph"; import {
channelsToGraph,
culturesToGraph,
statsToGraph,
} from "@/lib/constellation/graph";
export interface SceneDef { export interface SceneDef {
route: string; route: string;
@@ -17,6 +21,7 @@ export interface SceneDef {
export interface SceneSeed { export interface SceneSeed {
stats?: import("@/lib/types").DashboardStats; stats?: import("@/lib/types").DashboardStats;
channels?: import("@/lib/types").DashboardChannel[]; channels?: import("@/lib/types").DashboardChannel[];
cultures?: import("@/lib/types").ChannelCultureRow[];
guildLabel?: string; guildLabel?: string;
} }
@@ -29,11 +34,26 @@ export const SCENES: SceneDef[] = [
{ {
route: "/channels/", route: "/channels/",
label: "Channels", label: "Channels",
build: (s) => build: (s) => {
s.channels ? channelsToGraph(s.channels) : { nodes: [], edges: [] }, if (s.cultures && s.cultures.length > 0)
return culturesToGraph(s.cultures);
if (s.channels) return channelsToGraph(s.channels);
return { nodes: [], edges: [] };
},
}, },
]; ];
export function resolveScene(pathname: string): SceneDef | undefined { export function resolveScene(pathname: string): SceneDef | undefined {
return SCENES.find((sc) => sc.route === pathname); return SCENES.find((sc) => sc.route === pathname);
} }
export function buildDefaultGraph(
scene: SceneDef,
seed: SceneSeed,
): ConstellationGraph {
try {
return scene.build(seed);
} catch {
return { nodes: [], edges: [] };
}
}
@@ -0,0 +1,104 @@
"use client";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
/**
* SceneGraph bridge — views publish their live graph to the stage.
* The frame provides the setter; the stage consumes the graph.
* Keeps SSR seed pattern intact: page.tsx seeds view.tsx, view publishes.
*/
import type { ConstellationGraph } from "@/lib/constellation/graph";
export interface SceneGraphState {
graph: ConstellationGraph;
/** Node id currently focused (drives fly-to / highlight). */
focus: string | null;
}
type FocusUpdate = string | null | ((prev: string | null) => string | null);
interface SceneGraphContextValue {
state: SceneGraphState | null;
publish: (state: SceneGraphState) => void;
setFocus: (update: FocusUpdate) => void;
}
const SceneGraphContext = createContext<SceneGraphContextValue>({
state: null,
publish: () => {},
setFocus: () => {},
});
export function SceneGraphProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<SceneGraphState | null>(null);
const stateRef = useRef<SceneGraphState | null>(null);
const publish = useCallback((next: SceneGraphState) => {
const prev = stateRef.current;
const sameShape =
prev &&
prev.graph.nodes.length === next.graph.nodes.length &&
prev.graph.edges.length === next.graph.edges.length &&
prev.focus === next.focus;
if (!sameShape) {
stateRef.current = next;
setState(next);
return;
}
// Same shape — still update node values/labels in place.
let changed = false;
if (prev) {
for (let i = 0; i < next.graph.nodes.length; i++) {
const a = prev.graph.nodes[i];
const b = next.graph.nodes[i];
if (a?.id !== b?.id || a?.label !== b?.label || a?.value !== b?.value) {
changed = true;
break;
}
}
}
if (changed || !prev) {
stateRef.current = next;
setState(next);
}
}, []);
const setFocus = useCallback((update: FocusUpdate) => {
setState((prev) => {
if (!prev) return null;
const next = typeof update === "function" ? update(prev.focus) : update;
return next === prev.focus ? prev : { ...prev, focus: next };
});
}, []);
const value = useMemo(
() => ({ state, publish, setFocus }),
[state, publish, setFocus],
);
return (
<SceneGraphContext.Provider value={value}>
{children}
</SceneGraphContext.Provider>
);
}
export function useScenePublish(): (state: SceneGraphState) => void {
return useContext(SceneGraphContext).publish;
}
export function useSceneFocusSetter(): (update: FocusUpdate) => void {
return useContext(SceneGraphContext).setFocus;
}
export type { FocusUpdate };
export function useSceneGraph(): SceneGraphContextValue {
return useContext(SceneGraphContext);
}
@@ -2,7 +2,11 @@
* Constellation graph model — pure data, no React/DOM. * Constellation graph model — pure data, no React/DOM.
* Builders convert existing API payloads into star-graph structures. * Builders convert existing API payloads into star-graph structures.
*/ */
import type { DashboardChannel, DashboardStats } from "@/lib/types"; import type {
ChannelCultureRow,
DashboardChannel,
DashboardStats,
} from "@/lib/types";
export type NodeKind = export type NodeKind =
| "guild" | "guild"
@@ -94,3 +98,18 @@ export function channelsToGraph(
})); }));
return { nodes, edges: [] }; return { nodes, edges: [] };
} }
/** Channels scene variant fed by culture-knowledge rows. */
export function culturesToGraph(rows: ChannelCultureRow[]): ConstellationGraph {
const nodes: GraphNode[] = rows.map((r, i) => ({
id: `channel:${r.channel_id}`,
label: r.channel_name || r.channel_id,
kind: "channel",
value: r.culture_summary ? 0.4 + (i % 5) * 0.12 : 0.2,
meta: {
culture_summary: r.culture_summary,
last_analyzed_at: r.last_analyzed_at,
},
}));
return { nodes, edges: [] };
}