Geography Quiz Map

Interactive identify-mode map quiz — click the correct region per prompt with visual feedback, score, and results panel.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/geography-quiz-map.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

3 stories available:

Code

"use client";

import {
  type ComponentPropsWithoutRef,
  createContext,
  forwardRef,
  type ReactNode,
  useCallback,
  useContext,
  useId,
  useMemo,
  useState,
} from "react";

import { cn } from "../../lib/utils";

const VIEWBOX_WIDTH = 1000;
const VIEWBOX_HEIGHT = 500;
const FEEDBACK_DURATION_MS = 800;

/**
 * Geographic coordinate `[longitude, latitude]`.
 *
 * @public
 */
export type GeoPosition = [number, number];

/**
 * A region polygon — outer ring of `[lng, lat]` positions; close the
 * ring by repeating the first point.
 *
 * @public
 */
export type QuizRegion = {
  /** Outer ring positions. */
  coordinates: GeoPosition[];
  /** Stable identifier — referenced by `QuizQuestion.answerRegionId`. */
  id: string;
  /** Human-readable name (used in the prompt and results panel). */
  name: string;
};

/**
 * Identify-mode question. The user clicks the region matching `answerRegionId`.
 *
 * @public
 */
export type QuizQuestion = {
  /** Region id of the correct answer. */
  answerRegionId: string;
  /** Stable identifier. */
  id: string;
  /** Human-readable prompt rendered above the map. */
  prompt: ReactNode;
};

/**
 * Outcome for a single answered question.
 *
 * @public
 */
export type QuizAnswer = {
  /** `true` when the selected region matches the answer. */
  correct: boolean;
  /** The original question. */
  question: QuizQuestion;
  /** Region id the user clicked. */
  selectedRegionId: string;
};

/**
 * Localizable strings.
 *
 * @public
 */
export type GeographyQuizMapLabels = {
  /** Aria-label for the quiz region. Defaults to `"Geography quiz map"`. */
  region?: string;
};

const DEFAULT_LABELS = {
  region: "Geography quiz map",
} as const satisfies Required<GeographyQuizMapLabels>;

type Phase = "complete" | "playing";
type Feedback = "correct" | "incorrect";

type QuizCtx = {
  answers: QuizAnswer[];
  current?: QuizQuestion;
  feedback?: Feedback;
  phase: Phase;
  questionIndex: number;
  totalQuestions: number;
};

const QuizContext = createContext<null | QuizCtx>(null);

function useQuizContext(): QuizCtx {
  const ctx = useContext(QuizContext);
  if (!ctx) {
    throw new Error("GeographyQuizMap subcomponent used outside its root.");
  }
  return ctx;
}

function projectEquirectangular(position: GeoPosition): {
  x: number;
  y: number;
} {
  const [lng, lat] = position;
  const x = ((lng + 180) / 360) * VIEWBOX_WIDTH;
  const y = ((90 - lat) / 180) * VIEWBOX_HEIGHT;
  return { x, y };
}

function regionPath(region: QuizRegion): string {
  const points = region.coordinates
    .map((coord, index) => {
      const projected = projectEquirectangular(coord);
      return `${index === 0 ? "M" : "L"}${projected.x.toString()},${projected.y.toString()}`;
    })
    .join(" ");
  return `${points} Z`;
}

/**
 * Props for {@link GeographyQuizMap}.
 *
 * @public
 */
export type GeographyQuizMapProps = {
  /** Optional backdrop image URL. */
  backdrop?: string;
  /** Aria-label for the backdrop image. */
  backdropAlt?: string;
  /** Localizable strings. */
  labels?: GeographyQuizMapLabels;
  /** Fires once the user finishes every question. */
  onComplete?: (answers: QuizAnswer[]) => void;
  /** Quiz questions in order. */
  questions: QuizQuestion[];
  /** Region polygons. */
  regions: QuizRegion[];
} & ComponentPropsWithoutRef<"section">;

type RegionPathProps = {
  disabled: boolean;
  feedback?: Feedback;
  isAnswerForCurrent: boolean;
  isSelected: boolean;
  onSelect: (id: string) => void;
  region: QuizRegion;
  showAnswerFlash: boolean;
};

function RegionShape({
  disabled,
  feedback,
  isAnswerForCurrent,
  isSelected,
  onSelect,
  region,
  showAnswerFlash,
}: RegionPathProps): ReactNode {
  let fill = "rgb(226, 232, 240)";
  if (isSelected && feedback === "correct") fill = "rgb(34, 197, 94)";
  else if (isSelected && feedback === "incorrect") fill = "rgb(239, 68, 68)";
  else if (showAnswerFlash && isAnswerForCurrent && !isSelected)
    fill = "rgba(34, 197, 94, 0.4)";
  return (
    <path
      aria-label={region.name}
      className={cn(
        "stroke-background outline-none transition-colors",
        disabled
          ? "cursor-not-allowed"
          : "cursor-pointer hover:opacity-90 focus-visible:opacity-90",
      )}
      d={regionPath(region)}
      data-region-id={region.id}
      data-state={
        isSelected
          ? feedback === "correct"
            ? "correct"
            : "incorrect"
          : showAnswerFlash && isAnswerForCurrent
            ? "answer"
            : undefined
      }
      fill={fill}
      onClick={() => {
        if (!disabled) onSelect(region.id);
      }}
      onKeyDown={(event) => {
        if (disabled) return;
        if (event.key !== "Enter" && event.key !== " ") return;
        event.preventDefault();
        onSelect(region.id);
      }}
      role="button"
      strokeWidth={1}
      tabIndex={disabled ? -1 : 0}
    />
  );
}

