{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "geography-quiz-map",
  "type": "registry:component",
  "title": "Geography Quiz Map",
  "description": "Interactive identify-mode map quiz — click the correct region per prompt with visual feedback, score, and results panel.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/geography-quiz-map/geography-quiz-map.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  createContext,\n  forwardRef,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\n\nconst VIEWBOX_WIDTH = 1000;\nconst VIEWBOX_HEIGHT = 500;\nconst FEEDBACK_DURATION_MS = 800;\n\n/**\n * Geographic coordinate `[longitude, latitude]`.\n *\n * @public\n */\nexport type GeoPosition = [number, number];\n\n/**\n * A region polygon — outer ring of `[lng, lat]` positions; close the\n * ring by repeating the first point.\n *\n * @public\n */\nexport type QuizRegion = {\n  /** Outer ring positions. */\n  coordinates: GeoPosition[];\n  /** Stable identifier — referenced by `QuizQuestion.answerRegionId`. */\n  id: string;\n  /** Human-readable name (used in the prompt and results panel). */\n  name: string;\n};\n\n/**\n * Identify-mode question. The user clicks the region matching `answerRegionId`.\n *\n * @public\n */\nexport type QuizQuestion = {\n  /** Region id of the correct answer. */\n  answerRegionId: string;\n  /** Stable identifier. */\n  id: string;\n  /** Human-readable prompt rendered above the map. */\n  prompt: ReactNode;\n};\n\n/**\n * Outcome for a single answered question.\n *\n * @public\n */\nexport type QuizAnswer = {\n  /** `true` when the selected region matches the answer. */\n  correct: boolean;\n  /** The original question. */\n  question: QuizQuestion;\n  /** Region id the user clicked. */\n  selectedRegionId: string;\n};\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type GeographyQuizMapLabels = {\n  /** Aria-label for the quiz region. Defaults to `\"Geography quiz map\"`. */\n  region?: string;\n};\n\nconst DEFAULT_LABELS = {\n  region: \"Geography quiz map\",\n} as const satisfies Required<GeographyQuizMapLabels>;\n\ntype Phase = \"complete\" | \"playing\";\ntype Feedback = \"correct\" | \"incorrect\";\n\ntype QuizCtx = {\n  answers: QuizAnswer[];\n  current?: QuizQuestion;\n  feedback?: Feedback;\n  phase: Phase;\n  questionIndex: number;\n  totalQuestions: number;\n};\n\nconst QuizContext = createContext<null | QuizCtx>(null);\n\nfunction useQuizContext(): QuizCtx {\n  const ctx = useContext(QuizContext);\n  if (!ctx) {\n    throw new Error(\"GeographyQuizMap subcomponent used outside its root.\");\n  }\n  return ctx;\n}\n\nfunction projectEquirectangular(position: GeoPosition): {\n  x: number;\n  y: number;\n} {\n  const [lng, lat] = position;\n  const x = ((lng + 180) / 360) * VIEWBOX_WIDTH;\n  const y = ((90 - lat) / 180) * VIEWBOX_HEIGHT;\n  return { x, y };\n}\n\nfunction regionPath(region: QuizRegion): string {\n  const points = region.coordinates\n    .map((coord, index) => {\n      const projected = projectEquirectangular(coord);\n      return `${index === 0 ? \"M\" : \"L\"}${projected.x.toString()},${projected.y.toString()}`;\n    })\n    .join(\" \");\n  return `${points} Z`;\n}\n\n/**\n * Props for {@link GeographyQuizMap}.\n *\n * @public\n */\nexport type GeographyQuizMapProps = {\n  /** Optional backdrop image URL. */\n  backdrop?: string;\n  /** Aria-label for the backdrop image. */\n  backdropAlt?: string;\n  /** Localizable strings. */\n  labels?: GeographyQuizMapLabels;\n  /** Fires once the user finishes every question. */\n  onComplete?: (answers: QuizAnswer[]) => void;\n  /** Quiz questions in order. */\n  questions: QuizQuestion[];\n  /** Region polygons. */\n  regions: QuizRegion[];\n} & ComponentPropsWithoutRef<\"section\">;\n\ntype RegionPathProps = {\n  disabled: boolean;\n  feedback?: Feedback;\n  isAnswerForCurrent: boolean;\n  isSelected: boolean;\n  onSelect: (id: string) => void;\n  region: QuizRegion;\n  showAnswerFlash: boolean;\n};\n\nfunction RegionShape({\n  disabled,\n  feedback,\n  isAnswerForCurrent,\n  isSelected,\n  onSelect,\n  region,\n  showAnswerFlash,\n}: RegionPathProps): ReactNode {\n  let fill = \"rgb(226, 232, 240)\";\n  if (isSelected && feedback === \"correct\") fill = \"rgb(34, 197, 94)\";\n  else if (isSelected && feedback === \"incorrect\") fill = \"rgb(239, 68, 68)\";\n  else if (showAnswerFlash && isAnswerForCurrent && !isSelected)\n    fill = \"rgba(34, 197, 94, 0.4)\";\n  return (\n    <path\n      aria-label={region.name}\n      className={cn(\n        \"stroke-background outline-none transition-colors\",\n        disabled\n          ? \"cursor-not-allowed\"\n          : \"cursor-pointer hover:opacity-90 focus-visible:opacity-90\",\n      )}\n      d={regionPath(region)}\n      data-region-id={region.id}\n      data-state={\n        isSelected\n          ? feedback === \"correct\"\n            ? \"correct\"\n            : \"incorrect\"\n          : showAnswerFlash && isAnswerForCurrent\n            ? \"answer\"\n            : undefined\n      }\n      fill={fill}\n      onClick={() => {\n        if (!disabled) onSelect(region.id);\n      }}\n      onKeyDown={(event) => {\n        if (disabled) return;\n        if (event.key !== \"Enter\" && event.key !== \" \") return;\n        event.preventDefault();\n        onSelect(region.id);\n      }}\n      role=\"button\"\n      strokeWidth={1}\n      tabIndex={disabled ? -1 : 0}\n    />\n  );\n}\n\ntype StageProps = {\n  backdrop?: string;\n  backdropAlt?: string;\n  current?: QuizQuestion;\n  disabled: boolean;\n  feedback?: Feedback;\n  onSelect: (id: string) => void;\n  regions: QuizRegion[];\n  selectedRegionId?: string;\n};\n\nfunction Stage({\n  backdrop,\n  backdropAlt,\n  current,\n  disabled,\n  feedback,\n  onSelect,\n  regions,\n  selectedRegionId,\n}: StageProps): ReactNode {\n  return (\n    <svg\n      className=\"block h-full w-full\"\n      preserveAspectRatio=\"xMidYMid meet\"\n      role=\"img\"\n      viewBox={`0 0 ${VIEWBOX_WIDTH.toString()} ${VIEWBOX_HEIGHT.toString()}`}\n    >\n      <rect\n        className=\"fill-muted\"\n        height={VIEWBOX_HEIGHT}\n        width={VIEWBOX_WIDTH}\n        x=\"0\"\n        y=\"0\"\n      />\n      {backdrop ? (\n        <image\n          aria-label={backdropAlt}\n          height={VIEWBOX_HEIGHT}\n          href={backdrop}\n          preserveAspectRatio=\"xMidYMid slice\"\n          width={VIEWBOX_WIDTH}\n          x=\"0\"\n          y=\"0\"\n        />\n      ) : null}\n      {regions.map((region) => (\n        <RegionShape\n          disabled={disabled}\n          feedback={feedback}\n          isAnswerForCurrent={current?.answerRegionId === region.id}\n          isSelected={selectedRegionId === region.id}\n          key={region.id}\n          onSelect={onSelect}\n          region={region}\n          showAnswerFlash={feedback === \"incorrect\"}\n        />\n      ))}\n    </svg>\n  );\n}\n\n/**\n * Prompt slot. Renders the current question text on top of the map.\n *\n * @public\n */\nexport const GeographyQuizMapPrompt = forwardRef<\n  HTMLDivElement,\n  ComponentPropsWithoutRef<\"div\">\n>(({ className, ...rest }, ref) => {\n  const { current, phase } = useQuizContext();\n  if (phase !== \"playing\" || !current) return null;\n  return (\n    <div\n      className={cn(\n        \"absolute inset-x-3 top-3 z-10 rounded-md border bg-background/95 px-3 py-2 text-center text-sm font-medium text-foreground shadow-sm backdrop-blur\",\n        className,\n      )}\n      data-quiz-prompt\n      ref={ref}\n      {...rest}\n    >\n      {current.prompt}\n    </div>\n  );\n});\nGeographyQuizMapPrompt.displayName = \"GeographyQuizMapPrompt\";\n\n/**\n * Score slot. Renders `correct / total · streak%`.\n *\n * @public\n */\nexport const GeographyQuizMapScore = forwardRef<\n  HTMLDivElement,\n  ComponentPropsWithoutRef<\"div\">\n>(({ className, ...rest }, ref) => {\n  const { answers, totalQuestions } = useQuizContext();\n  const correct = answers.filter((entry) => entry.correct).length;\n  const accuracy =\n    answers.length === 0 ? 0 : Math.round((correct / answers.length) * 100);\n  return (\n    <div\n      className={cn(\n        \"absolute right-3 top-3 z-10 rounded-md border bg-background/95 px-2 py-1 text-xs font-medium text-foreground shadow-sm backdrop-blur\",\n        className,\n      )}\n      data-quiz-score\n      ref={ref}\n      {...rest}\n    >\n      {`${correct.toString()} / ${totalQuestions.toString()} · ${accuracy.toString()}%`}\n    </div>\n  );\n});\nGeographyQuizMapScore.displayName = \"GeographyQuizMapScore\";\n\n/**\n * Results slot. Renders the per-question outcome list once the\n * user finishes every question.\n *\n * @public\n */\nexport const GeographyQuizMapResults = forwardRef<\n  HTMLDivElement,\n  ComponentPropsWithoutRef<\"div\">\n>(({ className, ...rest }, ref) => {\n  const { answers, phase, totalQuestions } = useQuizContext();\n  if (phase !== \"complete\") return null;\n  const correct = answers.filter((entry) => entry.correct).length;\n  return (\n    <div\n      className={cn(\n        \"absolute inset-3 z-20 flex flex-col gap-3 overflow-auto rounded-md border bg-background/95 p-4 text-sm text-foreground shadow-md backdrop-blur\",\n        className,\n      )}\n      data-quiz-results\n      ref={ref}\n      {...rest}\n    >\n      <h3 className=\"text-base font-semibold\">\n        {`Results · ${correct.toString()} / ${totalQuestions.toString()}`}\n      </h3>\n      <ol className=\"space-y-1\">\n        {answers.map((entry) => (\n          <li\n            className={cn(\n              \"flex items-center gap-2 rounded-md border px-2 py-1\",\n              entry.correct\n                ? \"border-emerald-300 bg-emerald-500/10\"\n                : \"border-red-300 bg-red-500/10\",\n            )}\n            data-answer-correct={entry.correct ? \"true\" : \"false\"}\n            data-answer-id={entry.question.id}\n            key={entry.question.id}\n          >\n            <span aria-hidden=\"true\">{entry.correct ? \"✓\" : \"✗\"}</span>\n            <span className=\"flex-1\">{entry.question.prompt}</span>\n          </li>\n        ))}\n      </ol>\n    </div>\n  );\n});\nGeographyQuizMapResults.displayName = \"GeographyQuizMapResults\";\n\ntype QuizState = {\n  answers: QuizAnswer[];\n  feedback?: Feedback;\n  questionIndex: number;\n  selectedRegionId?: string;\n};\n\nfunction useQuizState(arguments_: {\n  onComplete?: (answers: QuizAnswer[]) => void;\n  questions: QuizQuestion[];\n}): {\n  handleSelect: (regionId: string) => void;\n  state: QuizState;\n} {\n  const { onComplete, questions } = arguments_;\n  const [state, setState] = useState<QuizState>({\n    answers: [],\n    feedback: undefined,\n    questionIndex: 0,\n    selectedRegionId: undefined,\n  });\n\n  const handleSelect = useCallback(\n    (regionId: string) => {\n      setState((current) => {\n        if (current.feedback) return current;\n        const question = questions[current.questionIndex];\n        if (!question) return current;\n        const correct = question.answerRegionId === regionId;\n        const next: QuizState = {\n          ...current,\n          answers: [\n            ...current.answers,\n            { correct, question, selectedRegionId: regionId },\n          ],\n          feedback: correct ? \"correct\" : \"incorrect\",\n          selectedRegionId: regionId,\n        };\n        scheduleAdvance(setState, onComplete, questions);\n        return next;\n      });\n    },\n    [onComplete, questions],\n  );\n\n  return { handleSelect, state };\n}\n\nfunction scheduleAdvance(\n  setState: React.Dispatch<React.SetStateAction<QuizState>>,\n  onComplete: ((answers: QuizAnswer[]) => void) | undefined,\n  questions: QuizQuestion[],\n): void {\n  if (typeof window === \"undefined\") return;\n  window.setTimeout(() => {\n    setState((current) => {\n      const nextIndex = current.questionIndex + 1;\n      if (nextIndex >= questions.length) {\n        onComplete?.(current.answers);\n        return {\n          ...current,\n          feedback: undefined,\n          questionIndex: questions.length,\n          selectedRegionId: undefined,\n        };\n      }\n      return {\n        ...current,\n        feedback: undefined,\n        questionIndex: nextIndex,\n        selectedRegionId: undefined,\n      };\n    });\n  }, FEEDBACK_DURATION_MS);\n}\n\n/**\n * Interactive map quiz. Identify-mode: each question asks the user to\n * click the region matching the prompt. Correct clicks flash green,\n * incorrect clicks flash red and reveal the correct region. After the\n * final question the {@link GeographyQuizMapResults} slot renders the\n * per-question outcome list and `onComplete` fires.\n *\n * Compose with {@link GeographyQuizMapPrompt}, {@link GeographyQuizMapScore},\n * and {@link GeographyQuizMapResults} as children.\n *\n * @example\n * ```tsx\n * <GeographyQuizMap\n *   regions={countries}\n *   questions={[\n *     { id: \"q1\", prompt: \"Click on France\", answerRegionId: \"FR\" },\n *     { id: \"q2\", prompt: \"Click on Germany\", answerRegionId: \"DE\" },\n *   ]}\n *   onComplete={(answers) => console.info(answers)}\n * >\n *   <GeographyQuizMapPrompt />\n *   <GeographyQuizMapScore />\n *   <GeographyQuizMapResults />\n * </GeographyQuizMap>\n * ```\n *\n * @public\n */\nexport const GeographyQuizMap = forwardRef<HTMLElement, GeographyQuizMapProps>(\n  (props, ref) => {\n    const {\n      backdrop,\n      backdropAlt,\n      children,\n      className,\n      labels,\n      onComplete,\n      questions,\n      regions,\n      ...rest\n    } = props;\n    const titleId = useId();\n    const resolvedLabels = useMemo(\n      () => ({ ...DEFAULT_LABELS, ...labels }),\n      [labels],\n    );\n    const { handleSelect, state } = useQuizState({ onComplete, questions });\n\n    const phase: Phase =\n      state.questionIndex >= questions.length ? \"complete\" : \"playing\";\n    const current = questions[state.questionIndex];\n\n    const ctx = useMemo<QuizCtx>(\n      () => ({\n        answers: state.answers,\n        current,\n        feedback: state.feedback,\n        phase,\n        questionIndex: state.questionIndex,\n        totalQuestions: questions.length,\n      }),\n      [\n        current,\n        phase,\n        questions.length,\n        state.answers,\n        state.feedback,\n        state.questionIndex,\n      ],\n    );\n\n    return (\n      <QuizContext.Provider value={ctx}>\n        <section\n          aria-labelledby={titleId}\n          className={cn(\n            \"relative aspect-[2/1] w-full overflow-hidden rounded-2xl border bg-background text-foreground\",\n            className,\n          )}\n          ref={ref}\n          {...rest}\n        >\n          <span className=\"sr-only\" id={titleId}>\n            {resolvedLabels.region}\n          </span>\n          <Stage\n            backdrop={backdrop}\n            backdropAlt={backdropAlt}\n            current={current}\n            disabled={phase === \"complete\" || Boolean(state.feedback)}\n            feedback={state.feedback}\n            onSelect={handleSelect}\n            regions={regions}\n            selectedRegionId={state.selectedRegionId}\n          />\n          {children}\n        </section>\n      </QuizContext.Provider>\n    );\n  },\n);\nGeographyQuizMap.displayName = \"GeographyQuizMap\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
