{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "disease-viewer",
  "title": "Disease viewer",
  "description": "Shows a disease on organ models: the structures it affects or spares at each stage and scale, with a panel for its ICD-10 code, course, findings and key investigations. Every disease component is built on it.",
  "dependencies": [
    "@base-ui/react@^1.8.0",
    "class-variance-authority@^0.7.1",
    "cn@^0.5.0"
  ],
  "registryDependencies": [
    "@organui/organ-viewer",
    "badge",
    "button"
  ],
  "files": [
    {
      "path": "registry/base-nova/disease-viewer/disease-viewer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"cn\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  diseaseModel,\n  scaleOf,\n  stageOf,\n  type Disease,\n  type DiseaseInvestigation,\n  type DiseaseScale,\n  type DiseaseStage,\n} from \"@/components/disease-model\"\nimport {\n  OrganViewer,\n  type OrganModel,\n  type OrganViewerProps,\n} from \"@/components/organ-viewer\"\nimport { IconPlaceholder } from \"@/icons/icon-placeholder\"\n\nfunction useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange?: (value: T) => void\n) {\n  const [inner, setInner] = React.useState(defaultValue)\n  const controlled = value !== undefined\n  const set = React.useCallback(\n    (next: T) => {\n      if (!controlled) setInner(next)\n      onChange?.(next)\n    },\n    [controlled, onChange]\n  )\n  return [controlled ? value : inner, set] as const\n}\n\nexport type DiseaseViewerProps = Omit<\n  OrganViewerProps,\n  \"model\" | \"selectedId\" | \"onSelectionChange\"\n> & {\n  /** The disease: its scales, stages, findings and investigations. */\n  disease: Disease\n  /** The scale shown, by ID. */\n  scale?: string\n  /** The scale shown first when `scale` is not set. Defaults to the first. */\n  defaultScale?: string\n  /** Called with the scale a reader chooses. */\n  onScaleChange?: (scale: string) => void\n  /** The stage shown, by ID. */\n  stage?: string\n  /** The stage shown first when `stage` is not set. Defaults to the first. */\n  defaultStage?: string\n  /** Called with the stage a reader chooses. */\n  onStageChange?: (stage: string) => void\n  /** The selected structure ID, or null. */\n  selectedId?: string | null\n  /** Called with the structure a reader selects, or null when they clear it. */\n  onSelectionChange?: (id: string | null) => void\n  /** Shows the panel with the stages, scales, findings and investigations. Defaults to true. */\n  panel?: boolean\n}\n\nexport function DiseaseViewer({\n  disease,\n  scale: scaleProp,\n  defaultScale = disease.scales[0]!.id,\n  onScaleChange,\n  stage: stageProp,\n  defaultStage = disease.stages?.[0]?.id ?? \"\",\n  onStageChange,\n  selectedId: selectedProp,\n  onSelectionChange,\n  panel = true,\n  label,\n  className,\n  ...props\n}: DiseaseViewerProps) {\n  const [scaleId, setScaleId] = useControllable(\n    scaleProp,\n    defaultScale,\n    onScaleChange\n  )\n  const [stageId, setStageId] = useControllable(\n    stageProp,\n    defaultStage,\n    onStageChange\n  )\n  const [selectedId, setSelectedId] = useControllable<string | null>(\n    selectedProp,\n    null,\n    onSelectionChange\n  )\n  const scale = scaleOf(disease, scaleId)\n  const stage = stageOf(disease, stageId)\n  const model = React.useMemo(\n    () => diseaseModel(disease, scale.id, stage?.id),\n    [disease, scale.id, stage?.id]\n  )\n  const changeScale = (next: string) => {\n    if (next === scale.id) return\n    setScaleId(next)\n    // Each scale has its own structures.\n    setSelectedId(null)\n  }\n  return (\n    // Side by side when the component itself is wide enough, so it fits a\n    // narrow column as well as a full page.\n    <div\n      data-slot=\"disease-viewer\"\n      className={cn(\"@container min-w-0\", className)}\n    >\n      <div\n        className={cn(\n          \"grid gap-4\",\n          panel && \"@3xl:grid-cols-[minmax(0,1fr)_20rem]\"\n        )}\n      >\n        <OrganViewer\n          // A new scale is a new model file.\n          key={scale.id}\n          model={model}\n          selectedId={selectedId}\n          onSelectionChange={setSelectedId}\n          label={\n            label ??\n            `Interactive ${disease.name.toLowerCase()} model${\n              disease.scales.length > 1 ? `, ${scale.label.toLowerCase()}` : \"\"\n            }`\n          }\n          {...props}\n        />\n        {panel && (\n          <DiseasePanel\n            disease={disease}\n            model={model}\n            scale={scale}\n            stage={stage}\n            selectedId={selectedId}\n            onScaleChange={changeScale}\n            onStageChange={setStageId}\n            onSelectionChange={setSelectedId}\n          />\n        )}\n      </div>\n    </div>\n  )\n}\n\nfunction DiseasePanel({\n  disease,\n  model,\n  scale,\n  stage,\n  selectedId,\n  onScaleChange,\n  onStageChange,\n  onSelectionChange,\n}: {\n  disease: Disease\n  model: OrganModel\n  scale: DiseaseScale\n  stage?: DiseaseStage\n  selectedId: string | null\n  onScaleChange: (scale: string) => void\n  onStageChange: (stage: string) => void\n  onSelectionChange: (id: string | null) => void\n}) {\n  const title = React.useId()\n  const investigations = stage?.investigations ?? disease.investigations ?? []\n  return (\n    <section\n      aria-labelledby={title}\n      className=\"flex min-w-0 flex-col gap-5 rounded-xl border p-4 text-sm @3xl:max-h-[28rem] @3xl:overflow-y-auto\"\n    >\n      <div className=\"flex flex-col gap-1.5\">\n        <div className=\"flex items-start justify-between gap-3\">\n          <p id={title} className=\"leading-snug font-semibold\">\n            {disease.name}\n          </p>\n          <Badge variant=\"outline\" className=\"shrink-0 font-mono\">\n            <span className=\"sr-only\">ICD-10 </span>\n            {disease.code}\n          </Badge>\n        </div>\n        <p className=\"text-muted-foreground\">{disease.summary}</p>\n      </div>\n      {disease.stages && disease.stages.length > 1 && stage && (\n        <div className=\"flex flex-col gap-2\">\n          <Choice\n            label=\"Stage\"\n            options={disease.stages}\n            value={stage.id}\n            onChange={onStageChange}\n          />\n          <p aria-live=\"polite\" className=\"text-muted-foreground\">\n            {stage.summary}\n          </p>\n        </div>\n      )}\n      {disease.scales.length > 1 && (\n        <Choice\n          label=\"Scale\"\n          options={disease.scales}\n          value={scale.id}\n          onChange={onScaleChange}\n        />\n      )}\n      <Findings\n        // Each scale starts with its spared structures folded.\n        key={scale.id}\n        model={model}\n        noted={\n          new Set(\n            Object.keys({\n              ...scale.findings,\n              ...stage?.findings?.[scale.id],\n            })\n          )\n        }\n        selectedId={selectedId}\n        onSelectionChange={onSelectionChange}\n      />\n      {investigations.length > 0 && (\n        <Investigations investigations={investigations} />\n      )}\n    </section>\n  )\n}\n\nfunction Choice({\n  label,\n  options,\n  value,\n  onChange,\n}: {\n  label: string\n  options: readonly { id: string; label: string }[]\n  value: string\n  onChange: (value: string) => void\n}) {\n  const id = React.useId()\n  return (\n    <div className=\"flex flex-col gap-1.5\">\n      <span id={id} className=\"text-xs font-medium text-muted-foreground\">\n        {label}\n      </span>\n      <div\n        role=\"group\"\n        aria-labelledby={id}\n        className=\"flex flex-wrap gap-0.5 rounded-lg bg-muted p-[3px]\"\n      >\n        {options.map((option) => (\n          <Button\n            key={option.id}\n            type=\"button\"\n            size=\"sm\"\n            variant=\"ghost\"\n            aria-pressed={option.id === value}\n            onClick={() => onChange(option.id)}\n            className=\"h-7 flex-1 px-2 text-muted-foreground aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-sm\"\n          >\n            {option.label}\n          </Button>\n        ))}\n      </div>\n    </div>\n  )\n}\n\nfunction Findings({\n  model,\n  noted,\n  selectedId,\n  onSelectionChange,\n}: {\n  model: OrganModel\n  noted: ReadonlySet<string>\n  selectedId: string | null\n  onSelectionChange: (id: string | null) => void\n}) {\n  const list = React.useRef<HTMLDivElement>(null)\n  const [expanded, setExpanded] = React.useState(false)\n  // A structure picked in 3D scrolls its finding into view.\n  React.useEffect(() => {\n    if (!selectedId) return\n    list.current\n      ?.querySelector(`[data-structure=\"${CSS.escape(selectedId)}\"]`)\n      ?.scrollIntoView({ block: \"nearest\" })\n  }, [selectedId])\n  const row = (structure: OrganModel[\"structures\"][number]) => {\n    const selected = structure.id === selectedId\n    const affected = structure.group === \"Affected\"\n    return (\n      <li key={structure.id} data-structure={structure.id}>\n        <button\n          type=\"button\"\n          aria-pressed={selected}\n          onClick={() => onSelectionChange(selected ? null : structure.id)}\n          className=\"flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring aria-pressed:bg-muted\"\n        >\n          <span\n            aria-hidden=\"true\"\n            className=\"size-2.5 shrink-0 rounded-full\"\n            style={{ backgroundColor: structure.color }}\n          />\n          <span className={cn(\"min-w-0 flex-1\", affected && \"font-medium\")}>\n            {structure.label}\n          </span>\n        </button>\n        {selected && structure.description && (\n          <p className=\"pt-1 pr-2 pb-2 pl-7 text-muted-foreground\">\n            {structure.description}\n          </p>\n        )}\n      </li>\n    )\n  }\n  const affected = model.structures.filter(({ group }) => group === \"Affected\")\n  const spared = model.structures.filter(({ group }) => group === \"Spared\")\n  // Spared structures the disease says something about come first; the rest\n  // of the model folds away, unless one of them is selected.\n  const shown = spared.filter(({ id }) => noted.has(id))\n  const folded = spared.filter(({ id }) => !noted.has(id))\n  const open = expanded || folded.some(({ id }) => id === selectedId)\n  return (\n    <div ref={list} className=\"flex flex-col gap-4\">\n      {affected.length > 0 && (\n        <div className=\"flex flex-col gap-1\">\n          <span className=\"text-xs font-medium text-muted-foreground\">\n            Affected · {affected.length}\n          </span>\n          <ul className=\"-mx-2 flex flex-col\">{affected.map(row)}</ul>\n        </div>\n      )}\n      {spared.length > 0 && (\n        <div className=\"flex flex-col gap-1\">\n          <span className=\"text-xs font-medium text-muted-foreground\">\n            Spared · {spared.length}\n          </span>\n          <ul className=\"-mx-2 flex flex-col\">\n            {shown.map(row)}\n            {open && folded.map(row)}\n          </ul>\n          {folded.length > 0 && (\n            <Button\n              type=\"button\"\n              variant=\"ghost\"\n              size=\"sm\"\n              aria-expanded={open}\n              onClick={() => setExpanded(!open)}\n              className=\"-mx-2 h-7 self-start px-2 text-muted-foreground\"\n            >\n              {open\n                ? \"Show fewer\"\n                : `Show ${folded.length} more ${folded.length === 1 ? \"structure\" : \"structures\"}`}\n            </Button>\n          )}\n        </div>\n      )}\n    </div>\n  )\n}\n\nconst flagLabels = { high: \"High\", low: \"Low\", abnormal: \"Abnormal\" }\n\nfunction Investigations({\n  investigations,\n}: {\n  investigations: readonly DiseaseInvestigation[]\n}) {\n  const id = React.useId()\n  return (\n    <div className=\"flex flex-col gap-1.5\">\n      <span id={id} className=\"text-xs font-medium text-muted-foreground\">\n        Investigations\n      </span>\n      <dl\n        aria-labelledby={id}\n        className=\"grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-1.5\"\n      >\n        {investigations.map(({ name, result, flag }) => (\n          <React.Fragment key={name}>\n            <dt className=\"text-muted-foreground\">{name}</dt>\n            <dd\n              className={cn(\n                \"flex items-center justify-end gap-1 text-right\",\n                flag && \"font-semibold\"\n              )}\n            >\n              {result}\n              {flag && (\n                <>\n                  <FlagIcon flag={flag} />\n                  <span className=\"sr-only\">({flagLabels[flag]})</span>\n                </>\n              )}\n            </dd>\n          </React.Fragment>\n        ))}\n      </dl>\n    </div>\n  )\n}\n\nfunction FlagIcon({\n  flag,\n}: {\n  flag: NonNullable<DiseaseInvestigation[\"flag\"]>\n}) {\n  if (flag === \"high\")\n    return (\n      <IconPlaceholder\n        lucide=\"ArrowUpIcon\"\n        tabler=\"IconArrowUp\"\n        hugeicons=\"ArrowUpIcon\"\n        phosphor=\"ArrowUpIcon\"\n        remixicon=\"RiArrowUpLine\"\n        aria-hidden=\"true\"\n        className=\"size-4 shrink-0\"\n      />\n    )\n  if (flag === \"low\")\n    return (\n      <IconPlaceholder\n        lucide=\"ArrowDownIcon\"\n        tabler=\"IconArrowDown\"\n        hugeicons=\"ArrowDownIcon\"\n        phosphor=\"ArrowDownIcon\"\n        remixicon=\"RiArrowDownLine\"\n        aria-hidden=\"true\"\n        className=\"size-4 shrink-0\"\n      />\n    )\n  return (\n    <IconPlaceholder\n      lucide=\"TriangleAlertIcon\"\n      tabler=\"IconAlertTriangle\"\n      hugeicons=\"Alert02Icon\"\n      phosphor=\"WarningIcon\"\n      remixicon=\"RiErrorWarningLine\"\n      aria-hidden=\"true\"\n      className=\"size-4 shrink-0\"\n    />\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/base-nova/disease-viewer/disease-model.ts",
      "content": "import type { OrganModel } from \"@/components/organ-viewer\"\n\n// The data a disease is made of, and the organ model it shows at each scale\n// and stage. Plain data and functions, so server code can use them too.\n\nexport type DiseaseFinding = {\n  /** Whether the disease involves the structure. */\n  affected: boolean\n  /** Replaces the structure's anatomical label, such as \"Portal tracts\". */\n  label?: string\n  /** What the disease does to the structure, or why it is spared. */\n  description: string\n}\n\nexport type DiseaseScale = {\n  /** The name of the organ or tissue item the model comes from, such as \"liver\". */\n  id: string\n  /** The name the scale switch shows, such as \"Liver\" or \"Lobule\". */\n  label: string\n  model: OrganModel\n  /** The named view the model opens on, where the disease shows best. Defaults to the model's own. */\n  defaultView?: string\n  /** Findings by structure ID. Structures without one are spared and keep their anatomical description. */\n  findings: Readonly<Record<string, DiseaseFinding>>\n}\n\nexport type DiseaseInvestigation = {\n  name: string\n  result: string\n  /** The direction of an abnormal result, shown bold with an arrow. */\n  flag?: \"high\" | \"low\" | \"abnormal\"\n}\n\nexport type DiseaseStage = {\n  id: string\n  label: string\n  /** What happens at this stage, in a sentence or two. */\n  summary: string\n  /** Findings that replace the scale's own at this stage, by scale ID, then structure ID. */\n  findings?: Readonly<Record<string, Readonly<Record<string, DiseaseFinding>>>>\n  /** Replaces the disease's investigations at this stage. */\n  investigations?: readonly DiseaseInvestigation[]\n}\n\nexport type Disease = {\n  name: string\n  /** The ICD-10 code. */\n  code: string\n  summary: string\n  /** One model per scale, the default first. */\n  scales: readonly DiseaseScale[]\n  /** The course of the disease, the default first. */\n  stages?: readonly DiseaseStage[]\n  investigations?: readonly DiseaseInvestigation[]\n}\n\n// Affected structures share one amber, so a disease that involves a whole\n// organ still reads as disease, and spared ones one muted tone. Amber, not\n// red: red is kept for critical results.\nexport const AFFECTED = \"#e0892f\"\nexport const SPARED = \"#a9a59c\"\n\nexport function scaleOf(disease: Disease, id?: string) {\n  return disease.scales.find((scale) => scale.id === id) ?? disease.scales[0]!\n}\n\nexport function stageOf(disease: Disease, id?: string) {\n  return disease.stages?.find((stage) => stage.id === id) ?? disease.stages?.[0]\n}\n\n/**\n * One scale of a disease at one stage, as an organ model: the base model's\n * GLB, views and credit, with its structures grouped as affected or spared,\n * the affected ones first.\n */\nexport function diseaseModel(\n  disease: Disease,\n  scaleId?: string,\n  stageId?: string\n): OrganModel {\n  const scale = scaleOf(disease, scaleId)\n  const findings = {\n    ...scale.findings,\n    ...stageOf(disease, stageId)?.findings?.[scale.id],\n  }\n  return {\n    ...scale.model,\n    name: disease.name,\n    defaultView: scale.defaultView ?? scale.model.defaultView,\n    structures: scale.model.structures\n      .map((structure) => {\n        const finding = findings[structure.id]\n        return {\n          ...structure,\n          label: finding?.label ?? structure.label,\n          group: finding?.affected ? \"Affected\" : \"Spared\",\n          color: finding?.affected ? AFFECTED : SPARED,\n          description: finding?.description ?? structure.description,\n        }\n      })\n      .sort(\n        (a, b) =>\n          Number(b.group === \"Affected\") - Number(a.group === \"Affected\")\n      ),\n  }\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Disease viewer docs: https://organui.com/docs/components/disease-viewer. Educational, not for diagnosis.",
  "categories": [
    "pathology"
  ],
  "type": "registry:component"
}