type StageProps = {
  backdrop?: string;
  backdropAlt?: string;
  current?: QuizQuestion;
  disabled: boolean;
  feedback?: Feedback;
  onSelect: (id: string) => void;
  regions: QuizRegion[];
  selectedRegionId?: string;
};

function Stage({
  backdrop,
  backdropAlt,
  current,
  disabled,
  feedback,
  onSelect,
  regions,
  selectedRegionId,
}: StageProps): ReactNode {
  return (
    <svg
      className="block h-full w-full"
      preserveAspectRatio="xMidYMid meet"
      role="img"
      viewBox={`0 0 ${VIEWBOX_WIDTH.toString()} ${VIEWBOX_HEIGHT.toString()}`}
    >
      <rect
        className="fill-muted"
        height={VIEWBOX_HEIGHT}
        width={VIEWBOX_WIDTH}
        x="0"
        y="0"
      />
      {backdrop ? (
        <image
          aria-label={backdropAlt}
          height={VIEWBOX_HEIGHT}
          href={backdrop}
          preserveAspectRatio="xMidYMid slice"
          width={VIEWBOX_WIDTH}
          x="0"
          y="0"
        />
      ) : null}
      {regions.map((region) => (
        <RegionShape
          disabled={disabled}
          feedback={feedback}
          isAnswerForCurrent={current?.answerRegionId === region.id}
          isSelected={selectedRegionId === region.id}
          key={region.id}
          onSelect={onSelect}
          region={region}
          showAnswerFlash={feedback === "incorrect"}
        />
      ))}
    </svg>
  );
}

/**
 * Prompt slot. Renders the current question text on top of the map.
 *
 * @public
 */
export const GeographyQuizMapPrompt = forwardRef<
  HTMLDivElement,
  ComponentPropsWithoutRef<"div">
>(({ className, ...rest }, ref) => {
  const { current, phase } = useQuizContext();
  if (phase !== "playing" || !current) return null;
  return (
    <div
      className={cn(
        "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",
        className,
      )}
      data-quiz-prompt
      ref={ref}
      {...rest}
    >
      {current.prompt}
    </div>
  );
});
GeographyQuizMapPrompt.displayName = "GeographyQuizMapPrompt";

/**
 * Score slot. Renders `correct / total · streak%`.
 *
 * @public
 */
export const GeographyQuizMapScore = forwardRef<
  HTMLDivElement,
  ComponentPropsWithoutRef<"div">
>(({ className, ...rest }, ref) => {
  const { answers, totalQuestions } = useQuizContext();
  const correct = answers.filter((entry) => entry.correct).length;
  const accuracy =
    answers.length === 0 ? 0 : Math.round((correct / answers.length) * 100);
  return (
    <div
      className={cn(
        "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",
        className,
      )}
      data-quiz-score
      ref={ref}
      {...rest}
    >
      {`${correct.toString()} / ${totalQuestions.toString()} · ${accuracy.toString()}%`}
    </div>
  );
});
GeographyQuizMapScore.displayName = "GeographyQuizMapScore";

/**
 * Results slot. Renders the per-question outcome list once the
 * user finishes every question.
 *
 * @public
 */
export const GeographyQuizMapResults = forwardRef<
  HTMLDivElement,
  ComponentPropsWithoutRef<"div">
>(({ className, ...rest }, ref) => {
  const { answers, phase, totalQuestions } = useQuizContext();
  if (phase !== "complete") return null;
  const correct = answers.filter((entry) => entry.correct).length;
  return (
    <div
      className={cn(
        "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",
        className,
      )}
      data-quiz-results
      ref={ref}
      {...rest}
    >
      <h3 className="text-base font-semibold">
        {`Results · ${correct.toString()} / ${totalQuestions.toString()}`}
      </h3>
      <ol className="space-y-1">
        {answers.map((entry) => (
          <li
            className={cn(
              "flex items-center gap-2 rounded-md border px-2 py-1",
              entry.correct
                ? "border-emerald-300 bg-emerald-500/10"
                : "border-red-300 bg-red-500/10",
            )}
            data-answer-correct={entry.correct ? "true" : "false"}
            data-answer-id={entry.question.id}
            key={entry.question.id}
          >
            <span aria-hidden="true">{entry.correct ? "✓" : "✗"}</span>
            <span className="flex-1">{entry.question.prompt}</span>
          </li>
        ))}
      </ol>
    </div>
  );
});
GeographyQuizMapResults.displayName = "GeographyQuizMapResults";

