// Concepts pane — unified view of taxonomy + glossary. The two layers share a // single TaxonomyTerm table, so what looked like two panes was always one // dataset rendered two ways. This pane gives the user a Tree/Alphabetical // toggle and a single Analyze action that refreshes both layers. // // Re-running Analyze does NOT replace; it produces a *review*. Each row // carries one of three statuses: // accepted — confirmed, surfaces normally // suggested — analyzer added in last run, awaits keep/discard // deprecated — analyzer didn't see in last run, awaits keep/discard // Pending statuses show a NEW / DEPRECATED badge plus inline Keep / Discard // buttons. A Pending filter narrows the list to just the changes awaiting // review when there are many. // // Each row is also draggable onto the Model canvas (MIME // `application/x-socrata-term`), and clicking a row opens TermDetail. "use client"; import { useMemo, useState } from "react"; import { PaneFrame } from "../PaneFrame"; import { PaneViewTabs } from "../PaneControls"; import { PaneEmpty } from "../PaneEmpty"; import { PaneDrawer } from "../PaneDrawer"; import { StatusChip } from "../StatusChip"; import { useAnalysis, type ClientTerm } from "../../../lib/workspace/analysisStore"; import { useOpenPanes } from "../../../lib/workspace/openPanesStore"; type ViewMode = "tree" | "alpha"; interface ConceptsPaneProps { /** Position of this pane in the column stack (0 = root). */ index: number; onClose: () => void; } export function ConceptsPane({ index, onClose }: ConceptsPaneProps) { const { terms, analyze, inFlight } = useAnalysis(); const [mode, setMode] = useState("tree"); const running = inFlight.has("concepts") || inFlight.has("all"); const kept = useMemo(() => terms.filter(t => t.status === "accepted"), [terms]); const pending = useMemo(() => terms.filter(t => t.status !== "accepted"), [terms]); const definedCount = kept.filter(t => t.definition && t.definition.length > 0).length; const subtitle = pending.length > 0 ? `${kept.length} kept · ${definedCount} defined · ${pending.length} pending` : `${kept.length} terms · ${definedCount} defined`; const right = (
value={mode} onChange={setMode} ariaLabel="Concepts view mode" tabs={[ { value: "tree", label: "Tree", title: "Hierarchical view" }, { value: "alpha", label: "A–Z", title: "Alphabetical view with definitions" }, ]} />
); return (
{kept.length === 0 && pending.length === 0 ? ( Concepts are extracted from your prose. Write your idea in the editor on the right, then run Analyze. } action={{ label: running ? "Analyzing…" : "Run Analyze →", onClick: () => void analyze("concepts"), disabled: running, }} /> ) : kept.length === 0 ? ( ) : mode === "tree" ? ( ) : ( )}
{pending.length > 0 ? ( ) : null}
); } interface TreeNode { term: ClientTerm; children: TreeNode[]; } function buildTree(terms: ClientTerm[]): TreeNode[] { const byId = new Map(terms.map(t => [t.id, { term: t, children: [] }])); const roots: TreeNode[] = []; for (const t of terms) { const node = byId.get(t.id)!; if (t.parentId && byId.has(t.parentId)) { byId.get(t.parentId)!.children.push(node); } else { roots.push(node); } } return roots; } function TreeView({ terms, parentIndex }: { terms: ClientTerm[]; parentIndex: number }) { const tree = useMemo(() => buildTree(terms), [terms]); return ( ); } function AlphaView({ terms, parentIndex }: { terms: ClientTerm[]; parentIndex: number }) { const sorted = useMemo(() => [...terms].sort((a, b) => a.label.localeCompare(b.label)), [terms]); const { pushFrom, columns } = useOpenPanes(); const activeId = columns[parentIndex + 1]?.kind === "term" ? columns[parentIndex + 1]!.id : null; return ( ); } /** The shared visual unit for both Tree and A–Z views in Concepts. The * leading column is ALWAYS the chevron slot — Tree passes a real toggle * button, A–Z passes nothing and we render an empty placeholder of the * same width. That keeps the cards visually identical at every depth and * across views; only the chevron's behavior differs. */ function ConceptCard({ term, active, onOpen, chevron, }: { term: ClientTerm; active: boolean; onOpen: () => void; chevron?: React.ReactNode; }) { return (
dragTerm(e, term)} onClick={onOpen} role="button" tabIndex={0} onKeyDown={e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(); } }} > {chevron}
{term.label}
{term.definition ? (
{term.definition}
) : (
(no definition yet)
)}
); } function TermNode({ node, depth, parentIndex, }: { node: TreeNode; depth: number; parentIndex: number; }) { const { pushFrom, columns } = useOpenPanes(); const [expanded, setExpanded] = useState(true); const t = node.term; const hasChildren = node.children.length > 0; const activeId = columns[parentIndex + 1]?.kind === "term" ? columns[parentIndex + 1]!.id : null; const open = activeId === t.id; // Only parents get a chevron; leaves render flush-left (matches A–Z view). const chevron = hasChildren ? ( ) : undefined; return (
  • pushFrom(parentIndex, { kind: "term", id: t.id })} chevron={chevron} /> {hasChildren && expanded ? (
      {node.children.map(c => ( ))}
    ) : null}
  • ); } function StatusBadge({ term }: { term: ClientTerm }) { if (term.status === "suggested") { return ; } if (term.status === "deprecated") { return ; } if (term.linkedBlockId) { return ; } return drag to canvas; } function DecisionRow({ term }: { term: ClientTerm }) { const { decideTerm } = useAnalysis(); if (term.status === "accepted") return null; const isSuggested = term.status === "suggested"; return (
    e.stopPropagation()}>
    ); } function dragTerm(e: React.DragEvent, t: ClientTerm) { e.dataTransfer.setData( "application/x-socrata-term", JSON.stringify({ termId: t.id, label: t.label, definition: t.definition }) ); e.dataTransfer.effectAllowed = "move"; }