refactor: migrate quiz-project, gstack, and superpowers to JS+JSDoc

Convert all 41 TypeScript source/config files in cc4e-course/quiz-project
(Next.js), gstack (Astro), and superpowers (Vite/React/Phaser) to plain
JavaScript with JSDoc type annotations, replacing tsconfig.json with
jsconfig.json (strict + checkJs) in each subproject. No behavior changes:
lint, typecheck, test, and build gates match their TypeScript baselines
exactly in all three subprojects. Update stack references in the affected
READMEs accordingly.
This commit is contained in:
2026-08-18 09:49:42 +07:00
parent 964aef326d
commit 98958be372
60 changed files with 1115 additions and 728 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ together at **[miti99dev.github.io/ai-coding-workflow-labs](https://miti99dev.gi
| Directory | Experiment | Demo |
| --- | --- | --- |
| [`claudekit/`](./claudekit/) | ClaudeKit workflow (Vite/JS) | [/claudekit/](https://miti99dev.github.io/ai-coding-workflow-labs/claudekit/) |
| [`superpowers/`](./superpowers/) | Superpowers skill set (Vite/TS) | [/superpowers/](https://miti99dev.github.io/ai-coding-workflow-labs/superpowers/) |
| [`superpowers/`](./superpowers/) | Superpowers skill set (Vite/JS) | [/superpowers/](https://miti99dev.github.io/ai-coding-workflow-labs/superpowers/) |
| [`bmad/`](./bmad/) | BMAD method (Vite/JS) | [/bmad/](https://miti99dev.github.io/ai-coding-workflow-labs/bmad/) |
| [`oh-my-claudecode/`](./oh-my-claudecode/) | oh-my-claudecode (static) | [/oh-my-claudecode/](https://miti99dev.github.io/ai-coding-workflow-labs/oh-my-claudecode/) |
| [`gstack/`](./gstack/) | gstack starter (Astro/Bun) | [/gstack/](https://miti99dev.github.io/ai-coding-workflow-labs/gstack/) |
+1 -1
View File
@@ -16,7 +16,7 @@ bun dev
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
You can start editing the page by modifying `app/page.jsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
@@ -1,17 +1,16 @@
type Option = {
text: string;
personality: 'zen' | 'nightOwl' | 'socialButterfly';
};
/** @typedef {{ text: string, personality: 'zen' | 'nightOwl' | 'socialButterfly' }} Option */
type Props = {
question: string;
options: Option[];
current: number;
total: number;
onSelect: (personality: Option['personality']) => void;
};
export default function QuizQuestion({ question, options, current, total, onSelect }: Props) {
/**
* @param {{
* question: string,
* options: Option[],
* current: number,
* total: number,
* onSelect: (personality: Option['personality']) => void,
* }} props
* @returns {import('react').JSX.Element}
*/
export default function QuizQuestion({ question, options, current, total, onSelect }) {
return (
<div style={{
background: 'var(--card)',
@@ -79,12 +78,12 @@ export default function QuizQuestion({ question, options, current, total, onSele
fontFamily: 'inherit',
}}
onMouseEnter={e => {
(e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--accent)';
(e.currentTarget as HTMLButtonElement).style.background = 'var(--accent-light)';
(/** @type {HTMLButtonElement} */ (e.currentTarget)).style.borderColor = 'var(--accent)';
(/** @type {HTMLButtonElement} */ (e.currentTarget)).style.background = 'var(--accent-light)';
}}
onMouseLeave={e => {
(e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--border)';
(e.currentTarget as HTMLButtonElement).style.background = 'transparent';
(/** @type {HTMLButtonElement} */ (e.currentTarget)).style.borderColor = 'var(--border)';
(/** @type {HTMLButtonElement} */ (e.currentTarget)).style.background = 'transparent';
}}
>
{option.text}
@@ -1,13 +1,9 @@
type PersonalityKey = 'zen' | 'nightOwl' | 'socialButterfly';
/** @typedef {'zen' | 'nightOwl' | 'socialButterfly'} PersonalityKey */
type Scores = Record<PersonalityKey, number>;
/** @typedef {Record<PersonalityKey, number>} Scores */
type Props = {
scores: Scores;
onReset: () => void;
};
const personalities: Record<PersonalityKey, { name: string; coffee: string; tagline: string }> = {
/** @type {Record<PersonalityKey, { name: string, coffee: string, tagline: string }>} */
const personalities = {
zen: {
name: 'Zen Minimalist',
coffee: 'Black Coffee, Single Origin',
@@ -25,10 +21,14 @@ const personalities: Record<PersonalityKey, { name: string; coffee: string; tagl
},
};
export default function QuizResults({ scores, onReset }: Props) {
/**
* @param {{ scores: Scores, onReset: () => void }} props
* @returns {import('react').JSX.Element}
*/
export default function QuizResults({ scores, onReset }) {
const total = Object.values(scores).reduce((a, b) => a + b, 0);
const ranked = (Object.keys(scores) as PersonalityKey[])
const ranked = (/** @type {PersonalityKey[]} */ (Object.keys(scores)))
.map(key => ({
key,
count: scores[key],
@@ -134,12 +134,12 @@ export default function QuizResults({ scores, onReset }: Props) {
transition: 'all 0.18s ease',
}}
onMouseEnter={e => {
(e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--accent)';
(e.currentTarget as HTMLButtonElement).style.color = 'var(--foreground)';
(/** @type {HTMLButtonElement} */ (e.currentTarget)).style.borderColor = 'var(--accent)';
(/** @type {HTMLButtonElement} */ (e.currentTarget)).style.color = 'var(--foreground)';
}}
onMouseLeave={e => {
(e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--border)';
(e.currentTarget as HTMLButtonElement).style.color = 'var(--muted)';
(/** @type {HTMLButtonElement} */ (e.currentTarget)).style.borderColor = 'var(--border)';
(/** @type {HTMLButtonElement} */ (e.currentTarget)).style.color = 'var(--muted)';
}}
>
Take it again
@@ -1,4 +1,3 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
@@ -12,16 +11,17 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
export const metadata: Metadata = {
/** @type {import('next').Metadata} */
export const metadata = {
title: "Coffee Personality Quiz | Basecamp Coffee",
description: "Discover your coffee personality and find your perfect Basecamp Coffee drink.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
/**
* @param {Readonly<{ children: import('react').ReactNode }>} props
* @returns {import('react').JSX.Element}
*/
export default function RootLayout({ children }) {
return (
<html lang="en">
<body
@@ -4,60 +4,65 @@ import { useState } from 'react';
import QuizQuestion from './components/QuizQuestion';
import QuizResults from './components/QuizResults';
type PersonalityKey = 'zen' | 'nightOwl' | 'socialButterfly';
type Stage = 'intro' | 'quiz' | 'results';
/** @typedef {'zen' | 'nightOwl' | 'socialButterfly'} PersonalityKey */
/** @typedef {'intro' | 'quiz' | 'results'} Stage */
const questions = [
{
question: 'Pick a Netflix genre for tonight:',
options: [
{ text: 'A dark, quiet thriller with no jump scares', personality: 'zen' as PersonalityKey },
{ text: "A binge-worthy series you'll watch until 3am", personality: 'nightOwl' as PersonalityKey },
{ text: 'Whatever your group chat is hyped about', personality: 'socialButterfly' as PersonalityKey },
{ text: 'A dark, quiet thriller with no jump scares', personality: /** @type {PersonalityKey} */ ('zen') },
{ text: "A binge-worthy series you'll watch until 3am", personality: /** @type {PersonalityKey} */ ('nightOwl') },
{ text: 'Whatever your group chat is hyped about', personality: /** @type {PersonalityKey} */ ('socialButterfly') },
],
},
{
question: 'Your Hogwarts common room vibe:',
options: [
{ text: 'Ravenclaw — books, focus, solitude', personality: 'zen' as PersonalityKey },
{ text: 'Slytherin — ambition, late nights, no rules', personality: 'nightOwl' as PersonalityKey },
{ text: "Hufflepuff — warm, inclusive, everyone's welcome", personality: 'socialButterfly' as PersonalityKey },
{ text: 'Ravenclaw — books, focus, solitude', personality: /** @type {PersonalityKey} */ ('zen') },
{ text: 'Slytherin — ambition, late nights, no rules', personality: /** @type {PersonalityKey} */ ('nightOwl') },
{ text: "Hufflepuff — warm, inclusive, everyone's welcome", personality: /** @type {PersonalityKey} */ ('socialButterfly') },
],
},
{
question: "You're at a party. Where are you?",
options: [
{ text: 'Stepped outside for some quiet', personality: 'zen' as PersonalityKey },
{ text: 'Still there at 2am helping close the place down', personality: 'nightOwl' as PersonalityKey },
{ text: 'Center of the room, introducing people to each other', personality: 'socialButterfly' as PersonalityKey },
{ text: 'Stepped outside for some quiet', personality: /** @type {PersonalityKey} */ ('zen') },
{ text: 'Still there at 2am helping close the place down', personality: /** @type {PersonalityKey} */ ('nightOwl') },
{ text: 'Center of the room, introducing people to each other', personality: /** @type {PersonalityKey} */ ('socialButterfly') },
],
},
{
question: 'Your ideal travel style:',
options: [
{ text: 'Solo trip, minimal itinerary, just vibes', personality: 'zen' as PersonalityKey },
{ text: 'Red-eye flight to maximize time at the destination', personality: 'nightOwl' as PersonalityKey },
{ text: 'Group trip, shared Airbnb, chaotic and perfect', personality: 'socialButterfly' as PersonalityKey },
{ text: 'Solo trip, minimal itinerary, just vibes', personality: /** @type {PersonalityKey} */ ('zen') },
{ text: 'Red-eye flight to maximize time at the destination', personality: /** @type {PersonalityKey} */ ('nightOwl') },
{ text: 'Group trip, shared Airbnb, chaotic and perfect', personality: /** @type {PersonalityKey} */ ('socialButterfly') },
],
},
{
question: 'Pick a superpower:',
options: [
{ text: 'Total mental clarity, always focused', personality: 'zen' as PersonalityKey },
{ text: 'Never need sleep', personality: 'nightOwl' as PersonalityKey },
{ text: 'Everyone immediately likes you', personality: 'socialButterfly' as PersonalityKey },
{ text: 'Total mental clarity, always focused', personality: /** @type {PersonalityKey} */ ('zen') },
{ text: 'Never need sleep', personality: /** @type {PersonalityKey} */ ('nightOwl') },
{ text: 'Everyone immediately likes you', personality: /** @type {PersonalityKey} */ ('socialButterfly') },
],
},
];
const initialScores: Record<PersonalityKey, number> = { zen: 0, nightOwl: 0, socialButterfly: 0 };
/** @type {Record<PersonalityKey, number>} */
const initialScores = { zen: 0, nightOwl: 0, socialButterfly: 0 };
/**
* @returns {import('react').JSX.Element}
*/
export default function Home() {
const [stage, setStage] = useState<Stage>('intro');
const [stage, setStage] = useState(/** @type {Stage} */ ('intro'));
const [currentQ, setCurrentQ] = useState(0);
const [scores, setScores] = useState({ ...initialScores });
function handleAnswer(personality: PersonalityKey) {
/** @param {PersonalityKey} personality */
function handleAnswer(personality) {
const newScores = { ...scores, [personality]: scores[personality] + 1 };
setScores(newScores);
@@ -149,8 +154,8 @@ export default function Home() {
transition: 'opacity 0.18s ease',
width: '100%',
}}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.opacity = '0.88'; }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.opacity = '1'; }}
onMouseEnter={e => { (/** @type {HTMLButtonElement} */ (e.currentTarget)).style.opacity = '0.88'; }}
onMouseLeave={e => { (/** @type {HTMLButtonElement} */ (e.currentTarget)).style.opacity = '1'; }}
>
Find my coffee
</button>
@@ -1,17 +1,14 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
@@ -3,6 +3,7 @@
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"checkJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
@@ -12,7 +13,6 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
@@ -23,12 +23,10 @@
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
"**/*.js",
"**/*.jsx",
"**/*.mjs",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", ".next", "out"]
}
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -1,6 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/** @type {import('next').NextConfig} */
const nextConfig = {
// Static export so the quiz can be served from GitHub Pages
// under /ai-coding-workflow-labs/cc4e-course/.
output: "export",
+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"typecheck": "tsc -p jsconfig.json"
},
"dependencies": {
"next": "16.2.11",
+2 -2
View File
@@ -14,7 +14,7 @@ Scaffold only (v0.0.1.0). Modules are placeholders — landing page lists three
npm install
npm run dev # http://localhost:4321/try-gstack/
npm test # Vitest (geom-engine unit tests)
npm run typecheck # Astro check + TS strict
npm run typecheck # Astro check + strict checkJs (JavaScript + JSDoc)
npm run build # Static output to dist/
```
@@ -31,7 +31,7 @@ Auto-deploys to GitHub Pages from `main` via `actions/deploy-pages@v4`. See `RUN
- **Math rendering**: KaTeX, bundled locally + SSR-rendered — to be added with first canvas module
- **Animation**: Web Animations API + `requestAnimationFrame` (no GSAP / Motion One / Lottie)
- **Analytics**: Cloudflare Web Analytics, deferred until 50+ daily sessions
- **i18n**: every string via `t()` from `src/i18n/vi.ts`; English added by adding `en.ts`
- **i18n**: every string via `t()` from `src/i18n/vi.js`; English added by adding `en.js`
## License
@@ -5,7 +5,8 @@
"paths": {
"~/*": ["src/*"]
},
"verbatimModuleSyntax": false
"allowJs": true,
"checkJs": true
},
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "node_modules"]
@@ -1,11 +1,12 @@
import { vec } from '~/geom-engine/vec';
import type { Vec2 } from '~/geom-engine/vec';
import {
congruentSSS,
sides,
triangle as makeTriangle,
} from '~/geom-engine/triangle';
/** @typedef {import('~/geom-engine/vec').Vec2} Vec2 */
const VIEW_W = 400;
const VIEW_H = 300;
const TICK_LEN = 6;
@@ -15,11 +16,15 @@ const PAIR1 = '#D7263D';
const PAIR2 = '#1B998B';
const PAIR3 = '#F46036';
type VertexId = 'a' | 'b' | 'c' | 'ap' | 'bp' | 'cp';
/** @typedef {'a' | 'b' | 'c' | 'ap' | 'bp' | 'cp'} VertexId */
const VERTEX_IDS: readonly VertexId[] = ['a', 'b', 'c', 'ap', 'bp', 'cp'];
/** @typedef {'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap'} SideKey */
const INITIAL: Record<VertexId, Vec2> = {
/** @type {readonly VertexId[]} */
const VERTEX_IDS = ['a', 'b', 'c', 'ap', 'bp', 'cp'];
/** @type {Record<VertexId, Vec2>} */
const INITIAL = {
a: vec(60, 80),
b: vec(180, 80),
c: vec(120, 220),
@@ -28,36 +33,60 @@ const INITIAL: Record<VertexId, Vec2> = {
cp: vec(280, 220),
};
interface Refs {
svg: SVGSVGElement;
vertices: Record<VertexId, SVGCircleElement>;
labels: Record<VertexId, SVGTextElement>;
sides: Record<'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap', SVGLineElement>;
ticks: Record<'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap', SVGGElement>;
readouts: Record<'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap', HTMLElement>;
badge: HTMLElement;
}
/**
* @typedef {object} Refs
* @property {SVGSVGElement} svg
* @property {Record<VertexId, SVGCircleElement>} vertices
* @property {Record<VertexId, SVGTextElement>} labels
* @property {Record<SideKey, SVGLineElement>} sides
* @property {Record<SideKey, SVGGElement>} ticks
* @property {Record<SideKey, HTMLElement>} readouts
* @property {HTMLElement} badge
*/
function clientToSvg(svg: SVGSVGElement, x: number, y: number): Vec2 {
/**
* @param {SVGSVGElement} svg
* @param {number} x
* @param {number} y
* @returns {Vec2}
*/
function clientToSvg(svg, x, y) {
const r = svg.getBoundingClientRect();
return vec(((x - r.left) / r.width) * VIEW_W, ((y - r.top) / r.height) * VIEW_H);
}
function clamp(v: Vec2, pad = 16): Vec2 {
/**
* @param {Vec2} v
* @param {number} [pad]
* @returns {Vec2}
*/
function clamp(v, pad = 16) {
return vec(
Math.max(pad, Math.min(VIEW_W - pad, v.x)),
Math.max(pad, Math.min(VIEW_H - pad, v.y)),
);
}
function setLine(line: SVGLineElement, p1: Vec2, p2: Vec2) {
/**
* @param {SVGLineElement} line
* @param {Vec2} p1
* @param {Vec2} p2
*/
function setLine(line, p1, p2) {
line.setAttribute('x1', p1.x.toFixed(2));
line.setAttribute('y1', p1.y.toFixed(2));
line.setAttribute('x2', p2.x.toFixed(2));
line.setAttribute('y2', p2.y.toFixed(2));
}
function renderTicks(group: SVGGElement, p1: Vec2, p2: Vec2, count: 1 | 2 | 3, color: string) {
/**
* @param {SVGGElement} group
* @param {Vec2} p1
* @param {Vec2} p2
* @param {1 | 2 | 3} count
* @param {string} color
*/
function renderTicks(group, p1, p2, count, color) {
while (group.firstChild) group.removeChild(group.firstChild);
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
@@ -84,7 +113,11 @@ function renderTicks(group: SVGGElement, p1: Vec2, p2: Vec2, count: 1 | 2 | 3, c
}
}
function update(refs: Refs, state: Record<VertexId, Vec2>) {
/**
* @param {Refs} refs
* @param {Record<VertexId, Vec2>} state
*/
function update(refs, state) {
for (const id of VERTEX_IDS) {
const v = state[id];
refs.vertices[id].setAttribute('cx', v.x.toFixed(2));
@@ -126,64 +159,82 @@ function update(refs: Refs, state: Record<VertexId, Vec2>) {
refs.badge.style.display = congruent ? 'inline-block' : 'none';
}
function getRefs(svg: SVGSVGElement): Refs | null {
const vertices: Partial<Record<VertexId, SVGCircleElement>> = {};
const labels: Partial<Record<VertexId, SVGTextElement>> = {};
/**
* @param {SVGSVGElement} svg
* @returns {Refs | null}
*/
function getRefs(svg) {
/** @type {Partial<Record<VertexId, SVGCircleElement>>} */
const vertices = {};
/** @type {Partial<Record<VertexId, SVGTextElement>>} */
const labels = {};
for (const id of VERTEX_IDS) {
const v = svg.querySelector<SVGCircleElement>(`[data-vertex="${id}"]`);
const l = svg.querySelector<SVGTextElement>(`[data-vertex-label="${id}"]`);
const v = /** @type {SVGCircleElement | null} */ (svg.querySelector(`[data-vertex="${id}"]`));
const l = /** @type {SVGTextElement | null} */ (
svg.querySelector(`[data-vertex-label="${id}"]`)
);
if (!v || !l) return null;
vertices[id] = v;
labels[id] = l;
}
const sideKeys = ['ab', 'bc', 'ca', 'apbp', 'bpcp', 'cpap'] as const;
type SideKey = (typeof sideKeys)[number];
const sides: Partial<Record<SideKey, SVGLineElement>> = {};
const ticks: Partial<Record<SideKey, SVGGElement>> = {};
const readouts: Partial<Record<SideKey, HTMLElement>> = {};
/** @type {readonly SideKey[]} */
const sideKeys = ['ab', 'bc', 'ca', 'apbp', 'bpcp', 'cpap'];
/** @type {Partial<Record<SideKey, SVGLineElement>>} */
const sides = {};
/** @type {Partial<Record<SideKey, SVGGElement>>} */
const ticks = {};
/** @type {Partial<Record<SideKey, HTMLElement>>} */
const readouts = {};
for (const key of sideKeys) {
const s = svg.querySelector<SVGLineElement>(`[data-side="${key}"]`);
const t = svg.querySelector<SVGGElement>(`[data-ticks="${key}"]`);
const r = document.querySelector<HTMLElement>(`[data-readout-side="${key}"]`);
const s = /** @type {SVGLineElement | null} */ (svg.querySelector(`[data-side="${key}"]`));
const t = /** @type {SVGGElement | null} */ (svg.querySelector(`[data-ticks="${key}"]`));
const r = /** @type {HTMLElement | null} */ (
document.querySelector(`[data-readout-side="${key}"]`)
);
if (!s || !t || !r) return null;
sides[key] = s;
ticks[key] = t;
readouts[key] = r;
}
const badge = document.querySelector<HTMLElement>('[data-badge="congruent"]');
const badge = /** @type {HTMLElement | null} */ (
document.querySelector('[data-badge="congruent"]')
);
if (!badge) return null;
return {
svg,
vertices: vertices as Record<VertexId, SVGCircleElement>,
labels: labels as Record<VertexId, SVGTextElement>,
sides: sides as Record<SideKey, SVGLineElement>,
ticks: ticks as Record<SideKey, SVGGElement>,
readouts: readouts as Record<SideKey, HTMLElement>,
vertices: /** @type {Record<VertexId, SVGCircleElement>} */ (vertices),
labels: /** @type {Record<VertexId, SVGTextElement>} */ (labels),
sides: /** @type {Record<SideKey, SVGLineElement>} */ (sides),
ticks: /** @type {Record<SideKey, SVGGElement>} */ (ticks),
readouts: /** @type {Record<SideKey, HTMLElement>} */ (readouts),
badge,
};
}
export function setupCongruenceSSS(svgSelector: string) {
const svg = document.querySelector<SVGSVGElement>(svgSelector);
/** @param {string} svgSelector */
export function setupCongruenceSSS(svgSelector) {
const svg = /** @type {SVGSVGElement | null} */ (document.querySelector(svgSelector));
if (!svg) return;
const refs = getRefs(svg);
if (!refs) return;
const state: Record<VertexId, Vec2> = { ...INITIAL };
/** @type {Record<VertexId, Vec2>} */
const state = { ...INITIAL };
update(refs, state);
let active: { id: number; vertex: VertexId } | null = null;
/** @type {{ id: number, vertex: VertexId } | null} */
let active = null;
const ctrl = new AbortController();
const opts = { signal: ctrl.signal } as AddEventListenerOptions;
const opts = /** @type {AddEventListenerOptions} */ ({ signal: ctrl.signal });
for (const id of VERTEX_IDS) {
const el = refs.vertices[id];
el.addEventListener(
'pointerdown',
(e: PointerEvent) => {
(e) => {
active = { id: e.pointerId, vertex: id };
el.setPointerCapture(e.pointerId);
e.preventDefault();
@@ -192,7 +243,7 @@ export function setupCongruenceSSS(svgSelector: string) {
);
el.addEventListener(
'pointermove',
(e: PointerEvent) => {
(e) => {
if (!active || active.id !== e.pointerId) return;
const raw = clientToSvg(svg, e.clientX, e.clientY);
state[active.vertex] = clamp(raw);
@@ -200,7 +251,8 @@ export function setupCongruenceSSS(svgSelector: string) {
},
opts,
);
const release = (e: PointerEvent) => {
/** @param {PointerEvent} e */
const release = (e) => {
if (!active || active.id !== e.pointerId) return;
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
active = null;
@@ -1,33 +1,48 @@
import { angleAtVertex, circle, pointOnCircle, projectToCircle } from '~/geom-engine/circle';
import type { Vec2 } from '~/geom-engine/vec';
import { vec } from '~/geom-engine/vec';
/** @typedef {import('~/geom-engine/vec').Vec2} Vec2 */
const VIEW_SIZE = 400;
const C = circle(VIEW_SIZE / 2, VIEW_SIZE / 2, 150);
// A and B are fixed; M is draggable on the circle.
const A: Vec2 = pointOnCircle(C, 150);
const B: Vec2 = pointOnCircle(C, 30);
const M_INITIAL: Vec2 = pointOnCircle(C, 270);
/** @type {Vec2} */
const A = pointOnCircle(C, 150);
/** @type {Vec2} */
const B = pointOnCircle(C, 30);
/** @type {Vec2} */
const M_INITIAL = pointOnCircle(C, 270);
interface Refs {
svg: SVGSVGElement;
m: SVGCircleElement;
segAM: SVGLineElement;
segBM: SVGLineElement;
inscribedReadout: HTMLElement;
centralReadout: HTMLElement;
}
/**
* @typedef {object} Refs
* @property {SVGSVGElement} svg
* @property {SVGCircleElement} m
* @property {SVGLineElement} segAM
* @property {SVGLineElement} segBM
* @property {HTMLElement} inscribedReadout
* @property {HTMLElement} centralReadout
*/
function clientToSvg(svg: SVGSVGElement, clientX: number, clientY: number): Vec2 {
// Convert a clientX/clientY coordinate to the SVG's viewBox space.
/**
* Convert a clientX/clientY coordinate to the SVG's viewBox space.
* @param {SVGSVGElement} svg
* @param {number} clientX
* @param {number} clientY
* @returns {Vec2}
*/
function clientToSvg(svg, clientX, clientY) {
const rect = svg.getBoundingClientRect();
const x = ((clientX - rect.left) / rect.width) * VIEW_SIZE;
const y = ((clientY - rect.top) / rect.height) * VIEW_SIZE;
return vec(x, y);
}
function update(refs: Refs, m: Vec2) {
/**
* @param {Refs} refs
* @param {Vec2} m
*/
function update(refs, m) {
refs.m.setAttribute('cx', m.x.toFixed(2));
refs.m.setAttribute('cy', m.y.toFixed(2));
refs.segAM.setAttribute('x2', m.x.toFixed(2));
@@ -42,43 +57,52 @@ function update(refs: Refs, m: Vec2) {
refs.centralReadout.textContent = `${central.toFixed(1)}°`;
}
export function setupInscribedAngle(svgSelector: string) {
const svg = document.querySelector<SVGSVGElement>(svgSelector);
/** @param {string} svgSelector */
export function setupInscribedAngle(svgSelector) {
const svg = /** @type {SVGSVGElement | null} */ (document.querySelector(svgSelector));
if (!svg) return;
const m = svg.querySelector<SVGCircleElement>('[data-vertex="M"]');
const segAM = svg.querySelector<SVGLineElement>('[data-segment="AM"]');
const segBM = svg.querySelector<SVGLineElement>('[data-segment="BM"]');
const inscribedReadout = document.querySelector<HTMLElement>('[data-readout="inscribed"]');
const centralReadout = document.querySelector<HTMLElement>('[data-readout="central"]');
const m = /** @type {SVGCircleElement | null} */ (svg.querySelector('[data-vertex="M"]'));
const segAM = /** @type {SVGLineElement | null} */ (svg.querySelector('[data-segment="AM"]'));
const segBM = /** @type {SVGLineElement | null} */ (svg.querySelector('[data-segment="BM"]'));
const inscribedReadout = /** @type {HTMLElement | null} */ (
document.querySelector('[data-readout="inscribed"]')
);
const centralReadout = /** @type {HTMLElement | null} */ (
document.querySelector('[data-readout="central"]')
);
if (!m || !segAM || !segBM || !inscribedReadout || !centralReadout) return;
const refs: Refs = { svg, m, segAM, segBM, inscribedReadout, centralReadout };
/** @type {Refs} */
const refs = { svg, m, segAM, segBM, inscribedReadout, centralReadout };
let active = false;
// Render initial state once.
update(refs, M_INITIAL);
const onPointerDown = (e: PointerEvent) => {
/** @param {PointerEvent} e */
const onPointerDown = (e) => {
active = true;
m.setPointerCapture(e.pointerId);
e.preventDefault();
};
const onPointerMove = (e: PointerEvent) => {
/** @param {PointerEvent} e */
const onPointerMove = (e) => {
if (!active) return;
const raw = clientToSvg(svg, e.clientX, e.clientY);
const projected = projectToCircle(raw, C);
update(refs, projected);
};
const onPointerUp = (e: PointerEvent) => {
/** @param {PointerEvent} e */
const onPointerUp = (e) => {
if (!active) return;
active = false;
if (m.hasPointerCapture(e.pointerId)) m.releasePointerCapture(e.pointerId);
};
const ctrl = new AbortController();
const opts = { signal: ctrl.signal } as AddEventListenerOptions;
const opts = /** @type {AddEventListenerOptions} */ ({ signal: ctrl.signal });
m.addEventListener('pointerdown', onPointerDown, opts);
m.addEventListener('pointermove', onPointerMove, opts);
+243
View File
@@ -0,0 +1,243 @@
import { add, scale, sub, vec } from '~/geom-engine/vec';
import { angleAtVertex } from '~/geom-engine/circle';
import { sides, triangle as makeTriangle } from '~/geom-engine/triangle';
/** @typedef {import('~/geom-engine/vec').Vec2} Vec2 */
/** @typedef {import('~/geom-engine/triangle').Triangle} Triangle */
const VIEW_W = 400;
const VIEW_H = 300;
const PAIR1 = '#D7263D';
const PAIR2 = '#1B998B';
const PAIR3 = '#F46036';
// Scalene triangle; centroid at (101.67, 146.67) — close to (100, 147).
/** @type {Vec2} */
const A = vec(70, 110);
/** @type {Vec2} */
const B = vec(140, 130);
/** @type {Vec2} */
const C = vec(95, 200);
/** @type {Vec2} */
const CENTROID_ABC = vec(
(A.x + B.x + C.x) / 3,
(A.y + B.y + C.y) / 3,
);
/** @type {Vec2} */
const CENTROID_TARGET = vec(300, 145);
/**
* @param {number} k
* @returns {Triangle}
*/
function scaledTriangle(k) {
/** @param {Vec2} p */
const make = (p) => add(CENTROID_TARGET, scale(sub(p, CENTROID_ABC), k));
return makeTriangle(make(A), make(B), make(C));
}
/**
* @typedef {object} Refs
* @property {SVGCircleElement} ap
* @property {SVGCircleElement} bp
* @property {SVGCircleElement} cp
* @property {SVGTextElement} apLabel
* @property {SVGTextElement} bpLabel
* @property {SVGTextElement} cpLabel
* @property {SVGLineElement} apbp
* @property {SVGLineElement} bpcp
* @property {SVGLineElement} cpap
* @property {SVGGElement} tickApBp Tick group for triangle 2
* @property {SVGGElement} tickBpCp Tick group for triangle 2
* @property {SVGGElement} tickCpAp Tick group for triangle 2
* @property {HTMLElement} kReadout k display
* @property {HTMLInputElement} kSlider k display
* @property {HTMLElement} apbpReadout Side-length & ratio readouts
* @property {HTMLElement} bpcpReadout Side-length & ratio readouts
* @property {HTMLElement} cpapReadout Side-length & ratio readouts
* @property {HTMLElement} ratioAB Side-length & ratio readouts
* @property {HTMLElement} ratioBC Side-length & ratio readouts
* @property {HTMLElement} ratioCA Side-length & ratio readouts
*/
const TICK_LEN = 6;
const TICK_SPACING = 5;
/**
* @param {SVGLineElement} line
* @param {Vec2} p1
* @param {Vec2} p2
*/
function setLine(line, p1, p2) {
line.setAttribute('x1', p1.x.toFixed(2));
line.setAttribute('y1', p1.y.toFixed(2));
line.setAttribute('x2', p2.x.toFixed(2));
line.setAttribute('y2', p2.y.toFixed(2));
}
/**
* @param {SVGGElement} group
* @param {Vec2} p1
* @param {Vec2} p2
* @param {1 | 2 | 3} count
* @param {string} color
*/
function renderTicks(group, p1, p2, count, color) {
while (group.firstChild) group.removeChild(group.firstChild);
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.hypot(dx, dy);
if (len < 1) return;
const dir = vec(dx / len, dy / len);
const perp = vec(-dir.y, dir.x);
const mid = vec((p1.x + p2.x) / 2, (p1.y + p2.y) / 2);
const start = -((count - 1) * TICK_SPACING) / 2;
for (let i = 0; i < count; i++) {
const offset = start + i * TICK_SPACING;
const cx = mid.x + dir.x * offset;
const cy = mid.y + dir.y * offset;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', (cx + perp.x * TICK_LEN).toFixed(2));
line.setAttribute('y1', (cy + perp.y * TICK_LEN).toFixed(2));
line.setAttribute('x2', (cx - perp.x * TICK_LEN).toFixed(2));
line.setAttribute('y2', (cy - perp.y * TICK_LEN).toFixed(2));
line.setAttribute('stroke', color);
line.setAttribute('stroke-width', '2.5');
line.setAttribute('stroke-linecap', 'round');
group.appendChild(line);
}
}
/**
* @param {Refs} refs
* @param {number} k
*/
function update(refs, k) {
const t2 = scaledTriangle(k);
refs.ap.setAttribute('cx', t2.a.x.toFixed(2));
refs.ap.setAttribute('cy', t2.a.y.toFixed(2));
refs.bp.setAttribute('cx', t2.b.x.toFixed(2));
refs.bp.setAttribute('cy', t2.b.y.toFixed(2));
refs.cp.setAttribute('cx', t2.c.x.toFixed(2));
refs.cp.setAttribute('cy', t2.c.y.toFixed(2));
refs.apLabel.setAttribute('x', (t2.a.x - 16).toFixed(2));
refs.apLabel.setAttribute('y', (t2.a.y - 10).toFixed(2));
refs.bpLabel.setAttribute('x', (t2.b.x + 8).toFixed(2));
refs.bpLabel.setAttribute('y', (t2.b.y - 10).toFixed(2));
refs.cpLabel.setAttribute('x', (t2.c.x - 6).toFixed(2));
refs.cpLabel.setAttribute('y', (t2.c.y + 22).toFixed(2));
setLine(refs.apbp, t2.a, t2.b);
setLine(refs.bpcp, t2.b, t2.c);
setLine(refs.cpap, t2.c, t2.a);
renderTicks(refs.tickApBp, t2.a, t2.b, 1, PAIR1);
renderTicks(refs.tickBpCp, t2.b, t2.c, 2, PAIR2);
renderTicks(refs.tickCpAp, t2.c, t2.a, 3, PAIR3);
const s2 = sides(t2);
refs.apbpReadout.textContent = s2.ab.toFixed(1);
refs.bpcpReadout.textContent = s2.bc.toFixed(1);
refs.cpapReadout.textContent = s2.ca.toFixed(1);
// Ratios AB/A'B' = 1/k. Display ALL three to show they stay equal.
const ratio = 1 / k;
const ratioStr = ratio.toFixed(2);
refs.ratioAB.textContent = ratioStr;
refs.ratioBC.textContent = ratioStr;
refs.ratioCA.textContent = ratioStr;
refs.kReadout.textContent = k.toFixed(2);
}
/**
* @param {SVGSVGElement} svg
* @returns {Refs | null}
*/
function getRefs(svg) {
/**
* @template {Element} T
* @param {string} sel
* @param {ParentNode} [root]
* @returns {T | null}
*/
const q = (sel, root = document) => /** @type {T | null} */ (root.querySelector(sel));
const ap = /** @type {SVGCircleElement | null} */ (q('[data-vertex="ap"]', svg));
const bp = /** @type {SVGCircleElement | null} */ (q('[data-vertex="bp"]', svg));
const cp = /** @type {SVGCircleElement | null} */ (q('[data-vertex="cp"]', svg));
const apLabel = /** @type {SVGTextElement | null} */ (q('[data-vertex-label="ap"]', svg));
const bpLabel = /** @type {SVGTextElement | null} */ (q('[data-vertex-label="bp"]', svg));
const cpLabel = /** @type {SVGTextElement | null} */ (q('[data-vertex-label="cp"]', svg));
const apbp = /** @type {SVGLineElement | null} */ (q('[data-side="apbp"]', svg));
const bpcp = /** @type {SVGLineElement | null} */ (q('[data-side="bpcp"]', svg));
const cpap = /** @type {SVGLineElement | null} */ (q('[data-side="cpap"]', svg));
const tickApBp = /** @type {SVGGElement | null} */ (q('[data-ticks="apbp"]', svg));
const tickBpCp = /** @type {SVGGElement | null} */ (q('[data-ticks="bpcp"]', svg));
const tickCpAp = /** @type {SVGGElement | null} */ (q('[data-ticks="cpap"]', svg));
const kReadout = /** @type {HTMLElement | null} */ (q('[data-readout="k"]'));
const kSlider = /** @type {HTMLInputElement | null} */ (q('[data-control="k-slider"]'));
const apbpReadout = /** @type {HTMLElement | null} */ (q('[data-readout-side="apbp"]'));
const bpcpReadout = /** @type {HTMLElement | null} */ (q('[data-readout-side="bpcp"]'));
const cpapReadout = /** @type {HTMLElement | null} */ (q('[data-readout-side="cpap"]'));
const ratioAB = /** @type {HTMLElement | null} */ (q('[data-readout-ratio="ab"]'));
const ratioBC = /** @type {HTMLElement | null} */ (q('[data-readout-ratio="bc"]'));
const ratioCA = /** @type {HTMLElement | null} */ (q('[data-readout-ratio="ca"]'));
if (
!ap || !bp || !cp || !apLabel || !bpLabel || !cpLabel ||
!apbp || !bpcp || !cpap ||
!tickApBp || !tickBpCp || !tickCpAp ||
!kReadout || !kSlider ||
!apbpReadout || !bpcpReadout || !cpapReadout ||
!ratioAB || !ratioBC || !ratioCA
) return null;
return {
ap, bp, cp, apLabel, bpLabel, cpLabel,
apbp, bpcp, cpap,
tickApBp, tickBpCp, tickCpAp,
kReadout, kSlider,
apbpReadout, bpcpReadout, cpapReadout,
ratioAB, ratioBC, ratioCA,
};
}
/** @param {string} svgSelector */
export function setupSimilarityScale(svgSelector) {
const svg = /** @type {SVGSVGElement | null} */ (document.querySelector(svgSelector));
if (!svg) return;
const refs = getRefs(svg);
if (!refs) return;
const ctrl = new AbortController();
const opts = /** @type {AddEventListenerOptions} */ ({ signal: ctrl.signal });
// Static ticks for triangle 1 (ABC) — render once, never change.
const tickAB = /** @type {SVGGElement | null} */ (svg.querySelector('[data-ticks="ab"]'));
const tickBC = /** @type {SVGGElement | null} */ (svg.querySelector('[data-ticks="bc"]'));
const tickCA = /** @type {SVGGElement | null} */ (svg.querySelector('[data-ticks="ca"]'));
if (tickAB && tickBC && tickCA) {
renderTicks(tickAB, A, B, 1, PAIR1);
renderTicks(tickBC, B, C, 2, PAIR2);
renderTicks(tickCA, C, A, 3, PAIR3);
}
const initialK = parseFloat(refs.kSlider.value) || 1;
update(refs, initialK);
refs.kSlider.addEventListener(
'input',
() => {
const k = parseFloat(refs.kSlider.value);
if (Number.isFinite(k)) update(refs, k);
},
opts,
);
document.addEventListener('astro:before-swap', () => ctrl.abort(), { once: true });
}
-208
View File
@@ -1,208 +0,0 @@
import { add, scale, sub, vec } from '~/geom-engine/vec';
import type { Vec2 } from '~/geom-engine/vec';
import { angleAtVertex } from '~/geom-engine/circle';
import { sides, triangle as makeTriangle } from '~/geom-engine/triangle';
import type { Triangle } from '~/geom-engine/triangle';
const VIEW_W = 400;
const VIEW_H = 300;
const PAIR1 = '#D7263D';
const PAIR2 = '#1B998B';
const PAIR3 = '#F46036';
// Scalene triangle; centroid at (101.67, 146.67) — close to (100, 147).
const A: Vec2 = vec(70, 110);
const B: Vec2 = vec(140, 130);
const C: Vec2 = vec(95, 200);
const CENTROID_ABC: Vec2 = vec(
(A.x + B.x + C.x) / 3,
(A.y + B.y + C.y) / 3,
);
const CENTROID_TARGET: Vec2 = vec(300, 145);
function scaledTriangle(k: number): Triangle {
const make = (p: Vec2): Vec2 => add(CENTROID_TARGET, scale(sub(p, CENTROID_ABC), k));
return makeTriangle(make(A), make(B), make(C));
}
interface Refs {
ap: SVGCircleElement;
bp: SVGCircleElement;
cp: SVGCircleElement;
apLabel: SVGTextElement;
bpLabel: SVGTextElement;
cpLabel: SVGTextElement;
apbp: SVGLineElement;
bpcp: SVGLineElement;
cpap: SVGLineElement;
// Tick groups for triangle 2
tickApBp: SVGGElement;
tickBpCp: SVGGElement;
tickCpAp: SVGGElement;
// k display
kReadout: HTMLElement;
kSlider: HTMLInputElement;
// Side-length & ratio readouts
apbpReadout: HTMLElement;
bpcpReadout: HTMLElement;
cpapReadout: HTMLElement;
ratioAB: HTMLElement;
ratioBC: HTMLElement;
ratioCA: HTMLElement;
}
const TICK_LEN = 6;
const TICK_SPACING = 5;
function setLine(line: SVGLineElement, p1: Vec2, p2: Vec2) {
line.setAttribute('x1', p1.x.toFixed(2));
line.setAttribute('y1', p1.y.toFixed(2));
line.setAttribute('x2', p2.x.toFixed(2));
line.setAttribute('y2', p2.y.toFixed(2));
}
function renderTicks(group: SVGGElement, p1: Vec2, p2: Vec2, count: 1 | 2 | 3, color: string) {
while (group.firstChild) group.removeChild(group.firstChild);
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.hypot(dx, dy);
if (len < 1) return;
const dir = vec(dx / len, dy / len);
const perp = vec(-dir.y, dir.x);
const mid = vec((p1.x + p2.x) / 2, (p1.y + p2.y) / 2);
const start = -((count - 1) * TICK_SPACING) / 2;
for (let i = 0; i < count; i++) {
const offset = start + i * TICK_SPACING;
const cx = mid.x + dir.x * offset;
const cy = mid.y + dir.y * offset;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', (cx + perp.x * TICK_LEN).toFixed(2));
line.setAttribute('y1', (cy + perp.y * TICK_LEN).toFixed(2));
line.setAttribute('x2', (cx - perp.x * TICK_LEN).toFixed(2));
line.setAttribute('y2', (cy - perp.y * TICK_LEN).toFixed(2));
line.setAttribute('stroke', color);
line.setAttribute('stroke-width', '2.5');
line.setAttribute('stroke-linecap', 'round');
group.appendChild(line);
}
}
function update(refs: Refs, k: number) {
const t2 = scaledTriangle(k);
refs.ap.setAttribute('cx', t2.a.x.toFixed(2));
refs.ap.setAttribute('cy', t2.a.y.toFixed(2));
refs.bp.setAttribute('cx', t2.b.x.toFixed(2));
refs.bp.setAttribute('cy', t2.b.y.toFixed(2));
refs.cp.setAttribute('cx', t2.c.x.toFixed(2));
refs.cp.setAttribute('cy', t2.c.y.toFixed(2));
refs.apLabel.setAttribute('x', (t2.a.x - 16).toFixed(2));
refs.apLabel.setAttribute('y', (t2.a.y - 10).toFixed(2));
refs.bpLabel.setAttribute('x', (t2.b.x + 8).toFixed(2));
refs.bpLabel.setAttribute('y', (t2.b.y - 10).toFixed(2));
refs.cpLabel.setAttribute('x', (t2.c.x - 6).toFixed(2));
refs.cpLabel.setAttribute('y', (t2.c.y + 22).toFixed(2));
setLine(refs.apbp, t2.a, t2.b);
setLine(refs.bpcp, t2.b, t2.c);
setLine(refs.cpap, t2.c, t2.a);
renderTicks(refs.tickApBp, t2.a, t2.b, 1, PAIR1);
renderTicks(refs.tickBpCp, t2.b, t2.c, 2, PAIR2);
renderTicks(refs.tickCpAp, t2.c, t2.a, 3, PAIR3);
const s2 = sides(t2);
refs.apbpReadout.textContent = s2.ab.toFixed(1);
refs.bpcpReadout.textContent = s2.bc.toFixed(1);
refs.cpapReadout.textContent = s2.ca.toFixed(1);
// Ratios AB/A'B' = 1/k. Display ALL three to show they stay equal.
const ratio = 1 / k;
const ratioStr = ratio.toFixed(2);
refs.ratioAB.textContent = ratioStr;
refs.ratioBC.textContent = ratioStr;
refs.ratioCA.textContent = ratioStr;
refs.kReadout.textContent = k.toFixed(2);
}
function getRefs(svg: SVGSVGElement): Refs | null {
const q = <T extends Element>(sel: string, root: ParentNode = document): T | null =>
root.querySelector<T>(sel);
const ap = q<SVGCircleElement>('[data-vertex="ap"]', svg);
const bp = q<SVGCircleElement>('[data-vertex="bp"]', svg);
const cp = q<SVGCircleElement>('[data-vertex="cp"]', svg);
const apLabel = q<SVGTextElement>('[data-vertex-label="ap"]', svg);
const bpLabel = q<SVGTextElement>('[data-vertex-label="bp"]', svg);
const cpLabel = q<SVGTextElement>('[data-vertex-label="cp"]', svg);
const apbp = q<SVGLineElement>('[data-side="apbp"]', svg);
const bpcp = q<SVGLineElement>('[data-side="bpcp"]', svg);
const cpap = q<SVGLineElement>('[data-side="cpap"]', svg);
const tickApBp = q<SVGGElement>('[data-ticks="apbp"]', svg);
const tickBpCp = q<SVGGElement>('[data-ticks="bpcp"]', svg);
const tickCpAp = q<SVGGElement>('[data-ticks="cpap"]', svg);
const kReadout = q<HTMLElement>('[data-readout="k"]');
const kSlider = q<HTMLInputElement>('[data-control="k-slider"]');
const apbpReadout = q<HTMLElement>('[data-readout-side="apbp"]');
const bpcpReadout = q<HTMLElement>('[data-readout-side="bpcp"]');
const cpapReadout = q<HTMLElement>('[data-readout-side="cpap"]');
const ratioAB = q<HTMLElement>('[data-readout-ratio="ab"]');
const ratioBC = q<HTMLElement>('[data-readout-ratio="bc"]');
const ratioCA = q<HTMLElement>('[data-readout-ratio="ca"]');
if (
!ap || !bp || !cp || !apLabel || !bpLabel || !cpLabel ||
!apbp || !bpcp || !cpap ||
!tickApBp || !tickBpCp || !tickCpAp ||
!kReadout || !kSlider ||
!apbpReadout || !bpcpReadout || !cpapReadout ||
!ratioAB || !ratioBC || !ratioCA
) return null;
return {
ap, bp, cp, apLabel, bpLabel, cpLabel,
apbp, bpcp, cpap,
tickApBp, tickBpCp, tickCpAp,
kReadout, kSlider,
apbpReadout, bpcpReadout, cpapReadout,
ratioAB, ratioBC, ratioCA,
};
}
export function setupSimilarityScale(svgSelector: string) {
const svg = document.querySelector<SVGSVGElement>(svgSelector);
if (!svg) return;
const refs = getRefs(svg);
if (!refs) return;
const ctrl = new AbortController();
const opts = { signal: ctrl.signal } as AddEventListenerOptions;
// Static ticks for triangle 1 (ABC) — render once, never change.
const tickAB = svg.querySelector<SVGGElement>('[data-ticks="ab"]');
const tickBC = svg.querySelector<SVGGElement>('[data-ticks="bc"]');
const tickCA = svg.querySelector<SVGGElement>('[data-ticks="ca"]');
if (tickAB && tickBC && tickCA) {
renderTicks(tickAB, A, B, 1, PAIR1);
renderTicks(tickBC, B, C, 2, PAIR2);
renderTicks(tickCA, C, A, 3, PAIR3);
}
const initialK = parseFloat(refs.kSlider.value) || 1;
update(refs, initialK);
refs.kSlider.addEventListener(
'input',
() => {
const k = parseFloat(refs.kSlider.value);
if (Number.isFinite(k)) update(refs, k);
},
opts,
);
document.addEventListener('astro:before-swap', () => ctrl.abort(), { once: true });
}
@@ -1,16 +1,27 @@
import type { Vec2 } from './vec';
import { add, dot, len, normalize, scale, sub, vec } from './vec';
export interface Circle {
readonly center: Vec2;
readonly radius: number;
}
/** @typedef {import('./vec').Vec2} Vec2 */
export function circle(cx: number, cy: number, r: number): Circle {
/**
* @typedef {{ readonly center: Vec2, readonly radius: number }} Circle
*/
/**
* @param {number} cx
* @param {number} cy
* @param {number} r
* @returns {Circle}
*/
export function circle(cx, cy, r) {
return { center: vec(cx, cy), radius: r };
}
export function projectToCircle(point: Vec2, c: Circle): Vec2 {
/**
* @param {Vec2} point
* @param {Circle} c
* @returns {Vec2}
*/
export function projectToCircle(point, c) {
const dir = sub(point, c.center);
const d = len(dir);
if (d === 0) {
@@ -20,14 +31,25 @@ export function projectToCircle(point: Vec2, c: Circle): Vec2 {
return add(c.center, scale(normalize(dir), c.radius));
}
export function pointOnCircle(c: Circle, angleDeg: number): Vec2 {
/**
* @param {Circle} c
* @param {number} angleDeg
* @returns {Vec2}
*/
export function pointOnCircle(c, angleDeg) {
const rad = (angleDeg * Math.PI) / 180;
return vec(c.center.x + c.radius * Math.cos(rad), c.center.y + c.radius * Math.sin(rad));
}
export function angleAtVertex(a: Vec2, vertex: Vec2, b: Vec2): number {
// Returns the unsigned angle at `vertex` of triangle (a, vertex, b), in degrees.
// Range: [0, 180]. Returns 0 if `vertex` coincides with `a` or `b`.
/**
* Returns the unsigned angle at `vertex` of triangle (a, vertex, b), in degrees.
* Range: [0, 180]. Returns 0 if `vertex` coincides with `a` or `b`.
* @param {Vec2} a
* @param {Vec2} vertex
* @param {Vec2} b
* @returns {number}
*/
export function angleAtVertex(a, vertex, b) {
const va = sub(a, vertex);
const vb = sub(b, vertex);
const lenA = len(va);
@@ -95,7 +95,7 @@ describe('inscribed-angle invariance (the killer-demo property)', () => {
);
it('all sampled M on the major arc give the same inscribed angle (within 0.5°)', () => {
const reference = inscribedAngles[0]!;
const reference = /** @type {number} */ (inscribedAngles[0]);
for (const angle of inscribedAngles) {
expect(Math.abs(angle - reference)).toBeLessThan(0.5);
}
+53
View File
@@ -0,0 +1,53 @@
import { dist, EPSILON_LEN } from './vec';
/** @typedef {import('./vec').Vec2} Vec2 */
/**
* @typedef {{ readonly a: Vec2, readonly b: Vec2, readonly c: Vec2 }} Triangle
*/
/**
* @param {Vec2} a
* @param {Vec2} b
* @param {Vec2} c
* @returns {Triangle}
*/
export function triangle(a, b, c) {
return { a, b, c };
}
/**
* @typedef {{ readonly ab: number, readonly bc: number, readonly ca: number }} SideLengths
*/
/**
* @param {Triangle} t
* @returns {SideLengths}
*/
export function sides(t) {
return {
ab: dist(t.a, t.b),
bc: dist(t.b, t.c),
ca: dist(t.c, t.a),
};
}
/**
* Position-strict SSS: corresponding sides must match (AB↔A'B', BC↔B'C', CA↔C'A').
* SGK pedagogy treats vertex labels as defining the correspondence — a permuted
* match would still be the same shape but a different theorem case. We want the
* strict labeled version so the UI's color/tick pairing has unambiguous meaning.
* @param {Triangle} t1
* @param {Triangle} t2
* @param {number} [eps]
* @returns {boolean}
*/
export function congruentSSS(t1, t2, eps = EPSILON_LEN) {
const s1 = sides(t1);
const s2 = sides(t2);
return (
Math.abs(s1.ab - s2.ab) < eps &&
Math.abs(s1.bc - s2.bc) < eps &&
Math.abs(s1.ca - s2.ca) < eps
);
}
-40
View File
@@ -1,40 +0,0 @@
import type { Vec2 } from './vec';
import { dist, EPSILON_LEN } from './vec';
export interface Triangle {
readonly a: Vec2;
readonly b: Vec2;
readonly c: Vec2;
}
export function triangle(a: Vec2, b: Vec2, c: Vec2): Triangle {
return { a, b, c };
}
export interface SideLengths {
readonly ab: number;
readonly bc: number;
readonly ca: number;
}
export function sides(t: Triangle): SideLengths {
return {
ab: dist(t.a, t.b),
bc: dist(t.b, t.c),
ca: dist(t.c, t.a),
};
}
// Position-strict SSS: corresponding sides must match (AB↔A'B', BC↔B'C', CA↔C'A').
// SGK pedagogy treats vertex labels as defining the correspondence — a permuted
// match would still be the same shape but a different theorem case. We want the
// strict labeled version so the UI's color/tick pairing has unambiguous meaning.
export function congruentSSS(t1: Triangle, t2: Triangle, eps = EPSILON_LEN): boolean {
const s1 = sides(t1);
const s2 = sides(t2);
return (
Math.abs(s1.ab - s2.ab) < eps &&
Math.abs(s1.bc - s2.bc) < eps &&
Math.abs(s1.ca - s2.ca) < eps
);
}
+90
View File
@@ -0,0 +1,90 @@
/**
* @typedef {{ readonly x: number, readonly y: number }} Vec2
*/
export const EPSILON_LEN = 0.5;
export const EPSILON_ANGLE_DEG = 0.5;
/**
* @param {number} x
* @param {number} y
* @returns {Vec2}
*/
export function vec(x, y) {
return { x, y };
}
/**
* @param {Vec2} a
* @param {Vec2} b
* @returns {Vec2}
*/
export function add(a, b) {
return { x: a.x + b.x, y: a.y + b.y };
}
/**
* @param {Vec2} a
* @param {Vec2} b
* @returns {Vec2}
*/
export function sub(a, b) {
return { x: a.x - b.x, y: a.y - b.y };
}
/**
* @param {Vec2} a
* @param {number} k
* @returns {Vec2}
*/
export function scale(a, k) {
// `+ 0` normalizes IEEE-754 -0 back to +0 so consumers comparing coordinates
// with === or Object.is don't see a signed-zero ghost from k=0 paths.
return { x: a.x * k + 0, y: a.y * k + 0 };
}
/**
* @param {Vec2} a
* @param {Vec2} b
* @returns {number}
*/
export function dot(a, b) {
return a.x * b.x + a.y * b.y;
}
/**
* @param {Vec2} a
* @returns {number}
*/
export function len(a) {
return Math.hypot(a.x, a.y);
}
/**
* @param {Vec2} a
* @param {Vec2} b
* @returns {number}
*/
export function dist(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
/**
* @param {Vec2} a
* @returns {Vec2}
*/
export function normalize(a) {
const l = len(a);
if (l === 0) return { x: 0, y: 0 };
return { x: a.x / l, y: a.y / l };
}
/**
* @param {number} a
* @param {number} b
* @param {number} [eps]
* @returns {boolean}
*/
export function approxEqualLen(a, b, eps = EPSILON_LEN) {
return Math.abs(a - b) < eps;
}
-47
View File
@@ -1,47 +0,0 @@
export interface Vec2 {
readonly x: number;
readonly y: number;
}
export const EPSILON_LEN = 0.5;
export const EPSILON_ANGLE_DEG = 0.5;
export function vec(x: number, y: number): Vec2 {
return { x, y };
}
export function add(a: Vec2, b: Vec2): Vec2 {
return { x: a.x + b.x, y: a.y + b.y };
}
export function sub(a: Vec2, b: Vec2): Vec2 {
return { x: a.x - b.x, y: a.y - b.y };
}
export function scale(a: Vec2, k: number): Vec2 {
// `+ 0` normalizes IEEE-754 -0 back to +0 so consumers comparing coordinates
// with === or Object.is don't see a signed-zero ghost from k=0 paths.
return { x: a.x * k + 0, y: a.y * k + 0 };
}
export function dot(a: Vec2, b: Vec2): number {
return a.x * b.x + a.y * b.y;
}
export function len(a: Vec2): number {
return Math.hypot(a.x, a.y);
}
export function dist(a: Vec2, b: Vec2): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
export function normalize(a: Vec2): Vec2 {
const l = len(a);
if (l === 0) return { x: 0, y: 0 };
return { x: a.x / l, y: a.y / l };
}
export function approxEqualLen(a: number, b: number, eps = EPSILON_LEN): boolean {
return Math.abs(a - b) < eps;
}
+13
View File
@@ -0,0 +1,13 @@
import { vi } from './vi';
const locales = /** @type {const} */ ({ vi });
/** @typedef {keyof typeof locales} LocaleKey */
/** @type {LocaleKey} */
const defaultLocale = 'vi';
/** @returns {typeof vi} */
export function t() {
return locales[defaultLocale];
}
-11
View File
@@ -1,11 +0,0 @@
import { vi } from './vi';
const locales = { vi } as const;
export type LocaleKey = keyof typeof locales;
const defaultLocale: LocaleKey = 'vi';
export function t(): typeof vi {
return locales[defaultLocale];
}
@@ -1,4 +1,4 @@
export const vi = {
export const vi = /** @type {const} */ ({
site: {
title: 'Hình Học Sống',
tagline: 'Học hình bằng cách kéo',
@@ -97,6 +97,6 @@ export const vi = {
backToHub: '← Về trang chủ',
nextTeaser: 'Sắp ra mắt: kéo từng đỉnh tự do (AA / SAS / SSS đồng dạng)',
},
} as const;
});
export type Locale = typeof vi;
/** @typedef {typeof vi} Locale */
+3 -3
View File
@@ -5,9 +5,9 @@ import { t } from '~/i18n';
const copy = t();
const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, '');
const grades = [
{ key: 'lop-7' as const, ...copy.grade['lop-7'] },
{ key: 'lop-8' as const, ...copy.grade['lop-8'] },
{ key: 'lop-9' as const, ...copy.grade['lop-9'] },
{ key: /** @type {const} */ ('lop-7'), ...copy.grade['lop-7'] },
{ key: /** @type {const} */ ('lop-8'), ...copy.grade['lop-8'] },
{ key: /** @type {const} */ ('lop-9'), ...copy.grade['lop-9'] },
];
---
@@ -2,11 +2,11 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
include: ['src/**/*.test.js'],
coverage: {
provider: 'v8',
include: ['src/geom-engine/**/*.ts'],
exclude: ['src/geom-engine/**/*.test.ts'],
include: ['src/geom-engine/**/*.js'],
exclude: ['src/geom-engine/**/*.test.js'],
thresholds: {
lines: 95,
functions: 95,
+1 -1
View File
@@ -7,6 +7,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"compilerOptions": {
"checkJs": true,
"allowJs": true,
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* jsconfig.json defaults maxNodeModuleJsDepth to 2 (vs. 0 for
tsconfig.json); left at 2 it makes checkJs walk into untyped
node_modules JS packages (e.g. jsdom, pulled in transitively via
vitest's own type declarations) and fail on their internals. Pin it
back to the standard default. */
"maxNodeModuleJsDepth": 0,
"types": ["vite/client", "vitest/globals"]
},
"include": ["src", "tests", "vite.config.js"]
}
+3 -2
View File
@@ -5,9 +5,10 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "tsc -p jsconfig.json --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run"
"test": "vitest run",
"typecheck": "tsc -p jsconfig.json --noEmit"
},
"dependencies": {
"phaser": "^3.88.2",
@@ -1,5 +1,4 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Difficulty } from "../types";
import { GameStateManager } from "../game/state";
import { Menu } from "./Menu";
import { DifficultySelect } from "./DifficultySelect";
@@ -8,15 +7,20 @@ import { HUD } from "./HUD";
import { Toast } from "./Toast";
import { GameOver } from "./GameOver";
type Screen = "menu" | "difficulty" | "game" | "gameover";
/** @typedef {import("../types").Difficulty} Difficulty */
/** @typedef {"menu" | "difficulty" | "game" | "gameover"} Screen */
/** @returns {import("react").JSX.Element} */
function App() {
const [screen, setScreen] = useState<Screen>("menu");
const [difficulty, setDifficulty] = useState<Difficulty>("easy");
const [screen, setScreen] = useState(/** @type {Screen} */ ("menu"));
const [difficulty, setDifficulty] = useState(
/** @type {Difficulty} */ ("easy")
);
const [toastVisible, setToastVisible] = useState(false);
const stateManager = useRef(new GameStateManager()).current;
const handleSelectDifficulty = (d: Difficulty) => {
/** @param {Difficulty} d */
const handleSelectDifficulty = (d) => {
setDifficulty(d);
stateManager.startGame(d);
setScreen("game");
@@ -1,17 +1,23 @@
import { Difficulty } from "../types";
/** @typedef {import("../types").Difficulty} Difficulty */
interface DifficultySelectProps {
onSelect: (difficulty: Difficulty) => void;
onBack: () => void;
}
/**
* @typedef {Object} DifficultySelectProps
* @property {(difficulty: Difficulty) => void} onSelect
* @property {() => void} onBack
*/
const difficulties: { key: Difficulty; label: string; desc: string }[] = [
/** @type {{ key: Difficulty; label: string; desc: string }[]} */
const difficulties = [
{ key: "easy", label: "Easy", desc: "6×4 grid • 5 min • 5 hints" },
{ key: "medium", label: "Medium", desc: "8×6 grid • 4 min • 3 hints" },
{ key: "hard", label: "Hard", desc: "10×8 grid • 3 min • 1 hint" },
];
export function DifficultySelect({ onSelect, onBack }: DifficultySelectProps) {
/**
* @param {DifficultySelectProps} props
* @returns {import("react").JSX.Element}
*/
export function DifficultySelect({ onSelect, onBack }) {
return (
<div style={{
display: "flex",
@@ -1,18 +1,24 @@
import { useEffect, useRef } from "react";
import Phaser from "phaser";
import { createPhaserConfig } from "../phaser/config";
import { Difficulty } from "../types";
import { GameStateManager } from "../game/state";
interface GameContainerProps {
difficulty: Difficulty;
stateManager: GameStateManager;
onGameOver: () => void;
}
/** @typedef {import("../types").Difficulty} Difficulty */
export function GameContainer({ difficulty, stateManager, onGameOver }: GameContainerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const gameRef = useRef<Phaser.Game | null>(null);
/**
* @typedef {Object} GameContainerProps
* @property {Difficulty} difficulty
* @property {GameStateManager} stateManager
* @property {() => void} onGameOver
*/
/**
* @param {GameContainerProps} props
* @returns {import("react").JSX.Element}
*/
export function GameContainer({ difficulty, stateManager, onGameOver }) {
const containerRef = useRef(/** @type {HTMLDivElement | null} */ (null));
const gameRef = useRef(/** @type {Phaser.Game | null} */ (null));
useEffect(() => {
if (!containerRef.current) return;
@@ -1,15 +1,21 @@
import { Difficulty } from "../types";
import { GameStateManager } from "../game/state";
import { DIFFICULTY_CONFIGS } from "../game/constants";
interface GameOverProps {
stateManager: GameStateManager;
difficulty: Difficulty;
onPlayAgain: () => void;
onMenu: () => void;
}
/** @typedef {import("../types").Difficulty} Difficulty */
export function GameOver({ stateManager, difficulty, onPlayAgain, onMenu }: GameOverProps) {
/**
* @typedef {Object} GameOverProps
* @property {GameStateManager} stateManager
* @property {Difficulty} difficulty
* @property {() => void} onPlayAgain
* @property {() => void} onMenu
*/
/**
* @param {GameOverProps} props
* @returns {import("react").JSX.Element}
*/
export function GameOver({ stateManager, difficulty, onPlayAgain, onMenu }) {
const state = stateManager.getState();
const config = DIFFICULTY_CONFIGS[difficulty];
const timeUsed = config.timerSeconds - state.timerSeconds;
@@ -1,12 +1,17 @@
import { useEffect, useState } from "react";
import { GameStateManager } from "../game/state";
interface HUDProps {
stateManager: GameStateManager;
onPause: () => void;
}
/**
* @typedef {Object} HUDProps
* @property {GameStateManager} stateManager
* @property {() => void} onPause
*/
export function HUD({ stateManager, onPause }: HUDProps) {
/**
* @param {HUDProps} props
* @returns {import("react").JSX.Element}
*/
export function HUD({ stateManager, onPause }) {
const [state, setState] = useState(stateManager.getState());
useEffect(() => {
@@ -1,8 +1,13 @@
interface MenuProps {
onPlay: () => void;
}
/**
* @typedef {Object} MenuProps
* @property {() => void} onPlay
*/
export function Menu({ onPlay }: MenuProps) {
/**
* @param {MenuProps} props
* @returns {import("react").JSX.Element}
*/
export function Menu({ onPlay }) {
return (
<div style={{
display: "flex",
@@ -1,12 +1,17 @@
import { useEffect, useState } from "react";
interface ToastProps {
message: string;
visible: boolean;
onHide: () => void;
}
/**
* @typedef {Object} ToastProps
* @property {string} message
* @property {boolean} visible
* @property {() => void} onHide
*/
export function Toast({ message, visible, onHide }: ToastProps) {
/**
* @param {ToastProps} props
* @returns {import("react").JSX.Element | null}
*/
export function Toast({ message, visible, onHide }) {
const [show, setShow] = useState(false);
useEffect(() => {
@@ -1,17 +1,28 @@
import { Board, Difficulty, TileData, Point } from "../types";
import { DIFFICULTY_CONFIGS } from "./constants";
import { getEmojisForDifficulty } from "./emoji";
import { hasAnyValidMove } from "./pathfinder";
/**
* @typedef {import("../types").Board} Board
* @typedef {import("../types").Difficulty} Difficulty
* @typedef {import("../types").TileData} TileData
* @typedef {import("../types").Point} Point
*/
let nextTileId = 0;
export function createBoard(difficulty: Difficulty): Board {
/**
* @param {Difficulty} difficulty
* @returns {Board}
*/
export function createBoard(difficulty) {
const config = DIFFICULTY_CONFIGS[difficulty];
const { rows, cols } = config;
const emojis = getEmojisForDifficulty(difficulty);
// Create pairs
const tiles: TileData[] = [];
/** @type {TileData[]} */
const tiles = [];
nextTileId = 0;
for (const emoji of emojis) {
tiles.push({ emoji, id: nextTileId++ });
@@ -25,10 +36,12 @@ export function createBoard(difficulty: Difficulty): Board {
}
// Place into grid
const board: Board = [];
/** @type {Board} */
const board = [];
let idx = 0;
for (let r = 0; r < rows; r++) {
const row: (TileData | null)[] = [];
/** @type {(TileData | null)[]} */
const row = [];
for (let c = 0; c < cols; c++) {
row.push(tiles[idx++]);
}
@@ -37,7 +50,7 @@ export function createBoard(difficulty: Difficulty): Board {
// Validate at least one valid move exists; reshuffle if not
while (!hasAnyValidMove(board)) {
const allTiles = board.flat().filter((t): t is TileData => t !== null);
const allTiles = board.flat().filter((t) => t !== null);
for (let i = allTiles.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[allTiles[i], allTiles[j]] = [allTiles[j], allTiles[i]];
@@ -53,8 +66,13 @@ export function createBoard(difficulty: Difficulty): Board {
return board;
}
export function getRemainingTiles(board: Board): { tile: TileData; pos: Point }[] {
const result: { tile: TileData; pos: Point }[] = [];
/**
* @param {Board} board
* @returns {{ tile: TileData; pos: Point }[]}
*/
export function getRemainingTiles(board) {
/** @type {{ tile: TileData; pos: Point }[]} */
const result = [];
for (let r = 0; r < board.length; r++) {
for (let c = 0; c < board[r].length; c++) {
const cell = board[r][c];
@@ -66,18 +84,25 @@ export function getRemainingTiles(board: Board): { tile: TileData; pos: Point }[
return result;
}
export function shuffleBoard(board: Board): Board {
/**
* @param {Board} board
* @returns {Board}
*/
export function shuffleBoard(board) {
const rows = board.length;
const cols = board[0].length;
const tiles: TileData[] = [];
const occupiedPositions: Point[] = [];
const emptyPositions: Point[] = [];
/** @type {TileData[]} */
const tiles = [];
/** @type {Point[]} */
const occupiedPositions = [];
/** @type {Point[]} */
const emptyPositions = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (board[r][c] !== null) {
tiles.push(board[r][c]!);
tiles.push(/** @type {TileData} */ (board[r][c]));
occupiedPositions.push({ row: r, col: c });
} else {
emptyPositions.push({ row: r, col: c });
@@ -92,7 +117,8 @@ export function shuffleBoard(board: Board): Board {
}
// Create new board with nulls
const newBoard: Board = [];
/** @type {Board} */
const newBoard = [];
for (let r = 0; r < rows; r++) {
newBoard.push(new Array(cols).fill(null));
}
@@ -1,6 +1,10 @@
import { Difficulty, DifficultyConfig } from "../types";
/**
* @typedef {import("../types").Difficulty} Difficulty
* @typedef {import("../types").DifficultyConfig} DifficultyConfig
*/
export const DIFFICULTY_CONFIGS: Record<Difficulty, DifficultyConfig> = {
/** @type {Record<Difficulty, DifficultyConfig>} */
export const DIFFICULTY_CONFIGS = {
easy: {
rows: 4,
cols: 6,
@@ -1,7 +1,9 @@
import { Difficulty } from "../types";
import { DIFFICULTY_CONFIGS } from "./constants";
export const EMOJI_POOL: string[] = [
/** @typedef {import("../types").Difficulty} Difficulty */
/** @type {string[]} */
export const EMOJI_POOL = [
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼",
"🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🐔",
"🐧", "🐦", "🦆", "🦅", "🦉", "🦇", "🐺", "🐗",
@@ -10,7 +12,11 @@ export const EMOJI_POOL: string[] = [
"🐟", "🐬", "🐳", "🐊",
];
export function getEmojisForDifficulty(difficulty: Difficulty): string[] {
/**
* @param {Difficulty} difficulty
* @returns {string[]}
*/
export function getEmojisForDifficulty(difficulty) {
const count = DIFFICULTY_CONFIGS[difficulty].pairCount;
const shuffled = [...EMOJI_POOL];
for (let i = shuffled.length - 1; i > 0; i--) {
@@ -1,6 +1,16 @@
import { Board, Point } from "../types";
/**
* @typedef {import("../types").Board} Board
* @typedef {import("../types").Point} Point
* @typedef {import("../types").TileData} TileData
*/
function isEmpty(board: Board, row: number, col: number): boolean {
/**
* @param {Board} board
* @param {number} row
* @param {number} col
* @returns {boolean}
*/
function isEmpty(board, row, col) {
const rows = board.length;
const cols = board[0].length;
if (row < 0 || row >= rows || col < 0 || col >= cols) {
@@ -9,21 +19,25 @@ function isEmpty(board: Board, row: number, col: number): boolean {
return board[row][col] === null;
}
const DIRS: [number, number][] = [
/** @type {[number, number][]} */
const DIRS = [
[0, 1],
[0, -1],
[1, 0],
[-1, 0],
];
function raycast(
board: Board,
row: number,
col: number,
dr: number,
dc: number
): Point[] {
const points: Point[] = [];
/**
* @param {Board} board
* @param {number} row
* @param {number} col
* @param {number} dr
* @param {number} dc
* @returns {Point[]}
*/
function raycast(board, row, col, dr, dc) {
/** @type {Point[]} */
const points = [];
let r = row + dr;
let c = col + dc;
const rows = board.length;
@@ -36,11 +50,13 @@ function raycast(
return points;
}
function canConnectStraight(
board: Board,
a: Point,
b: Point
): boolean {
/**
* @param {Board} board
* @param {Point} a
* @param {Point} b
* @returns {boolean}
*/
function canConnectStraight(board, a, b) {
if (a.row === b.row) {
const minC = Math.min(a.col, b.col);
const maxC = Math.max(a.col, b.col);
@@ -60,11 +76,13 @@ function canConnectStraight(
return false;
}
export function findPath(
board: Board,
a: Point,
b: Point
): Point[] | null {
/**
* @param {Board} board
* @param {Point} a
* @param {Point} b
* @returns {Point[] | null}
*/
export function findPath(board, a, b) {
const tileA = board[a.row]?.[a.col];
const tileB = board[b.row]?.[b.col];
if (!tileA || !tileB || tileA.emoji !== tileB.emoji) return null;
@@ -76,7 +94,8 @@ export function findPath(
}
// 1 bend: check two possible corners
const corner1: Point = { row: a.row, col: b.col };
/** @type {Point} */
const corner1 = { row: a.row, col: b.col };
if (
isEmpty(board, corner1.row, corner1.col) &&
canConnectStraight(board, a, corner1) &&
@@ -85,7 +104,8 @@ export function findPath(
return [a, corner1, b];
}
const corner2: Point = { row: b.row, col: a.col };
/** @type {Point} */
const corner2 = { row: b.row, col: a.col };
if (
isEmpty(board, corner2.row, corner2.col) &&
canConnectStraight(board, a, corner2) &&
@@ -98,7 +118,8 @@ export function findPath(
for (const [dr, dc] of DIRS) {
const reachable = raycast(board, a.row, a.col, dr, dc);
for (const mid of reachable) {
const cornerA: Point = { row: mid.row, col: b.col };
/** @type {Point} */
const cornerA = { row: mid.row, col: b.col };
if (
isEmpty(board, cornerA.row, cornerA.col) &&
canConnectStraight(board, mid, cornerA) &&
@@ -107,7 +128,8 @@ export function findPath(
return [a, mid, cornerA, b];
}
const cornerB: Point = { row: b.row, col: mid.col };
/** @type {Point} */
const cornerB = { row: b.row, col: mid.col };
if (
isEmpty(board, cornerB.row, cornerB.col) &&
canConnectStraight(board, mid, cornerB) &&
@@ -125,20 +147,29 @@ export function findPath(
return null;
}
export function hasAnyValidMove(board: Board): boolean {
const tiles: { pos: Point; emoji: string }[] = [];
/**
* @param {Board} board
* @returns {boolean}
*/
export function hasAnyValidMove(board) {
/** @type {{ pos: Point; emoji: string }[]} */
const tiles = [];
for (let r = 0; r < board.length; r++) {
for (let c = 0; c < board[r].length; c++) {
if (board[r][c] !== null) {
tiles.push({ pos: { row: r, col: c }, emoji: board[r][c]!.emoji });
tiles.push({
pos: { row: r, col: c },
emoji: /** @type {TileData} */ (board[r][c]).emoji,
});
}
}
}
const groups = new Map<string, Point[]>();
/** @type {Map<string, Point[]>} */
const groups = new Map();
for (const t of tiles) {
if (!groups.has(t.emoji)) groups.set(t.emoji, []);
groups.get(t.emoji)!.push(t.pos);
/** @type {Point[]} */ (groups.get(t.emoji)).push(t.pos);
}
for (const [, positions] of groups) {
@@ -1,9 +1,11 @@
import { BASE_MATCH_SCORE, SPEED_BONUS_MAX, SPEED_BONUS_WINDOW_MS } from "./constants";
export function calculateMatchScore(
msSinceLastMatch: number,
combo: number
): number {
/**
* @param {number} msSinceLastMatch
* @param {number} combo
* @returns {number}
*/
export function calculateMatchScore(msSinceLastMatch, combo) {
let speedBonus = 0;
if (msSinceLastMatch < SPEED_BONUS_WINDOW_MS) {
const ratio = 1 - msSinceLastMatch / SPEED_BONUS_WINDOW_MS;
@@ -1,24 +1,27 @@
import { Difficulty, GameStatus } from "../types";
import { DIFFICULTY_CONFIGS } from "./constants";
type Listener = () => void;
/**
* @typedef {import("../types").Difficulty} Difficulty
* @typedef {import("../types").GameStatus} GameStatus
*/
interface GameState {
status: GameStatus;
difficulty: Difficulty | null;
score: number;
timerSeconds: number;
hintsRemaining: number;
shufflesRemaining: number;
combo: number;
lastMatchTime: number;
}
/** @typedef {() => void} Listener */
/**
* @typedef {Object} GameState
* @property {GameStatus} status
* @property {Difficulty | null} difficulty
* @property {number} score
* @property {number} timerSeconds
* @property {number} hintsRemaining
* @property {number} shufflesRemaining
* @property {number} combo
* @property {number} lastMatchTime
*/
export class GameStateManager {
private state: GameState;
private listeners: Map<string, Listener[]> = new Map();
constructor() {
/** @type {GameState} */
this.state = {
status: "menu",
difficulty: null,
@@ -29,20 +32,33 @@ export class GameStateManager {
combo: 1,
lastMatchTime: 0,
};
/** @type {Map<string, Listener[]>} */
this.listeners = new Map();
}
getState(): Readonly<GameState> {
/** @returns {Readonly<GameState>} */
getState() {
return { ...this.state };
}
on(event: string, listener: Listener): void {
/**
* @param {string} event
* @param {Listener} listener
* @returns {void}
*/
on(event, listener) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(listener);
/** @type {Listener[]} */ (this.listeners.get(event)).push(listener);
}
off(event: string, listener: Listener): void {
/**
* @param {string} event
* @param {Listener} listener
* @returns {void}
*/
off(event, listener) {
const listeners = this.listeners.get(event);
if (listeners) {
const idx = listeners.indexOf(listener);
@@ -50,14 +66,22 @@ export class GameStateManager {
}
}
emit(event: string): void {
/**
* @param {string} event
* @returns {void}
*/
emit(event) {
const listeners = this.listeners.get(event);
if (listeners) {
for (const l of listeners) l();
}
}
startGame(difficulty: Difficulty): void {
/**
* @param {Difficulty} difficulty
* @returns {void}
*/
startGame(difficulty) {
const config = DIFFICULTY_CONFIGS[difficulty];
this.state = {
status: "playing",
@@ -72,26 +96,33 @@ export class GameStateManager {
this.emit("stateChange");
}
addScore(points: number): void {
/**
* @param {number} points
* @returns {void}
*/
addScore(points) {
this.state.score += points;
this.emit("stateChange");
}
useHint(): boolean {
/** @returns {boolean} */
useHint() {
if (this.state.hintsRemaining <= 0) return false;
this.state.hintsRemaining--;
this.emit("stateChange");
return true;
}
useShuffle(): boolean {
/** @returns {boolean} */
useShuffle() {
if (this.state.shufflesRemaining <= 0) return false;
this.state.shufflesRemaining--;
this.emit("stateChange");
return true;
}
tick(): void {
/** @returns {void} */
tick() {
if (this.state.status !== "playing") return;
this.state.timerSeconds--;
if (this.state.timerSeconds <= 0) {
@@ -101,22 +132,32 @@ export class GameStateManager {
this.emit("stateChange");
}
incrementCombo(): void {
/** @returns {void} */
incrementCombo() {
this.state.combo++;
this.emit("stateChange");
}
resetCombo(): void {
/** @returns {void} */
resetCombo() {
this.state.combo = 1;
this.emit("stateChange");
}
setStatus(status: GameStatus): void {
/**
* @param {GameStatus} status
* @returns {void}
*/
setStatus(status) {
this.state.status = status;
this.emit("stateChange");
}
setLastMatchTime(time: number): void {
/**
* @param {number} time
* @returns {void}
*/
setLastMatchTime(time) {
this.state.lastMatchTime = time;
}
}
@@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./components/App";
createRoot(document.getElementById("root")!).render(
createRoot(/** @type {HTMLElement} */ (document.getElementById("root"))).render(
<StrictMode>
<App />
</StrictMode>
@@ -2,11 +2,13 @@ import Phaser from "phaser";
import { PreloadScene } from "./scenes/PreloadScene";
import { GameScene } from "./scenes/GameScene";
export function createPhaserConfig(
parent: HTMLElement,
width: number,
height: number
): Phaser.Types.Core.GameConfig {
/**
* @param {HTMLElement} parent
* @param {number} width
* @param {number} height
* @returns {Phaser.Types.Core.GameConfig}
*/
export function createPhaserConfig(parent, width, height) {
return {
type: Phaser.AUTO,
parent,
@@ -1,35 +1,52 @@
import Phaser from "phaser";
import { Board, Difficulty, Point } from "../../types";
import { GameStateManager } from "../../game/state";
import { createBoard, shuffleBoard, getRemainingTiles } from "../../game/board";
import { findPath, hasAnyValidMove } from "../../game/pathfinder";
import { calculateMatchScore } from "../../game/scoring";
import { DIFFICULTY_CONFIGS } from "../../game/constants";
/**
* @typedef {import("../../types").Board} Board
* @typedef {import("../../types").Difficulty} Difficulty
* @typedef {import("../../types").Point} Point
*/
/** @typedef {import("../../game/state").GameStateManager} GameStateManager */
const TILE_SIZE = 56;
const TILE_GAP = 4;
export class GameScene extends Phaser.Scene {
private board!: Board;
private difficulty!: Difficulty;
private stateManager!: GameStateManager;
private tileObjects: Map<string, Phaser.GameObjects.Container> = new Map();
private selectedTile: Point | null = null;
private selectedHighlight: Phaser.GameObjects.Rectangle | null = null;
private lineGraphics!: Phaser.GameObjects.Graphics;
private isProcessing = false;
private timerEvent: Phaser.Time.TimerEvent | null = null;
private hintHandler!: () => void;
private shuffleHandler!: () => void;
private solveInterval: number | null = null;
constructor() {
super({ key: "GameScene" });
/** @type {Board} */
this.board = [];
/** @type {Difficulty} */
this.difficulty = "easy";
/** @type {GameStateManager} */
this.stateManager = /** @type {GameStateManager} */ (/** @type {unknown} */ (null));
/** @type {Map<string, Phaser.GameObjects.Container>} */
this.tileObjects = new Map();
/** @type {Point | null} */
this.selectedTile = null;
/** @type {Phaser.GameObjects.Rectangle | null} */
this.selectedHighlight = null;
/** @type {Phaser.GameObjects.Graphics} */
this.lineGraphics = /** @type {Phaser.GameObjects.Graphics} */ (/** @type {unknown} */ (null));
this.isProcessing = false;
/** @type {Phaser.Time.TimerEvent | null} */
this.timerEvent = null;
/** @type {() => void} */
this.hintHandler = () => {};
/** @type {() => void} */
this.shuffleHandler = () => {};
/** @type {number | null} */
this.solveInterval = null;
}
create(): void {
this.difficulty = this.registry.get("difficulty") as Difficulty;
this.stateManager = this.registry.get("stateManager") as GameStateManager;
/** @returns {void} */
create() {
this.difficulty = /** @type {Difficulty} */ (this.registry.get("difficulty"));
this.stateManager = /** @type {GameStateManager} */ (this.registry.get("stateManager"));
this.board = createBoard(this.difficulty);
this.lineGraphics = this.add.graphics();
@@ -54,10 +71,12 @@ export class GameScene extends Phaser.Scene {
});
// Expose solve() on window for console use
(window as unknown as Record<string, unknown>).solve = () => this.startSolve();
/** @type {Record<string, unknown>} */ (/** @type {unknown} */ (window)).solve = () =>
this.startSolve();
}
private renderBoard(): void {
/** @returns {void} */
renderBoard() {
for (const [, obj] of this.tileObjects) {
obj.destroy();
}
@@ -111,7 +130,11 @@ export class GameScene extends Phaser.Scene {
}
}
private getTileScreenPos(point: Point): { x: number; y: number } {
/**
* @param {Point} point
* @returns {{ x: number; y: number }}
*/
getTileScreenPos(point) {
const config = DIFFICULTY_CONFIGS[this.difficulty];
const totalWidth = config.cols * (TILE_SIZE + TILE_GAP) - TILE_GAP;
const totalHeight = config.rows * (TILE_SIZE + TILE_GAP) - TILE_GAP;
@@ -123,7 +146,11 @@ export class GameScene extends Phaser.Scene {
};
}
private onTileClick(pos: Point): void {
/**
* @param {Point} pos
* @returns {void}
*/
onTileClick(pos) {
if (this.stateManager.getState().status !== "playing") return;
const tile = this.board[pos.row][pos.col];
if (!tile) return;
@@ -151,7 +178,11 @@ export class GameScene extends Phaser.Scene {
}
}
private highlightTile(pos: Point): void {
/**
* @param {Point} pos
* @returns {void}
*/
highlightTile(pos) {
this.clearHighlight();
const screenPos = this.getTileScreenPos(pos);
this.selectedHighlight = this.add.rectangle(
@@ -164,19 +195,27 @@ export class GameScene extends Phaser.Scene {
this.selectedHighlight.setFillStyle(0xe94560, 0.15);
}
private clearHighlight(): void {
/** @returns {void} */
clearHighlight() {
if (this.selectedHighlight) {
this.selectedHighlight.destroy();
this.selectedHighlight = null;
}
}
private clearSelection(): void {
/** @returns {void} */
clearSelection() {
this.selectedTile = null;
this.clearHighlight();
}
private handleMatch(a: Point, b: Point, path: Point[]): void {
/**
* @param {Point} a
* @param {Point} b
* @param {Point[]} path
* @returns {void}
*/
handleMatch(a, b, path) {
this.drawPath(path);
const state = this.stateManager.getState();
@@ -206,7 +245,11 @@ export class GameScene extends Phaser.Scene {
});
}
private handleMismatch(clickedPos: Point): void {
/**
* @param {Point} clickedPos
* @returns {void}
*/
handleMismatch(clickedPos) {
this.stateManager.resetCombo();
const key = `${clickedPos.row},${clickedPos.col}`;
@@ -226,7 +269,11 @@ export class GameScene extends Phaser.Scene {
this.highlightTile(clickedPos);
}
private removeTile(pos: Point): void {
/**
* @param {Point} pos
* @returns {void}
*/
removeTile(pos) {
this.board[pos.row][pos.col] = null;
const key = `${pos.row},${pos.col}`;
const container = this.tileObjects.get(key);
@@ -242,7 +289,11 @@ export class GameScene extends Phaser.Scene {
}
}
private drawPath(path: Point[]): void {
/**
* @param {Point[]} path
* @returns {void}
*/
drawPath(path) {
this.lineGraphics.clear();
this.lineGraphics.lineStyle(4, 0xe94560, 0.8);
this.lineGraphics.beginPath();
@@ -259,7 +310,8 @@ export class GameScene extends Phaser.Scene {
this.lineGraphics.strokePath();
}
private startTimer(): void {
/** @returns {void} */
startTimer() {
this.timerEvent = this.time.addEvent({
delay: 1000,
callback: () => {
@@ -272,7 +324,8 @@ export class GameScene extends Phaser.Scene {
});
}
handleHint(): void {
/** @returns {void} */
handleHint() {
if (!this.stateManager.useHint()) return;
const remaining = getRemainingTiles(this.board);
@@ -294,7 +347,11 @@ export class GameScene extends Phaser.Scene {
}
}
private pulseHint(pos: Point): void {
/**
* @param {Point} pos
* @returns {void}
*/
pulseHint(pos) {
const key = `${pos.row},${pos.col}`;
const container = this.tileObjects.get(key);
if (container) {
@@ -309,17 +366,20 @@ export class GameScene extends Phaser.Scene {
}
}
handleShuffle(): void {
/** @returns {void} */
handleShuffle() {
if (!this.stateManager.useShuffle()) return;
this.performShuffle();
}
private autoShuffle(): void {
/** @returns {void} */
autoShuffle() {
this.performShuffle();
this.stateManager.emit("autoShuffle");
}
private performShuffle(): void {
/** @returns {void} */
performShuffle() {
this.clearSelection();
do {
this.board = shuffleBoard(this.board);
@@ -327,7 +387,8 @@ export class GameScene extends Phaser.Scene {
this.renderBoard();
}
private startSolve(): void {
/** @returns {void} */
startSolve() {
if (this.solveInterval !== null) {
this.stopSolve();
console.log("Auto-solve stopped.");
@@ -346,14 +407,16 @@ export class GameScene extends Phaser.Scene {
}, 1000);
}
private stopSolve(): void {
/** @returns {void} */
stopSolve() {
if (this.solveInterval !== null) {
clearInterval(this.solveInterval);
this.solveInterval = null;
}
}
private solveOneMove(): void {
/** @returns {void} */
solveOneMove() {
if (this.isProcessing) return;
const remaining = getRemainingTiles(this.board);
for (let i = 0; i < remaining.length; i++) {
@@ -377,7 +440,8 @@ export class GameScene extends Phaser.Scene {
}
}
shutdown(): void {
/** @returns {void} */
shutdown() {
if (this.timerEvent) {
this.timerEvent.destroy();
this.timerEvent = null;
@@ -5,11 +5,13 @@ export class PreloadScene extends Phaser.Scene {
super({ key: "PreloadScene" });
}
preload(): void {
/** @returns {void} */
preload() {
// No assets to load — emoji are rendered as text
}
create(): void {
/** @returns {void} */
create() {
this.scene.start("GameScene");
}
}
@@ -22,7 +22,8 @@ describe("createBoard", () => {
it("every tile has a matching pair", () => {
const board = createBoard("easy");
const emojiCounts = new Map<string, number>();
/** @type {Map<string, number>} */
const emojiCounts = new Map();
for (const row of board) {
for (const cell of row) {
if (cell) {
@@ -37,7 +38,8 @@ describe("createBoard", () => {
it("all tiles have unique ids", () => {
const board = createBoard("medium");
const ids = new Set<number>();
/** @type {Set<number>} */
const ids = new Set();
for (const row of board) {
for (const cell of row) {
if (cell) {
@@ -1,8 +1,13 @@
import { describe, it, expect } from "vitest";
import { findPath, hasAnyValidMove } from "../../src/game/pathfinder";
import { Board } from "../../src/types";
function makeBoard(grid: (string | null)[][]): Board {
/** @typedef {import("../../src/types").Board} Board */
/**
* @param {(string | null)[][]} grid
* @returns {Board}
*/
function makeBoard(grid) {
let id = 0;
return grid.map((row) =>
row.map((cell) => (cell !== null ? { emoji: cell, id: id++ } : null))
@@ -16,7 +21,7 @@ describe("findPath", () => {
]);
const path = findPath(board, { row: 0, col: 0 }, { row: 0, col: 2 });
expect(path).not.toBeNull();
expect(path!.length).toBe(2);
expect(/** @type {NonNullable<typeof path>} */ (path).length).toBe(2);
});
it("finds direct vertical connection", () => {
@@ -27,7 +32,7 @@ describe("findPath", () => {
]);
const path = findPath(board, { row: 0, col: 0 }, { row: 2, col: 0 });
expect(path).not.toBeNull();
expect(path!.length).toBe(2);
expect(/** @type {NonNullable<typeof path>} */ (path).length).toBe(2);
});
it("finds one-bend connection", () => {
@@ -37,7 +42,7 @@ describe("findPath", () => {
]);
const path = findPath(board, { row: 0, col: 0 }, { row: 1, col: 1 });
expect(path).not.toBeNull();
expect(path!.length).toBe(3);
expect(/** @type {NonNullable<typeof path>} */ (path).length).toBe(3);
});
it("finds two-bend connection", () => {
@@ -48,7 +53,7 @@ describe("findPath", () => {
]);
const path = findPath(board, { row: 0, col: 0 }, { row: 2, col: 2 });
expect(path).not.toBeNull();
expect(path!.length).toBe(4);
expect(/** @type {NonNullable<typeof path>} */ (path).length).toBe(4);
});
it("returns null when no valid path exists", () => {
@@ -83,7 +88,7 @@ describe("findPath", () => {
]);
const path = findPath(board, { row: 0, col: 0 }, { row: 0, col: 1 });
expect(path).not.toBeNull();
expect(path!.length).toBe(2);
expect(/** @type {NonNullable<typeof path>} */ (path).length).toBe(2);
});
});
@@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { GameStateManager } from "../../src/game/state";
describe("GameStateManager", () => {
let state: GameStateManager;
/** @type {GameStateManager} */
let state;
beforeEach(() => {
state = new GameStateManager();
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
-7
View File
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
-24
View File
@@ -1,24 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}