{"kit":"constellation-graph","requires":[],"install_order":[{"id":"constellation-graph","version":"1.0.0","fingerprint":"fb6711ea27942638","files":[{"path":"backend/app/api/v1/endpoints/constellation/__init__.py","content":""},{"path":"backend/app/api/v1/endpoints/constellation/router.py","content":"\"\"\"Constellation graph store — CRUD over saved graphs, owner-scoped, generic subject binding.\n\nEvery route is auth-gated and ownership-enforced (a user only ever touches their own rows — no\nIDOR). Graphs attach to any record via ``(subject_type, subject_id)``. No external secret.\n\"\"\"\nimport uuid\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom pydantic import BaseModel, Field\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app.core.deps import get_current_user\nfrom app.db.session import get_db\nfrom app.models.constellation_graph import ConstellationGraph\nfrom app.models.user import User\n\nrouter = APIRouter()\n\n\nclass GraphIn(BaseModel):\n    subject_type: str = Field(\"default\", max_length=60)\n    subject_id: str = Field(\"default\", max_length=120)\n    title: str = Field(\"Constellation\", min_length=1, max_length=180)\n    data: dict = Field(default_factory=dict)\n\n\ndef _brief(row: ConstellationGraph) -> dict:\n    return {\n        \"id\": str(row.id), \"subject_type\": row.subject_type, \"subject_id\": row.subject_id,\n        \"title\": row.title, \"updated_at\": row.updated_at.isoformat() if row.updated_at else None,\n    }\n\n\ndef _full(row: ConstellationGraph) -> dict:\n    return {**_brief(row), \"data\": row.data or {\"nodes\": [], \"edges\": []}}\n\n\ndef _sanitize(data: dict) -> dict:\n    \"\"\"Keep only the portable graph shape; degrade to empty rather than trust arbitrary input.\"\"\"\n    if not isinstance(data, dict):\n        return {\"nodes\": [], \"edges\": []}\n    out: dict = {\"nodes\": data.get(\"nodes\") if isinstance(data.get(\"nodes\"), list) else [],\n                 \"edges\": data.get(\"edges\") if isinstance(data.get(\"edges\"), list) else []}\n    for opt in (\"groups\", \"ghosts\"):\n        if isinstance(data.get(opt), list):\n            out[opt] = data[opt]\n    return out\n\n\n@router.get(\"\")\nasync def list_or_get(\n    subject_type: str | None = None, subject_id: str | None = None,\n    current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db),\n):\n    \"\"\"List the caller's graphs; or, with both subject params, return that one graph in full.\"\"\"\n    if subject_type and subject_id:\n        row = (\n            await db.execute(select(ConstellationGraph).where(\n                ConstellationGraph.user_id == current_user.id,\n                ConstellationGraph.subject_type == subject_type,\n                ConstellationGraph.subject_id == subject_id,\n            ))\n        ).scalar_one_or_none()\n        if not row:\n            raise HTTPException(status_code=404, detail=\"Constellation not found\")\n        return _full(row)\n    rows = (\n        await db.execute(select(ConstellationGraph).where(ConstellationGraph.user_id == current_user.id)\n                         .order_by(ConstellationGraph.updated_at.desc()))\n    ).scalars().all()\n    return {\"constellations\": [_brief(r) for r in rows]}\n\n\n@router.post(\"\")\nasync def upsert(payload: GraphIn, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):\n    \"\"\"Create or replace the caller's graph for a subject (unique per owner+subject).\"\"\"\n    row = (\n        await db.execute(select(ConstellationGraph).where(\n            ConstellationGraph.user_id == current_user.id,\n            ConstellationGraph.subject_type == payload.subject_type,\n            ConstellationGraph.subject_id == payload.subject_id,\n        ))\n    ).scalar_one_or_none()\n    if row is None:\n        row = ConstellationGraph(user_id=current_user.id, subject_type=payload.subject_type, subject_id=payload.subject_id)\n        db.add(row)\n    row.title = payload.title\n    row.data = _sanitize(payload.data)\n    await db.commit()\n    await db.refresh(row)\n    return {\"id\": str(row.id)}\n\n\n@router.get(\"/{graph_id}\")\nasync def get_one(graph_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):\n    row = await db.get(ConstellationGraph, uuid.UUID(graph_id))\n    if not row or row.user_id != current_user.id:\n        raise HTTPException(status_code=404, detail=\"Constellation not found\")\n    return _full(row)\n\n\n@router.delete(\"/{graph_id}\")\nasync def delete_one(graph_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):\n    row = await db.get(ConstellationGraph, uuid.UUID(graph_id))\n    if not row or row.user_id != current_user.id:\n        raise HTTPException(status_code=404, detail=\"Constellation not found\")\n    await db.delete(row)\n    await db.commit()\n    return {\"deleted\": True}\n"},{"path":"backend/app/models/constellation_graph.py","content":"\"\"\"Constellation graph store — a saved node/edge graph attached to any subject.\n\nAppForge-kit-ready: a brand-new table (created by ``Base.metadata.create_all`` at startup, no\nmigration), owned by a user, and bound to any record via a generic ``(subject_type, subject_id)``\npair — so the same kit renders an org chart, a knowledge map, a dependency graph, whatever the\nhost app feeds it. ``data`` holds the portable ``{nodes, edges, groups?, ghosts?}`` JSON.\n\"\"\"\nimport uuid\nfrom datetime import UTC, datetime\n\nfrom sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint\nfrom sqlalchemy.dialects.postgresql import UUID\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom app.models.base import Base\n\n\ndef _utcnow() -> datetime:\n    return datetime.now(UTC)\n\n\nclass ConstellationGraph(Base):\n    __tablename__ = \"constellation_graphs\"\n    __table_args__ = (\n        UniqueConstraint(\"user_id\", \"subject_type\", \"subject_id\", name=\"uq_constellation_owner_subject\"),\n    )\n\n    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)\n    user_id: Mapped[uuid.UUID] = mapped_column(\n        UUID(as_uuid=True), ForeignKey(\"users.id\", ondelete=\"CASCADE\"), nullable=False, index=True\n    )\n    subject_type: Mapped[str] = mapped_column(String(60), nullable=False, default=\"default\", index=True)\n    subject_id: Mapped[str] = mapped_column(String(120), nullable=False, default=\"default\", index=True)\n    title: Mapped[str] = mapped_column(String(180), nullable=False, default=\"Constellation\")\n    # {\"nodes\":[{id,label,group,...}], \"edges\":[{source,target,type}], \"groups\"?:[], \"ghosts\"?:[]}\n    data: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)\n    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)\n    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)\n"},{"path":"frontend/src/app/constellation/page.tsx","content":"\"use client\"\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\"\nimport { useSearchParams } from \"next/navigation\"\nimport { Suspense } from \"react\"\nimport { Network, RotateCcw } from \"lucide-react\"\nimport { api } from \"@/lib/api\"\nimport { AppShell } from \"@/components/app-shell\"\nimport { Card } from \"@/components/ui/card\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport { useI18n } from \"@/lib/i18n\"\nimport { cn } from \"@/lib/utils\"\nimport { ConstellationGraph } from \"@/components/constellation/constellation-graph\"\nimport {\n  BACKDROPS, DEFAULT_CONFIG, THEMES, type ConstellationData, type GraphConfig, makeGroupColor, themeById,\n} from \"@/components/constellation/themes\"\n\nconst LS_KEY = \"constellation.config.v1\"\n\n// A built-in demo graph so the page dazzles with zero data (kit rule: degrade gracefully).\nconst SAMPLE: ConstellationData = (() => {\n  const groups = [\"core\", \"growth\", \"signal\", \"people\"]\n  const names: Record<string, string[]> = {\n    core: [\"Nova\", \"Orion\", \"Vega\", \"Rigel\", \"Atlas\"],\n    growth: [\"Lyra\", \"Cygnus\", \"Draco\", \"Corvus\"],\n    signal: [\"Aria\", \"Echo\", \"Iris\", \"Sol\"],\n    people: [\"Mira\", \"Juno\", \"Cleo\", \"Remy\", \"Wren\"],\n  }\n  const nodes: ConstellationData[\"nodes\"] = []\n  const edges: ConstellationData[\"edges\"] = []\n  const sig = [\"calm\", \"steady\", \"watch\", \"warn\", \"critical\"]\n  let idx = 0\n  const roots: Record<string, string> = {}\n  groups.forEach((g, gi) => {\n    names[g].forEach((label, i) => {\n      const id = `${g}-${i}`\n      const isRoot = i === 0\n      if (isRoot) roots[g] = id\n      nodes.push({\n        id, label, group: g, sub: isRoot ? \"lead\" : \"member\",\n        badge: `${g} · ${label}`, signal: sig[(idx + gi) % sig.length],\n        score: 40 + ((idx * 17) % 55), size: isRoot ? 90 : 35 + ((idx * 13) % 45),\n        parent: isRoot ? null : roots[g],\n      })\n      if (!isRoot) edges.push({ source: roots[g], target: id, type: \"link\" })\n      idx++\n    })\n  })\n  // cross-group relationships\n  edges.push({ source: \"core-0\", target: \"growth-0\", type: \"harmony\" })\n  edges.push({ source: \"core-0\", target: \"signal-0\", type: \"harmony\" })\n  edges.push({ source: \"core-0\", target: \"people-0\", type: \"link\" })\n  edges.push({ source: \"growth-0\", target: \"people-0\", type: \"harmony\" })\n  edges.push({ source: \"signal-0\", target: \"people-2\", type: \"tension\" })\n  edges.push({ source: \"core-2\", target: \"growth-2\", type: \"tension\" })\n  return {\n    nodes, edges,\n    groups: groups.map((g) => ({ key: g, label: g[0].toUpperCase() + g.slice(1) })),\n    ghosts: [{ id: \"ghost-1\", label: \"Mentor\", group: \"growth\" }, { id: \"ghost-2\", label: \"Advisor\", group: \"core\" }],\n  }\n})()\n\nfunction Seg<T extends string>({ value, onChange, options }: { value: T; onChange: (v: T) => void; options: { v: T; label: string }[] }) {\n  return (\n    <div className=\"inline-flex flex-wrap gap-0.5 rounded-md border border-border/60 p-0.5\">\n      {options.map((o) => (\n        <button key={o.v} onClick={() => onChange(o.v)} className={cn(\"rounded px-2 py-1 text-[11px] transition-colors\", value === o.v ? \"bg-primary text-primary-foreground\" : \"text-muted-foreground hover:text-foreground\")}>{o.label}</button>\n      ))}\n    </div>\n  )\n}\nfunction Row({ label, children }: { label: string; children: React.ReactNode }) {\n  return <div className=\"space-y-1.5\"><div className=\"text-xs font-medium text-muted-foreground\">{label}</div>{children}</div>\n}\n\nfunction ConstellationView() {\n  const { t } = useI18n()\n  const params = useSearchParams()\n  const subjectType = params.get(\"subject_type\")\n  const subjectId = params.get(\"subject_id\")\n\n  const [data, setData] = useState<ConstellationData | null>(null)\n  const [loading, setLoading] = useState(true)\n  const [config, setConfig] = useState<GraphConfig>(DEFAULT_CONFIG)\n\n  useEffect(() => { try { const s = localStorage.getItem(LS_KEY); if (s) setConfig({ ...DEFAULT_CONFIG, ...JSON.parse(s) }) } catch { /* ignore */ } }, [])\n  useEffect(() => { try { localStorage.setItem(LS_KEY, JSON.stringify(config)) } catch { /* ignore */ } }, [config])\n  function set<K extends keyof GraphConfig>(k: K, v: GraphConfig[K]) { setConfig((c) => ({ ...c, [k]: v })) }\n\n  const load = useCallback(async () => {\n    setLoading(true)\n    if (subjectType && subjectId) {\n      try { const rec = await api.getConstellation(subjectType, subjectId); setData(rec.data as ConstellationData) }\n      catch { setData(SAMPLE) }  // no saved graph for this subject → fall back to the demo\n    } else { setData(SAMPLE) }\n    setLoading(false)\n  }, [subjectType, subjectId])\n  useEffect(() => { void load() }, [load])\n\n  const theme = themeById(config.theme)\n  const legend = useMemo(() => {\n    if (!data) return []\n    const gc = makeGroupColor(data.nodes, theme)\n    const labels = new Map((data.groups ?? []).map((g) => [g.key, g.label]))\n    return [...new Set(data.nodes.map((n) => n.group))].sort().map((g) => ({ g, label: labels.get(g) ?? g, color: gc(g) }))\n  }, [data, theme])\n\n  const labels = {\n    fsEnter: t(\"constellation.fs.enter\"), fsExit: t(\"constellation.fs.exit\"),\n    tourPlay: t(\"constellation.tour.play\"), tourStop: t(\"constellation.tour.stop\"),\n    allGroups: t(\"constellation.all\"), tourOf: (name: string) => t(\"constellation.tour.of\", { name }),\n  }\n\n  return (\n    <div className=\"mx-auto max-w-6xl px-4 py-6 sm:py-10\">\n      <div className=\"mb-5 flex flex-wrap items-center gap-3\">\n        <Network size={20} className=\"text-muted-foreground\" />\n        <div className=\"flex-1\">\n          <div className=\"text-sm font-medium text-primary\">{t(\"constellation.title\")}</div>\n          <h1 className=\"text-2xl font-bold tracking-tight\">{t(\"constellation.heading\")}</h1>\n          <p className=\"mt-0.5 text-sm text-muted-foreground\">{t(\"constellation.subtitle\")}</p>\n        </div>\n      </div>\n\n      <div className=\"grid gap-4 lg:grid-cols-[minmax(0,1fr)_16rem]\">\n        <Card className=\"overflow-hidden border-indigo-400/25 p-0 shadow-[0_24px_80px_-24px_rgba(99,102,241,0.45)]\">\n          {loading ? <Skeleton className=\"h-[460px] w-full\" /> : data ? (\n            <ConstellationGraph data={data} config={config} labels={labels} />\n          ) : (\n            <div className=\"p-10 text-center text-sm text-muted-foreground\">{t(\"constellation.empty\")}</div>\n          )}\n        </Card>\n\n        <Card className=\"space-y-4 p-4\">\n          <div className=\"flex items-center justify-between\">\n            <h2 className=\"text-sm font-semibold\">{t(\"constellation.studio.title\")}</h2>\n            <button onClick={() => setConfig(DEFAULT_CONFIG)} className=\"inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground\"><RotateCcw size={12} />{t(\"constellation.studio.reset\")}</button>\n          </div>\n\n          <Row label={t(\"constellation.studio.theme\")}>\n            <div className=\"grid grid-cols-2 gap-1.5\">\n              {THEMES.map((th) => (\n                <button key={th.id} onClick={() => set(\"theme\", th.id)} className={cn(\"flex items-center gap-1.5 rounded-md border px-2 py-1.5\", config.theme === th.id ? \"border-primary\" : \"border-border/60 hover:border-border\")}>\n                  <span className=\"flex gap-0.5\">{th.palette.slice(0, 4).map((c, i) => <span key={i} className=\"h-2.5 w-2.5 rounded-full\" style={{ background: c }} />)}</span>\n                  <span className=\"text-[11px]\">{th.name}</span>\n                </button>\n              ))}\n            </div>\n          </Row>\n\n          <Row label={t(\"constellation.studio.backdrop\")}>\n            <div className=\"grid grid-cols-2 gap-1.5\">\n              {BACKDROPS.map((b) => (\n                <button key={b.id} onClick={() => set(\"backdrop\", b.id)} className={cn(\"flex items-center gap-1.5 rounded-md border px-2 py-1.5\", config.backdrop === b.id ? \"border-primary\" : \"border-border/60 hover:border-border\")}>\n                  <span className=\"h-4 w-4 shrink-0 rounded-full border border-white/10\" style={{ background: b.swatch }} />\n                  <span className=\"truncate text-[11px]\">{t(b.label)}</span>\n                </button>\n              ))}\n            </div>\n          </Row>\n\n          <Row label={t(\"constellation.studio.layout\")}>\n            <Seg value={config.layout} onChange={(v) => set(\"layout\", v)} options={[{ v: \"force\", label: t(\"constellation.layout.force\") }, { v: \"cluster\", label: t(\"constellation.layout.cluster\") }, { v: \"radial\", label: t(\"constellation.layout.radial\") }]} />\n          </Row>\n          <Row label={t(\"constellation.studio.color\")}>\n            <Seg value={config.colorBy} onChange={(v) => set(\"colorBy\", v)} options={[{ v: \"group\", label: t(\"constellation.color.group\") }, { v: \"signal\", label: t(\"constellation.color.signal\") }, { v: \"score\", label: t(\"constellation.color.score\") }]} />\n          </Row>\n          <Row label={t(\"constellation.studio.size\")}>\n            <Seg value={config.sizeBy} onChange={(v) => set(\"sizeBy\", v)} options={[{ v: \"size\", label: t(\"constellation.size.size\") }, { v: \"score\", label: t(\"constellation.size.score\") }, { v: \"uniform\", label: t(\"constellation.size.uniform\") }]} />\n          </Row>\n\n          <Row label={t(\"constellation.studio.edges\")}>\n            <div className=\"flex flex-wrap gap-1.5\">\n              {([[\"link\", config.showLink, \"showLink\"], [\"harmony\", config.showHarmony, \"showHarmony\"], [\"tension\", config.showTension, \"showTension\"]] as const).map(([k, on, key]) => (\n                <button key={k} onClick={() => set(key, !on)} className={cn(\"inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2 py-1 text-[11px]\", !on && \"opacity-40\")}>\n                  <span className=\"h-2.5 w-2.5 rounded-sm\" style={{ background: theme.edge[k] }} />{t(`constellation.edge.${k}`)}\n                </button>\n              ))}\n            </div>\n          </Row>\n\n          <div className=\"flex items-center justify-between gap-2\">\n            <Row label={t(\"constellation.studio.labels\")}>\n              <Seg value={config.labels} onChange={(v) => set(\"labels\", v)} options={[{ v: \"all\", label: t(\"constellation.labels.all\") }, { v: \"none\", label: t(\"constellation.labels.none\") }]} />\n            </Row>\n            <label className=\"flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground\"><input type=\"checkbox\" checked={config.showGhosts} onChange={(e) => set(\"showGhosts\", e.target.checked)} />{t(\"constellation.studio.ghosts\")}</label>\n          </div>\n        </Card>\n      </div>\n\n      <div className=\"mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted-foreground\">\n        {config.colorBy === \"group\" ? legend.map((l) => (\n          <span key={l.g} className=\"inline-flex items-center gap-1.5\"><span className=\"h-3 w-3 rounded-full\" style={{ background: l.color }} />{l.label}</span>\n        )) : config.colorBy === \"signal\" ? (\n          [\"calm\", \"steady\", \"watch\", \"warn\", \"critical\"].map((lv) => (\n            <span key={lv} className=\"inline-flex items-center gap-1.5\"><span className=\"h-3 w-3 rounded-full\" style={{ background: { calm: \"#3FA85E\", steady: \"#639922\", watch: \"#EF9F27\", warn: \"#BA7517\", critical: \"#E24B4A\" }[lv] }} />{t(`constellation.signal.${lv}`)}</span>\n          ))\n        ) : <span>{t(\"constellation.legend.score\")}</span>}\n        <span>{t(\"constellation.hint\")}</span>\n      </div>\n    </div>\n  )\n}\n\nexport default function ConstellationPage() {\n  return (\n    <AppShell>\n      <Suspense fallback={<div className=\"mx-auto max-w-6xl px-4 py-10\"><Skeleton className=\"h-[460px] w-full rounded-xl\" /></div>}>\n        <ConstellationView />\n      </Suspense>\n    </AppShell>\n  )\n}\n"},{"path":"frontend/src/components/constellation/constellation-graph.tsx","content":"\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport * as d3 from \"d3\"\nimport { Maximize2, Minimize2, Play, Square } from \"lucide-react\"\nimport {\n  type BackdropId, type ConstEdge, type ConstEdgeType, type ConstNode, type ConstellationData,\n  type GraphConfig, type Theme, BACKDROP_CSS, makeGroupColor, scoreColor, signalColor, themeById,\n} from \"./themes\"\n\ninterface SimNode extends ConstNode {\n  x?: number; y?: number; fx?: number | null; fy?: number | null; depth?: number; ghost?: boolean\n}\ninterface SimLink extends d3.SimulationLinkDatum<SimNode> {\n  type: ConstEdgeType\n}\n/** Optional labels for the built-in controls, so the kit works without any i18n framework. */\nexport interface ConstLabels {\n  fsEnter?: string; fsExit?: string; tourPlay?: string; tourStop?: string; allGroups?: string\n  tourOf?: (name: string) => string\n}\n\nconst W = 1000, H = 700\nconst ALERT = (s: string | null | undefined) => s === \"critical\" || s === \"warn\"\nconst nid = (x: string | number | SimNode): string => (typeof x === \"object\" ? x.id : String(x))\nconst esc = (s: string): string => s.replace(/[&<>]/g, (x) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\" }[x] as string))\n\nfunction radius(d: SimNode, c: GraphConfig): number {\n  if (d.ghost) return 9\n  if (c.sizeBy === \"uniform\") return 10\n  if (c.sizeBy === \"score\") return 7 + ((d.score ?? 55) / 100) * 12\n  return 7 + ((d.size ?? 40) / 100) * 14\n}\nfunction fill(d: SimNode, c: GraphConfig, gc: (g: string) => string): string {\n  if (c.colorBy === \"signal\") return signalColor(d.signal)\n  if (c.colorBy === \"score\") return scoreColor(d.score)\n  return gc(d.group)\n}\nfunction ring(d: SimNode, c: GraphConfig, gc: (g: string) => string): string {\n  return c.colorBy === \"signal\" ? gc(d.group) : signalColor(d.signal)\n}\nfunction visible(type: ConstEdgeType, c: GraphConfig): boolean {\n  return type === \"link\" ? c.showLink : type === \"harmony\" ? c.showHarmony : c.showTension\n}\n\n// ── living backdrops ──────────────────────────────────────────────────────────\ntype Sel<E extends Element> = d3.Selection<E, unknown, null, undefined>\nfunction sprinkle(g: Sel<SVGGElement>, n: number, opts?: { maxOp?: number; yMax?: number; twinkle?: boolean }): void {\n  const { maxOp = 0.62, yMax = H, twinkle = true } = opts ?? {}\n  for (let i = 0; i < n; i++) {\n    const s = g.append(\"circle\")\n      .attr(\"cx\", Math.random() * W).attr(\"cy\", Math.random() * yMax)\n      .attr(\"r\", Math.random() > 0.85 ? 1.1 + Math.random() * 0.9 : 0.5 + Math.random() * 0.7)\n      .attr(\"fill\", \"#fff\").attr(\"opacity\", 0.12 + Math.random() * maxOp)\n    if (twinkle) s.attr(\"class\", \"tg-star\")\n      .style(\"animation-duration\", `${(2.2 + Math.random() * 4).toFixed(2)}s`)\n      .style(\"animation-delay\", `${(Math.random() * 5).toFixed(2)}s`)\n  }\n}\nfunction sceneGrad(defs: Sel<SVGDefsElement>, id: string, stops: [string, string, number][], radial = true): string {\n  const g = defs.append<SVGElement>(radial ? \"radialGradient\" : \"linearGradient\")\n  g.attr(\"id\", id).attr(\"data-scene\", \"1\")\n  if (!radial) g.attr(\"x1\", \"0\").attr(\"y1\", \"0\").attr(\"x2\", \"0\").attr(\"y2\", \"1\")\n  stops.forEach(([off, color, op]) => g.append(\"stop\").attr(\"offset\", off).attr(\"stop-color\", color).attr(\"stop-opacity\", op))\n  return `url(#${id})`\n}\nfunction buildScene(defs: Sel<SVGDefsElement>, bg: Sel<SVGGElement>, id: BackdropId, space: [string, string, string]): void {\n  defs.selectAll(\"[data-scene]\").remove()\n  bg.selectAll(\"*\").remove()\n  const [c0, c1, c2] = space\n  if (id === \"void\") { sprinkle(bg.append(\"g\"), 18, { maxOp: 0.22, twinkle: false }); return }\n  if (id === \"nebula\") {\n    const spots: [number, number, number, number, string][] = [\n      [W * 0.24, H * 0.28, W * 0.44, H * 0.46, c0], [W * 0.8, H * 0.34, W * 0.38, H * 0.42, c1], [W * 0.55, H * 0.88, W * 0.46, H * 0.36, c2],\n    ]\n    spots.forEach(([cx, cy, rx, ry, col], i) => {\n      const f = sceneGrad(defs, `tg-sc-neb${i}`, [[\"0%\", col, 0.5], [\"100%\", col, 0]])\n      bg.append(\"ellipse\").attr(\"cx\", cx).attr(\"cy\", cy).attr(\"rx\", rx).attr(\"ry\", ry).attr(\"fill\", f).attr(\"opacity\", 0.5 - i * 0.08)\n    })\n    sprinkle(bg.append(\"g\"), 130); return\n  }\n  if (id === \"galaxy\") {\n    sprinkle(bg.append(\"g\"), 70, { maxOp: 0.4 })\n    const core = sceneGrad(defs, \"tg-sc-core\", [[\"0%\", \"#ffffff\", 0.85], [\"18%\", c0, 0.55], [\"55%\", c0, 0.16], [\"100%\", c0, 0]])\n    const swirl = bg.append(\"g\")\n    swirl.append(\"ellipse\").attr(\"cx\", W / 2).attr(\"cy\", H / 2).attr(\"rx\", 240).attr(\"ry\", 170).attr(\"fill\", core)\n    for (let arm = 0; arm < 3; arm++) {\n      const armCol = [c0, c1, c2][arm]\n      for (let i = 0; i < 85; i++) {\n        const t = i / 85, ang = (arm * Math.PI * 2) / 3 + t * Math.PI * 3.1 + (Math.random() - 0.5) * 0.35, rad = 34 + t * 420 + (Math.random() - 0.5) * 26\n        swirl.append(\"circle\").attr(\"cx\", W / 2 + Math.cos(ang) * rad).attr(\"cy\", H / 2 + Math.sin(ang) * rad * 0.66)\n          .attr(\"r\", Math.random() < 0.9 ? 0.5 + Math.random() * 0.8 : 1.2 + Math.random() * 0.8)\n          .attr(\"fill\", Math.random() < 0.6 ? \"#fff\" : armCol).attr(\"opacity\", 0.85 - t * 0.6)\n      }\n    }\n    swirl.append(\"animateTransform\").attr(\"attributeName\", \"transform\").attr(\"type\", \"rotate\")\n      .attr(\"from\", `0 ${W / 2} ${H / 2}`).attr(\"to\", `360 ${W / 2} ${H / 2}`).attr(\"dur\", \"240s\").attr(\"repeatCount\", \"indefinite\")\n    return\n  }\n  if (id === \"aurora\") {\n    sprinkle(bg.append(\"g\"), 70, { maxOp: 0.4, yMax: H * 0.7 })\n    const blur = defs.append(\"filter\").attr(\"id\", \"tg-sc-blur\").attr(\"data-scene\", \"1\").attr(\"x\", \"-60%\").attr(\"y\", \"-60%\").attr(\"width\", \"220%\").attr(\"height\", \"220%\")\n    blur.append(\"feGaussianBlur\").attr(\"stdDeviation\", 22)\n    ;[c0, c1, c2].forEach((col, i) => {\n      const grad = sceneGrad(defs, `tg-sc-aur${i}`, [[\"0%\", col, 0.55], [\"70%\", col, 0.18], [\"100%\", col, 0]], false)\n      const x0 = W * (0.12 + i * 0.3), amp = 60 + i * 22\n      const d = `M${x0},-40 C${x0 + amp},${H * 0.16} ${x0 - amp},${H * 0.34} ${x0 + amp * 0.7},${H * 0.52} L${x0 + amp * 0.7 + 120},${H * 0.48} C${x0 + 130 - amp},${H * 0.3} ${x0 + 130 + amp},${H * 0.14} ${x0 + 110},-40 Z`\n      bg.append(\"path\").attr(\"d\", d).attr(\"fill\", grad).attr(\"filter\", \"url(#tg-sc-blur)\").attr(\"class\", \"tg-aur\")\n        .style(\"animation-duration\", `${10 + i * 3.5}s`).style(\"animation-delay\", `${i * 1.8}s`)\n    })\n    const ground = sceneGrad(defs, \"tg-sc-ground\", [[\"0%\", c1, 0.22], [\"100%\", c1, 0]])\n    bg.append(\"ellipse\").attr(\"cx\", W / 2).attr(\"cy\", H + 60).attr(\"rx\", W * 0.7).attr(\"ry\", 160).attr(\"fill\", ground)\n    return\n  }\n  if (id === \"abyss\") {\n    const ray = sceneGrad(defs, \"tg-sc-ray\", [[\"0%\", \"#cfeef7\", 0.14], [\"100%\", \"#cfeef7\", 0]], false)\n    for (let i = 0; i < 3; i++) {\n      const x = W * (0.22 + i * 0.28)\n      bg.append(\"polygon\").attr(\"points\", `${x - 34},0 ${x + 34},0 ${x + 150},${H * 0.78} ${x - 150},${H * 0.78}`)\n        .attr(\"fill\", ray).attr(\"class\", \"tg-ray\").style(\"animation-delay\", `${i * 2.4}s`)\n    }\n    const plank = bg.append(\"g\")\n    for (let i = 0; i < 46; i++) {\n      plank.append(\"circle\").attr(\"cx\", Math.random() * W).attr(\"cy\", H * 0.25 + Math.random() * H)\n        .attr(\"r\", 0.7 + Math.random() * 1.6).attr(\"fill\", Math.random() < 0.65 ? c1 : c2).attr(\"opacity\", 0.14 + Math.random() * 0.4)\n        .attr(\"class\", \"tg-rise\").style(\"animation-duration\", `${(16 + Math.random() * 18).toFixed(1)}s`).style(\"animation-delay\", `-${(Math.random() * 20).toFixed(1)}s`)\n    }\n    return\n  }\n  if (id === \"cyber\") {\n    const hy = H * 0.62\n    sprinkle(bg.append(\"g\"), 60, { maxOp: 0.45, yMax: hy - 30 })\n    const sun = sceneGrad(defs, \"tg-sc-sun\", [[\"0%\", c0, 0.65], [\"45%\", c0, 0.25], [\"100%\", c0, 0]])\n    bg.append(\"circle\").attr(\"cx\", W / 2).attr(\"cy\", hy).attr(\"r\", 220).attr(\"fill\", sun).attr(\"class\", \"tg-pulse\")\n    const grid = bg.append(\"g\").attr(\"stroke\", c0).attr(\"stroke-width\", 1)\n    for (let i = 0; i < 9; i++) { const y = hy + Math.pow(i / 8, 1.7) * (H - hy); grid.append(\"line\").attr(\"x1\", 0).attr(\"x2\", W).attr(\"y1\", y).attr(\"y2\", y).attr(\"opacity\", 0.32 - i * 0.026) }\n    for (let i = 0; i <= 14; i++) { const bx = (i / 14) * W * 1.9 - W * 0.45; grid.append(\"line\").attr(\"x1\", W / 2 + (bx - W / 2) * 0.08).attr(\"y1\", hy).attr(\"x2\", bx).attr(\"y2\", H).attr(\"opacity\", 0.2) }\n    grid.append(\"line\").attr(\"x1\", 0).attr(\"x2\", W).attr(\"y1\", hy).attr(\"y2\", hy).attr(\"stroke\", c2).attr(\"stroke-width\", 1.6).attr(\"opacity\", 0.75)\n    return\n  }\n  // meteor\n  sprinkle(bg.append(\"g\"), 150)\n  const streak = sceneGrad(defs, \"tg-sc-met\", [[\"0%\", \"#ffffff\", 0.9], [\"100%\", \"#ffffff\", 0]], false)\n  defs.select(\"#tg-sc-met\").attr(\"x1\", \"0\").attr(\"y1\", \"0\").attr(\"x2\", \"1\").attr(\"y2\", \"0.42\")\n  for (let i = 0; i < 6; i++) {\n    const x1 = Math.random() * W * 0.7 - 60, y1 = Math.random() * H * 0.45 - 40\n    bg.append(\"line\").attr(\"x1\", x1).attr(\"y1\", y1).attr(\"x2\", x1 + 95).attr(\"y2\", y1 + 40)\n      .attr(\"stroke\", streak).attr(\"stroke-width\", 2).attr(\"stroke-linecap\", \"round\").attr(\"class\", \"tg-met\")\n      .style(\"animation-duration\", `${(7 + Math.random() * 9).toFixed(1)}s`).style(\"animation-delay\", `${(Math.random() * 10).toFixed(1)}s`)\n  }\n}\n\nexport function ConstellationGraph({ data, config, onPick, labels }: {\n  data: ConstellationData; config: GraphConfig; onPick?: (id: string) => void; labels?: ConstLabels\n}) {\n  const svgRef = useRef<SVGSVGElement>(null)\n  const tipRef = useRef<HTMLDivElement>(null)\n  const wrapRef = useRef<HTMLDivElement>(null)\n  const store = useRef<{ apply: (c: GraphConfig) => void; startTour: () => void; stopTour: () => void; focusHub: (i: number) => void; resetView: () => void } | null>(null)\n  const cfg = useRef(config); cfg.current = config\n  const lab = useRef(labels); lab.current = labels\n  const [touring, setTouring] = useState(false)\n  const [caption, setCaption] = useState(\"\")\n  const [fs, setFs] = useState(false)\n  const [hubList, setHubList] = useState<{ i: number; name: string; size: number }[]>([])\n\n  function toggleFs(): void {\n    const el = wrapRef.current\n    if (!el) return\n    if (document.fullscreenElement) void document.exitFullscreen()\n    else void el.requestFullscreen?.()\n  }\n  useEffect(() => {\n    const onFs = (): void => setFs(!!document.fullscreenElement)\n    document.addEventListener(\"fullscreenchange\", onFs)\n    return () => document.removeEventListener(\"fullscreenchange\", onFs)\n  }, [])\n\n  useEffect(() => {\n    const svgEl = svgRef.current, tip = tipRef.current\n    if (!svgEl || !tip) return\n    const svg = d3.select(svgEl)\n    svg.selectAll(\"*\").remove()\n    const defs = svg.append(\"defs\")\n    const eGlow = defs.append(\"filter\").attr(\"id\", \"tg-eglow\").attr(\"x\", \"-40%\").attr(\"y\", \"-40%\").attr(\"width\", \"180%\").attr(\"height\", \"180%\")\n    eGlow.append(\"feGaussianBlur\").attr(\"stdDeviation\", 1.6).attr(\"result\", \"b\")\n    const em = eGlow.append(\"feMerge\"); em.append(\"feMergeNode\").attr(\"in\", \"b\"); em.append(\"feMergeNode\").attr(\"in\", \"SourceGraphic\")\n    const bg = svg.append(\"g\").attr(\"pointer-events\", \"none\")\n    let sceneKey = \"\"\n    const root = svg.append(\"g\")\n    const zoom = d3.zoom<SVGSVGElement, unknown>().scaleExtent([0.25, 3]).on(\"zoom\", (e) => root.attr(\"transform\", e.transform.toString()))\n    svg.call(zoom).on(\"dblclick.zoom\", null)\n\n    const gradIds = new Map<string, string>()\n    function orb(hex: string): string {\n      let id = gradIds.get(hex)\n      if (!id) {\n        id = `tg-orb${gradIds.size}`; const c = d3.rgb(hex)\n        const g = defs.append(\"radialGradient\").attr(\"id\", id).attr(\"cx\", \"34%\").attr(\"cy\", \"28%\").attr(\"r\", \"78%\")\n        g.append(\"stop\").attr(\"offset\", \"0%\").attr(\"stop-color\", c.brighter(1.1).formatHex())\n        g.append(\"stop\").attr(\"offset\", \"52%\").attr(\"stop-color\", hex)\n        g.append(\"stop\").attr(\"offset\", \"100%\").attr(\"stop-color\", c.darker(1.15).formatHex())\n        gradIds.set(hex, id)\n      }\n      return `url(#${id})`\n    }\n    const auraIds = new Map<string, string>()\n    function aura(hex: string): string {\n      let id = auraIds.get(hex)\n      if (!id) {\n        id = `tg-aura${auraIds.size}`\n        const g = defs.append(\"radialGradient\").attr(\"id\", id)\n        g.append(\"stop\").attr(\"offset\", \"0%\").attr(\"stop-color\", hex).attr(\"stop-opacity\", 0.55)\n        g.append(\"stop\").attr(\"offset\", \"55%\").attr(\"stop-color\", hex).attr(\"stop-opacity\", 0.22)\n        g.append(\"stop\").attr(\"offset\", \"100%\").attr(\"stop-color\", hex).attr(\"stop-opacity\", 0)\n        auraIds.set(hex, id)\n      }\n      return `url(#${id})`\n    }\n\n    const nodes: SimNode[] = data.nodes.map((n) => ({ ...n }))\n    const byId = new Map(nodes.map((n) => [n.id, n]))\n    nodes.forEach((n) => {\n      let depth = 0; let cur: SimNode | undefined = n; const seen = new Set<string>()\n      while (cur?.parent && !seen.has(cur.id)) { seen.add(cur.id); cur = byId.get(cur.parent); depth++ }\n      n.depth = depth\n    })\n    const ghosts: SimNode[] = (data.ghosts ?? []).map((m) => ({\n      id: m.id, label: m.label, group: m.group, signal: \"none\", score: null, size: 0, parent: null, ghost: true,\n    }))\n    const links: SimLink[] = data.edges.map((e: ConstEdge) => ({ source: e.source, target: e.target, type: e.type }))\n\n    const link = root.append(\"g\").attr(\"fill\", \"none\").attr(\"filter\", \"url(#tg-eglow)\")\n      .selectAll<SVGPathElement, SimLink>(\"path\").data(links).join(\"path\").attr(\"stroke-linecap\", \"round\")\n    const node = root.append(\"g\").selectAll<SVGGElement, SimNode>(\"g\").data(nodes, (d) => d.id).join(\"g\").attr(\"data-id\", (d) => d.id).style(\"cursor\", \"pointer\")\n    node.append(\"circle\").attr(\"class\", \"tg-aura\").style(\"pointer-events\", \"none\")\n    node.append(\"circle\").attr(\"class\", \"tg-halo\").attr(\"fill\", \"none\").attr(\"stroke-width\", 2.5)\n    node.append(\"circle\").attr(\"class\", \"tg-core\")\n    node.append(\"text\").attr(\"text-anchor\", \"middle\").style(\"font-size\", \"10.5px\").style(\"font-weight\", \"500\")\n      .attr(\"fill\", \"#E9EAF6\").attr(\"stroke\", \"rgba(6,9,22,0.85)\").attr(\"stroke-width\", 3).attr(\"paint-order\", \"stroke\").style(\"pointer-events\", \"none\")\n    const gGhost = root.append(\"g\")\n    const ghost = gGhost.selectAll<SVGGElement, SimNode>(\"g\").data(ghosts).join(\"g\")\n    ghost.append(\"circle\").attr(\"r\", 9).attr(\"fill\", \"rgba(255,255,255,0.03)\").attr(\"stroke-dasharray\", \"2 3\").attr(\"stroke-width\", 1.5)\n    ghost.append(\"text\").attr(\"text-anchor\", \"middle\").attr(\"dy\", 22).style(\"font-size\", \"10px\")\n      .attr(\"stroke\", \"rgba(6,9,22,0.8)\").attr(\"stroke-width\", 2.5).attr(\"paint-order\", \"stroke\").text((d) => d.label)\n\n    const nbr = new Map<string, Set<string>>(); nodes.forEach((n) => nbr.set(n.id, new Set([n.id])))\n    links.forEach((l) => { const s = nid(l.source), t2 = nid(l.target); nbr.get(s)?.add(t2); nbr.get(t2)?.add(s) })\n\n    const sim = d3.forceSimulation<SimNode>(nodes)\n      .force(\"link\", d3.forceLink<SimNode, SimLink>(links).id((d) => d.id).distance((l) => l.type === \"link\" ? 50 : 82).strength((l) => l.type === \"link\" ? 0.6 : 0.08))\n      .force(\"charge\", d3.forceManyBody().strength(-170))\n      .force(\"collide\", d3.forceCollide<SimNode>().radius((d) => radius(d, cfg.current) + 6))\n      .force(\"center\", d3.forceCenter(W / 2, H / 2))\n\n    sim.on(\"tick\", () => {\n      nodes.forEach((d) => { d.x = Math.max(20, Math.min(W - 20, d.x ?? W / 2)); d.y = Math.max(20, Math.min(H - 26, d.y ?? H / 2)) })\n      link.attr(\"d\", (l) => {\n        const s = l.source as SimNode, t2 = l.target as SimNode\n        const x1 = s.x ?? 0, y1 = s.y ?? 0, x2 = t2.x ?? 0, y2 = t2.y ?? 0\n        const dx = x2 - x1, dy = y2 - y1, dist = Math.hypot(dx, dy) || 1, off = dist * 0.14\n        return `M${x1},${y1} Q${(x1 + x2) / 2 - (dy / dist) * off},${(y1 + y2) / 2 + (dx / dist) * off} ${x2},${y2}`\n      })\n      node.attr(\"transform\", (d) => `translate(${d.x},${d.y})`)\n    })\n    ghosts.forEach((g, i) => { const a = (Math.PI * 2 * i) / Math.max(1, ghosts.length) - Math.PI / 2; g.x = W / 2 + Math.cos(a) * (W / 2 - 28); g.y = H / 2 + Math.sin(a) * (H / 2 - 30) })\n    ghost.attr(\"transform\", (d) => `translate(${d.x},${d.y})`)\n\n    node.call(d3.drag<SVGGElement, SimNode>()\n      .on(\"start\", (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })\n      .on(\"drag\", (e, d) => { d.fx = e.x; d.fy = e.y })\n      .on(\"end\", (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null }))\n\n    const touringRef = { flag: false, timer: 0 as ReturnType<typeof setTimeout> | 0 }\n    function applyLinkVis(): void {\n      const c = cfg.current\n      link.style(\"opacity\", (l) => visible(l.type, c) ? 0.55 : 0).attr(\"display\", (l) => visible(l.type, c) ? null : \"none\")\n    }\n    node.on(\"mouseenter\", function (_e, d) {\n      if (touringRef.flag) return\n      const s = nbr.get(d.id) as Set<string>\n      node.style(\"opacity\", (n) => s.has(n.id) ? 1 : 0.1)\n      link.style(\"opacity\", (l) => (nid(l.source) === d.id || nid(l.target) === d.id) ? 0.95 : 0.04)\n      ghost.style(\"opacity\", 0.25)\n      const g = d3.select(this); g.raise()\n      const r = radius(d, cfg.current)\n      g.select(\"circle.tg-core\").transition().duration(160).attr(\"r\", r * 1.28)\n      g.select(\"circle.tg-aura\").transition().duration(160).attr(\"r\", r * 3)\n      const t2 = themeById(cfg.current.theme), gc = makeGroupColor(nodes, t2), dot = fill(d, cfg.current, gc)\n      tip.style.display = \"block\"\n      tip.innerHTML = `<div class=\"flex items-center gap-2\"><span class=\"h-2.5 w-2.5 shrink-0 rounded-full\" style=\"background:${dot};box-shadow:0 0 8px ${dot}\"></span><span class=\"font-semibold\">${esc(d.label)}</span></div>` +\n        (d.sub ? `<div class=\"mt-0.5 text-[11px] text-white/50\">${esc(d.sub)}</div>` : \"\") +\n        (d.badge ? `<div class=\"mt-1.5 text-[12px] text-white/85\">${esc(d.badge)}</div>` : \"\") +\n        (d.signal || d.score != null ? `<div class=\"mt-1 flex items-center gap-1.5 text-[11px] text-white/60\"><span class=\"h-2 w-2 rounded-full\" style=\"background:${signalColor(d.signal)}\"></span>${d.signal ? esc(d.signal) : \"\"}${d.score != null ? `${d.signal ? \" · \" : \"\"}score ${d.score}` : \"\"}</div>` : \"\")\n    }).on(\"mousemove\", (e) => {\n      const r = svgEl.getBoundingClientRect()\n      tip.style.left = (e.clientX - r.left + 14) + \"px\"; tip.style.top = (e.clientY - r.top + 14) + \"px\"\n    }).on(\"mouseleave\", function (_e, d) {\n      if (touringRef.flag) return\n      node.style(\"opacity\", 1); applyLinkVis(); ghost.style(\"opacity\", 1); tip.style.display = \"none\"\n      const g = d3.select(this), r = radius(d, cfg.current)\n      g.select(\"circle.tg-core\").transition().duration(200).attr(\"r\", r)\n      g.select(\"circle.tg-aura\").transition().duration(200).attr(\"r\", r * 2.3)\n    }).on(\"click\", (_e, d) => onPick?.(d.id))\n\n    function apply(c: GraphConfig): void {\n      const t2: Theme = themeById(c.theme)\n      const gc = makeGroupColor(nodes, t2)\n      const key = c.backdrop + \"|\" + c.theme\n      if (key !== sceneKey) { sceneKey = key; buildScene(defs, bg, c.backdrop, t2.space) }\n      node.select<SVGCircleElement>(\"circle.tg-core\").attr(\"r\", (d) => radius(d, c)).attr(\"fill\", (d) => orb(fill(d, c, gc))).attr(\"stroke\", (d) => ring(d, c, gc)).attr(\"stroke-width\", 1.75)\n      node.select<SVGCircleElement>(\"circle.tg-aura\").attr(\"r\", (d) => radius(d, c) * 2.3).attr(\"fill\", (d) => aura(fill(d, c, gc))).style(\"opacity\", 0.9)\n      node.select<SVGCircleElement>(\"circle.tg-halo\").attr(\"r\", (d) => radius(d, c) + 4).attr(\"stroke\", (d) => signalColor(d.signal)).style(\"display\", (d) => ALERT(d.signal) ? \"\" : \"none\")\n      node.select<SVGTextElement>(\"text\").attr(\"dy\", (d) => radius(d, c) + 14).style(\"display\", c.labels === \"all\" ? \"\" : \"none\").text((d) => d.label)\n      link.attr(\"stroke\", (l) => t2.edge[l.type]).attr(\"stroke-width\", (l) => l.type === \"link\" ? 1.3 : 1.6)\n        .attr(\"stroke-dasharray\", (l) => l.type === \"harmony\" ? \"5 4\" : l.type === \"tension\" ? \"1 5\" : null)\n        .classed(\"tg-flow\", (l) => l.type !== \"link\")\n      applyLinkVis()\n      gGhost.style(\"display\", c.showGhosts ? \"\" : \"none\")\n      ghost.select(\"circle\").attr(\"stroke\", t2.ghost); ghost.select(\"text\").attr(\"fill\", t2.ghost)\n      sim.force(\"x\", null).force(\"y\", null).force(\"radial\", null)\n      if (c.layout === \"cluster\") {\n        const keys = [...new Set(nodes.map((n) => n.group))].sort()\n        const cols = 2, cell = [[W * 0.28, H * 0.3], [W * 0.72, H * 0.3], [W * 0.28, H * 0.72], [W * 0.72, H * 0.72]]\n        const anc = (g: string): [number, number] => cell[keys.indexOf(g) % cell.length] as [number, number]\n        void cols\n        sim.force(\"center\", null).force(\"x\", d3.forceX<SimNode>((d) => anc(d.group)[0]).strength(0.25)).force(\"y\", d3.forceY<SimNode>((d) => anc(d.group)[1]).strength(0.25))\n      } else if (c.layout === \"radial\") {\n        sim.force(\"center\", null).force(\"radial\", d3.forceRadial<SimNode>((d) => (d.depth ?? 0) * 115 + 24, W / 2, H / 2).strength(0.75))\n      } else {\n        sim.force(\"center\", d3.forceCenter(W / 2, H / 2))\n      }\n      sim.force(\"collide\", d3.forceCollide<SimNode>().radius((d) => radius(d, c) + 6))\n      sim.alpha(0.6).restart()\n    }\n\n    // --- guided tour: cinematic zoom through each hub cluster --------------------------------\n    const parentIds = new Set(nodes.filter((n) => n.parent).map((n) => n.parent as string))\n    const hubs = nodes.filter((n) => parentIds.has(n.id))\n    const clusters = (hubs.length\n      ? hubs.map((hub) => ({ name: hub.label, members: [hub, ...nodes.filter((n) => n.parent === hub.id)] }))\n      : [{ name: \"\", members: nodes }]\n    ).sort((a, b) => b.members.length - a.members.length).slice(0, 12)\n    setHubList(clusters.map((tm, i) => ({ i, name: tm.name, size: tm.members.length })).filter((tm) => tm.name))\n\n    function zoomTo(members: SimNode[], dur: number): void {\n      const set = new Set(members.map((m) => m.id)); const xs: number[] = [], ys: number[] = []\n      svgEl!.querySelectorAll<SVGGElement>(\"g[data-id]\").forEach((el) => {\n        if (!set.has(el.getAttribute(\"data-id\") || \"\")) return\n        const m = /translate\\(([-\\d.]+),([-\\d.]+)\\)/.exec(el.getAttribute(\"transform\") || \"\")\n        if (m) { xs.push(+m[1]); ys.push(+m[2]) }\n      })\n      if (!xs.length) return\n      const mx = xs.reduce((a, b) => a + b, 0) / xs.length, my = ys.reduce((a, b) => a + b, 0) / ys.length\n      const spread = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)) + 140\n      const k = Math.max(1.15, Math.min(2.6, 760 / spread))\n      svg.transition().duration(dur).ease(d3.easeCubicInOut).call(zoom.transform, d3.zoomIdentity.translate(W / 2 - k * mx, H / 2 - k * my).scale(k))\n    }\n    function spotlight(members: SimNode[]): void {\n      const set = new Set(members.map((m) => m.id))\n      node.transition().duration(500).style(\"opacity\", (n) => set.has(n.id) ? 1 : 0.07)\n      link.transition().duration(500).style(\"opacity\", (l) => set.has(nid(l.source)) && set.has(nid(l.target)) ? 0.95 : 0.03)\n    }\n    function step(i: number): void {\n      if (!touringRef.flag) return\n      if (i >= clusters.length) { stopTour(); return }\n      zoomTo(clusters[i].members, 950); spotlight(clusters[i].members); setCaption(clusters[i].name)\n      touringRef.timer = setTimeout(() => step(i + 1), 2400)\n    }\n    function startTour(): void { if (!clusters.length) return; touringRef.flag = true; setTouring(true); step(0) }\n    function clearSpotlight(): void { node.transition().duration(400).style(\"opacity\", 1); applyLinkVis(); ghost.transition().duration(400).style(\"opacity\", 1) }\n    function resetZoom(): void { svg.transition().duration(650).ease(d3.easeCubicInOut).call(zoom.transform, d3.zoomIdentity) }\n    function stopTour(): void { touringRef.flag = false; clearTimeout(touringRef.timer); setTouring(false); setCaption(\"\"); clearSpotlight(); resetZoom() }\n    function focusHub(i: number): void {\n      const tm = clusters[i]; if (!tm) return\n      touringRef.flag = false; clearTimeout(touringRef.timer); setTouring(false)\n      zoomTo(tm.members, 800); spotlight(tm.members); setCaption(tm.name)\n    }\n    function resetView(): void { setCaption(\"\"); clearSpotlight(); resetZoom() }\n\n    store.current = { apply, startTour, stopTour, focusHub, resetView }\n    apply(cfg.current)\n    sim.alpha(1).restart()\n    node.attr(\"opacity\", 0).transition().delay((_d, i) => 120 + i * 14).duration(600).ease(d3.easeCubicOut).attr(\"opacity\", 1)\n    link.style(\"opacity\", 0)\n    setTimeout(() => { if (svgEl.isConnected && !touringRef.flag) applyLinkVis() }, 900)\n    ghost.attr(\"opacity\", 0).transition().delay(700).duration(700).attr(\"opacity\", 1)\n\n    return () => { touringRef.flag = false; clearTimeout(touringRef.timer); sim.stop() }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [data])\n\n  useEffect(() => { store.current?.apply(config) }, [config])\n\n  const L = lab.current\n  return (\n    <div ref={wrapRef} className=\"relative w-full overflow-hidden\" style={{ background: BACKDROP_CSS[config.backdrop] ?? BACKDROP_CSS.nebula }}>\n      <style>{`\n        @keyframes tg-dash{to{stroke-dashoffset:-18}}.tg-flow{animation:tg-dash .8s linear infinite}\n        @keyframes tg-ping{0%{transform:scale(1);opacity:.55}70%{transform:scale(1.9);opacity:0}100%{opacity:0}}\n        .tg-halo{transform-box:fill-box;transform-origin:center;animation:tg-ping 1.9s ease-out infinite}\n        @keyframes tg-tw{0%,100%{opacity:.1}50%{opacity:.75}}\n        .tg-star{animation-name:tg-tw;animation-timing-function:ease-in-out;animation-iteration-count:infinite}\n        @keyframes tg-aurk{0%{transform:translateX(0)}50%{transform:translateX(46px)}100%{transform:translateX(0)}}\n        .tg-aur{animation-name:tg-aurk;animation-timing-function:ease-in-out;animation-iteration-count:infinite}\n        @keyframes tg-rayk{0%,100%{opacity:.4}50%{opacity:1}}.tg-ray{animation:tg-rayk 7s ease-in-out infinite}\n        @keyframes tg-risek{to{transform:translateY(-760px)}}.tg-rise{animation-name:tg-risek;animation-timing-function:linear;animation-iteration-count:infinite}\n        @keyframes tg-pulsek{0%,100%{opacity:.55}50%{opacity:.95}}.tg-pulse{animation:tg-pulsek 6s ease-in-out infinite}\n        @keyframes tg-metk{0%{transform:translate(0,0);opacity:0}4%{opacity:.95}13%{transform:translate(860px,360px);opacity:0}100%{transform:translate(860px,360px);opacity:0}}\n        .tg-met{animation-name:tg-metk;animation-timing-function:ease-in;animation-iteration-count:infinite}\n      `}</style>\n      <svg ref={svgRef} viewBox={`0 0 ${W} ${H}`} width=\"100%\" preserveAspectRatio=\"xMidYMid meet\" style={{ display: \"block\", position: \"relative\", height: fs ? \"100vh\" : \"auto\", touchAction: \"none\" }} role=\"img\" aria-label=\"Constellation network graph\" />\n      <div className=\"pointer-events-none absolute inset-0\" style={{ background: \"radial-gradient(ellipse 115% 100% at 50% 42%, transparent 52%, rgba(2,4,12,0.6) 100%)\" }} />\n\n      {hubList.length > 0 && (\n        <div className=\"absolute left-3 top-3 z-10\">\n          <select aria-label={L?.allGroups ?? \"All\"} defaultValue=\"\"\n            onChange={(e) => { const v = e.target.value; if (v === \"\") store.current?.resetView(); else store.current?.focusHub(Number(v)) }}\n            className=\"max-w-[180px] rounded-lg border border-white/15 bg-[#0d1226]/80 px-2 py-1.5 text-xs text-white/85 backdrop-blur-md focus:outline-none\">\n            <option value=\"\">{L?.allGroups ?? \"All\"}</option>\n            {hubList.map((tm) => <option key={tm.i} value={tm.i}>{tm.name} ({tm.size})</option>)}\n          </select>\n        </div>\n      )}\n\n      <button onClick={toggleFs} aria-label={fs ? (L?.fsExit ?? \"Exit fullscreen\") : (L?.fsEnter ?? \"Fullscreen\")} className=\"absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-lg border border-white/15 bg-white/[0.07] px-2.5 py-1.5 text-xs font-medium text-white/85 backdrop-blur-md transition-colors hover:bg-white/15\">\n        {fs ? <Minimize2 size={13} /> : <Maximize2 size={13} />}\n      </button>\n\n      {hubList.length > 0 && (\n        <button onClick={() => (touring ? store.current?.stopTour() : store.current?.startTour())} className=\"absolute bottom-3 left-3 z-10 inline-flex items-center gap-1.5 rounded-lg border border-white/15 bg-white/[0.07] px-2.5 py-1.5 text-xs font-medium text-white/85 backdrop-blur-md transition-colors hover:bg-white/15\">\n          {touring ? <><Square size={12} /> {L?.tourStop ?? \"Stop\"}</> : <><Play size={12} /> {L?.tourPlay ?? \"Tour\"}</>}\n        </button>\n      )}\n\n      {caption && (\n        <div className=\"pointer-events-none absolute left-1/2 top-3 z-10 -translate-x-1/2 rounded-full border border-white/15 bg-[#0d1226]/85 px-3.5 py-1 text-xs font-medium text-white/90 shadow-[0_0_24px_rgba(109,94,247,0.35)] backdrop-blur-md\">\n          {L?.tourOf ? L.tourOf(caption) : caption}\n        </div>\n      )}\n      <div ref={tipRef} className=\"pointer-events-none absolute z-10 hidden max-w-[230px] rounded-xl border border-white/12 bg-[#0d1226]/95 px-3 py-2.5 text-[13px] text-white shadow-[0_12px_40px_rgba(0,0,0,0.55)] backdrop-blur-md\" />\n    </div>\n  )\n}\n"},{"path":"frontend/src/components/constellation/themes.ts","content":"// Constellation graph — studio vocabulary: themes (palettes), layouts, encodings, and 7 living\n// backdrops. Fully generic: nodes carry a free-string `group` (colored from an ordered palette),\n// an optional `signal` ramp key (ring/alert), and an optional 0–100 `score`. No app-specific domain.\n\nexport type ConstEdgeType = \"link\" | \"harmony\" | \"tension\"\n\nexport interface ConstNode {\n  id: string\n  label: string\n  /** secondary line in the tooltip (e.g. role, path) */\n  sub?: string | null\n  /** any string — colored from the theme palette by stable order */\n  group: string\n  /** extra tooltip detail (e.g. category, tag) */\n  badge?: string | null\n  /** ring/alert ramp key: calm | steady | watch | warn | critical | none */\n  signal?: string | null\n  /** 0–100 — used when colorBy/sizeBy = score */\n  score?: number | null\n  /** 0–100 — drives node radius when sizeBy = size */\n  size?: number | null\n  /** id of a parent node (hierarchy → radial layout + a `link` edge is implied by data) */\n  parent?: string | null\n}\nexport interface ConstEdge { source: string; target: string; type: ConstEdgeType }\nexport interface ConstGhost { id: string; label: string; group: string }\nexport interface ConstellationData {\n  nodes: ConstNode[]\n  edges: ConstEdge[]\n  /** optional legend labels for groups */\n  groups?: { key: string; label: string }[]\n  /** optional placeholder nodes ringed around the edge (dashed) */\n  ghosts?: ConstGhost[]\n}\n\nexport type LayoutMode = \"force\" | \"cluster\" | \"radial\"\nexport type ColorBy = \"group\" | \"signal\" | \"score\"\nexport type SizeBy = \"size\" | \"score\" | \"uniform\"\nexport type LabelMode = \"all\" | \"none\"\nexport type BackdropId = \"nebula\" | \"galaxy\" | \"aurora\" | \"abyss\" | \"cyber\" | \"meteor\" | \"void\"\n\nexport interface GraphConfig {\n  theme: string\n  backdrop: BackdropId\n  layout: LayoutMode\n  colorBy: ColorBy\n  sizeBy: SizeBy\n  showLink: boolean\n  showHarmony: boolean\n  showTension: boolean\n  showGhosts: boolean\n  labels: LabelMode\n}\n\nexport const DEFAULT_CONFIG: GraphConfig = {\n  theme: \"aurora\",\n  backdrop: \"nebula\",\n  layout: \"force\",\n  colorBy: \"group\",\n  sizeBy: \"size\",\n  showLink: true,\n  showHarmony: true,\n  showTension: true,\n  showGhosts: true,\n  labels: \"all\",\n}\n\nexport interface Theme {\n  id: string\n  name: string\n  /** ordered group palette — nodes map to a color by the stable order of their group value */\n  palette: string[]\n  edge: Record<ConstEdgeType, string>\n  ghost: string\n  /** three nebula hues for the deep-space backdrops */\n  space: [string, string, string]\n}\n\nexport const THEMES: Theme[] = [\n  {\n    id: \"aurora\", name: \"Aurora\",\n    palette: [\"#8B82F0\", \"#25B586\", \"#F5A93B\", \"#E0648F\", \"#5CA8F0\", \"#B679F0\"],\n    edge: { link: \"#8E93B8\", harmony: \"#2BBD8D\", tension: \"#F0605F\" }, ghost: \"#7E84A8\",\n    space: [\"#6D5EF7\", \"#1D9E75\", \"#D4537E\"],\n  },\n  {\n    id: \"ocean\", name: \"Ocean\",\n    palette: [\"#3D86D8\", \"#25B586\", \"#5CA8F0\", \"#5DCAA5\", \"#7FB2E8\", \"#0FA3B1\"],\n    edge: { link: \"#6E9FD6\", harmony: \"#2BBD8D\", tension: \"#E8764A\" }, ghost: \"#6E92BE\",\n    space: [\"#185FA5\", \"#0FA3B1\", \"#5DCAA5\"],\n  },\n  {\n    id: \"sunset\", name: \"Sunset\",\n    palette: [\"#E0648F\", \"#F5A93B\", \"#EE7546\", \"#8B82F0\", \"#F06AA0\", \"#D98BE0\"],\n    edge: { link: \"#A79FB8\", harmony: \"#F5A93B\", tension: \"#D14545\" }, ghost: \"#9A90A8\",\n    space: [\"#D4537E\", \"#EF9F27\", \"#7F77DD\"],\n  },\n  {\n    id: \"mono\", name: \"Mono\",\n    palette: [\"#E8E6F0\", \"#ADB2C8\", \"#7E84A8\", \"#565C7A\", \"#C6CADD\", \"#8E93B8\"],\n    edge: { link: \"#787E9E\", harmony: \"#ADB2C8\", tension: \"#F0605F\" }, ghost: \"#666C8C\",\n    space: [\"#3A4066\", \"#565C7A\", \"#23273F\"],\n  },\n]\n\n// Semantic signal ramp — fixed across themes so an alert reads the same everywhere.\n// Apps map their own status onto these keys (e.g. a high-risk signal → \"critical\").\nconst SIGNAL: Record<string, string> = {\n  critical: \"#E24B4A\", warn: \"#BA7517\", watch: \"#EF9F27\", steady: \"#639922\", calm: \"#3FA85E\", none: \"#9c9a92\",\n}\n\nexport function themeById(id: string): Theme {\n  return THEMES.find((t) => t.id === id) ?? THEMES[0]\n}\nexport function signalColor(level: string | null | undefined): string {\n  return (level && SIGNAL[level]) || SIGNAL.none\n}\nexport function scoreColor(score: number | null | undefined): string {\n  if (score == null) return \"#9c9a92\"\n  if (score >= 75) return \"#1D9E75\"\n  if (score >= 60) return \"#639922\"\n  if (score >= 45) return \"#EF9F27\"\n  return \"#E24B4A\"\n}\n/** Stable group→color: sort unique groups, index into the theme palette (cycles if > palette). */\nexport function makeGroupColor(nodes: ConstNode[], theme: Theme): (g: string) => string {\n  const keys = [...new Set(nodes.map((n) => n.group))].sort()\n  return (g: string) => theme.palette[Math.max(0, keys.indexOf(g)) % theme.palette.length]\n}\n\nexport const BACKDROPS: { id: BackdropId; label: string; swatch: string }[] = [\n  { id: \"nebula\", label: \"constellation.bg.nebula\", swatch: \"radial-gradient(circle at 35% 32%, #8b7cf7, #16204a 55%, #0a0e1f)\" },\n  { id: \"galaxy\", label: \"constellation.bg.galaxy\", swatch: \"radial-gradient(circle, #fff 4%, #6d5ef7 26%, #131a36 70%, #05070f)\" },\n  { id: \"aurora\", label: \"constellation.bg.aurora\", swatch: \"linear-gradient(160deg, #25e2a2 0%, #1a6b8f 55%, #071018)\" },\n  { id: \"abyss\", label: \"constellation.bg.abyss\", swatch: \"linear-gradient(180deg, #0a3550, #04121f 80%)\" },\n  { id: \"cyber\", label: \"constellation.bg.cyber\", swatch: \"linear-gradient(180deg, #8a5cf6 0%, #150a38 62%, #ff5c9e 63%, #150a38 66%, #0a0723)\" },\n  { id: \"meteor\", label: \"constellation.bg.meteor\", swatch: \"linear-gradient(135deg, #0a0e1f 42%, #fff 50%, #0a0e1f 58%)\" },\n  { id: \"void\", label: \"constellation.bg.void\", swatch: \"radial-gradient(circle at 50% 40%, #12141d, #07080d)\" },\n]\n\nexport const BACKDROP_CSS: Record<BackdropId, string> = {\n  nebula: \"radial-gradient(ellipse 90% 70% at 50% 0%, #10162e 0%, #0a0e1f 48%, #060917 100%)\",\n  galaxy: \"radial-gradient(ellipse 80% 65% at 50% 45%, #131a36 0%, #0a0e21 55%, #05070f 100%)\",\n  aurora: \"linear-gradient(180deg, #06141c 0%, #071018 45%, #020409 100%)\",\n  abyss: \"linear-gradient(180deg, #04121f 0%, #062333 55%, #021018 100%)\",\n  cyber: \"linear-gradient(180deg, #0b0723 0%, #150a38 58%, #060312 100%)\",\n  meteor: \"radial-gradient(ellipse 90% 70% at 50% 0%, #0d1226 0%, #0a0e1f 50%, #05070f 100%)\",\n  void: \"radial-gradient(ellipse 80% 70% at 50% 42%, #0b0d14 0%, #07080d 100%)\",\n}\n"}],"wiring":{"router_imports":["from app.api.v1.endpoints.constellation.router import router as constellation_router"],"router_includes":["api_router.include_router(constellation_router, prefix=\"/constellation\", tags=[\"Constellation\"])"],"models":["import app.models.constellation_graph  # noqa: F401"],"nav":["  { href: \"/constellation\", icon: Network, label: \"nav.constellation\" },"],"dashboard":["        <a href={\"/constellation\"} className=\"group block rounded-lg border border-border/70 bg-background p-4 text-left transition-all hover:border-primary/40 hover:bg-accent/40\">\n          <div className=\"flex items-center justify-between gap-3\">\n            <span className=\"text-sm font-semibold\">Constellation</span>\n            <span className=\"text-xs font-medium text-muted-foreground group-hover:text-primary\">Open</span>\n          </div>\n          <p className=\"mt-1 text-xs text-muted-foreground\">Feature workspace is ready.</p>\n        </a>"],"api":["  listConstellations: () => request<{ constellations: { id: string; subject_type: string; subject_id: string; title: string; updated_at: string | null }[] }>(\"/constellation\"),","  getConstellation: (subjectType: string, subjectId: string) => request<{ id: string; subject_type: string; subject_id: string; title: string; updated_at: string | null; data: unknown }>(`/constellation?subject_type=${encodeURIComponent(subjectType)}&subject_id=${encodeURIComponent(subjectId)}`),","  saveConstellation: (body: { subject_type: string; subject_id: string; title: string; data: unknown }) => request<{ id: string }>(\"/constellation\", { method: \"POST\", body: JSON.stringify(body) }),","  deleteConstellation: (id: string) => request<{ deleted: boolean }>(`/constellation/${id}`, { method: \"DELETE\" })"],"admin_imports":[],"admin_sections":[],"middleware_imports":[],"middleware":[]},"deps":{"npm":{"@types/d3":"^7.4.3","d3":"^7.9.0"},"py":[],"npm_dev":{},"scripts":{}},"setup":{}}]}