type QuizState = {
  answers: QuizAnswer[];
  feedback?: Feedback;
  questionIndex: number;
  selectedRegionId?: string;
};

function useQuizState(arguments_: {
  onComplete?: (answers: QuizAnswer[]) => void;
  questions: QuizQuestion[];
}): {
  handleSelect: (regionId: string) => void;
  state: QuizState;
} {
  const { onComplete, questions } = arguments_;
  const [state, setState] = useState<QuizState>({
    answers: [],
    feedback: undefined,
    questionIndex: 0,
    selectedRegionId: undefined,
  });

  const handleSelect = useCallback(
    (regionId: string) => {
      setState((current) => {
        if (current.feedback) return current;
        const question = questions[current.questionIndex];
        if (!question) return current;
        const correct = question.answerRegionId === regionId;
        const next: QuizState = {
          ...current,
          answers: [
            ...current.answers,
            { correct, question, selectedRegionId: regionId },
          ],
          feedback: correct ? "correct" : "incorrect",
          selectedRegionId: regionId,
        };
        scheduleAdvance(setState, onComplete, questions);
        return next;
      });
    },
    [onComplete, questions],
  );

  return { handleSelect, state };
}

function scheduleAdvance(
  setState: React.Dispatch<React.SetStateAction<QuizState>>,
  onComplete: ((answers: QuizAnswer[]) => void) | undefined,
  questions: QuizQuestion[],
): void {
  if (typeof window === "undefined") return;
  window.setTimeout(() => {
    setState((current) => {
      const nextIndex = current.questionIndex + 1;
      if (nextIndex >= questions.length) {
        onComplete?.(current.answers);
        return {
          ...current,
          feedback: undefined,
          questionIndex: questions.length,
          selectedRegionId: undefined,
        };
      }
      return {
        ...current,
        feedback: undefined,
        questionIndex: nextIndex,
        selectedRegionId: undefined,
      };
    });
  }, FEEDBACK_DURATION_MS);
}

/**
 * Interactive map quiz. Identify-mode: each question asks the user to
 * click the region matching the prompt. Correct clicks flash green,
 * incorrect clicks flash red and reveal the correct region. After the
 * final question the {@link GeographyQuizMapResults} slot renders the
 * per-question outcome list and `onComplete` fires.
 *
 * Compose with {@link GeographyQuizMapPrompt}, {@link GeographyQuizMapScore},
 * and {@link GeographyQuizMapResults} as children.
 *
 * @example
 * ```tsx
 * <GeographyQuizMap
 *   regions={countries}
 *   questions={[
 *     { id: "q1", prompt: "Click on France", answerRegionId: "FR" },
 *     { id: "q2", prompt: "Click on Germany", answerRegionId: "DE" },
 *   ]}
 *   onComplete={(answers) => console.info(answers)}
 * >
 *   <GeographyQuizMapPrompt />
 *   <GeographyQuizMapScore />
 *   <GeographyQuizMapResults />
 * </GeographyQuizMap>
 * ```
 *
 * @public
 */
export const GeographyQuizMap = forwardRef<HTMLElement, GeographyQuizMapProps>(
  (props, ref) => {
    const {
      backdrop,
      backdropAlt,
      children,
      className,
      labels,
      onComplete,
      questions,
      regions,
      ...rest
    } = props;
    const titleId = useId();
    const resolvedLabels = useMemo(
      () => ({ ...DEFAULT_LABELS, ...labels }),
      [labels],
    );
    const { handleSelect, state } = useQuizState({ onComplete, questions });

    const phase: Phase =
      state.questionIndex >= questions.length ? "complete" : "playing";
    const current = questions[state.questionIndex];

    const ctx = useMemo<QuizCtx>(
      () => ({
        answers: state.answers,
        current,
        feedback: state.feedback,
        phase,
        questionIndex: state.questionIndex,
        totalQuestions: questions.length,
      }),
      [
        current,
        phase,
        questions.length,
        state.answers,
        state.feedback,
        state.questionIndex,
      ],
    );

    return (
      <QuizContext.Provider value={ctx}>
        <section
          aria-labelledby={titleId}
          className={cn(
            "relative aspect-[2/1] w-full overflow-hidden rounded-2xl border bg-background text-foreground",
            className,
          )}
          ref={ref}
          {...rest}
        >
          <span className="sr-only" id={titleId}>
            {resolvedLabels.region}
          </span>
          <Stage
            backdrop={backdrop}
            backdropAlt={backdropAlt}
            current={current}
            disabled={phase === "complete" || Boolean(state.feedback)}
            feedback={state.feedback}
            onSelect={handleSelect}
            regions={regions}
            selectedRegionId={state.selectedRegionId}
          />
          {children}
        </section>
      </QuizContext.Provider>
    );
  },
);
GeographyQuizMap.displayName = "GeographyQuizMap";
typescript

Dependencies

  • @vllnt/ui@^0.2.1