Compare commits
10 Commits
f3d051cdd1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| de0a92d10f | |||
| 6300fe1082 | |||
| ac3c939051 | |||
| e148a957ea | |||
| a73a9afcc3 | |||
| 33e065301f | |||
| 282e4d64cf | |||
| 503bad859e | |||
| 2bbe03b1aa | |||
| e0d8fc19d5 |
@@ -35,10 +35,10 @@ AI 에이전트(Claude Code 등)가 이 프로젝트에서 작업할 때 따라
|
|||||||
app/ # Next.js App Router 루트
|
app/ # Next.js App Router 루트
|
||||||
(game)/ # 게임 화면 라우트 그룹
|
(game)/ # 게임 화면 라우트 그룹
|
||||||
number-parentheses/ # 숫자 괄호 준비 화면
|
number-parentheses/ # 숫자 괄호 준비 화면
|
||||||
|
play/ # 숫자 괄호 풀이 화면
|
||||||
variable-parentheses/ # 미지수 괄호 준비 화면
|
variable-parentheses/ # 미지수 괄호 준비 화면
|
||||||
play/ # 미지수 괄호 풀이 화면
|
play/ # 미지수 괄호 풀이 화면
|
||||||
select-mode/ # 게임 모드 선택 (미구현)
|
select-mode/ # 게임 모드 선택 (미구현)
|
||||||
play/ # 숫자 괄호 풀이 화면
|
|
||||||
api/
|
api/
|
||||||
auth/ # NextAuth.js 핸들러
|
auth/ # NextAuth.js 핸들러
|
||||||
problem/ # 문제 생성 API
|
problem/ # 문제 생성 API
|
||||||
@@ -51,6 +51,7 @@ components/
|
|||||||
lib/
|
lib/
|
||||||
engine/ # 문제 생성 알고리즘 엔진
|
engine/ # 문제 생성 알고리즘 엔진
|
||||||
parenthesis-sign.ts # 숫자 괄호 엔진
|
parenthesis-sign.ts # 숫자 괄호 엔진
|
||||||
|
parenthesis-latex.ts # 괄호 식 KaTeX 포맷 및 현재 항 강조 유틸
|
||||||
variable-parenthesis.ts # 미지수 괄호 엔진
|
variable-parenthesis.ts # 미지수 괄호 엔진
|
||||||
types.ts # 숫자 괄호 타입
|
types.ts # 숫자 괄호 타입
|
||||||
variable-types.ts # 미지수 괄호 타입
|
variable-types.ts # 미지수 괄호 타입
|
||||||
@@ -82,6 +83,7 @@ prisma/
|
|||||||
- 모든 수학 수식은 KaTeX로 렌더링한다. 일반 텍스트나 Unicode 수식 문자를 직접 사용하지 않는다.
|
- 모든 수학 수식은 KaTeX로 렌더링한다. 일반 텍스트나 Unicode 수식 문자를 직접 사용하지 않는다.
|
||||||
- KaTeX 컴포넌트는 `components/math/` 하위에 위치한다.
|
- KaTeX 컴포넌트는 `components/math/` 하위에 위치한다.
|
||||||
- 현재 수식 컴포넌트는 `katex.renderToString()`을 직접 사용한다. 별도 래퍼 라이브러리를 추가하기 전에 Next.js App Router 클라이언트 번들에서 런타임 문제가 없는지 확인한다.
|
- 현재 수식 컴포넌트는 `katex.renderToString()`을 직접 사용한다. 별도 래퍼 라이브러리를 추가하기 전에 Next.js App Router 클라이언트 번들에서 런타임 문제가 없는지 확인한다.
|
||||||
|
- 괄호 식 수식 조립과 풀이 중 현재 항 강조는 `lib/engine/parenthesis-latex.ts`의 공통 유틸을 사용한다. 숫자/미지수 풀이 화면에 같은 포맷 로직을 중복 구현하지 않는다.
|
||||||
|
|
||||||
### 상태 관리
|
### 상태 관리
|
||||||
|
|
||||||
@@ -95,7 +97,7 @@ prisma/
|
|||||||
- 게임 화면에서 재사용되는 입력, 피드백, 점수 요약, 진행 단계 UI는 `components/game/` 하위에 둔다.
|
- 게임 화면에서 재사용되는 입력, 피드백, 점수 요약, 진행 단계 UI는 `components/game/` 하위에 둔다.
|
||||||
- 정답·오답 판정 라인처럼 피드백 화면과 결과 화면이 공유하는 표시는 `components/game/` 하위 공통 컴포넌트로 분리한다.
|
- 정답·오답 판정 라인처럼 피드백 화면과 결과 화면이 공유하는 표시는 `components/game/` 하위 공통 컴포넌트로 분리한다.
|
||||||
- 수식 렌더링 전용 컴포넌트는 `components/math/` 하위에 둔다.
|
- 수식 렌더링 전용 컴포넌트는 `components/math/` 하위에 둔다.
|
||||||
- 라우트별 조립 컴포넌트(`PlayClient`, `NumberParenthesesClient` 등)는 해당 `app/` 라우트 폴더에 둘 수 있다.
|
- 라우트별 조립 컴포넌트(`NumberPlayClient`, `NumberParenthesesClient` 등)는 해당 `app/` 라우트 폴더에 둘 수 있다.
|
||||||
|
|
||||||
### 접근성 / 키보드 조작
|
### 접근성 / 키보드 조작
|
||||||
|
|
||||||
@@ -104,12 +106,13 @@ prisma/
|
|||||||
- 최종 결과 화면이 표시되면 다시 시작 버튼에 focus를 둔다.
|
- 최종 결과 화면이 표시되면 다시 시작 버튼에 focus를 둔다.
|
||||||
- 아이콘이나 기호만으로 상태를 표시할 때는 `aria-label`, `sr-only` 텍스트 등으로 의미를 함께 제공한다.
|
- 아이콘이나 기호만으로 상태를 표시할 때는 `aria-label`, `sr-only` 텍스트 등으로 의미를 함께 제공한다.
|
||||||
|
|
||||||
#### 숫자 괄호 풀이 화면 (`/play`)
|
#### 숫자 괄호 풀이 화면 (`/number-parentheses/play`)
|
||||||
|
|
||||||
- 모든 항을 하나의 공유 input으로 입력한다.
|
- 모든 항을 하나의 공유 input으로 입력한다.
|
||||||
- input 아래에는 각 항의 입력값 버튼을 버튼 그룹으로 표시한다. 현재 입력 중인 항은 `aria-pressed`로 표시하고, 비어 있는 항은 `?`로 표시한다.
|
- input 위 우상단에는 각 항의 진행 상태를 progress 스타일로 표시한다.
|
||||||
- 첫째 항에서 Enter를 누르면 같은 input이 둘째 항 입력으로 전환된다.
|
- 진행 상태는 값 입력 전에는 괄호 안 원래 항 값을 표시하고, 값 입력 중이거나 입력 후에는 사용자가 입력한 값을 표시한다.
|
||||||
- 둘째 항에서 Enter를 누르면 제출 동작을 실행한다. 별도 제출 버튼이 없어도 form submit 흐름이 유지되어야 한다.
|
- Enter를 누르면 다음 항 입력으로 전환되고, 마지막 항에서 Enter를 누르면 제출 동작을 실행한다. 별도 제출 버튼이 없어도 form submit 흐름이 유지되어야 한다.
|
||||||
|
- 풀이 중인 항의 수, 곱해지는 수, 해당 괄호는 KaTeX 색상으로 강조하고 나머지 항은 강조하지 않는다.
|
||||||
- 터치 기반 입력 환경에서는 input focus 시 커스텀 숫자 키패드를 표시한다. 키패드는 숫자, `+/-`, `.`, `⌫`, `↵` 버튼을 제공한다.
|
- 터치 기반 입력 환경에서는 input focus 시 커스텀 숫자 키패드를 표시한다. 키패드는 숫자, `+/-`, `.`, `⌫`, `↵` 버튼을 제공한다.
|
||||||
- 마우스/트랙패드처럼 hover 가능한 정밀 포인터가 있는 환경은 물리 키보드 사용 환경으로 간주하고 커스텀 숫자 키패드를 숨긴다. 웹 표준 API로 물리 키보드 연결 여부 자체를 안정적으로 판별할 수 없으므로 해상도 기준으로 분기하지 않는다. (`lib/input-device.ts`의 `DESKTOP_INPUT_DEVICE_QUERY` 사용)
|
- 마우스/트랙패드처럼 hover 가능한 정밀 포인터가 있는 환경은 물리 키보드 사용 환경으로 간주하고 커스텀 숫자 키패드를 숨긴다. 웹 표준 API로 물리 키보드 연결 여부 자체를 안정적으로 판별할 수 없으므로 해상도 기준으로 분기하지 않는다. (`lib/input-device.ts`의 `DESKTOP_INPUT_DEVICE_QUERY` 사용)
|
||||||
- 입력 중에는 `-`, `1.` 같은 임시 값을 허용할 수 있지만 제출 시에는 정상적인 양의 실수 또는 음의 실수 표현만 통과시킨다.
|
- 입력 중에는 `-`, `1.` 같은 임시 값을 허용할 수 있지만 제출 시에는 정상적인 양의 실수 또는 음의 실수 표현만 통과시킨다.
|
||||||
@@ -119,9 +122,11 @@ prisma/
|
|||||||
#### 미지수 괄호 풀이 화면 (`/variable-parentheses/play`)
|
#### 미지수 괄호 풀이 화면 (`/variable-parentheses/play`)
|
||||||
|
|
||||||
- 답을 직접 입력하지 않는다. 각 항의 보기 버튼 중에서 선택한다.
|
- 답을 직접 입력하지 않는다. 각 항의 보기 버튼 중에서 선택한다.
|
||||||
- 첫째 항 선택 후 자동으로 둘째 항 보기로 focus가 이동한다.
|
- 항을 선택하면 자동으로 다음 항 보기로 focus가 이동한다.
|
||||||
- 두 항을 모두 선택하면 `정답 확인` 버튼에 focus가 이동하고 버튼이 활성화된다.
|
- 마지막 항을 선택하면 별도 `정답 확인` 버튼 없이 즉시 제출되어 피드백 화면으로 전환된다.
|
||||||
|
- 입력 항 진행 상태는 progress 스타일로 표시한다. 선택 전에는 괄호 안 원래 항 값을 표시하고, 선택 후에는 고른 보기 값을 표시한다.
|
||||||
- 선택된 보기 버튼은 `aria-pressed`로 표시한다.
|
- 선택된 보기 버튼은 `aria-pressed`로 표시한다.
|
||||||
|
- 풀이 중인 항의 수, 곱해지는 수, 해당 괄호는 KaTeX 색상으로 강조하고 나머지 항은 강조하지 않는다.
|
||||||
|
|
||||||
### API / 백엔드
|
### API / 백엔드
|
||||||
|
|
||||||
@@ -138,8 +143,11 @@ prisma/
|
|||||||
- 문제 세션에서 중복을 방지할 때는 랜덤 id가 아니라 수학적 문제 조건을 나타내는 안정적인 key를 기준으로 한다.
|
- 문제 세션에서 중복을 방지할 때는 랜덤 id가 아니라 수학적 문제 조건을 나타내는 안정적인 key를 기준으로 한다.
|
||||||
- 모든 엔진은 전체 가능한 시드 목록을 모듈 로드 시 한 번 생성(`createAllProblemSeeds`)하고, 세션 생성 시 셔플 후 슬라이스하는 방식으로 중복 없이 출제한다.
|
- 모든 엔진은 전체 가능한 시드 목록을 모듈 로드 시 한 번 생성(`createAllProblemSeeds`)하고, 세션 생성 시 셔플 후 슬라이스하는 방식으로 중복 없이 출제한다.
|
||||||
- 숫자 괄호 엔진: `lib/engine/parenthesis-sign.ts` / 타입: `lib/engine/types.ts`
|
- 숫자 괄호 엔진: `lib/engine/parenthesis-sign.ts` / 타입: `lib/engine/types.ts`
|
||||||
|
- 문제 구조는 `terms`, `operators`, `distributedTerms` 배열을 기준으로 한다. `a`, `b`, `distributedA`, `distributedB` 같은 2항 전용 필드를 추가하지 않는다.
|
||||||
|
- 채점 입력은 `{ terms: number[] }` 형태만 사용한다.
|
||||||
- 미지수 괄호 엔진: `lib/engine/variable-parenthesis.ts` / 타입: `lib/engine/variable-types.ts`
|
- 미지수 괄호 엔진: `lib/engine/variable-parenthesis.ts` / 타입: `lib/engine/variable-types.ts`
|
||||||
- 괄호 안 두 항 중 정확히 하나가 `정수 × x` 항이다.
|
- 괄호 안 항 중 정확히 하나가 `정수 × x` 항이다.
|
||||||
|
- 문제 구조는 `terms`, `operators`, `correctTerms`, `choices` 배열을 기준으로 한다. `firstTerm`, `secondTerm`, `operator` 같은 2항 전용 필드를 추가하지 않는다.
|
||||||
- 각 항의 보기는 정답 계수의 절대값 기준 음수/양수 두 개를 셔플해 제공한다.
|
- 각 항의 보기는 정답 계수의 절대값 기준 음수/양수 두 개를 셔플해 제공한다.
|
||||||
- 채점은 choice id 기반으로 수행한다.
|
- 채점은 choice id 기반으로 수행한다.
|
||||||
|
|
||||||
@@ -173,7 +181,7 @@ NEXTAUTH_SECRET=your-secret
|
|||||||
|
|
||||||
| 유형 | 상태 | 라우트 | 답변 방식 |
|
| 유형 | 상태 | 라우트 | 답변 방식 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 숫자 괄호 풀기 | 구현 완료 | `/number-parentheses` → `/play` | 직접 입력 |
|
| 숫자 괄호 풀기 | 구현 완료 | `/number-parentheses` → `/number-parentheses/play` | 직접 입력 |
|
||||||
| 미지수 괄호 풀기 | 구현 완료 | `/variable-parentheses` → `/variable-parentheses/play` | 보기 선택 |
|
| 미지수 괄호 풀기 | 구현 완료 | `/variable-parentheses` → `/variable-parentheses/play` | 보기 선택 |
|
||||||
| 분수 괄호 풀기 | 미구현 | — | — |
|
| 분수 괄호 풀기 | 미구현 | — | — |
|
||||||
| 실수 괄호 풀기 | 미구현 | — | — |
|
| 실수 괄호 풀기 | 미구현 | — | — |
|
||||||
@@ -192,9 +200,10 @@ NEXTAUTH_SECRET=your-secret
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 시작 화면(`/`)은 학습 내용 선택 화면이다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성화되어 있고 분수/실수 괄호는 disabled 상태다.
|
- 시작 화면(`/`)은 학습 내용 선택 화면이다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성화되어 있고 분수/실수 괄호는 disabled 상태다.
|
||||||
- 준비 화면은 문제 수 `[5] [10]` 버튼 그룹과 괄호 종류 버튼 그룹을 제공한다. 기본값은 `10`이며, 현재는 `()`만 선택 가능하다.
|
- 준비 화면은 문제 수 `[5] [10]`, 괄호 종류, 괄호 안 최대 항 수 `[2] [3] [4]` 버튼 그룹을 제공한다. 기본 문제 수는 `10`, 기본 최대 항 수는 `2`이며, 괄호 종류는 현재 `()`만 선택 가능하다.
|
||||||
- 준비 화면의 `연습 시작`은 Zustand 세션을 초기화하고 풀이 화면으로 이동한다.
|
- 준비 화면의 `연습 시작`은 Zustand 세션을 초기화하고 풀이 화면으로 이동한다.
|
||||||
- 풀이 화면은 URL의 `count` 쿼리와 Zustand의 선택 문제 수를 동기화한다. 허용 문제 수는 `5`, `10`이며 유효하지 않은 값은 `10`으로 처리한다.
|
- 풀이 화면은 URL의 `count`, `terms` 쿼리와 Zustand의 선택 문제 수·최대 항 수를 동기화한다. 허용 문제 수는 `5`, `10`이며 유효하지 않은 값은 `10`으로 처리한다. 허용 최대 항 수는 `2`, `3`, `4`이며 유효하지 않은 값은 `2`로 처리한다.
|
||||||
|
- 풀이 화면의 문제 수식은 현재 풀고 있는 항의 수, 곱해지는 수, 해당 괄호를 강조 색상으로 표시하고 나머지 항은 기본 색상으로 표시한다.
|
||||||
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시한다. 정답은 `○`, 오답은 붉은색 답과 `×`, 굵은 정답값을 함께 보여준다.
|
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시한다. 정답은 `○`, 오답은 붉은색 답과 `×`, 굵은 정답값을 함께 보여준다.
|
||||||
- 최종 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하고, 피드백 화면과 같은 정답·오답 색상 및 기호 규칙을 사용한다.
|
- 최종 결과 화면은 지금까지 푼 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하고, 피드백 화면과 같은 정답·오답 색상 및 기호 규칙을 사용한다.
|
||||||
|
|
||||||
|
|||||||
@@ -47,28 +47,30 @@
|
|||||||
|
|
||||||
- 시작 화면(`/`)에서 학습 내용을 선택합니다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성 상태이고 분수·실수 괄호는 준비 중으로 비활성화되어 있습니다.
|
- 시작 화면(`/`)에서 학습 내용을 선택합니다. `숫자 괄호 풀기`, `미지수 괄호 풀기`가 활성 상태이고 분수·실수 괄호는 준비 중으로 비활성화되어 있습니다.
|
||||||
- 문제와 정답 수식은 KaTeX로 렌더링합니다.
|
- 문제와 정답 수식은 KaTeX로 렌더링합니다.
|
||||||
|
- 풀이 중인 문제 수식은 현재 항, 곱해지는 수, 해당 괄호를 강조 색상으로 표시하고 나머지 항은 기본 색상으로 표시합니다.
|
||||||
- 진행 단계는 현재 문제, 완료된 정답, 완료된 오답, 남은 문제를 구분해서 표시합니다.
|
- 진행 단계는 현재 문제, 완료된 정답, 완료된 오답, 남은 문제를 구분해서 표시합니다.
|
||||||
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시합니다.
|
- 정답·오답 피드백은 `계산식 = 내가 입력한(고른) 답` 형식으로 표시합니다.
|
||||||
- 정답은 정답 색상 박스와 `○` 표시, 오답은 오답 색상 박스, 붉은색 답, `×`, 굵은 정답값을 표시합니다.
|
- 정답은 정답 색상 박스와 `○` 표시, 오답은 오답 색상 박스, 붉은색 답, `×`, 굵은 정답값을 표시합니다.
|
||||||
- 결과 화면은 전체 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하며, 피드백과 같은 색상·기호 규칙을 사용합니다.
|
- 결과 화면은 전체 문제를 `문제 = 내가 입력한(고른) 답` 형식으로 나열하며, 피드백과 같은 색상·기호 규칙을 사용합니다.
|
||||||
- 피드백 화면 진입 시 다음 진행 버튼에, 결과 화면 진입 시 다시 시작 버튼에 자동으로 focus가 이동합니다.
|
- 피드백 화면 진입 시 다음 진행 버튼에, 결과 화면 진입 시 다시 시작 버튼에 자동으로 focus가 이동합니다.
|
||||||
|
|
||||||
### 숫자 괄호 풀기 (`/number-parentheses` → `/play`)
|
### 숫자 괄호 풀기 (`/number-parentheses` → `/number-parentheses/play`)
|
||||||
|
|
||||||
- 준비 화면에서 문제 수 5 / 10을 선택하고 연습을 시작합니다. 기본값은 10.
|
- 준비 화면에서 문제 수 5 / 10과 괄호 안 최대 항 수 2 / 3 / 4를 선택하고 연습을 시작합니다. 기본값은 문제 수 10, 최대 항 수 2입니다.
|
||||||
- 괄호 종류는 현재 `()`만 선택 가능하고 `{}`, `[]`는 비활성화되어 있습니다.
|
- 괄호 종류는 현재 `()`만 선택 가능하고 `{}`, `[]`는 비활성화되어 있습니다.
|
||||||
- 풀이 화면에서 두 항을 직접 숫자로 입력합니다.
|
- 풀이 화면에서 각 항을 직접 숫자로 입력합니다.
|
||||||
- 모든 항은 하나의 input에서 순서대로 입력하며, 첫째 항 Enter → 둘째 항 전환, 둘째 항 Enter → 제출.
|
- 모든 항은 하나의 input에서 순서대로 입력하며, Enter로 다음 항으로 이동하고 마지막 항에서 Enter를 누르면 제출합니다.
|
||||||
- input 아래 항 버튼 그룹에서 현재 입력 중인 항을 표시하고, 버튼을 누르면 해당 항을 초기화해 다시 입력합니다.
|
- input 위 우상단의 progress 표시에서 각 항의 진행 상태를 보여줍니다. 입력 전에는 괄호 안 원래 항 값, 입력 중/입력 후에는 사용자가 입력한 값을 표시합니다.
|
||||||
- 터치 환경에서는 커스텀 숫자 키패드(숫자, `+/-`, `.`, `⌫`, `↵`)가 표시되며, 정밀 포인터가 있는 환경(마우스/트랙패드)에서는 숨깁니다.
|
- 터치 환경에서는 커스텀 숫자 키패드(숫자, `+/-`, `.`, `⌫`, `↵`)가 표시되며, 정밀 포인터가 있는 환경(마우스/트랙패드)에서는 숨깁니다.
|
||||||
|
|
||||||
### 미지수 괄호 풀기 (`/variable-parentheses` → `/variable-parentheses/play`)
|
### 미지수 괄호 풀기 (`/variable-parentheses` → `/variable-parentheses/play`)
|
||||||
|
|
||||||
- 준비 화면 구성은 숫자 괄호와 동일합니다.
|
- 준비 화면 구성은 숫자 괄호와 동일합니다.
|
||||||
- 풀이 화면에서 두 항을 보기 중에서 선택합니다.
|
- 풀이 화면에서 각 항을 보기 중에서 선택합니다.
|
||||||
- 각 항의 보기는 정답 계수의 절대값을 기준으로 음수/양수 두 개를 제공합니다.
|
- 각 항의 보기는 정답 계수의 절대값을 기준으로 음수/양수 두 개를 제공합니다.
|
||||||
- 첫째 항 선택 후 자동으로 둘째 항 보기로 focus가 이동합니다.
|
- 항 선택 후 자동으로 다음 항 보기로 focus가 이동합니다.
|
||||||
- 두 항 선택 완료 후 `정답 확인` 버튼이 활성화되며 버튼에 focus가 이동합니다.
|
- 마지막 항을 선택하면 별도 `정답 확인` 버튼 없이 즉시 피드백 화면으로 전환됩니다.
|
||||||
|
- 입력 항 progress 표시는 선택 전에는 괄호 안 원래 항 값, 선택 후에는 고른 보기 값을 표시합니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,9 +127,9 @@ pnpm start
|
|||||||
app/ # Next.js App Router 루트
|
app/ # Next.js App Router 루트
|
||||||
(game)/ # 게임 화면 라우트 그룹
|
(game)/ # 게임 화면 라우트 그룹
|
||||||
number-parentheses/ # 숫자 괄호 준비 화면
|
number-parentheses/ # 숫자 괄호 준비 화면
|
||||||
|
play/ # 숫자 괄호 풀이 화면
|
||||||
variable-parentheses/ # 미지수 괄호 준비 화면
|
variable-parentheses/ # 미지수 괄호 준비 화면
|
||||||
play/ # 미지수 괄호 풀이 화면
|
play/ # 미지수 괄호 풀이 화면
|
||||||
play/ # 숫자 괄호 풀이 화면
|
|
||||||
api/
|
api/
|
||||||
auth/ # NextAuth.js 핸들러
|
auth/ # NextAuth.js 핸들러
|
||||||
problem/ # 문제 생성 API
|
problem/ # 문제 생성 API
|
||||||
@@ -140,7 +142,10 @@ components/
|
|||||||
lib/
|
lib/
|
||||||
engine/ # 문제 생성 알고리즘 엔진
|
engine/ # 문제 생성 알고리즘 엔진
|
||||||
parenthesis-sign.ts # 숫자 괄호 엔진
|
parenthesis-sign.ts # 숫자 괄호 엔진
|
||||||
|
parenthesis-latex.ts # 괄호 식 KaTeX 포맷 및 현재 항 강조 유틸
|
||||||
variable-parenthesis.ts # 미지수 괄호 엔진
|
variable-parenthesis.ts # 미지수 괄호 엔진
|
||||||
|
types.ts # 숫자 괄호 타입
|
||||||
|
variable-types.ts # 미지수 괄호 타입
|
||||||
input-device.ts # 입력 장치 감지 미디어 쿼리
|
input-device.ts # 입력 장치 감지 미디어 쿼리
|
||||||
prisma.ts # Prisma Client 싱글턴
|
prisma.ts # Prisma Client 싱글턴
|
||||||
redis.ts # Redis Client 싱글턴
|
redis.ts # Redis Client 싱글턴
|
||||||
|
|||||||
@@ -1,142 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import ParenthesesPreparationClient from "@/components/game/ParenthesesPreparationClient";
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import { useGameStore } from "@/store/gameStore";
|
import { useGameStore } from "@/store/gameStore";
|
||||||
import type { ProblemCount } from "@/store/gameStore";
|
|
||||||
|
|
||||||
const problemCounts: ProblemCount[] = [5, 10];
|
|
||||||
|
|
||||||
const bracketKinds = [
|
|
||||||
{
|
|
||||||
label: "()",
|
|
||||||
name: "소괄호",
|
|
||||||
selected: true,
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "{}",
|
|
||||||
name: "중괄호",
|
|
||||||
selected: false,
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "[]",
|
|
||||||
name: "대괄호",
|
|
||||||
selected: false,
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function NumberParenthesesClient() {
|
export default function NumberParenthesesClient() {
|
||||||
const startLinkRef = useRef<HTMLAnchorElement>(null);
|
|
||||||
const selectedProblemCount = useGameStore(
|
const selectedProblemCount = useGameStore(
|
||||||
(state) => state.selectedProblemCount,
|
(state) => state.selectedProblemCount,
|
||||||
);
|
);
|
||||||
const setProblemCount = useGameStore((state) => state.setProblemCount);
|
const setProblemCount = useGameStore((state) => state.setProblemCount);
|
||||||
|
const selectedMaxTermCount = useGameStore(
|
||||||
|
(state) => state.selectedMaxTermCount,
|
||||||
|
);
|
||||||
|
const setMaxTermCount = useGameStore((state) => state.setMaxTermCount);
|
||||||
const initSession = useGameStore((state) => state.initSession);
|
const initSession = useGameStore((state) => state.initSession);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
startLinkRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
|
<ParenthesesPreparationClient
|
||||||
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
|
eyebrow="숫자 괄호 학습 준비"
|
||||||
<div className="space-y-4">
|
title="숫자 괄호 풀기"
|
||||||
<p className="text-sm font-semibold text-emerald-700">
|
description="정수로 이루어진 괄호식을 풀며 음수 분배와 부호 변화를 연습합니다."
|
||||||
숫자 괄호 학습 준비
|
notice="현재는 소괄호 숫자 문제만 선택할 수 있습니다."
|
||||||
</p>
|
playHref={`/number-parentheses/play?count=${selectedProblemCount}&terms=${selectedMaxTermCount}`}
|
||||||
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
|
selectedProblemCount={selectedProblemCount}
|
||||||
숫자 괄호 풀기
|
selectedMaxTermCount={selectedMaxTermCount}
|
||||||
</h1>
|
onProblemCountChange={setProblemCount}
|
||||||
<p className="max-w-2xl text-lg leading-8 text-slate-700">
|
onMaxTermCountChange={setMaxTermCount}
|
||||||
정수로 이루어진 괄호식을 풀며 음수 분배와 부호 변화를
|
onStart={() => initSession(selectedProblemCount, selectedMaxTermCount)}
|
||||||
연습합니다.
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<fieldset className="space-y-3">
|
|
||||||
<legend className="text-sm font-semibold text-slate-700">
|
|
||||||
문제 수
|
|
||||||
</legend>
|
|
||||||
<div
|
|
||||||
className="inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm"
|
|
||||||
role="group"
|
|
||||||
aria-label="문제 수 선택"
|
|
||||||
>
|
|
||||||
{problemCounts.map((count) => {
|
|
||||||
const selected = selectedProblemCount === count;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={count}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setProblemCount(count)}
|
|
||||||
aria-pressed={selected}
|
|
||||||
className={`h-10 min-w-16 rounded px-4 text-base font-semibold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
|
|
||||||
selected
|
|
||||||
? "bg-emerald-700 text-white"
|
|
||||||
: "text-slate-700 hover:bg-slate-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset className="space-y-3">
|
|
||||||
<legend className="text-sm font-semibold text-slate-700">
|
|
||||||
괄호 종류
|
|
||||||
</legend>
|
|
||||||
<div
|
|
||||||
className="inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm"
|
|
||||||
role="group"
|
|
||||||
aria-label="괄호 종류 선택"
|
|
||||||
>
|
|
||||||
{bracketKinds.map((bracketKind) => (
|
|
||||||
<button
|
|
||||||
key={bracketKind.label}
|
|
||||||
type="button"
|
|
||||||
disabled={bracketKind.disabled}
|
|
||||||
aria-pressed={bracketKind.selected}
|
|
||||||
aria-label={`${bracketKind.name} ${bracketKind.disabled ? "준비 중" : "선택됨"}`}
|
|
||||||
className={`h-10 min-w-16 rounded px-4 text-base font-semibold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
|
|
||||||
bracketKind.selected
|
|
||||||
? "bg-emerald-700 text-white"
|
|
||||||
: "text-slate-700 hover:bg-slate-100"
|
|
||||||
} ${
|
|
||||||
bracketKind.disabled
|
|
||||||
? "cursor-not-allowed bg-slate-100 text-slate-400 hover:bg-slate-100"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{bracketKind.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
ref={startLinkRef}
|
|
||||||
href={`/play?count=${selectedProblemCount}`}
|
|
||||||
onClick={() => initSession(selectedProblemCount)}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
연습 시작
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-l-4 border-emerald-700 pl-4 text-sm leading-6 text-slate-600">
|
|
||||||
<p>현재는 소괄호 숫자 문제만 선택할 수 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-33
@@ -1,21 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
|
|
||||||
import AnswerForm from "@/components/game/AnswerForm";
|
import NumberAnswerForm from "@/components/game/NumberAnswerForm";
|
||||||
import type {
|
import type {
|
||||||
ActiveAnswerInput,
|
ActiveAnswerInput,
|
||||||
AnswerFormHandle,
|
NumberAnswerFormHandle,
|
||||||
} from "@/components/game/AnswerForm";
|
} from "@/components/game/NumberAnswerForm";
|
||||||
import FeedbackPanel from "@/components/game/FeedbackPanel";
|
import FeedbackPanel from "@/components/game/FeedbackPanel";
|
||||||
import ScoreSummary from "@/components/game/ScoreSummary";
|
import ScoreSummary from "@/components/game/ScoreSummary";
|
||||||
import StageIndicator from "@/components/game/StageIndicator";
|
import StageIndicator from "@/components/game/StageIndicator";
|
||||||
import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device";
|
import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device";
|
||||||
import KatexRenderer from "@/components/math/KatexRenderer";
|
import KatexRenderer from "@/components/math/KatexRenderer";
|
||||||
import { useGameStore } from "@/store/gameStore";
|
import { useGameStore } from "@/store/gameStore";
|
||||||
import type { ProblemCount } from "@/store/gameStore";
|
import type { MaxTermCount, ProblemCount } from "@/store/gameStore";
|
||||||
|
import { formatParenthesisExpressionLatex } from "@/lib/engine/parenthesis-latex";
|
||||||
|
|
||||||
const REAL_NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/;
|
const REAL_NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/;
|
||||||
|
|
||||||
@@ -27,26 +29,35 @@ function parseAnswer(value: string) {
|
|||||||
return Number(trimmed);
|
return Number(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlayClientProps {
|
interface NumberPlayClientProps {
|
||||||
problemCount: ProblemCount;
|
problemCount: ProblemCount;
|
||||||
|
maxTermCount: MaxTermCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlayClient({ problemCount }: PlayClientProps) {
|
export default function NumberPlayClient({
|
||||||
|
problemCount,
|
||||||
|
maxTermCount,
|
||||||
|
}: NumberPlayClientProps) {
|
||||||
const session = useGameStore((state) => state.session);
|
const session = useGameStore((state) => state.session);
|
||||||
const selectedProblemCount = useGameStore(
|
const selectedProblemCount = useGameStore(
|
||||||
(state) => state.selectedProblemCount,
|
(state) => state.selectedProblemCount,
|
||||||
);
|
);
|
||||||
const setProblemCount = useGameStore((state) => state.setProblemCount);
|
const setProblemCount = useGameStore((state) => state.setProblemCount);
|
||||||
|
const selectedMaxTermCount = useGameStore(
|
||||||
|
(state) => state.selectedMaxTermCount,
|
||||||
|
);
|
||||||
|
const setMaxTermCount = useGameStore((state) => state.setMaxTermCount);
|
||||||
const initSession = useGameStore((state) => state.initSession);
|
const initSession = useGameStore((state) => state.initSession);
|
||||||
const submitAnswer = useGameStore((state) => state.submitAnswer);
|
const submitAnswer = useGameStore((state) => state.submitAnswer);
|
||||||
const nextProblem = useGameStore((state) => state.nextProblem);
|
const nextProblem = useGameStore((state) => state.nextProblem);
|
||||||
const resetSession = useGameStore((state) => state.resetSession);
|
const resetSession = useGameStore((state) => state.resetSession);
|
||||||
|
|
||||||
const problemCardRef = useRef<HTMLDivElement>(null);
|
const problemCardRef = useRef<HTMLDivElement>(null);
|
||||||
const answerFormRef = useRef<AnswerFormHandle>(null);
|
const answerFormRef = useRef<NumberAnswerFormHandle>(null);
|
||||||
|
|
||||||
const [answerA, setAnswerA] = useState("");
|
const [answers, setAnswers] = useState<string[]>(() =>
|
||||||
const [answerB, setAnswerB] = useState("");
|
Array.from({ length: maxTermCount }, () => ""),
|
||||||
|
);
|
||||||
const [activeInput, setActiveInput] = useState<ActiveAnswerInput>(0);
|
const [activeInput, setActiveInput] = useState<ActiveAnswerInput>(0);
|
||||||
const [inputError, setInputError] = useState("");
|
const [inputError, setInputError] = useState("");
|
||||||
|
|
||||||
@@ -56,7 +67,8 @@ export default function PlayClient({ problemCount }: PlayClientProps) {
|
|||||||
const isComplete = Boolean(
|
const isComplete = Boolean(
|
||||||
session && session.currentIndex >= session.problems.length,
|
session && session.currentIndex >= session.problems.length,
|
||||||
);
|
);
|
||||||
const sessionNeedsInit = sessionProblemCount !== problemCount;
|
const sessionNeedsInit =
|
||||||
|
sessionProblemCount !== problemCount || session?.termCount !== maxTermCount;
|
||||||
const correctCount =
|
const correctCount =
|
||||||
session?.results.filter((result) => result?.correct).length ?? 0;
|
session?.results.filter((result) => result?.correct).length ?? 0;
|
||||||
const totalCount = session?.problems.length ?? 0;
|
const totalCount = session?.problems.length ?? 0;
|
||||||
@@ -66,40 +78,53 @@ export default function PlayClient({ problemCount }: PlayClientProps) {
|
|||||||
setProblemCount(problemCount);
|
setProblemCount(problemCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedMaxTermCount !== maxTermCount) {
|
||||||
|
setMaxTermCount(maxTermCount);
|
||||||
|
}
|
||||||
|
|
||||||
if (sessionNeedsInit) {
|
if (sessionNeedsInit) {
|
||||||
initSession(problemCount);
|
initSession(problemCount, maxTermCount);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
initSession,
|
initSession,
|
||||||
|
maxTermCount,
|
||||||
problemCount,
|
problemCount,
|
||||||
|
selectedMaxTermCount,
|
||||||
selectedProblemCount,
|
selectedProblemCount,
|
||||||
sessionProblemCount,
|
sessionProblemCount,
|
||||||
sessionNeedsInit,
|
sessionNeedsInit,
|
||||||
|
setMaxTermCount,
|
||||||
setProblemCount,
|
setProblemCount,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAnswerA("");
|
setAnswers(Array.from({ length: currentProblem?.terms.length ?? maxTermCount }, () => ""));
|
||||||
setAnswerB("");
|
|
||||||
setActiveInput(0);
|
setActiveInput(0);
|
||||||
setInputError("");
|
setInputError("");
|
||||||
}, [currentProblem?.key]);
|
}, [currentProblem?.key, currentProblem?.terms.length, maxTermCount]);
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (currentResult || !currentProblem) return;
|
if (currentResult || !currentProblem) return;
|
||||||
|
|
||||||
const parsedA = parseAnswer(answerA);
|
const parsedAnswers = answers.map(parseAnswer);
|
||||||
const parsedB = parseAnswer(answerB);
|
|
||||||
|
|
||||||
if (parsedA === null || parsedB === null) {
|
if (parsedAnswers.some((answer) => answer === null)) {
|
||||||
setInputError("두 칸 모두 실수로 입력하세요.");
|
setInputError("모든 항을 실수로 입력하세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputError("");
|
setInputError("");
|
||||||
submitAnswer({ a: parsedA, b: parsedB });
|
submitAnswer({ terms: parsedAnswers as number[] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAnswerChange(index: ActiveAnswerInput, value: string) {
|
||||||
|
setAnswers((currentAnswers) =>
|
||||||
|
currentAnswers.map((answer, answerIndex) =>
|
||||||
|
answerIndex === index ? value : answer,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNextProblem() {
|
function handleNextProblem() {
|
||||||
@@ -181,6 +206,22 @@ export default function PlayClient({ problemCount }: PlayClientProps) {
|
|||||||
|
|
||||||
const submitted = currentResult !== null;
|
const submitted = currentResult !== null;
|
||||||
const isLastProblem = session.currentIndex === session.problems.length - 1;
|
const isLastProblem = session.currentIndex === session.problems.length - 1;
|
||||||
|
const problemLatex = submitted
|
||||||
|
? currentProblem.latexBefore
|
||||||
|
: formatParenthesisExpressionLatex({
|
||||||
|
multiplier: currentProblem.multiplier,
|
||||||
|
terms: currentProblem.terms,
|
||||||
|
operators: currentProblem.operators,
|
||||||
|
formatTermLatex: (term) => `${term}`,
|
||||||
|
activeTermIndex: activeInput,
|
||||||
|
});
|
||||||
|
const pendingAnswerLabels = currentProblem.terms.map((term, index) => {
|
||||||
|
if (index === 0) return String(term);
|
||||||
|
|
||||||
|
return String(
|
||||||
|
currentProblem.operators[index - 1] === "-" ? -term : term,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950">
|
<main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950">
|
||||||
@@ -194,34 +235,39 @@ export default function PlayClient({ problemCount }: PlayClientProps) {
|
|||||||
문제 풀이
|
문제 풀이
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<StageIndicator
|
<Link
|
||||||
currentIndex={session.currentIndex}
|
href="/number-parentheses"
|
||||||
results={session.results}
|
onClick={resetSession}
|
||||||
/>
|
className="inline-flex h-10 items-center justify-center rounded-md bg-red-700 px-4 text-sm font-semibold text-white transition hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
포기
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={problemCardRef}
|
ref={problemCardRef}
|
||||||
className="rounded-md bg-white px-6 pt-6 pb-2 shadow-sm ring-1 ring-slate-200 sm:p-8"
|
className="rounded-md bg-white px-6 pt-6 pb-4 shadow-sm ring-1 ring-slate-200 sm:p-8"
|
||||||
>
|
>
|
||||||
<p className="mb-4 text-sm font-semibold text-slate-500">
|
|
||||||
괄호를 풀어 두 항을 순서대로 입력하세요.
|
|
||||||
</p>
|
|
||||||
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
|
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
|
||||||
<KatexRenderer latex={currentProblem.latexBefore} displayMode />
|
<KatexRenderer latex={problemLatex} displayMode />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
<StageIndicator
|
||||||
|
currentIndex={session.currentIndex}
|
||||||
|
results={session.results}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!submitted ? (
|
{!submitted ? (
|
||||||
<AnswerForm
|
<NumberAnswerForm
|
||||||
ref={answerFormRef}
|
ref={answerFormRef}
|
||||||
answerA={answerA}
|
answers={answers}
|
||||||
answerB={answerB}
|
|
||||||
activeInput={activeInput}
|
activeInput={activeInput}
|
||||||
error={inputError}
|
error={inputError}
|
||||||
|
pendingAnswerLabels={pendingAnswerLabels}
|
||||||
onActiveInputChange={setActiveInput}
|
onActiveInputChange={setActiveInput}
|
||||||
onAnswerAChange={setAnswerA}
|
onAnswerChange={handleAnswerChange}
|
||||||
onAnswerBChange={setAnswerB}
|
|
||||||
onInputFocus={scrollKatexToViewportTop}
|
onInputFocus={scrollKatexToViewportTop}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import NumberPlayClient from "./NumberPlayClient";
|
||||||
|
import type { MaxTermCount, ProblemCount } from "@/store/gameStore";
|
||||||
|
|
||||||
|
interface NumberPlayPageProps {
|
||||||
|
searchParams?: {
|
||||||
|
count?: string | string[];
|
||||||
|
terms?: string | string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProblemCount(count?: string | string[]): ProblemCount {
|
||||||
|
if (Array.isArray(count)) {
|
||||||
|
return parseProblemCount(count[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count === "5" ? 5 : 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMaxTermCount(terms?: string | string[]): MaxTermCount {
|
||||||
|
if (Array.isArray(terms)) {
|
||||||
|
return parseMaxTermCount(terms[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terms === "3") return 3;
|
||||||
|
if (terms === "4") return 4;
|
||||||
|
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NumberPlayPage({ searchParams }: NumberPlayPageProps) {
|
||||||
|
return (
|
||||||
|
<NumberPlayClient
|
||||||
|
problemCount={parseProblemCount(searchParams?.count)}
|
||||||
|
maxTermCount={parseMaxTermCount(searchParams?.terms)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import PlayClient from "./PlayClient";
|
|
||||||
import type { ProblemCount } from "@/store/gameStore";
|
|
||||||
|
|
||||||
interface PlayPageProps {
|
|
||||||
searchParams?: {
|
|
||||||
count?: string | string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseProblemCount(count?: string | string[]): ProblemCount {
|
|
||||||
if (Array.isArray(count)) {
|
|
||||||
return parseProblemCount(count[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return count === "5" ? 5 : 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlayPage({ searchParams }: PlayPageProps) {
|
|
||||||
return <PlayClient problemCount={parseProblemCount(searchParams?.count)} />;
|
|
||||||
}
|
|
||||||
@@ -1,146 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import ParenthesesPreparationClient from "@/components/game/ParenthesesPreparationClient";
|
||||||
import { useEffect, useRef } from "react";
|
import { useVariableGameStore } from "@/store/variableGameStore";
|
||||||
|
|
||||||
import {
|
|
||||||
useVariableGameStore,
|
|
||||||
type VariableProblemCount,
|
|
||||||
} from "@/store/variableGameStore";
|
|
||||||
|
|
||||||
const problemCounts: VariableProblemCount[] = [5, 10];
|
|
||||||
|
|
||||||
const bracketKinds = [
|
|
||||||
{
|
|
||||||
label: "()",
|
|
||||||
name: "소괄호",
|
|
||||||
selected: true,
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "{}",
|
|
||||||
name: "중괄호",
|
|
||||||
selected: false,
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "[]",
|
|
||||||
name: "대괄호",
|
|
||||||
selected: false,
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function VariableParenthesesClient() {
|
export default function VariableParenthesesClient() {
|
||||||
const startLinkRef = useRef<HTMLAnchorElement>(null);
|
|
||||||
const selectedProblemCount = useVariableGameStore(
|
const selectedProblemCount = useVariableGameStore(
|
||||||
(state) => state.selectedProblemCount,
|
(state) => state.selectedProblemCount,
|
||||||
);
|
);
|
||||||
const setProblemCount = useVariableGameStore(
|
const setProblemCount = useVariableGameStore(
|
||||||
(state) => state.setProblemCount,
|
(state) => state.setProblemCount,
|
||||||
);
|
);
|
||||||
|
const selectedMaxTermCount = useVariableGameStore(
|
||||||
|
(state) => state.selectedMaxTermCount,
|
||||||
|
);
|
||||||
|
const setMaxTermCount = useVariableGameStore(
|
||||||
|
(state) => state.setMaxTermCount,
|
||||||
|
);
|
||||||
const initSession = useVariableGameStore((state) => state.initSession);
|
const initSession = useVariableGameStore((state) => state.initSession);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
startLinkRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
|
<ParenthesesPreparationClient
|
||||||
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
|
eyebrow="미지수 괄호 학습 준비"
|
||||||
<div className="space-y-4">
|
title="미지수 괄호 풀기"
|
||||||
<p className="text-sm font-semibold text-emerald-700">
|
description="괄호 안의 한 항에 미지수 x가 들어간 식을 풀며 부호 변화를 연습합니다."
|
||||||
미지수 괄호 학습 준비
|
notice="현재는 소괄호와 미지수 x 문제만 선택할 수 있습니다."
|
||||||
</p>
|
playHref={`/variable-parentheses/play?count=${selectedProblemCount}&terms=${selectedMaxTermCount}`}
|
||||||
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
|
selectedProblemCount={selectedProblemCount}
|
||||||
미지수 괄호 풀기
|
selectedMaxTermCount={selectedMaxTermCount}
|
||||||
</h1>
|
onProblemCountChange={setProblemCount}
|
||||||
<p className="max-w-2xl text-lg leading-8 text-slate-700">
|
onMaxTermCountChange={setMaxTermCount}
|
||||||
괄호 안의 한 항에 미지수 x가 들어간 식을 풀며 부호 변화를
|
onStart={() => initSession(selectedProblemCount, selectedMaxTermCount)}
|
||||||
연습합니다.
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<fieldset className="space-y-3">
|
|
||||||
<legend className="text-sm font-semibold text-slate-700">
|
|
||||||
문제 수
|
|
||||||
</legend>
|
|
||||||
<div
|
|
||||||
className="inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm"
|
|
||||||
role="group"
|
|
||||||
aria-label="문제 수 선택"
|
|
||||||
>
|
|
||||||
{problemCounts.map((count) => {
|
|
||||||
const selected = selectedProblemCount === count;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={count}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setProblemCount(count)}
|
|
||||||
aria-pressed={selected}
|
|
||||||
className={`h-10 min-w-16 rounded px-4 text-base font-semibold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
|
|
||||||
selected
|
|
||||||
? "bg-emerald-700 text-white"
|
|
||||||
: "text-slate-700 hover:bg-slate-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset className="space-y-3">
|
|
||||||
<legend className="text-sm font-semibold text-slate-700">
|
|
||||||
괄호 종류
|
|
||||||
</legend>
|
|
||||||
<div
|
|
||||||
className="inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm"
|
|
||||||
role="group"
|
|
||||||
aria-label="괄호 종류 선택"
|
|
||||||
>
|
|
||||||
{bracketKinds.map((bracketKind) => (
|
|
||||||
<button
|
|
||||||
key={bracketKind.label}
|
|
||||||
type="button"
|
|
||||||
disabled={bracketKind.disabled}
|
|
||||||
aria-pressed={bracketKind.selected}
|
|
||||||
aria-label={`${bracketKind.name} ${bracketKind.disabled ? "준비 중" : "선택됨"}`}
|
|
||||||
className={`h-10 min-w-16 rounded px-4 text-base font-semibold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
|
|
||||||
bracketKind.selected
|
|
||||||
? "bg-emerald-700 text-white"
|
|
||||||
: "text-slate-700 hover:bg-slate-100"
|
|
||||||
} ${
|
|
||||||
bracketKind.disabled
|
|
||||||
? "cursor-not-allowed bg-slate-100 text-slate-400 hover:bg-slate-100"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{bracketKind.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
ref={startLinkRef}
|
|
||||||
href={`/variable-parentheses/play?count=${selectedProblemCount}`}
|
|
||||||
onClick={() => initSession(selectedProblemCount)}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
연습 시작
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-l-4 border-emerald-700 pl-4 text-sm leading-6 text-slate-600">
|
|
||||||
<p>현재는 소괄호와 미지수 x 문제만 선택할 수 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
|
|
||||||
@@ -8,17 +9,22 @@ import VariableChoiceAnswerForm from "@/components/game/VariableChoiceAnswerForm
|
|||||||
import VariableFeedbackPanel from "@/components/game/VariableFeedbackPanel";
|
import VariableFeedbackPanel from "@/components/game/VariableFeedbackPanel";
|
||||||
import VariableScoreSummary from "@/components/game/VariableScoreSummary";
|
import VariableScoreSummary from "@/components/game/VariableScoreSummary";
|
||||||
import KatexRenderer from "@/components/math/KatexRenderer";
|
import KatexRenderer from "@/components/math/KatexRenderer";
|
||||||
|
import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis";
|
||||||
|
import { formatParenthesisExpressionLatex } from "@/lib/engine/parenthesis-latex";
|
||||||
import {
|
import {
|
||||||
useVariableGameStore,
|
useVariableGameStore,
|
||||||
|
type VariableMaxTermCount,
|
||||||
type VariableProblemCount,
|
type VariableProblemCount,
|
||||||
} from "@/store/variableGameStore";
|
} from "@/store/variableGameStore";
|
||||||
|
|
||||||
interface VariablePlayClientProps {
|
interface VariablePlayClientProps {
|
||||||
problemCount: VariableProblemCount;
|
problemCount: VariableProblemCount;
|
||||||
|
maxTermCount: VariableMaxTermCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VariablePlayClient({
|
export default function VariablePlayClient({
|
||||||
problemCount,
|
problemCount,
|
||||||
|
maxTermCount,
|
||||||
}: VariablePlayClientProps) {
|
}: VariablePlayClientProps) {
|
||||||
const session = useVariableGameStore((state) => state.session);
|
const session = useVariableGameStore((state) => state.session);
|
||||||
const selectedProblemCount = useVariableGameStore(
|
const selectedProblemCount = useVariableGameStore(
|
||||||
@@ -27,15 +33,21 @@ export default function VariablePlayClient({
|
|||||||
const setProblemCount = useVariableGameStore(
|
const setProblemCount = useVariableGameStore(
|
||||||
(state) => state.setProblemCount,
|
(state) => state.setProblemCount,
|
||||||
);
|
);
|
||||||
|
const selectedMaxTermCount = useVariableGameStore(
|
||||||
|
(state) => state.selectedMaxTermCount,
|
||||||
|
);
|
||||||
|
const setMaxTermCount = useVariableGameStore(
|
||||||
|
(state) => state.setMaxTermCount,
|
||||||
|
);
|
||||||
const initSession = useVariableGameStore((state) => state.initSession);
|
const initSession = useVariableGameStore((state) => state.initSession);
|
||||||
const submitAnswer = useVariableGameStore((state) => state.submitAnswer);
|
const submitAnswer = useVariableGameStore((state) => state.submitAnswer);
|
||||||
const nextProblem = useVariableGameStore((state) => state.nextProblem);
|
const nextProblem = useVariableGameStore((state) => state.nextProblem);
|
||||||
const resetSession = useVariableGameStore((state) => state.resetSession);
|
const resetSession = useVariableGameStore((state) => state.resetSession);
|
||||||
|
|
||||||
const [selectedChoiceIds, setSelectedChoiceIds] = useState<
|
const [selectedChoiceIds, setSelectedChoiceIds] = useState<(string | null)[]>(
|
||||||
[string | null, string | null]
|
() => Array.from({ length: maxTermCount }, () => null),
|
||||||
>([null, null]);
|
);
|
||||||
const [activeTermIndex, setActiveTermIndex] = useState<0 | 1>(0);
|
const [activeTermIndex, setActiveTermIndex] = useState(0);
|
||||||
|
|
||||||
const sessionProblemCount = session?.problems.length ?? null;
|
const sessionProblemCount = session?.problems.length ?? null;
|
||||||
const currentProblem = session?.problems[session.currentIndex] ?? null;
|
const currentProblem = session?.problems[session.currentIndex] ?? null;
|
||||||
@@ -43,7 +55,8 @@ export default function VariablePlayClient({
|
|||||||
const isComplete = Boolean(
|
const isComplete = Boolean(
|
||||||
session && session.currentIndex >= session.problems.length,
|
session && session.currentIndex >= session.problems.length,
|
||||||
);
|
);
|
||||||
const sessionNeedsInit = sessionProblemCount !== problemCount;
|
const sessionNeedsInit =
|
||||||
|
sessionProblemCount !== problemCount || session?.termCount !== maxTermCount;
|
||||||
const correctCount =
|
const correctCount =
|
||||||
session?.results.filter((result) => result?.correct).length ?? 0;
|
session?.results.filter((result) => result?.correct).length ?? 0;
|
||||||
const totalCount = session?.problems.length ?? 0;
|
const totalCount = session?.problems.length ?? 0;
|
||||||
@@ -53,40 +66,51 @@ export default function VariablePlayClient({
|
|||||||
setProblemCount(problemCount);
|
setProblemCount(problemCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedMaxTermCount !== maxTermCount) {
|
||||||
|
setMaxTermCount(maxTermCount);
|
||||||
|
}
|
||||||
|
|
||||||
if (sessionNeedsInit) {
|
if (sessionNeedsInit) {
|
||||||
initSession(problemCount);
|
initSession(problemCount, maxTermCount);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
initSession,
|
initSession,
|
||||||
|
maxTermCount,
|
||||||
problemCount,
|
problemCount,
|
||||||
|
selectedMaxTermCount,
|
||||||
selectedProblemCount,
|
selectedProblemCount,
|
||||||
sessionProblemCount,
|
sessionProblemCount,
|
||||||
sessionNeedsInit,
|
sessionNeedsInit,
|
||||||
|
setMaxTermCount,
|
||||||
setProblemCount,
|
setProblemCount,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedChoiceIds([null, null]);
|
setSelectedChoiceIds(
|
||||||
|
Array.from({ length: currentProblem?.terms.length ?? maxTermCount }, () => null),
|
||||||
|
);
|
||||||
setActiveTermIndex(0);
|
setActiveTermIndex(0);
|
||||||
}, [currentProblem?.key]);
|
}, [currentProblem?.key, currentProblem?.terms.length, maxTermCount]);
|
||||||
|
|
||||||
function handleSelectChoice(termIndex: 0 | 1, choiceId: string) {
|
function handleSelectChoice(termIndex: number, choiceId: string) {
|
||||||
setSelectedChoiceIds((current) => {
|
const nextSelectedChoiceIds = [...selectedChoiceIds];
|
||||||
const next: [string | null, string | null] = [...current];
|
nextSelectedChoiceIds[termIndex] = choiceId;
|
||||||
next[termIndex] = choiceId;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (termIndex === 0) {
|
setSelectedChoiceIds(nextSelectedChoiceIds);
|
||||||
setActiveTermIndex(1);
|
|
||||||
|
if (termIndex < nextSelectedChoiceIds.length - 1) {
|
||||||
|
setActiveTermIndex(termIndex + 1);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit() {
|
|
||||||
if (currentResult || !currentProblem) return;
|
if (currentResult || !currentProblem) return;
|
||||||
if (selectedChoiceIds[0] === null || selectedChoiceIds[1] === null) return;
|
if (
|
||||||
|
nextSelectedChoiceIds.some((selectedChoiceId) => selectedChoiceId === null)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
submitAnswer({ selectedChoiceIds });
|
submitAnswer({ selectedChoiceIds: nextSelectedChoiceIds });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNextProblem() {
|
function handleNextProblem() {
|
||||||
@@ -133,6 +157,24 @@ export default function VariablePlayClient({
|
|||||||
|
|
||||||
const submitted = currentResult !== null;
|
const submitted = currentResult !== null;
|
||||||
const isLastProblem = session.currentIndex === session.problems.length - 1;
|
const isLastProblem = session.currentIndex === session.problems.length - 1;
|
||||||
|
const problemLatex = submitted
|
||||||
|
? currentProblem.latexBefore
|
||||||
|
: formatParenthesisExpressionLatex({
|
||||||
|
multiplier: currentProblem.multiplier,
|
||||||
|
terms: currentProblem.terms,
|
||||||
|
operators: currentProblem.operators,
|
||||||
|
formatTermLatex: formatVariableTermLatex,
|
||||||
|
activeTermIndex,
|
||||||
|
});
|
||||||
|
const pendingChoiceLabels = currentProblem.terms.map((term, index) =>
|
||||||
|
formatVariableTermLatex({
|
||||||
|
...term,
|
||||||
|
coefficient:
|
||||||
|
index > 0 && currentProblem.operators[index - 1] === "-"
|
||||||
|
? -term.coefficient
|
||||||
|
: term.coefficient,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950">
|
<main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950">
|
||||||
@@ -146,18 +188,24 @@ export default function VariablePlayClient({
|
|||||||
문제 풀이
|
문제 풀이
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<StageIndicator
|
<Link
|
||||||
currentIndex={session.currentIndex}
|
href="/variable-parentheses"
|
||||||
results={session.results}
|
onClick={resetSession}
|
||||||
/>
|
className="inline-flex h-10 items-center justify-center rounded-md bg-red-700 px-4 text-sm font-semibold text-white transition hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
포기
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md bg-white px-6 pt-6 pb-2 shadow-sm ring-1 ring-slate-200 sm:p-8">
|
<div className="rounded-md bg-white px-6 pt-6 pb-4 shadow-sm ring-1 ring-slate-200 sm:p-8">
|
||||||
<p className="mb-4 text-sm font-semibold text-slate-500">
|
|
||||||
괄호를 풀어 첫째 항과 둘째 항의 답을 보기에서 고르세요.
|
|
||||||
</p>
|
|
||||||
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
|
<div className="text-3xl font-semibold text-slate-950 sm:text-4xl">
|
||||||
<KatexRenderer latex={currentProblem.latexBefore} displayMode />
|
<KatexRenderer latex={problemLatex} displayMode />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
<StageIndicator
|
||||||
|
currentIndex={session.currentIndex}
|
||||||
|
results={session.results}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -166,8 +214,8 @@ export default function VariablePlayClient({
|
|||||||
choices={currentProblem.choices}
|
choices={currentProblem.choices}
|
||||||
selectedChoiceIds={selectedChoiceIds}
|
selectedChoiceIds={selectedChoiceIds}
|
||||||
activeTermIndex={activeTermIndex}
|
activeTermIndex={activeTermIndex}
|
||||||
|
pendingChoiceLabels={pendingChoiceLabels}
|
||||||
onSelectChoice={handleSelectChoice}
|
onSelectChoice={handleSelectChoice}
|
||||||
onSubmit={handleSubmit}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import VariablePlayClient from "./VariablePlayClient";
|
import VariablePlayClient from "./VariablePlayClient";
|
||||||
import type { VariableProblemCount } from "@/store/variableGameStore";
|
import type {
|
||||||
|
VariableMaxTermCount,
|
||||||
|
VariableProblemCount,
|
||||||
|
} from "@/store/variableGameStore";
|
||||||
|
|
||||||
interface VariablePlayPageProps {
|
interface VariablePlayPageProps {
|
||||||
searchParams?: {
|
searchParams?: {
|
||||||
count?: string | string[];
|
count?: string | string[];
|
||||||
|
terms?: string | string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,10 +19,24 @@ function parseProblemCount(count?: string | string[]): VariableProblemCount {
|
|||||||
return count === "5" ? 5 : 10;
|
return count === "5" ? 5 : 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMaxTermCount(terms?: string | string[]): VariableMaxTermCount {
|
||||||
|
if (Array.isArray(terms)) {
|
||||||
|
return parseMaxTermCount(terms[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terms === "3") return 3;
|
||||||
|
if (terms === "4") return 4;
|
||||||
|
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
export default function VariablePlayPage({
|
export default function VariablePlayPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: VariablePlayPageProps) {
|
}: VariablePlayPageProps) {
|
||||||
return (
|
return (
|
||||||
<VariablePlayClient problemCount={parseProblemCount(searchParams?.count)} />
|
<VariablePlayClient
|
||||||
|
problemCount={parseProblemCount(searchParams?.count)}
|
||||||
|
maxTermCount={parseMaxTermCount(searchParams?.terms)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ export default function FeedbackPanel({
|
|||||||
onNext,
|
onNext,
|
||||||
}: FeedbackPanelProps) {
|
}: FeedbackPanelProps) {
|
||||||
const nextButtonRef = useRef<HTMLButtonElement>(null);
|
const nextButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const signedTerms = [
|
const signedTerms = problem.terms.map((term, index) => {
|
||||||
problem.a,
|
if (index === 0) return term;
|
||||||
problem.operator === "-" ? -problem.b : problem.b,
|
|
||||||
] as const;
|
return problem.operators[index - 1] === "-" ? -term : term;
|
||||||
|
});
|
||||||
const feedbackItems = signedTerms.map((term, index) => ({
|
const feedbackItems = signedTerms.map((term, index) => ({
|
||||||
formulaLatex: `${formatSignedNumber(
|
formulaLatex: `${formatSignedNumber(
|
||||||
problem.multiplier,
|
problem.multiplier,
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import {
|
|||||||
import type { FocusEvent, FormEvent } from "react";
|
import type { FocusEvent, FormEvent } from "react";
|
||||||
|
|
||||||
import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device";
|
import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device";
|
||||||
|
import KatexRenderer from "@/components/math/KatexRenderer";
|
||||||
|
|
||||||
export type ActiveAnswerInput = 0 | 1;
|
export type ActiveAnswerInput = number;
|
||||||
|
|
||||||
const ANSWER_INPUT_PATTERN = /^-?\d*(?:\.\d*)?$/;
|
const ANSWER_INPUT_PATTERN = /^-?\d*(?:\.\d*)?$/;
|
||||||
const KEYPAD_KEYS = [
|
const KEYPAD_KEYS = [
|
||||||
@@ -30,31 +31,32 @@ const KEYPAD_KEYS = [
|
|||||||
"0",
|
"0",
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface AnswerFormHandle {
|
export interface NumberAnswerFormHandle {
|
||||||
focusInput: () => void;
|
focusInput: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnswerFormProps {
|
interface NumberAnswerFormProps {
|
||||||
answerA: string;
|
answers: string[];
|
||||||
answerB: string;
|
|
||||||
activeInput: ActiveAnswerInput;
|
activeInput: ActiveAnswerInput;
|
||||||
error: string;
|
error: string;
|
||||||
onAnswerAChange: (value: string) => void;
|
pendingAnswerLabels: string[];
|
||||||
onAnswerBChange: (value: string) => void;
|
onAnswerChange: (index: ActiveAnswerInput, value: string) => void;
|
||||||
onActiveInputChange: (input: ActiveAnswerInput) => void;
|
onActiveInputChange: (input: ActiveAnswerInput) => void;
|
||||||
onInputBlur?: () => void;
|
onInputBlur?: () => void;
|
||||||
onInputFocus?: () => void;
|
onInputFocus?: () => void;
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnswerForm = forwardRef<AnswerFormHandle, AnswerFormProps>(function AnswerForm(
|
const NumberAnswerForm = forwardRef<
|
||||||
|
NumberAnswerFormHandle,
|
||||||
|
NumberAnswerFormProps
|
||||||
|
>(function NumberAnswerForm(
|
||||||
{
|
{
|
||||||
answerA,
|
answers,
|
||||||
answerB,
|
|
||||||
activeInput,
|
activeInput,
|
||||||
error,
|
error,
|
||||||
onAnswerAChange,
|
pendingAnswerLabels,
|
||||||
onAnswerBChange,
|
onAnswerChange,
|
||||||
onActiveInputChange,
|
onActiveInputChange,
|
||||||
onInputBlur,
|
onInputBlur,
|
||||||
onInputFocus,
|
onInputFocus,
|
||||||
@@ -69,16 +71,16 @@ const AnswerForm = forwardRef<AnswerFormHandle, AnswerFormProps>(function Answer
|
|||||||
const [keypadOpen, setKeypadOpen] = useState(false);
|
const [keypadOpen, setKeypadOpen] = useState(false);
|
||||||
const [desktopInputDevice, setDesktopInputDevice] = useState(false);
|
const [desktopInputDevice, setDesktopInputDevice] = useState(false);
|
||||||
|
|
||||||
const activeAnswer = activeInput === 0 ? answerA : answerB;
|
const activeAnswer = answers[activeInput] ?? "";
|
||||||
const activeLabel = activeInput === 0 ? "첫째 항" : "둘째 항";
|
const activeLabel = `${activeInput + 1}항`;
|
||||||
const activeEnterKeyHint = activeInput === 0 ? "next" : "done";
|
const activeDescription = `${activeLabel}의 답을 입력하세요.`;
|
||||||
const activeAnswerChange =
|
const lastInputIndex = answers.length - 1;
|
||||||
activeInput === 0 ? onAnswerAChange : onAnswerBChange;
|
const activeEnterKeyHint = activeInput < lastInputIndex ? "next" : "done";
|
||||||
|
|
||||||
function updateAnswer(value: string) {
|
function updateAnswer(value: string) {
|
||||||
if (!ANSWER_INPUT_PATTERN.test(value)) return;
|
if (!ANSWER_INPUT_PATTERN.test(value)) return;
|
||||||
|
|
||||||
activeAnswerChange(value);
|
onAnswerChange(activeInput, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInputFocus() {
|
function handleInputFocus() {
|
||||||
@@ -147,16 +149,9 @@ const AnswerForm = forwardRef<AnswerFormHandle, AnswerFormProps>(function Answer
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function setAnswer(input: ActiveAnswerInput, value: string) {
|
|
||||||
if (input === 0) {
|
|
||||||
onAnswerAChange(value);
|
|
||||||
} else {
|
|
||||||
onAnswerBChange(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMinus() {
|
function toggleMinus() {
|
||||||
activeAnswerChange(
|
onAnswerChange(
|
||||||
|
activeInput,
|
||||||
activeAnswer.startsWith("-") ? activeAnswer.slice(1) : `-${activeAnswer}`,
|
activeAnswer.startsWith("-") ? activeAnswer.slice(1) : `-${activeAnswer}`,
|
||||||
);
|
);
|
||||||
focusInput();
|
focusInput();
|
||||||
@@ -175,14 +170,14 @@ const AnswerForm = forwardRef<AnswerFormHandle, AnswerFormProps>(function Answer
|
|||||||
function moveToInput(input: ActiveAnswerInput, reset = false) {
|
function moveToInput(input: ActiveAnswerInput, reset = false) {
|
||||||
onActiveInputChange(input);
|
onActiveInputChange(input);
|
||||||
if (reset) {
|
if (reset) {
|
||||||
setAnswer(input, "");
|
onAnswerChange(input, "");
|
||||||
}
|
}
|
||||||
focusInput();
|
focusInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEnter() {
|
function handleEnter() {
|
||||||
if (activeInput === 0) {
|
if (activeInput < lastInputIndex) {
|
||||||
moveToInput(1);
|
moveToInput(activeInput + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,18 +185,11 @@ const AnswerForm = forwardRef<AnswerFormHandle, AnswerFormProps>(function Answer
|
|||||||
focusInput();
|
focusInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
const answerButtons = [
|
const answerButtons = answers.map((value, index) => ({
|
||||||
{
|
index,
|
||||||
index: 0 as const,
|
label: `${index + 1}항`,
|
||||||
label: "첫째 항",
|
value,
|
||||||
value: answerA,
|
}));
|
||||||
},
|
|
||||||
{
|
|
||||||
index: 1 as const,
|
|
||||||
label: "둘째 항",
|
|
||||||
value: answerB,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
@@ -210,62 +198,75 @@ const AnswerForm = forwardRef<AnswerFormHandle, AnswerFormProps>(function Answer
|
|||||||
className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
|
className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<label
|
<label
|
||||||
htmlFor={inputId}
|
htmlFor={inputId}
|
||||||
className="mb-2 block text-sm font-semibold text-slate-700"
|
className="text-sm font-semibold text-slate-500"
|
||||||
>
|
>
|
||||||
{activeLabel}
|
{activeDescription}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<ol
|
||||||
id={inputId}
|
className="flex min-w-40 items-center gap-1"
|
||||||
ref={inputRef}
|
aria-label="입력 항 진행 상태"
|
||||||
type="text"
|
>
|
||||||
value={activeAnswer}
|
{answerButtons.map((button) => {
|
||||||
onFocus={handleInputFocus}
|
const selected = activeInput === button.index;
|
||||||
onBlur={handleInputBlur}
|
const completed = button.value !== "";
|
||||||
onChange={(event) => updateAnswer(event.target.value)}
|
const displayValue =
|
||||||
onKeyDown={(event) => {
|
button.value || pendingAnswerLabels[button.index];
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
return (
|
||||||
handleEnter();
|
<li
|
||||||
}
|
key={button.index}
|
||||||
}}
|
className="min-w-0 flex-1"
|
||||||
autoComplete="off"
|
aria-label={`${button.label} ${
|
||||||
enterKeyHint={activeEnterKeyHint}
|
completed
|
||||||
inputMode="none"
|
? `입력값 ${button.value}`
|
||||||
pattern="-?[0-9]*(\.[0-9]*)?"
|
: `원래 항 ${displayValue}, 미입력`
|
||||||
className="h-12 w-full min-w-0 rounded-md border border-slate-300 bg-white px-3 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 sm:px-4"
|
}${selected ? ", 현재 입력 중" : ""}`}
|
||||||
/>
|
>
|
||||||
|
<span
|
||||||
|
className={`block truncate text-center text-xs font-bold ${
|
||||||
|
selected || completed ? "text-emerald-800" : "text-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<KatexRenderer latex={displayValue} />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`mt-1 block h-1.5 rounded-full ${
|
||||||
|
completed
|
||||||
|
? "bg-emerald-700"
|
||||||
|
: selected
|
||||||
|
? "bg-emerald-300 ring-1 ring-emerald-700"
|
||||||
|
: "bg-slate-200"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<input
|
||||||
className="inline-flex w-fit max-w-full gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm"
|
id={inputId}
|
||||||
role="group"
|
ref={inputRef}
|
||||||
aria-label="입력 항 선택"
|
type="text"
|
||||||
>
|
value={activeAnswer}
|
||||||
{answerButtons.map((button) => {
|
onFocus={handleInputFocus}
|
||||||
const selected = activeInput === button.index;
|
onBlur={handleInputBlur}
|
||||||
|
onChange={(event) => updateAnswer(event.target.value)}
|
||||||
return (
|
onKeyDown={(event) => {
|
||||||
<button
|
if (event.key === "Enter") {
|
||||||
key={button.index}
|
event.preventDefault();
|
||||||
type="button"
|
handleEnter();
|
||||||
onPointerDown={(event) => event.preventDefault()}
|
}
|
||||||
onClick={() => moveToInput(button.index, true)}
|
}}
|
||||||
aria-pressed={selected}
|
autoComplete="off"
|
||||||
aria-label={`${button.label} 다시 입력`}
|
enterKeyHint={activeEnterKeyHint}
|
||||||
className={`inline-flex h-10 min-w-20 items-center justify-center rounded px-3 text-base font-semibold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
|
inputMode="none"
|
||||||
selected
|
pattern="-?[0-9]*(\.[0-9]*)?"
|
||||||
? "bg-emerald-700 text-white"
|
className="h-12 w-full min-w-0 rounded-md border border-slate-300 bg-white px-3 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 sm:px-4"
|
||||||
: "text-slate-700 hover:bg-slate-100"
|
/>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{button.value || "?"}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{keypadOpen && !desktopInputDevice ? (
|
{keypadOpen && !desktopInputDevice ? (
|
||||||
<div
|
<div
|
||||||
@@ -314,9 +315,9 @@ const AnswerForm = forwardRef<AnswerFormHandle, AnswerFormProps>(function Answer
|
|||||||
onPointerDown={(event) => event.preventDefault()}
|
onPointerDown={(event) => event.preventDefault()}
|
||||||
onClick={handleEnter}
|
onClick={handleEnter}
|
||||||
aria-label={
|
aria-label={
|
||||||
activeInput === 0
|
activeInput < lastInputIndex
|
||||||
? "첫째 항 입력 완료 후 둘째 항으로 이동"
|
? "현재 항 입력 완료 후 다음 항으로 이동"
|
||||||
: "둘째 항 입력 완료 후 제출"
|
: "마지막 항 입력 완료 후 제출"
|
||||||
}
|
}
|
||||||
className="col-span-2 inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 text-base font-semibold text-white shadow-sm transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
className="col-span-2 inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 text-base font-semibold text-white shadow-sm transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
@@ -333,4 +334,4 @@ const AnswerForm = forwardRef<AnswerFormHandle, AnswerFormProps>(function Answer
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default AnswerForm;
|
export default NumberAnswerForm;
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export type PreparationProblemCount = 5 | 10;
|
||||||
|
export type PreparationMaxTermCount = 2 | 3 | 4;
|
||||||
|
|
||||||
|
interface ParenthesesPreparationClientProps {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
notice: string;
|
||||||
|
playHref: string;
|
||||||
|
selectedProblemCount: PreparationProblemCount;
|
||||||
|
selectedMaxTermCount: PreparationMaxTermCount;
|
||||||
|
onProblemCountChange: (count: PreparationProblemCount) => void;
|
||||||
|
onMaxTermCountChange: (count: PreparationMaxTermCount) => void;
|
||||||
|
onStart: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const problemCounts: PreparationProblemCount[] = [5, 10];
|
||||||
|
const maxTermCounts: PreparationMaxTermCount[] = [2, 3, 4];
|
||||||
|
|
||||||
|
const buttonGroupClassName =
|
||||||
|
"inline-flex gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm";
|
||||||
|
|
||||||
|
function optionButtonClassName({
|
||||||
|
selected,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
selected: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return `h-10 min-w-16 rounded px-4 text-base font-semibold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
|
||||||
|
selected ? "bg-emerald-700 text-white" : "text-slate-700 hover:bg-slate-100"
|
||||||
|
} ${
|
||||||
|
disabled
|
||||||
|
? "cursor-not-allowed bg-slate-100 text-slate-400 hover:bg-slate-100"
|
||||||
|
: ""
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bracketKinds = [
|
||||||
|
{
|
||||||
|
label: "( )",
|
||||||
|
name: "소괄호",
|
||||||
|
selected: true,
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "{ }",
|
||||||
|
name: "중괄호",
|
||||||
|
selected: false,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "[ ]",
|
||||||
|
name: "대괄호",
|
||||||
|
selected: false,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ParenthesesPreparationClient({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
notice,
|
||||||
|
playHref,
|
||||||
|
selectedProblemCount,
|
||||||
|
selectedMaxTermCount,
|
||||||
|
onProblemCountChange,
|
||||||
|
onMaxTermCountChange,
|
||||||
|
onStart,
|
||||||
|
}: ParenthesesPreparationClientProps) {
|
||||||
|
const startLinkRef = useRef<HTMLAnchorElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startLinkRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
|
||||||
|
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-semibold text-emerald-700">{eyebrow}</p>
|
||||||
|
<h1 className="text-4xl font-bold tracking-normal sm:text-5xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-2xl text-lg leading-8 text-slate-700">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<fieldset className="space-y-3">
|
||||||
|
<legend className="text-sm font-semibold text-slate-700">
|
||||||
|
문제 수
|
||||||
|
</legend>
|
||||||
|
<div
|
||||||
|
className={buttonGroupClassName}
|
||||||
|
role="group"
|
||||||
|
aria-label="문제 수 선택"
|
||||||
|
>
|
||||||
|
{problemCounts.map((count) => {
|
||||||
|
const selected = selectedProblemCount === count;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={count}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onProblemCountChange(count)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={optionButtonClassName({ selected })}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
|
||||||
|
<fieldset className="space-y-3">
|
||||||
|
<legend className="text-sm font-semibold text-slate-700">
|
||||||
|
괄호 종류
|
||||||
|
</legend>
|
||||||
|
<div
|
||||||
|
className={buttonGroupClassName}
|
||||||
|
role="group"
|
||||||
|
aria-label="괄호 종류 선택"
|
||||||
|
>
|
||||||
|
{bracketKinds.map((bracketKind) => (
|
||||||
|
<button
|
||||||
|
key={bracketKind.label}
|
||||||
|
type="button"
|
||||||
|
disabled={bracketKind.disabled}
|
||||||
|
aria-pressed={bracketKind.selected}
|
||||||
|
aria-label={`${bracketKind.name} ${
|
||||||
|
bracketKind.disabled ? "준비 중" : "선택됨"
|
||||||
|
}`}
|
||||||
|
className={optionButtonClassName({
|
||||||
|
selected: bracketKind.selected,
|
||||||
|
disabled: bracketKind.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{bracketKind.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="space-y-3">
|
||||||
|
<legend className="text-sm font-semibold text-slate-700">
|
||||||
|
괄호 안 최대 항 수
|
||||||
|
</legend>
|
||||||
|
<div
|
||||||
|
className={buttonGroupClassName}
|
||||||
|
role="group"
|
||||||
|
aria-label="괄호 안 최대 항 수 선택"
|
||||||
|
>
|
||||||
|
{maxTermCounts.map((count) => {
|
||||||
|
const selected = selectedMaxTermCount === count;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={count}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onMaxTermCountChange(count)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={optionButtonClassName({ selected })}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link
|
||||||
|
ref={startLinkRef}
|
||||||
|
href={playHref}
|
||||||
|
onClick={onStart}
|
||||||
|
className="inline-flex h-12 items-center justify-center rounded-md bg-emerald-700 px-6 text-base font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
연습 시작
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-12 items-center justify-center rounded-md border border-slate-300 bg-white px-6 text-base font-semibold text-slate-700 transition hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
뒤로가기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-4 border-emerald-700 pl-4 text-sm leading-6 text-slate-600">
|
||||||
|
<p>{notice}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,50 +15,54 @@ interface ScoreSummaryProps {
|
|||||||
onRestart: () => void;
|
onRestart: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAnswerPairLatex(first: number, second: number) {
|
function formatAnswerLatex(terms: number[]) {
|
||||||
const operator = second < 0 ? "-" : "+";
|
return terms
|
||||||
|
.map((term, index) => {
|
||||||
|
if (index === 0) return `${term}`;
|
||||||
|
|
||||||
return `${first} ${operator} ${Math.abs(second)}`;
|
const operator = term < 0 ? "-" : "+";
|
||||||
|
return `${operator} ${Math.abs(term)}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUserAnswerPair(result: GradeResult) {
|
function renderUserAnswerPair(result: GradeResult) {
|
||||||
const secondOperator = result.userAnswer[1] < 0 ? "-" : "+";
|
|
||||||
const firstCorrect = result.userAnswer[0] === result.correctAnswer[0];
|
|
||||||
const secondCorrect = result.userAnswer[1] === result.correctAnswer[1];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className={firstCorrect ? undefined : "text-red-700"}>
|
{result.userAnswer.map((term, index) => {
|
||||||
<KatexRenderer latex={`${result.userAnswer[0]}`} output="mathml" />
|
const operator = term < 0 ? "-" : "+";
|
||||||
</span>
|
const correct = term === result.correctAnswer[index];
|
||||||
<span className={secondCorrect ? undefined : "text-red-700"}>
|
const displayLatex =
|
||||||
<KatexRenderer
|
index === 0 ? `${term}` : `${operator} ${Math.abs(term)}`;
|
||||||
latex={`${secondOperator} ${Math.abs(result.userAnswer[1])}`}
|
|
||||||
output="mathml"
|
return (
|
||||||
/>
|
<span key={index} className={correct ? undefined : "text-red-700"}>
|
||||||
</span>
|
<KatexRenderer latex={displayLatex} output="mathml" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCorrectAnswerPair(result: GradeResult) {
|
function renderCorrectAnswerPair(result: GradeResult) {
|
||||||
const secondOperator = result.correctAnswer[1] < 0 ? "-" : "+";
|
|
||||||
const firstCorrect = result.userAnswer[0] === result.correctAnswer[0];
|
|
||||||
const secondCorrect = result.userAnswer[1] === result.correctAnswer[1];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className={firstCorrect ? undefined : "font-bold text-blue-700"}>
|
{result.correctAnswer.map((term, index) => {
|
||||||
<KatexRenderer latex={`${result.correctAnswer[0]}`} output="mathml" />
|
const operator = term < 0 ? "-" : "+";
|
||||||
</span>
|
const correct = result.userAnswer[index] === term;
|
||||||
<span
|
const displayLatex =
|
||||||
className={secondCorrect ? undefined : "font-bold text-blue-700"}
|
index === 0 ? `${term}` : `${operator} ${Math.abs(term)}`;
|
||||||
>
|
|
||||||
<KatexRenderer
|
return (
|
||||||
latex={`${secondOperator} ${Math.abs(result.correctAnswer[1])}`}
|
<span
|
||||||
output="mathml"
|
key={index}
|
||||||
/>
|
className={correct ? undefined : "font-bold text-blue-700"}
|
||||||
</span>
|
>
|
||||||
|
<KatexRenderer latex={displayLatex} output="mathml" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -102,10 +106,7 @@ export default function ScoreSummary({
|
|||||||
|
|
||||||
<ol className="space-y-3">
|
<ol className="space-y-3">
|
||||||
{solvedItems.map(({ problem, result }, index) => {
|
{solvedItems.map(({ problem, result }, index) => {
|
||||||
const userAnswerLatex = formatAnswerPairLatex(
|
const userAnswerLatex = formatAnswerLatex(result.userAnswer);
|
||||||
result.userAnswer[0],
|
|
||||||
result.userAnswer[1],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnswerResultLine
|
<AnswerResultLine
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ export default function StageIndicator({
|
|||||||
.length;
|
.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex max-w-full flex-col items-end gap-2">
|
<div className="w-full">
|
||||||
<p className="sr-only">
|
<p className="sr-only">
|
||||||
총 {results.length}문제 중 {completedCount}문제 완료, 정답{" "}
|
총 {results.length}문제 중 {completedCount}문제 완료, 정답{" "}
|
||||||
{correctCount}개, 오답 {incorrectCount}개, 현재 {currentIndex + 1}번
|
{correctCount}개, 오답 {incorrectCount}개, 현재 {currentIndex + 1}번
|
||||||
문제
|
문제
|
||||||
</p>
|
</p>
|
||||||
<ol
|
<ol
|
||||||
className="flex max-w-full flex-wrap items-center justify-end gap-2"
|
className="flex w-full items-center gap-1"
|
||||||
aria-label="문제 풀이 진행 단계"
|
aria-label="문제 풀이 진행 단계"
|
||||||
>
|
>
|
||||||
{results.map((result, index) => {
|
{results.map((result, index) => {
|
||||||
@@ -33,34 +33,26 @@ export default function StageIndicator({
|
|||||||
const isCompleted = result !== null;
|
const isCompleted = result !== null;
|
||||||
|
|
||||||
let label = `${stageNumber}번 남은 단계`;
|
let label = `${stageNumber}번 남은 단계`;
|
||||||
let content = "";
|
|
||||||
let className =
|
let className =
|
||||||
"flex size-8 items-center justify-center rounded-full border text-sm font-bold leading-none transition";
|
"block h-2 w-full rounded-full transition";
|
||||||
|
|
||||||
if (isCompleted) {
|
if (isCompleted) {
|
||||||
label = result.correct
|
label = result.correct
|
||||||
? `${stageNumber}번 완료, 정답`
|
? `${stageNumber}번 완료, 정답`
|
||||||
: `${stageNumber}번 완료, 오답`;
|
: `${stageNumber}번 완료, 오답`;
|
||||||
content = result.correct ? "✓" : "×";
|
|
||||||
className += result.correct
|
className += result.correct
|
||||||
? " border-emerald-700 bg-emerald-700 text-white"
|
? " bg-emerald-700"
|
||||||
: " border-red-700 bg-red-700 text-white";
|
: " bg-red-700";
|
||||||
} else if (isCurrent) {
|
} else if (isCurrent) {
|
||||||
label = `${stageNumber}번 현재 단계`;
|
label = `${stageNumber}번 현재 단계`;
|
||||||
content = String(stageNumber);
|
className += " bg-emerald-300 ring-2 ring-emerald-700";
|
||||||
className +=
|
|
||||||
" border-emerald-700 bg-white text-emerald-800 shadow-sm ring-2 ring-emerald-200";
|
|
||||||
} else {
|
} else {
|
||||||
className += " border-slate-300 bg-white text-slate-300";
|
className += " bg-slate-200";
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={stageNumber}>
|
<li key={stageNumber} className="min-w-0 flex-1">
|
||||||
<span className={className} aria-label={label} title={label}>
|
<span className={className} aria-label={label} title={label} />
|
||||||
{content || (
|
|
||||||
<span className="size-2 rounded-full bg-slate-300" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -6,74 +6,96 @@ import KatexRenderer from "@/components/math/KatexRenderer";
|
|||||||
import type { VariableTermChoice } from "@/lib/engine/variable-types";
|
import type { VariableTermChoice } from "@/lib/engine/variable-types";
|
||||||
|
|
||||||
interface VariableChoiceAnswerFormProps {
|
interface VariableChoiceAnswerFormProps {
|
||||||
choices: [VariableTermChoice[], VariableTermChoice[]];
|
choices: VariableTermChoice[][];
|
||||||
selectedChoiceIds: [string | null, string | null];
|
selectedChoiceIds: (string | null)[];
|
||||||
activeTermIndex: 0 | 1;
|
activeTermIndex: number;
|
||||||
onSelectChoice: (termIndex: 0 | 1, choiceId: string) => void;
|
pendingChoiceLabels: string[];
|
||||||
onSubmit: () => void;
|
onSelectChoice: (termIndex: number, choiceId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VariableChoiceAnswerForm({
|
export default function VariableChoiceAnswerForm({
|
||||||
choices,
|
choices,
|
||||||
selectedChoiceIds,
|
selectedChoiceIds,
|
||||||
activeTermIndex,
|
activeTermIndex,
|
||||||
|
pendingChoiceLabels,
|
||||||
onSelectChoice,
|
onSelectChoice,
|
||||||
onSubmit,
|
|
||||||
}: VariableChoiceAnswerFormProps) {
|
}: VariableChoiceAnswerFormProps) {
|
||||||
const firstChoiceRef = useRef<HTMLButtonElement>(null);
|
const firstChoiceRef = useRef<HTMLButtonElement>(null);
|
||||||
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
const activeChoices = [...choices[activeTermIndex]].sort(
|
||||||
const canSubmit = selectedChoiceIds[0] !== null && selectedChoiceIds[1] !== null;
|
(a, b) => a.coefficient - b.coefficient,
|
||||||
const activeChoices = choices[activeTermIndex];
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
firstChoiceRef.current?.focus();
|
firstChoiceRef.current?.focus();
|
||||||
}, [activeTermIndex]);
|
}, [activeTermIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
function getSelectedChoice(index: number) {
|
||||||
if (canSubmit) {
|
const selectedChoiceId = selectedChoiceIds[index];
|
||||||
submitButtonRef.current?.focus();
|
|
||||||
}
|
if (!selectedChoiceId) return null;
|
||||||
}, [canSubmit]);
|
|
||||||
|
return choices[index].find((choice) => choice.id === selectedChoiceId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200">
|
<div className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-slate-500">
|
<p className="text-sm font-semibold text-slate-500">
|
||||||
{activeTermIndex === 0 ? "첫째 항" : "둘째 항"}을 고르세요.
|
{activeTermIndex + 1}항을 고르세요.
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-base text-slate-700">
|
|
||||||
괄호를 풀었을 때의 부호를 선택합니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<ol
|
||||||
className="inline-flex gap-1 rounded-md border border-slate-300 bg-slate-50 p-1"
|
className="flex min-w-40 items-center gap-1"
|
||||||
role="group"
|
|
||||||
aria-label="입력 항 진행 상태"
|
aria-label="입력 항 진행 상태"
|
||||||
>
|
>
|
||||||
{selectedChoiceIds.map((choiceId, index) => {
|
{selectedChoiceIds.map((choiceId, index) => {
|
||||||
const selected = activeTermIndex === index;
|
const selected = activeTermIndex === index;
|
||||||
|
const selectedChoice = getSelectedChoice(index);
|
||||||
|
const completed = choiceId !== null && selectedChoice !== null;
|
||||||
|
const statusLabel =
|
||||||
|
completed && selectedChoice
|
||||||
|
? `선택값 ${selectedChoice.latex}`
|
||||||
|
: `원래 항 ${pendingChoiceLabels[index]}, 미선택`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<li
|
||||||
key={index}
|
key={index}
|
||||||
className={`inline-flex h-9 min-w-16 items-center justify-center rounded px-3 text-sm font-bold ${
|
className="min-w-0 flex-1"
|
||||||
selected
|
aria-label={`${index + 1}항 ${statusLabel}${
|
||||||
? "bg-emerald-700 text-white"
|
selected ? ", 현재 선택 중" : ""
|
||||||
: "bg-white text-slate-700"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{choiceId ? `${index + 1}항 완료` : `${index + 1}항`}
|
<span
|
||||||
</span>
|
className={`block truncate text-center text-xs font-bold ${
|
||||||
|
selected || completed ? "text-emerald-800" : "text-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{completed ? (
|
||||||
|
<KatexRenderer latex={selectedChoice.latex} />
|
||||||
|
) : (
|
||||||
|
<KatexRenderer latex={pendingChoiceLabels[index]} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`mt-1 block h-1.5 rounded-full ${
|
||||||
|
completed
|
||||||
|
? "bg-emerald-700"
|
||||||
|
: selected
|
||||||
|
? "bg-emerald-300 ring-1 ring-emerald-700"
|
||||||
|
: "bg-slate-200"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="mt-5 grid gap-3 sm:grid-cols-2"
|
className="mt-5 grid gap-3 sm:grid-cols-2"
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={`${activeTermIndex === 0 ? "첫째 항" : "둘째 항"} 보기 선택`}
|
aria-label={`${activeTermIndex + 1}항 보기 선택`}
|
||||||
>
|
>
|
||||||
{activeChoices.map((choice, index) => {
|
{activeChoices.map((choice, index) => {
|
||||||
const selected = selectedChoiceIds[activeTermIndex] === choice.id;
|
const selected = selectedChoiceIds[activeTermIndex] === choice.id;
|
||||||
@@ -96,16 +118,6 @@ export default function VariableChoiceAnswerForm({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
ref={submitButtonRef}
|
|
||||||
type="button"
|
|
||||||
onClick={onSubmit}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
className="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-slate-950 px-5 text-base font-semibold text-white transition hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-slate-300"
|
|
||||||
>
|
|
||||||
정답 확인
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import AnswerResultLine from "@/components/game/AnswerResultLine";
|
|||||||
import type {
|
import type {
|
||||||
VariableGradeResult,
|
VariableGradeResult,
|
||||||
VariableParenthesisProblem,
|
VariableParenthesisProblem,
|
||||||
VariableParenthesisTerm,
|
|
||||||
} from "@/lib/engine/variable-types";
|
} from "@/lib/engine/variable-types";
|
||||||
import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis";
|
import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis";
|
||||||
|
|
||||||
@@ -21,12 +20,6 @@ function formatSignedNumber(value: number) {
|
|||||||
return value < 0 ? `(${value})` : `${value}`;
|
return value < 0 ? `(${value})` : `${value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSignedTerm(term: VariableParenthesisTerm, sign: 1 | -1) {
|
|
||||||
const coefficient = term.coefficient * sign;
|
|
||||||
|
|
||||||
return formatVariableTermLatex({ ...term, coefficient });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VariableFeedbackPanel({
|
export default function VariableFeedbackPanel({
|
||||||
result,
|
result,
|
||||||
problem,
|
problem,
|
||||||
@@ -34,14 +27,18 @@ export default function VariableFeedbackPanel({
|
|||||||
onNext,
|
onNext,
|
||||||
}: VariableFeedbackPanelProps) {
|
}: VariableFeedbackPanelProps) {
|
||||||
const nextButtonRef = useRef<HTMLButtonElement>(null);
|
const nextButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const signedTerms = [
|
const signedTerms = problem.terms.map((term, index) =>
|
||||||
formatSignedTerm(problem.firstTerm, 1),
|
formatVariableTermLatex({
|
||||||
formatSignedTerm(problem.secondTerm, problem.operator === "-" ? -1 : 1),
|
...term,
|
||||||
] as const;
|
coefficient:
|
||||||
|
index > 0 && problem.operators[index - 1] === "-"
|
||||||
|
? -term.coefficient
|
||||||
|
: term.coefficient,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const feedbackItems = signedTerms.map((termLatex, index) => {
|
const feedbackItems = signedTerms.map((termLatex, index) => {
|
||||||
const termIndex = index as 0 | 1;
|
const userAnswer = result.userAnswer[index];
|
||||||
const userAnswer = result.userAnswer[termIndex];
|
const correctAnswer = result.correctAnswer[index];
|
||||||
const correctAnswer = result.correctAnswer[termIndex];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
formulaLatex: `${formatSignedNumber(problem.multiplier)}\\times ${termLatex}`,
|
formulaLatex: `${formatSignedNumber(problem.multiplier)}\\times ${termLatex}`,
|
||||||
|
|||||||
@@ -21,14 +21,10 @@ interface VariableScoreSummaryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTermPair(
|
function renderTermPair(
|
||||||
terms: [DistributedVariableTerm | null, DistributedVariableTerm | null],
|
terms: (DistributedVariableTerm | null)[],
|
||||||
comparisonTerms: [DistributedVariableTerm | null, DistributedVariableTerm | null],
|
comparisonTerms: (DistributedVariableTerm | null)[],
|
||||||
mode: "user" | "correct",
|
mode: "user" | "correct",
|
||||||
) {
|
) {
|
||||||
const secondTerm = terms[1];
|
|
||||||
const secondOperator =
|
|
||||||
secondTerm && secondTerm.coefficient < 0 ? "-" : "+";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
{terms.map((term, index) => {
|
{terms.map((term, index) => {
|
||||||
@@ -49,10 +45,9 @@ function renderTermPair(
|
|||||||
: correct
|
: correct
|
||||||
? undefined
|
? undefined
|
||||||
: "font-bold text-blue-700";
|
: "font-bold text-blue-700";
|
||||||
|
const operator = term.coefficient < 0 ? "-" : "+";
|
||||||
const displayLatex =
|
const displayLatex =
|
||||||
index === 1
|
index === 0 ? term.latex : `${operator} ${term.latex.replace(/^-/, "")}`;
|
||||||
? `${secondOperator} ${term.latex.replace(/^-/, "")}`
|
|
||||||
: term.latex;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span key={index} className={className}>
|
<span key={index} className={className}>
|
||||||
@@ -111,8 +106,9 @@ export default function VariableScoreSummary({
|
|||||||
key={problem.key}
|
key={problem.key}
|
||||||
expressionLatex={problem.latexBefore}
|
expressionLatex={problem.latexBefore}
|
||||||
userAnswerLatex={formatVariableExpressionLatex([
|
userAnswerLatex={formatVariableExpressionLatex([
|
||||||
result.userAnswer[0] ?? result.correctAnswer[0],
|
...result.correctAnswer.map(
|
||||||
result.userAnswer[1] ?? result.correctAnswer[1],
|
(term, termIndex) => result.userAnswer[termIndex] ?? term,
|
||||||
|
),
|
||||||
])}
|
])}
|
||||||
correctAnswerLatex={problem.latexAfter}
|
correctAnswerLatex={problem.latexAfter}
|
||||||
correct={result.correct}
|
correct={result.correct}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export const ACTIVE_DISTRIBUTION_COLOR = "#f97316";
|
||||||
|
|
||||||
|
function colorLatex(latex: string, color: string) {
|
||||||
|
return `\\textcolor{${color}}{${latex}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormatParenthesisExpressionLatexParams<T> {
|
||||||
|
multiplier: number;
|
||||||
|
terms: T[];
|
||||||
|
operators: ("+" | "-")[];
|
||||||
|
formatTermLatex: (term: T) => string;
|
||||||
|
activeTermIndex?: number;
|
||||||
|
activeColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatParenthesisExpressionLatex<T>({
|
||||||
|
multiplier,
|
||||||
|
terms,
|
||||||
|
operators,
|
||||||
|
formatTermLatex,
|
||||||
|
activeTermIndex,
|
||||||
|
activeColor = ACTIVE_DISTRIBUTION_COLOR,
|
||||||
|
}: FormatParenthesisExpressionLatexParams<T>) {
|
||||||
|
const hasActiveTerm = activeTermIndex !== undefined;
|
||||||
|
const maybeColor = (latex: string, active: boolean) =>
|
||||||
|
hasActiveTerm && active ? colorLatex(latex, activeColor) : latex;
|
||||||
|
const termLatex = terms
|
||||||
|
.map((term, index) => {
|
||||||
|
const displayTerm = maybeColor(
|
||||||
|
formatTermLatex(term),
|
||||||
|
index === activeTermIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (index === 0) return displayTerm;
|
||||||
|
|
||||||
|
return `${operators[index - 1]} ${displayTerm}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
if (hasActiveTerm) {
|
||||||
|
return `${colorLatex(`${multiplier}`, activeColor)}${colorLatex(
|
||||||
|
"(",
|
||||||
|
activeColor,
|
||||||
|
)}${termLatex}${colorLatex(")", activeColor)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${multiplier}\\left(${termLatex}\\right)`;
|
||||||
|
}
|
||||||
@@ -14,22 +14,26 @@ describe("parenthesis sign problem engine", () => {
|
|||||||
expect(Number.isInteger(problem.multiplier)).toBe(true);
|
expect(Number.isInteger(problem.multiplier)).toBe(true);
|
||||||
expect(problem.multiplier).toBeGreaterThanOrEqual(-9);
|
expect(problem.multiplier).toBeGreaterThanOrEqual(-9);
|
||||||
expect(problem.multiplier).toBeLessThanOrEqual(-1);
|
expect(problem.multiplier).toBeLessThanOrEqual(-1);
|
||||||
expect(problem.a).toBeGreaterThanOrEqual(1);
|
expect(problem.terms).toHaveLength(2);
|
||||||
expect(problem.a).toBeLessThanOrEqual(9);
|
expect(problem.operators).toHaveLength(1);
|
||||||
expect(problem.b).toBeGreaterThanOrEqual(1);
|
expect(problem.terms.every((term) => term >= 1 && term <= 9)).toBe(true);
|
||||||
expect(problem.b).toBeLessThanOrEqual(9);
|
expect(new Set(problem.terms).size).toBe(problem.terms.length);
|
||||||
expect(problem.a).not.toBe(problem.b);
|
expect(["+", "-"]).toContain(problem.operators[0]);
|
||||||
expect(["+", "-"]).toContain(problem.operator);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("distributes the multiplier correctly for both operators", () => {
|
it("distributes the multiplier correctly for both operators", () => {
|
||||||
for (let index = 0; index < 100; index += 1) {
|
for (let index = 0; index < 100; index += 1) {
|
||||||
const problem = generateProblem();
|
const problem = generateProblem();
|
||||||
const signedB = problem.operator === "-" ? -problem.b : problem.b;
|
const signedTerms = problem.terms.map((term, termIndex) => {
|
||||||
|
if (termIndex === 0) return term;
|
||||||
|
|
||||||
expect(problem.distributedA).toBe(problem.multiplier * problem.a);
|
return problem.operators[termIndex - 1] === "-" ? -term : term;
|
||||||
expect(problem.distributedB).toBe(problem.multiplier * signedB);
|
});
|
||||||
|
|
||||||
|
expect(problem.distributedTerms).toEqual(
|
||||||
|
signedTerms.map((term) => problem.multiplier * term),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,20 +41,15 @@ describe("parenthesis sign problem engine", () => {
|
|||||||
const problem = generateProblem();
|
const problem = generateProblem();
|
||||||
|
|
||||||
const correctResult = gradeAnswer(problem, {
|
const correctResult = gradeAnswer(problem, {
|
||||||
a: problem.distributedA,
|
terms: problem.distributedTerms,
|
||||||
b: problem.distributedB,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(correctResult.correct).toBe(true);
|
expect(correctResult.correct).toBe(true);
|
||||||
expect(correctResult.userAnswer).toEqual([
|
expect(correctResult.userAnswer).toEqual(problem.distributedTerms);
|
||||||
problem.distributedA,
|
|
||||||
problem.distributedB,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
gradeAnswer(problem, {
|
gradeAnswer(problem, {
|
||||||
a: problem.distributedB,
|
terms: [...problem.distributedTerms].reverse(),
|
||||||
b: problem.distributedA,
|
|
||||||
}).correct,
|
}).correct,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -63,8 +62,19 @@ describe("parenthesis sign problem engine", () => {
|
|||||||
expect(keys.size).toBe(10);
|
expect(keys.size).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("generates the selected number of terms inside parentheses", () => {
|
||||||
|
const problem = generateProblem(4);
|
||||||
|
|
||||||
|
expect(problem.terms).toHaveLength(4);
|
||||||
|
expect(problem.operators).toHaveLength(3);
|
||||||
|
expect(problem.distributedTerms).toHaveLength(4);
|
||||||
|
expect(
|
||||||
|
gradeAnswer(problem, { terms: problem.distributedTerms }).correct,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects impossible session sizes", () => {
|
it("rejects impossible session sizes", () => {
|
||||||
expect(() => generateSession(0)).toThrow();
|
expect(() => generateSession(0)).toThrow();
|
||||||
expect(() => generateSession(145)).toThrow();
|
expect(() => generateSession(1297)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+132
-44
@@ -3,66 +3,125 @@ import type {
|
|||||||
ParenthesisSignProblem,
|
ParenthesisSignProblem,
|
||||||
UserAnswer,
|
UserAnswer,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { formatParenthesisExpressionLatex } from "./parenthesis-latex";
|
||||||
|
|
||||||
const MIN_MULTIPLIER = -9;
|
const MIN_MULTIPLIER = -9;
|
||||||
const MAX_MULTIPLIER = -1;
|
const MAX_MULTIPLIER = -1;
|
||||||
const MIN_TERM = 1;
|
const MIN_TERM = 1;
|
||||||
const MAX_TERM = 9;
|
const MAX_TERM = 9;
|
||||||
const MAX_PROBLEM_COUNT = 9 * 8 * 2;
|
const MAX_TERM_COUNT = 4;
|
||||||
|
|
||||||
type ProblemSeed = Pick<
|
export type ParenthesisTermCount = 2 | 3 | 4;
|
||||||
ParenthesisSignProblem,
|
|
||||||
"multiplier" | "a" | "operator" | "b"
|
interface ProblemSeed {
|
||||||
>;
|
multiplier: number;
|
||||||
|
terms: number[];
|
||||||
|
operators: ("+" | "-")[];
|
||||||
|
}
|
||||||
|
|
||||||
function randomInt(min: number, max: number) {
|
function randomInt(min: number, max: number) {
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLatexTermPair(first: number, second: number) {
|
function formatLatexTerms(terms: number[]) {
|
||||||
const operator = second < 0 ? "-" : "+";
|
return terms
|
||||||
|
.map((term, index) => {
|
||||||
|
if (index === 0) return `${term}`;
|
||||||
|
|
||||||
return `${first} ${operator} ${Math.abs(second)}`;
|
const operator = term < 0 ? "-" : "+";
|
||||||
|
return `${operator} ${Math.abs(term)}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function createProblem({
|
function createProblem({
|
||||||
multiplier,
|
multiplier,
|
||||||
a,
|
terms,
|
||||||
operator,
|
operators,
|
||||||
b,
|
|
||||||
}: ProblemSeed): ParenthesisSignProblem {
|
}: ProblemSeed): ParenthesisSignProblem {
|
||||||
const distributedA = multiplier * a;
|
const signedTerms = terms.map((term, index) => {
|
||||||
const distributedB = operator === "-" ? multiplier * -b : multiplier * b;
|
if (index === 0) return term;
|
||||||
const key = `${multiplier}:${a}:${operator}:${b}`;
|
|
||||||
|
return operators[index - 1] === "-" ? -term : term;
|
||||||
|
});
|
||||||
|
const distributedTerms = signedTerms.map((term) => multiplier * term);
|
||||||
|
const key = [
|
||||||
|
multiplier,
|
||||||
|
...terms.flatMap((term, index) =>
|
||||||
|
index === 0 ? [`${term}`] : [operators[index - 1], `${term}`],
|
||||||
|
),
|
||||||
|
].join(":");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `parenthesis-sign:${key}`,
|
id: `parenthesis-sign:${key}`,
|
||||||
key,
|
key,
|
||||||
multiplier,
|
multiplier,
|
||||||
a,
|
terms,
|
||||||
operator,
|
operators,
|
||||||
b,
|
distributedTerms,
|
||||||
distributedA,
|
latexBefore: formatParenthesisExpressionLatex({
|
||||||
distributedB,
|
multiplier,
|
||||||
latexBefore: `${multiplier}\\left(${a} ${operator} ${b}\\right)`,
|
terms,
|
||||||
latexAfter: formatLatexTermPair(distributedA, distributedB),
|
operators,
|
||||||
|
formatTermLatex: (term) => `${term}`,
|
||||||
|
}),
|
||||||
|
latexAfter: formatLatexTerms(distributedTerms),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAllProblemSeeds() {
|
function createTermCombinations(termCount: ParenthesisTermCount) {
|
||||||
|
const combinations: number[][] = [];
|
||||||
|
|
||||||
|
function addTerms(nextTerms: number[]) {
|
||||||
|
if (nextTerms.length === termCount) {
|
||||||
|
combinations.push(nextTerms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let term = MIN_TERM; term <= MAX_TERM; term += 1) {
|
||||||
|
if (nextTerms.includes(term)) continue;
|
||||||
|
|
||||||
|
addTerms([...nextTerms, term]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addTerms([]);
|
||||||
|
|
||||||
|
return combinations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOperatorCombinations(termCount: ParenthesisTermCount) {
|
||||||
|
const combinations: ("+" | "-")[][] = [];
|
||||||
|
const operatorCount = termCount - 1;
|
||||||
|
|
||||||
|
function addOperators(nextOperators: ("+" | "-")[]) {
|
||||||
|
if (nextOperators.length === operatorCount) {
|
||||||
|
combinations.push(nextOperators);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addOperators([...nextOperators, "+"]);
|
||||||
|
addOperators([...nextOperators, "-"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
addOperators([]);
|
||||||
|
|
||||||
|
return combinations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAllProblemSeeds(termCount: ParenthesisTermCount) {
|
||||||
const seeds: ProblemSeed[] = [];
|
const seeds: ProblemSeed[] = [];
|
||||||
|
const termCombinations = createTermCombinations(termCount);
|
||||||
|
const operatorCombinations = createOperatorCombinations(termCount);
|
||||||
|
|
||||||
for (
|
for (
|
||||||
let multiplier = MIN_MULTIPLIER;
|
let multiplier = MIN_MULTIPLIER;
|
||||||
multiplier <= MAX_MULTIPLIER;
|
multiplier <= MAX_MULTIPLIER;
|
||||||
multiplier += 1
|
multiplier += 1
|
||||||
) {
|
) {
|
||||||
for (let a = MIN_TERM; a <= MAX_TERM; a += 1) {
|
for (const terms of termCombinations) {
|
||||||
for (let b = MIN_TERM; b <= MAX_TERM; b += 1) {
|
for (const operators of operatorCombinations) {
|
||||||
if (a === b) continue;
|
seeds.push({ multiplier, terms, operators });
|
||||||
|
|
||||||
seeds.push({ multiplier, a, operator: "+", b });
|
|
||||||
seeds.push({ multiplier, a, operator: "-", b });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +129,14 @@ function createAllProblemSeeds() {
|
|||||||
return seeds;
|
return seeds;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_PROBLEM_SEEDS = createAllProblemSeeds();
|
const ALL_PROBLEM_SEEDS_BY_TERM_COUNT: Record<
|
||||||
|
ParenthesisTermCount,
|
||||||
|
ProblemSeed[]
|
||||||
|
> = {
|
||||||
|
2: createAllProblemSeeds(2),
|
||||||
|
3: createAllProblemSeeds(3),
|
||||||
|
4: createAllProblemSeeds(4),
|
||||||
|
};
|
||||||
|
|
||||||
function shuffle<T>(items: T[]) {
|
function shuffle<T>(items: T[]) {
|
||||||
const shuffled = [...items];
|
const shuffled = [...items];
|
||||||
@@ -85,32 +151,53 @@ function shuffle<T>(items: T[]) {
|
|||||||
return shuffled;
|
return shuffled;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateProblem(): ParenthesisSignProblem {
|
function assertTermCount(termCount: number): asserts termCount is ParenthesisTermCount {
|
||||||
const a = randomInt(MIN_TERM, MAX_TERM);
|
if (!Number.isInteger(termCount) || termCount < 2 || termCount > MAX_TERM_COUNT) {
|
||||||
let b = randomInt(MIN_TERM, MAX_TERM);
|
throw new Error("termCount must be 2, 3, or 4.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while (a === b) {
|
export function generateProblem(
|
||||||
b = randomInt(MIN_TERM, MAX_TERM);
|
termCount: ParenthesisTermCount = 2,
|
||||||
|
): ParenthesisSignProblem {
|
||||||
|
assertTermCount(termCount);
|
||||||
|
|
||||||
|
const terms: number[] = [];
|
||||||
|
|
||||||
|
while (terms.length < termCount) {
|
||||||
|
const term = randomInt(MIN_TERM, MAX_TERM);
|
||||||
|
|
||||||
|
if (!terms.includes(term)) {
|
||||||
|
terms.push(term);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return createProblem({
|
return createProblem({
|
||||||
multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER),
|
multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER),
|
||||||
a,
|
terms,
|
||||||
operator: Math.random() < 0.5 ? "+" : "-",
|
operators: Array.from({ length: termCount - 1 }, () =>
|
||||||
b,
|
Math.random() < 0.5 ? "+" : "-",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateSession(count = 10): ParenthesisSignProblem[] {
|
export function generateSession(
|
||||||
|
count = 10,
|
||||||
|
termCount: ParenthesisTermCount = 2,
|
||||||
|
): ParenthesisSignProblem[] {
|
||||||
if (!Number.isInteger(count) || count < 1) {
|
if (!Number.isInteger(count) || count < 1) {
|
||||||
throw new Error("count must be a positive integer.");
|
throw new Error("count must be a positive integer.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count > MAX_PROBLEM_COUNT) {
|
assertTermCount(termCount);
|
||||||
throw new Error(`count cannot exceed ${MAX_PROBLEM_COUNT}.`);
|
|
||||||
|
const seeds = ALL_PROBLEM_SEEDS_BY_TERM_COUNT[termCount];
|
||||||
|
|
||||||
|
if (count > seeds.length) {
|
||||||
|
throw new Error(`count cannot exceed ${seeds.length}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return shuffle(ALL_PROBLEM_SEEDS).slice(0, count).map(createProblem);
|
return shuffle(seeds).slice(0, count).map(createProblem);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function gradeAnswer(
|
export function gradeAnswer(
|
||||||
@@ -119,8 +206,9 @@ export function gradeAnswer(
|
|||||||
): GradeResult {
|
): GradeResult {
|
||||||
return {
|
return {
|
||||||
correct:
|
correct:
|
||||||
answer.a === problem.distributedA && answer.b === problem.distributedB,
|
answer.terms.length === problem.distributedTerms.length &&
|
||||||
userAnswer: [answer.a, answer.b],
|
answer.terms.every((value, index) => value === problem.distributedTerms[index]),
|
||||||
correctAnswer: [problem.distributedA, problem.distributedB],
|
userAnswer: answer.terms,
|
||||||
|
correctAnswer: problem.distributedTerms,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-9
@@ -2,22 +2,19 @@ export interface ParenthesisSignProblem {
|
|||||||
id: string;
|
id: string;
|
||||||
key: string;
|
key: string;
|
||||||
multiplier: number;
|
multiplier: number;
|
||||||
a: number;
|
terms: number[];
|
||||||
operator: "+" | "-";
|
operators: ("+" | "-")[];
|
||||||
b: number;
|
distributedTerms: number[];
|
||||||
distributedA: number;
|
|
||||||
distributedB: number;
|
|
||||||
latexBefore: string;
|
latexBefore: string;
|
||||||
latexAfter: string;
|
latexAfter: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserAnswer {
|
export interface UserAnswer {
|
||||||
a: number;
|
terms: number[];
|
||||||
b: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GradeResult {
|
export interface GradeResult {
|
||||||
correct: boolean;
|
correct: boolean;
|
||||||
userAnswer: [number, number];
|
userAnswer: number[];
|
||||||
correctAnswer: [number, number];
|
correctAnswer: number[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { VariableParenthesisProblem } from "./variable-types";
|
|||||||
|
|
||||||
function findCorrectChoiceIds(problem: VariableParenthesisProblem) {
|
function findCorrectChoiceIds(problem: VariableParenthesisProblem) {
|
||||||
return problem.correctTerms.map((term, index) => {
|
return problem.correctTerms.map((term, index) => {
|
||||||
const choices = problem.choices[index as 0 | 1];
|
const choices = problem.choices[index];
|
||||||
const choice = choices.find(
|
const choice = choices.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.coefficient === term.coefficient && item.variable === term.variable,
|
item.coefficient === term.coefficient && item.variable === term.variable,
|
||||||
@@ -19,33 +19,32 @@ function findCorrectChoiceIds(problem: VariableParenthesisProblem) {
|
|||||||
if (!choice) throw new Error("correct choice not found.");
|
if (!choice) throw new Error("correct choice not found.");
|
||||||
|
|
||||||
return choice.id;
|
return choice.id;
|
||||||
}) as [string, string];
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("variable parenthesis engine", () => {
|
describe("variable parenthesis engine", () => {
|
||||||
it("generates a problem with exactly one variable term inside parentheses", () => {
|
it("generates a problem with exactly one variable term inside parentheses", () => {
|
||||||
const problem = generateVariableParenthesisProblem();
|
const problem = generateVariableParenthesisProblem();
|
||||||
const terms = [problem.firstTerm, problem.secondTerm];
|
|
||||||
|
|
||||||
expect(terms.filter((term) => term.kind === "variable")).toHaveLength(1);
|
expect(problem.terms.filter((term) => term.kind === "variable")).toHaveLength(1);
|
||||||
expect(terms.filter((term) => term.kind === "constant")).toHaveLength(1);
|
expect(problem.terms.filter((term) => term.kind === "constant")).toHaveLength(1);
|
||||||
expect(problem.multiplier).toBeLessThan(0);
|
expect(problem.multiplier).toBeLessThan(0);
|
||||||
expect(Number.isInteger(problem.multiplier)).toBe(true);
|
expect(Number.isInteger(problem.multiplier)).toBe(true);
|
||||||
expect(problem.firstTerm.coefficient).toBeGreaterThanOrEqual(1);
|
expect(
|
||||||
expect(problem.secondTerm.coefficient).toBeGreaterThanOrEqual(1);
|
problem.terms.every((term) => term.coefficient >= 1 && term.coefficient <= 9),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("distributes signs correctly for addition and subtraction", () => {
|
it("distributes signs correctly for addition and subtraction", () => {
|
||||||
const problems = generateVariableParenthesisSession(50);
|
const problems = generateVariableParenthesisSession(50);
|
||||||
|
|
||||||
for (const problem of problems) {
|
for (const problem of problems) {
|
||||||
const secondSign = problem.operator === "-" ? -1 : 1;
|
expect(problem.correctTerms.map((term) => term.coefficient)).toEqual(
|
||||||
|
problem.terms.map((term, index) => {
|
||||||
|
const sign = index === 0 || problem.operators[index - 1] === "+" ? 1 : -1;
|
||||||
|
|
||||||
expect(problem.correctTerms[0].coefficient).toBe(
|
return problem.multiplier * sign * term.coefficient;
|
||||||
problem.multiplier * problem.firstTerm.coefficient,
|
}),
|
||||||
);
|
|
||||||
expect(problem.correctTerms[1].coefficient).toBe(
|
|
||||||
problem.multiplier * secondSign * problem.secondTerm.coefficient,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -97,6 +96,21 @@ describe("variable parenthesis engine", () => {
|
|||||||
expect(keys.size).toBe(10);
|
expect(keys.size).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("generates the selected number of variable choices", () => {
|
||||||
|
const problem = generateVariableParenthesisProblem(4);
|
||||||
|
|
||||||
|
expect(problem.terms).toHaveLength(4);
|
||||||
|
expect(problem.operators).toHaveLength(3);
|
||||||
|
expect(problem.correctTerms).toHaveLength(4);
|
||||||
|
expect(problem.choices).toHaveLength(4);
|
||||||
|
expect(problem.terms.filter((term) => term.kind === "variable")).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
gradeVariableParenthesisAnswer(problem, {
|
||||||
|
selectedChoiceIds: findCorrectChoiceIds(problem),
|
||||||
|
}).correct,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("formats expressions with variable terms", () => {
|
it("formats expressions with variable terms", () => {
|
||||||
expect(
|
expect(
|
||||||
formatVariableExpressionLatex([
|
formatVariableExpressionLatex([
|
||||||
|
|||||||
@@ -6,18 +6,20 @@ import type {
|
|||||||
VariableUserAnswer,
|
VariableUserAnswer,
|
||||||
VariableGradeResult,
|
VariableGradeResult,
|
||||||
} from "./variable-types";
|
} from "./variable-types";
|
||||||
|
import { formatParenthesisExpressionLatex } from "./parenthesis-latex";
|
||||||
|
|
||||||
const MIN_MULTIPLIER = -9;
|
const MIN_MULTIPLIER = -9;
|
||||||
const MAX_MULTIPLIER = -1;
|
const MAX_MULTIPLIER = -1;
|
||||||
const MIN_COEFFICIENT = 1;
|
const MIN_COEFFICIENT = 1;
|
||||||
const MAX_COEFFICIENT = 9;
|
const MAX_COEFFICIENT = 9;
|
||||||
const MAX_PROBLEM_COUNT = 9 * 9 * 9 * 2 * 2;
|
const MAX_TERM_COUNT = 4;
|
||||||
|
|
||||||
|
export type VariableParenthesisTermCount = 2 | 3 | 4;
|
||||||
|
|
||||||
interface VariableProblemSeed {
|
interface VariableProblemSeed {
|
||||||
multiplier: number;
|
multiplier: number;
|
||||||
operator: "+" | "-";
|
terms: VariableParenthesisTerm[];
|
||||||
firstTerm: VariableParenthesisTerm;
|
operators: ("+" | "-")[];
|
||||||
secondTerm: VariableParenthesisTerm;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomInt(min: number, max: number) {
|
function randomInt(min: number, max: number) {
|
||||||
@@ -52,6 +54,26 @@ function createVariableTerm(coefficient: number): VariableParenthesisTerm {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createUniqueConstantTerms(
|
||||||
|
baseCoefficient: number,
|
||||||
|
count: number,
|
||||||
|
): VariableParenthesisTerm[] {
|
||||||
|
const used = new Set<number>();
|
||||||
|
const terms: VariableParenthesisTerm[] = [];
|
||||||
|
let candidate = baseCoefficient;
|
||||||
|
|
||||||
|
while (terms.length < count) {
|
||||||
|
const coef = ((candidate - 1) % MAX_COEFFICIENT) + 1;
|
||||||
|
if (!used.has(coef)) {
|
||||||
|
used.add(coef);
|
||||||
|
terms.push(createConstantTerm(coef));
|
||||||
|
}
|
||||||
|
candidate += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return terms;
|
||||||
|
}
|
||||||
|
|
||||||
function termKey(term: VariableParenthesisTerm) {
|
function termKey(term: VariableParenthesisTerm) {
|
||||||
return `${term.kind}:${term.coefficient}`;
|
return `${term.kind}:${term.coefficient}`;
|
||||||
}
|
}
|
||||||
@@ -74,23 +96,22 @@ export function formatVariableTermLatex(
|
|||||||
return `${term.coefficient}x`;
|
return `${term.coefficient}x`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatParenthesisTermLatex(term: VariableParenthesisTerm) {
|
|
||||||
return formatVariableTermLatex(term);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatVariableExpressionLatex(
|
export function formatVariableExpressionLatex(
|
||||||
terms: [DistributedVariableTerm, DistributedVariableTerm],
|
terms: DistributedVariableTerm[],
|
||||||
) {
|
) {
|
||||||
const [firstTerm, secondTerm] = terms;
|
return terms
|
||||||
const operator = secondTerm.coefficient < 0 ? "-" : "+";
|
.map((term, index) => {
|
||||||
const positiveSecondTerm = {
|
if (index === 0) return term.latex;
|
||||||
...secondTerm,
|
|
||||||
coefficient: Math.abs(secondTerm.coefficient),
|
|
||||||
};
|
|
||||||
|
|
||||||
return `${firstTerm.latex} ${operator} ${formatVariableTermLatex(
|
const operator = term.coefficient < 0 ? "-" : "+";
|
||||||
positiveSecondTerm,
|
const positiveTerm = {
|
||||||
)}`;
|
...term,
|
||||||
|
coefficient: Math.abs(term.coefficient),
|
||||||
|
};
|
||||||
|
|
||||||
|
return `${operator} ${formatVariableTermLatex(positiveTerm)}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function distributeTerm(
|
function distributeTerm(
|
||||||
@@ -110,7 +131,7 @@ function distributeTerm(
|
|||||||
|
|
||||||
function createChoice(
|
function createChoice(
|
||||||
key: string,
|
key: string,
|
||||||
termIndex: 0 | 1,
|
termIndex: number,
|
||||||
term: DistributedVariableTerm,
|
term: DistributedVariableTerm,
|
||||||
coefficient: number,
|
coefficient: number,
|
||||||
): VariableTermChoice {
|
): VariableTermChoice {
|
||||||
@@ -126,7 +147,7 @@ function createChoice(
|
|||||||
|
|
||||||
function createChoices(
|
function createChoices(
|
||||||
key: string,
|
key: string,
|
||||||
termIndex: 0 | 1,
|
termIndex: number,
|
||||||
term: DistributedVariableTerm,
|
term: DistributedVariableTerm,
|
||||||
) {
|
) {
|
||||||
const absoluteCoefficient = Math.abs(term.coefficient);
|
const absoluteCoefficient = Math.abs(term.coefficient);
|
||||||
@@ -139,40 +160,63 @@ function createChoices(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createProblem(seed: VariableProblemSeed): VariableParenthesisProblem {
|
function createProblem(seed: VariableProblemSeed): VariableParenthesisProblem {
|
||||||
const { multiplier, operator, firstTerm, secondTerm } = seed;
|
const { multiplier, terms, operators } = seed;
|
||||||
const key = [
|
const key = [
|
||||||
multiplier,
|
multiplier,
|
||||||
termKey(firstTerm),
|
...terms.flatMap((term, index) =>
|
||||||
operator,
|
index === 0 ? [termKey(term)] : [operators[index - 1], termKey(term)],
|
||||||
termKey(secondTerm),
|
),
|
||||||
].join(":");
|
].join(":");
|
||||||
const correctTerms: [DistributedVariableTerm, DistributedVariableTerm] = [
|
const correctTerms = terms.map((term, index) =>
|
||||||
distributeTerm(multiplier, firstTerm, 1),
|
distributeTerm(
|
||||||
distributeTerm(multiplier, secondTerm, operator === "-" ? -1 : 1),
|
multiplier,
|
||||||
];
|
term,
|
||||||
const choices: [VariableTermChoice[], VariableTermChoice[]] = [
|
index === 0 || operators[index - 1] === "+" ? 1 : -1,
|
||||||
createChoices(key, 0, correctTerms[0]),
|
),
|
||||||
createChoices(key, 1, correctTerms[1]),
|
);
|
||||||
];
|
const choices = correctTerms.map((term, index) =>
|
||||||
|
createChoices(key, index, term),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `variable-parenthesis:${key}`,
|
id: `variable-parenthesis:${key}`,
|
||||||
key,
|
key,
|
||||||
multiplier,
|
multiplier,
|
||||||
operator,
|
terms,
|
||||||
firstTerm,
|
operators,
|
||||||
secondTerm,
|
|
||||||
correctTerms,
|
correctTerms,
|
||||||
choices,
|
choices,
|
||||||
latexBefore: `${multiplier}\\left(${formatParenthesisTermLatex(
|
latexBefore: formatParenthesisExpressionLatex({
|
||||||
firstTerm,
|
multiplier,
|
||||||
)} ${operator} ${formatParenthesisTermLatex(secondTerm)}\\right)`,
|
terms,
|
||||||
|
operators,
|
||||||
|
formatTermLatex: formatVariableTermLatex,
|
||||||
|
}),
|
||||||
latexAfter: formatVariableExpressionLatex(correctTerms),
|
latexAfter: formatVariableExpressionLatex(correctTerms),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAllProblemSeeds() {
|
function createOperatorCombinations(termCount: VariableParenthesisTermCount) {
|
||||||
|
const combinations: ("+" | "-")[][] = [];
|
||||||
|
|
||||||
|
function addOperators(nextOperators: ("+" | "-")[]) {
|
||||||
|
if (nextOperators.length === termCount - 1) {
|
||||||
|
combinations.push(nextOperators);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addOperators([...nextOperators, "+"]);
|
||||||
|
addOperators([...nextOperators, "-"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
addOperators([]);
|
||||||
|
|
||||||
|
return combinations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAllProblemSeeds(termCount: VariableParenthesisTermCount) {
|
||||||
const seeds: VariableProblemSeed[] = [];
|
const seeds: VariableProblemSeed[] = [];
|
||||||
|
const operatorCombinations = createOperatorCombinations(termCount);
|
||||||
|
|
||||||
for (
|
for (
|
||||||
let multiplier = MIN_MULTIPLIER;
|
let multiplier = MIN_MULTIPLIER;
|
||||||
@@ -189,19 +233,22 @@ function createAllProblemSeeds() {
|
|||||||
variableCoefficient <= MAX_COEFFICIENT;
|
variableCoefficient <= MAX_COEFFICIENT;
|
||||||
variableCoefficient += 1
|
variableCoefficient += 1
|
||||||
) {
|
) {
|
||||||
for (const operator of ["+", "-"] as const) {
|
for (let variableIndex = 0; variableIndex < termCount; variableIndex += 1) {
|
||||||
seeds.push({
|
const constantTerms = createUniqueConstantTerms(constant, termCount - 1);
|
||||||
multiplier,
|
let constantTermIndex = 0;
|
||||||
operator,
|
const terms = Array.from({ length: termCount }, (_, index) =>
|
||||||
firstTerm: createVariableTerm(variableCoefficient),
|
index === variableIndex
|
||||||
secondTerm: createConstantTerm(constant),
|
? createVariableTerm(variableCoefficient)
|
||||||
});
|
: constantTerms[constantTermIndex++],
|
||||||
seeds.push({
|
);
|
||||||
multiplier,
|
|
||||||
operator,
|
for (const operators of operatorCombinations) {
|
||||||
firstTerm: createConstantTerm(constant),
|
seeds.push({
|
||||||
secondTerm: createVariableTerm(variableCoefficient),
|
multiplier,
|
||||||
});
|
terms,
|
||||||
|
operators,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,43 +257,70 @@ function createAllProblemSeeds() {
|
|||||||
return seeds;
|
return seeds;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_PROBLEM_SEEDS = createAllProblemSeeds();
|
const ALL_PROBLEM_SEEDS_BY_TERM_COUNT: Record<
|
||||||
|
VariableParenthesisTermCount,
|
||||||
|
VariableProblemSeed[]
|
||||||
|
> = {
|
||||||
|
2: createAllProblemSeeds(2),
|
||||||
|
3: createAllProblemSeeds(3),
|
||||||
|
4: createAllProblemSeeds(4),
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertTermCount(
|
||||||
|
termCount: number,
|
||||||
|
): asserts termCount is VariableParenthesisTermCount {
|
||||||
|
if (!Number.isInteger(termCount) || termCount < 2 || termCount > MAX_TERM_COUNT) {
|
||||||
|
throw new Error("termCount must be 2, 3, or 4.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateVariableParenthesisProblem(
|
||||||
|
termCount: VariableParenthesisTermCount = 2,
|
||||||
|
): VariableParenthesisProblem {
|
||||||
|
assertTermCount(termCount);
|
||||||
|
|
||||||
export function generateVariableParenthesisProblem(): VariableParenthesisProblem {
|
|
||||||
const constant = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT);
|
const constant = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT);
|
||||||
const variableCoefficient = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT);
|
const variableCoefficient = randomInt(MIN_COEFFICIENT, MAX_COEFFICIENT);
|
||||||
const variableFirst = Math.random() < 0.5;
|
const variableIndex = randomInt(0, termCount - 1);
|
||||||
const operator = Math.random() < 0.5 ? "+" : "-";
|
|
||||||
|
const constantTerms = createUniqueConstantTerms(constant, termCount - 1);
|
||||||
|
let constantTermIndex = 0;
|
||||||
|
|
||||||
return createProblem({
|
return createProblem({
|
||||||
multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER),
|
multiplier: randomInt(MIN_MULTIPLIER, MAX_MULTIPLIER),
|
||||||
operator,
|
terms: Array.from({ length: termCount }, (_, index) =>
|
||||||
firstTerm: variableFirst
|
index === variableIndex
|
||||||
? createVariableTerm(variableCoefficient)
|
? createVariableTerm(variableCoefficient)
|
||||||
: createConstantTerm(constant),
|
: constantTerms[constantTermIndex++],
|
||||||
secondTerm: variableFirst
|
),
|
||||||
? createConstantTerm(constant)
|
operators: Array.from({ length: termCount - 1 }, () =>
|
||||||
: createVariableTerm(variableCoefficient),
|
Math.random() < 0.5 ? "+" : "-",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateVariableParenthesisSession(
|
export function generateVariableParenthesisSession(
|
||||||
count = 10,
|
count = 10,
|
||||||
|
termCount: VariableParenthesisTermCount = 2,
|
||||||
): VariableParenthesisProblem[] {
|
): VariableParenthesisProblem[] {
|
||||||
if (!Number.isInteger(count) || count < 1) {
|
if (!Number.isInteger(count) || count < 1) {
|
||||||
throw new Error("count must be a positive integer.");
|
throw new Error("count must be a positive integer.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count > MAX_PROBLEM_COUNT) {
|
assertTermCount(termCount);
|
||||||
throw new Error(`count cannot exceed ${MAX_PROBLEM_COUNT}.`);
|
|
||||||
|
const seeds = ALL_PROBLEM_SEEDS_BY_TERM_COUNT[termCount];
|
||||||
|
|
||||||
|
if (count > seeds.length) {
|
||||||
|
throw new Error(`count cannot exceed ${seeds.length}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return shuffle(ALL_PROBLEM_SEEDS).slice(0, count).map(createProblem);
|
return shuffle(seeds).slice(0, count).map(createProblem);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findSelectedChoice(
|
function findSelectedChoice(
|
||||||
problem: VariableParenthesisProblem,
|
problem: VariableParenthesisProblem,
|
||||||
termIndex: 0 | 1,
|
termIndex: number,
|
||||||
choiceId: string | null,
|
choiceId: string | null,
|
||||||
): DistributedVariableTerm | null {
|
): DistributedVariableTerm | null {
|
||||||
if (!choiceId) return null;
|
if (!choiceId) return null;
|
||||||
@@ -267,16 +341,14 @@ export function gradeVariableParenthesisAnswer(
|
|||||||
problem: VariableParenthesisProblem,
|
problem: VariableParenthesisProblem,
|
||||||
answer: VariableUserAnswer,
|
answer: VariableUserAnswer,
|
||||||
): VariableGradeResult {
|
): VariableGradeResult {
|
||||||
const userAnswer: [
|
const userAnswer = problem.correctTerms.map((_, index) =>
|
||||||
DistributedVariableTerm | null,
|
findSelectedChoice(problem, index, answer.selectedChoiceIds[index] ?? null),
|
||||||
DistributedVariableTerm | null,
|
);
|
||||||
] = [
|
|
||||||
findSelectedChoice(problem, 0, answer.selectedChoiceIds[0]),
|
|
||||||
findSelectedChoice(problem, 1, answer.selectedChoiceIds[1]),
|
|
||||||
];
|
|
||||||
const correct =
|
const correct =
|
||||||
userAnswer[0]?.coefficient === problem.correctTerms[0].coefficient &&
|
userAnswer.length === problem.correctTerms.length &&
|
||||||
userAnswer[1]?.coefficient === problem.correctTerms[1].coefficient;
|
userAnswer.every(
|
||||||
|
(term, index) => term?.coefficient === problem.correctTerms[index].coefficient,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
correct,
|
correct,
|
||||||
|
|||||||
@@ -24,21 +24,20 @@ export interface VariableParenthesisProblem {
|
|||||||
id: string;
|
id: string;
|
||||||
key: string;
|
key: string;
|
||||||
multiplier: number;
|
multiplier: number;
|
||||||
operator: "+" | "-";
|
terms: VariableParenthesisTerm[];
|
||||||
firstTerm: VariableParenthesisTerm;
|
operators: ("+" | "-")[];
|
||||||
secondTerm: VariableParenthesisTerm;
|
correctTerms: DistributedVariableTerm[];
|
||||||
correctTerms: [DistributedVariableTerm, DistributedVariableTerm];
|
choices: VariableTermChoice[][];
|
||||||
choices: [VariableTermChoice[], VariableTermChoice[]];
|
|
||||||
latexBefore: string;
|
latexBefore: string;
|
||||||
latexAfter: string;
|
latexAfter: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VariableUserAnswer {
|
export interface VariableUserAnswer {
|
||||||
selectedChoiceIds: [string | null, string | null];
|
selectedChoiceIds: (string | null)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VariableGradeResult {
|
export interface VariableGradeResult {
|
||||||
correct: boolean;
|
correct: boolean;
|
||||||
userAnswer: [DistributedVariableTerm | null, DistributedVariableTerm | null];
|
userAnswer: (DistributedVariableTerm | null)[];
|
||||||
correctAnswer: [DistributedVariableTerm, DistributedVariableTerm];
|
correctAnswer: DistributedVariableTerm[];
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-6
@@ -4,6 +4,7 @@ import {
|
|||||||
generateSession,
|
generateSession,
|
||||||
gradeAnswer,
|
gradeAnswer,
|
||||||
} from "@/lib/engine/parenthesis-sign";
|
} from "@/lib/engine/parenthesis-sign";
|
||||||
|
import type { ParenthesisTermCount } from "@/lib/engine/parenthesis-sign";
|
||||||
import type {
|
import type {
|
||||||
GradeResult,
|
GradeResult,
|
||||||
ParenthesisSignProblem,
|
ParenthesisSignProblem,
|
||||||
@@ -14,42 +15,56 @@ export interface GameSession {
|
|||||||
problems: ParenthesisSignProblem[];
|
problems: ParenthesisSignProblem[];
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
results: (GradeResult | null)[];
|
results: (GradeResult | null)[];
|
||||||
|
termCount: ParenthesisTermCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProblemCount = 5 | 10;
|
export type ProblemCount = 5 | 10;
|
||||||
|
export type MaxTermCount = ParenthesisTermCount;
|
||||||
|
|
||||||
interface GameState {
|
interface GameState {
|
||||||
session: GameSession | null;
|
session: GameSession | null;
|
||||||
selectedProblemCount: ProblemCount;
|
selectedProblemCount: ProblemCount;
|
||||||
|
selectedMaxTermCount: MaxTermCount;
|
||||||
setProblemCount: (count: ProblemCount) => void;
|
setProblemCount: (count: ProblemCount) => void;
|
||||||
initSession: (count?: ProblemCount) => void;
|
setMaxTermCount: (count: MaxTermCount) => void;
|
||||||
|
initSession: (count?: ProblemCount, maxTermCount?: MaxTermCount) => void;
|
||||||
submitAnswer: (answer: UserAnswer) => void;
|
submitAnswer: (answer: UserAnswer) => void;
|
||||||
nextProblem: () => void;
|
nextProblem: () => void;
|
||||||
resetSession: () => void;
|
resetSession: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SESSION_COUNT: ProblemCount = 10;
|
const DEFAULT_SESSION_COUNT: ProblemCount = 10;
|
||||||
|
const DEFAULT_MAX_TERM_COUNT: MaxTermCount = 2;
|
||||||
|
|
||||||
function createSession(count: ProblemCount = DEFAULT_SESSION_COUNT): GameSession {
|
function createSession(
|
||||||
const problems = generateSession(count);
|
count: ProblemCount = DEFAULT_SESSION_COUNT,
|
||||||
|
maxTermCount: MaxTermCount = DEFAULT_MAX_TERM_COUNT,
|
||||||
|
): GameSession {
|
||||||
|
const problems = generateSession(count, maxTermCount);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
problems,
|
problems,
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
results: problems.map(() => null),
|
results: problems.map(() => null),
|
||||||
|
termCount: maxTermCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGameStore = create<GameState>((set, get) => ({
|
export const useGameStore = create<GameState>((set, get) => ({
|
||||||
session: null,
|
session: null,
|
||||||
selectedProblemCount: DEFAULT_SESSION_COUNT,
|
selectedProblemCount: DEFAULT_SESSION_COUNT,
|
||||||
|
selectedMaxTermCount: DEFAULT_MAX_TERM_COUNT,
|
||||||
setProblemCount: (count) => {
|
setProblemCount: (count) => {
|
||||||
set({ selectedProblemCount: count });
|
set({ selectedProblemCount: count });
|
||||||
},
|
},
|
||||||
initSession: (count) => {
|
setMaxTermCount: (count) => {
|
||||||
|
set({ selectedMaxTermCount: count });
|
||||||
|
},
|
||||||
|
initSession: (count, maxTermCount) => {
|
||||||
const selectedProblemCount = count ?? get().selectedProblemCount;
|
const selectedProblemCount = count ?? get().selectedProblemCount;
|
||||||
|
const selectedMaxTermCount = maxTermCount ?? get().selectedMaxTermCount;
|
||||||
|
|
||||||
set({ session: createSession(selectedProblemCount) });
|
set({ session: createSession(selectedProblemCount, selectedMaxTermCount) });
|
||||||
},
|
},
|
||||||
submitAnswer: (answer) => {
|
submitAnswer: (answer) => {
|
||||||
const { session } = get();
|
const { session } = get();
|
||||||
@@ -83,6 +98,11 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
resetSession: () => {
|
resetSession: () => {
|
||||||
set({ session: createSession(get().selectedProblemCount) });
|
set({
|
||||||
|
session: createSession(
|
||||||
|
get().selectedProblemCount,
|
||||||
|
get().selectedMaxTermCount,
|
||||||
|
),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
generateVariableParenthesisSession,
|
generateVariableParenthesisSession,
|
||||||
gradeVariableParenthesisAnswer,
|
gradeVariableParenthesisAnswer,
|
||||||
} from "@/lib/engine/variable-parenthesis";
|
} from "@/lib/engine/variable-parenthesis";
|
||||||
|
import type { VariableParenthesisTermCount } from "@/lib/engine/variable-parenthesis";
|
||||||
import type {
|
import type {
|
||||||
VariableGradeResult,
|
VariableGradeResult,
|
||||||
VariableParenthesisProblem,
|
VariableParenthesisProblem,
|
||||||
@@ -14,44 +15,59 @@ export interface VariableGameSession {
|
|||||||
problems: VariableParenthesisProblem[];
|
problems: VariableParenthesisProblem[];
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
results: (VariableGradeResult | null)[];
|
results: (VariableGradeResult | null)[];
|
||||||
|
termCount: VariableParenthesisTermCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VariableProblemCount = 5 | 10;
|
export type VariableProblemCount = 5 | 10;
|
||||||
|
export type VariableMaxTermCount = VariableParenthesisTermCount;
|
||||||
|
|
||||||
interface VariableGameState {
|
interface VariableGameState {
|
||||||
session: VariableGameSession | null;
|
session: VariableGameSession | null;
|
||||||
selectedProblemCount: VariableProblemCount;
|
selectedProblemCount: VariableProblemCount;
|
||||||
|
selectedMaxTermCount: VariableMaxTermCount;
|
||||||
setProblemCount: (count: VariableProblemCount) => void;
|
setProblemCount: (count: VariableProblemCount) => void;
|
||||||
initSession: (count?: VariableProblemCount) => void;
|
setMaxTermCount: (count: VariableMaxTermCount) => void;
|
||||||
|
initSession: (
|
||||||
|
count?: VariableProblemCount,
|
||||||
|
maxTermCount?: VariableMaxTermCount,
|
||||||
|
) => void;
|
||||||
submitAnswer: (answer: VariableUserAnswer) => void;
|
submitAnswer: (answer: VariableUserAnswer) => void;
|
||||||
nextProblem: () => void;
|
nextProblem: () => void;
|
||||||
resetSession: () => void;
|
resetSession: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SESSION_COUNT: VariableProblemCount = 10;
|
const DEFAULT_SESSION_COUNT: VariableProblemCount = 10;
|
||||||
|
const DEFAULT_MAX_TERM_COUNT: VariableMaxTermCount = 2;
|
||||||
|
|
||||||
function createSession(
|
function createSession(
|
||||||
count: VariableProblemCount = DEFAULT_SESSION_COUNT,
|
count: VariableProblemCount = DEFAULT_SESSION_COUNT,
|
||||||
|
maxTermCount: VariableMaxTermCount = DEFAULT_MAX_TERM_COUNT,
|
||||||
): VariableGameSession {
|
): VariableGameSession {
|
||||||
const problems = generateVariableParenthesisSession(count);
|
const problems = generateVariableParenthesisSession(count, maxTermCount);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
problems,
|
problems,
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
results: problems.map(() => null),
|
results: problems.map(() => null),
|
||||||
|
termCount: maxTermCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useVariableGameStore = create<VariableGameState>((set, get) => ({
|
export const useVariableGameStore = create<VariableGameState>((set, get) => ({
|
||||||
session: null,
|
session: null,
|
||||||
selectedProblemCount: DEFAULT_SESSION_COUNT,
|
selectedProblemCount: DEFAULT_SESSION_COUNT,
|
||||||
|
selectedMaxTermCount: DEFAULT_MAX_TERM_COUNT,
|
||||||
setProblemCount: (count) => {
|
setProblemCount: (count) => {
|
||||||
set({ selectedProblemCount: count });
|
set({ selectedProblemCount: count });
|
||||||
},
|
},
|
||||||
initSession: (count) => {
|
setMaxTermCount: (count) => {
|
||||||
|
set({ selectedMaxTermCount: count });
|
||||||
|
},
|
||||||
|
initSession: (count, maxTermCount) => {
|
||||||
const selectedProblemCount = count ?? get().selectedProblemCount;
|
const selectedProblemCount = count ?? get().selectedProblemCount;
|
||||||
|
const selectedMaxTermCount = maxTermCount ?? get().selectedMaxTermCount;
|
||||||
|
|
||||||
set({ session: createSession(selectedProblemCount) });
|
set({ session: createSession(selectedProblemCount, selectedMaxTermCount) });
|
||||||
},
|
},
|
||||||
submitAnswer: (answer) => {
|
submitAnswer: (answer) => {
|
||||||
const { session } = get();
|
const { session } = get();
|
||||||
@@ -85,6 +101,11 @@ export const useVariableGameStore = create<VariableGameState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
resetSession: () => {
|
resetSession: () => {
|
||||||
set({ session: createSession(get().selectedProblemCount) });
|
set({
|
||||||
|
session: createSession(
|
||||||
|
get().selectedProblemCount,
|
||||||
|
get().selectedMaxTermCount,
|
||||||
|
),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user