diff --git a/src/App.tsx b/src/App.tsx
index 84bba9d..a663eed 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -8,47 +8,19 @@ import {
computeInterval,
SWAP_TRANS_MS,
type Step,
-} from './visualizer';
-
-/* === Mantine UI === */
-import {
- Container,
- Paper,
- Group,
- Button,
- Title,
- Text,
- Slider,
- ActionIcon,
- Accordion,
- Badge,
- Stack,
- Box,
- Anchor,
-} from '@mantine/core';
+} from './plugins/visualizer';
+/* Mantine */
+import { Container, Paper, Accordion } from '@mantine/core';
import { useTranslation } from 'react-i18next';
-import LanguageSwitcher from './LanguageSwitcher';
-type Kind = 'bubble' | 'quick';
-type Range = { lo: number; hi: number } | null;
+import HeaderBar from './components/HeaderBar';
+import ControlBar from './components/ControlBar';
+import SortSection, { BoardState } from './components/SortSection';
+import { BubbleLegend } from './components/algorithms/Bubble';
+import { QuickLegend, QuickOverlay } from './components/algorithms/Quick';
-type BoardState = {
- kind: Kind;
- data: number[];
- ids: number[];
- steps: Step[];
- stepIndex: number;
- finished: boolean;
- compare?: [number, number] | null;
- swapPair?: [number, number] | null;
- candL?: number | null;
- candR?: number | null;
- pivotIndex: number | null;
- range: Range;
- boundaryIndex: number | null;
- boundaryVisible: boolean;
-};
+type Kind = 'bubble' | 'quick';
function makeBoard(kind: Kind, base: number[]): BoardState {
const n = base.length;
@@ -127,111 +99,8 @@ function applyStep(b: BoardState, step: Step): BoardState {
}
}
-function Bars({ board }: { board: BoardState }) {
- const { t } = useTranslation();
- const max = Math.max(...board.data, 1);
- const n = board.data.length;
-
- const isCompare = (idx: number) =>
- !!board.compare && (idx === board.compare[0] || idx === board.compare[1]);
- const isSwap = (idx: number) =>
- !!board.swapPair && (idx === board.swapPair[0] || idx === board.swapPair[1]);
- const isPivot = (idx: number) => board.pivotIndex === idx;
- const isCandL = (idx: number) => board.candL === idx;
- const isCandR = (idx: number) => board.candR === idx;
-
- return (
-
- {board.kind === 'quick' &&
}
-
- {Array.from({ length: n }, (_, i) => {
- const h = (board.data[i] / max) * 100;
- const classes = [
- 'bar',
- isPivot(i) ? 'pivot' : '',
- isCompare(i) ? 'compare' : '',
- isSwap(i) ? 'swap' : '',
- isCandL(i) ? 'candL' : '',
- isCandR(i) ? 'candR' : '',
- board.finished ? 'sorted' : '',
- ]
- .filter(Boolean)
- .join(' ');
- return (
-
- );
- })}
-
- );
-}
-
-function QuickOverlay({ board }: { board: BoardState }) {
- const n = Math.max(board.data.length, 1);
- const range = board.range;
- const lo = range?.lo ?? 0;
- const hi = range?.hi ?? n - 1;
-
- const showRange = range != null;
- const leftPct = (lo / n) * 100;
- const rightPct = ((hi + 1) / n) * 100;
- const boundaryPct = ((board.boundaryIndex ?? lo) / n) * 100;
-
- const pivotHeightPct =
- board.pivotIndex != null && board.data.length
- ? (board.data[board.pivotIndex] / Math.max(...board.data, 1)) * 100
- : null;
-
- return (
-
- );
-}
-
const App: React.FC = () => {
- const { t, i18n } = useTranslation();
+ const { i18n } = useTranslation();
const browserLanguage = i18n.language;
const translatedLanguage = browserLanguage.startsWith('ja') ? 'ja' : 'en';
@@ -246,7 +115,7 @@ const App: React.FC = () => {
const stepsBubble = bubble.steps.length;
const stepsQuick = quick.steps.length;
- // タイマー(speed / playing の変更で再スケジュール)
+ // タイマー
React.useEffect(() => {
if (!playing) return;
const iv = computeInterval(speed);
@@ -286,7 +155,6 @@ const App: React.FC = () => {
browser_language: browserLanguage,
translated_language: translatedLanguage,
});
- // 両方終われば自動停止
if (playing && bubble.finished && quick.finished) setPlaying(false);
}, [playing, bubble.finished, quick.finished]);
@@ -298,14 +166,12 @@ const App: React.FC = () => {
};
const handleStart = () => {
- // number型で送る(toFixedしない)
ReactGA.event('play_click', {
animation_speed: speed,
bar_size: size,
browser_language: browserLanguage,
translated_language: translatedLanguage,
});
-
setBubble((prev) =>
prev.steps.length ? prev : { ...prev, steps: buildBubbleSteps(prev.data) },
);
@@ -313,7 +179,6 @@ const App: React.FC = () => {
setPlaying(true);
};
const handlePause = () => {
- // number型で送る(toFixedしない)
ReactGA.event('pause_click', {
animation_speed: speed,
bar_size: size,
@@ -323,7 +188,6 @@ const App: React.FC = () => {
setPlaying(false);
};
const handleShuffle = () => {
- // number型で送る(toFixedしない)
ReactGA.event('shuffle_click', {
animation_speed: speed,
bar_size: size,
@@ -332,7 +196,6 @@ const App: React.FC = () => {
});
resetFrom(genArray(size));
};
-
const handleSizeInput = (n: number) => {
const nn = Math.max(5, Math.min(50, Math.floor(n)));
setSize(nn);
@@ -343,33 +206,13 @@ const App: React.FC = () => {
setSpeed(ss);
};
- // CSS カスタムプロパティを型安全に
const rootStyle: React.CSSProperties & Record<'--transMs', string> = {
'--transMs': `${SWAP_TRANS_MS}ms`,
};
return (
-
-
-
-
- {t('app_title')}
-
-
-
-
- {t('app_desc')}
-
-
-
-
-
-
-
+
{
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
}}
>
- {/* ツールバー */}
-
- {/* 本数 */}
-
-
- {t('count_label')} {size}
-
-
- handleSizeInput(size - 1)}
- style={stepperBtnStyle}
- >
- −
-
- handleSizeInput(v)}
- min={5}
- max={50}
- step={1}
- w={220}
- styles={{
- root: {
- paddingTop: 8,
- paddingBottom: 8,
- background: '#0f1b3a',
- border: '1px solid rgba(255,255,255,0.08)',
- borderRadius: 10,
- },
- thumb: { borderColor: 'var(--accent)', background: 'var(--accent)' },
- }}
- />
- handleSizeInput(size + 1)}
- style={stepperBtnStyle}
- >
- +
-
-
-
-
- {/* 速度 */}
-
-
- {t('speed_label')} {speed.toFixed(2)}
-
-
- handleSpeedInput(Number((speed - 0.05).toFixed(2)))}
- style={stepperBtnStyle}
- >
- −
-
- handleSpeedInput(v)}
- min={0.2}
- max={10}
- step={0.05}
- w={220}
- styles={{
- root: {
- paddingTop: 8,
- paddingBottom: 8,
- background: '#0f1b3a',
- border: '1px solid rgba(255,255,255,0.08)',
- borderRadius: 10,
- },
- thumb: { borderColor: 'var(--accent)', background: 'var(--accent)' },
- }}
- />
- handleSpeedInput(Number((speed + 0.05).toFixed(2)))}
- style={stepperBtnStyle}
- >
- +
-
-
-
-
-
-
- {/* 再生:従来のプライマリ */}
-
+
- {/* 一時停止:ゴースト風 */}
-
-
- {/* シャッフル */}
-
-
-
-
-
- {/* ▼ トグルアイコンを左に */}
{
variant="separated"
chevronPosition="left"
>
-
-
-
-
-
- {t('bubble')}
-
-
-
- {t('steps', { n: stepsBubble })}
-
-
-
-
-
-
- }>
- {t('badge_swap')}
-
- }>
- {t('badge_sorted')}
-
-
-
-
-
-
-
-
-
-
- {t('quick')}
-
-
-
- {t('steps', { n: stepsQuick })}
-
-
-
-
-
-
- }>
- {t('badge_swap')}
-
- }>
- {t('badge_pivot')}
-
- }
- >
- {t('badge_boundary')}
-
- }
- >
- {t('badge_pivotline')}
-
- }>
- {t('badge_sorted')}
-
-
-
-
+
+
);
};
-/* ---- 見た目を旧サイトに寄せるための最小インライン style ---- */
-const stepperBtnStyle: React.CSSProperties = {
- width: 28,
- height: 28,
- borderRadius: 8,
- border: '1px solid rgba(255, 255, 255, 0.15)',
- background: 'linear-gradient(180deg, #1a2552 0%, #131d40 100%)',
- color: '#e6ebff',
- fontWeight: 700,
- lineHeight: 1,
-};
-
-const primaryBtnStyle: React.CSSProperties = {
- background: 'linear-gradient(180deg, #2854ff 0%, #1d36a8 100%)',
- border: '1px solid #4062ff',
- borderRadius: 12,
- padding: '10px 14px',
- fontWeight: 600,
-};
-
-/* ▼ 一時停止(ゴースト風) */
-const pauseBtnStyle: React.CSSProperties = {
- background: 'transparent',
- border: '1px solid rgba(255, 255, 255, 0.35)',
- color: 'var(--text)',
- borderRadius: 12,
- padding: '10px 14px',
- fontWeight: 600,
-};
-
-const ghostBtnStyle: React.CSSProperties = {
- background: 'linear-gradient(180deg, #1a2552 0%, #131d40 100%)',
- border: '1px solid rgba(255, 255, 255, 0.12)',
- borderRadius: 12,
- padding: '10px 14px',
- fontWeight: 600,
-};
-
-const boardStyle: React.CSSProperties = {
- marginTop: 14,
- padding: 0,
- borderRadius: 14,
- background: '#0a1330',
- border: '1px dashed rgba(255, 255, 255, 0.08)',
- boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
-};
-
-const boardSummaryStyle: React.CSSProperties = {
- padding: '12px 14px',
-};
-
export default App;
diff --git a/src/__tests__/visualizer.spec.ts b/src/__tests__/visualizer.spec.ts
index dd12e2d..92f019a 100644
--- a/src/__tests__/visualizer.spec.ts
+++ b/src/__tests__/visualizer.spec.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
-import { buildBubbleSteps, buildQuickSteps, genArray, type Step } from '../visualizer';
+import { buildBubbleSteps, buildQuickSteps, genArray, type Step } from '../plugins/visualizer';
afterEach(() => {
vi.restoreAllMocks();
diff --git a/src/components/ControlBar.tsx b/src/components/ControlBar.tsx
new file mode 100644
index 0000000..3cc08b5
--- /dev/null
+++ b/src/components/ControlBar.tsx
@@ -0,0 +1,202 @@
+import React from 'react';
+import { Group, Text, ActionIcon, Slider, Button, Box } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+ size: number;
+ speed: number;
+ playing: boolean;
+ onSizeChange: (n: number) => void;
+ onSpeedChange: (s: number) => void;
+ onStart: () => void;
+ onPause: () => void;
+ onShuffle: () => void;
+};
+
+const ControlBar: React.FC = ({
+ size,
+ speed,
+ playing,
+ onSizeChange,
+ onSpeedChange,
+ onStart,
+ onPause,
+ onShuffle,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+ {/* 本数 */}
+
+
+ {t('count_label')} {size}
+
+
+ onSizeChange(size - 1)}
+ style={stepperBtnStyle}
+ >
+ −
+
+
+ onSizeChange(size + 1)}
+ style={stepperBtnStyle}
+ >
+ +
+
+
+
+
+ {/* 速度 */}
+
+
+ {t('speed_label')} {speed.toFixed(2)}
+
+
+ onSpeedChange(Number((speed - 0.05).toFixed(2)))}
+ style={stepperBtnStyle}
+ >
+ −
+
+
+ onSpeedChange(Number((speed + 0.05).toFixed(2)))}
+ style={stepperBtnStyle}
+ >
+ +
+
+
+
+
+ {/* 右寄せ:操作ボタン */}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+/* ---- 局所スタイル ---- */
+const stepperBtnStyle: React.CSSProperties = {
+ width: 28,
+ height: 28,
+ borderRadius: 8,
+ border: '1px solid rgba(255, 255, 255, 0.15)',
+ background: 'linear-gradient(180deg, #1a2552 0%, #131d40 100%)',
+ color: '#e6ebff',
+ fontWeight: 700,
+ lineHeight: 1,
+};
+
+const primaryBtnStyle: React.CSSProperties = {
+ background: 'linear-gradient(180deg, #2854ff 0%, #1d36a8 100%)',
+ border: '1px solid #4062ff',
+ borderRadius: 12,
+ padding: '10px 14px',
+ fontWeight: 600,
+};
+
+const pauseBtnStyle: React.CSSProperties = {
+ background: 'transparent',
+ border: '1px solid rgba(255, 255, 255, 0.35)',
+ color: 'var(--text)',
+ borderRadius: 12,
+ padding: '10px 14px',
+ fontWeight: 600,
+};
+
+const ghostBtnStyle: React.CSSProperties = {
+ background: 'linear-gradient(180deg, #1a2552 0%, #131d40 100%)',
+ border: '1px solid rgba(255, 255, 255, 0.12)',
+ borderRadius: 12,
+ padding: '10px 14px',
+ fontWeight: 600,
+};
+
+export default ControlBar;
diff --git a/src/components/HeaderBar.tsx b/src/components/HeaderBar.tsx
new file mode 100644
index 0000000..e5e2134
--- /dev/null
+++ b/src/components/HeaderBar.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { Group, Stack, Title, Text, Anchor } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import LanguageSwitcher from './LanguageSwitcher';
+
+const HeaderBar: React.FC = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ {t('app_title')}
+
+
+
+ {t('app_desc')}
+
+
+
+ {/* 右上:言語切替(既存コンポーネントをそのまま使用) */}
+
+
+
+
+ );
+};
+
+export default HeaderBar;
diff --git a/src/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx
similarity index 96%
rename from src/LanguageSwitcher.tsx
rename to src/components/LanguageSwitcher.tsx
index a35306d..c46aff1 100644
--- a/src/LanguageSwitcher.tsx
+++ b/src/components/LanguageSwitcher.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { Select } from '@mantine/core';
import { useTranslation } from 'react-i18next';
-import i18n from './i18n';
+import i18n from '../plugins/i18n';
const LanguageSwitcher: React.FC = () => {
const { i18n: inst, t } = useTranslation();
diff --git a/src/components/SortSection.tsx b/src/components/SortSection.tsx
new file mode 100644
index 0000000..e6bc7ac
--- /dev/null
+++ b/src/components/SortSection.tsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import { Accordion, Group, Text } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import type { Step } from '../plugins/visualizer';
+
+export type Kind = 'bubble' | 'quick';
+export type Range = { lo: number; hi: number } | null;
+
+export type BoardState = {
+ kind: Kind;
+ data: number[];
+ ids: number[];
+ steps: Step[];
+ stepIndex: number;
+ finished: boolean;
+ compare?: [number, number] | null;
+ swapPair?: [number, number] | null;
+ candL?: number | null;
+ candR?: number | null;
+ pivotIndex: number | null;
+ range: Range;
+ boundaryIndex: number | null;
+ boundaryVisible: boolean;
+};
+
+type Props = {
+ value: Kind;
+ // i18n キー
+ titleKey: Kind;
+ stepsCount: number;
+ board: BoardState;
+ // アルゴリズム固有の凡例(必須)
+ Legend: React.ComponentType;
+ // アルゴリズム固有のオーバレイ(任意)
+ Overlay?: React.ComponentType<{ board: BoardState }>;
+};
+
+const SortSection: React.FC = ({ value, titleKey, stepsCount, board, Legend, Overlay }) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ {t(titleKey)}
+
+
+
+ {t('steps', { n: stepsCount })}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+/* ============= 内部:Bars(共通) ============= */
+const Bars: React.FC<{
+ board: BoardState;
+ ariaLabel: string;
+ Overlay?: React.ComponentType<{ board: BoardState }>;
+}> = ({ board, ariaLabel, Overlay }) => {
+ const max = Math.max(...board.data, 1);
+ const n = board.data.length;
+
+ const isCompare = (idx: number) =>
+ !!board.compare && (idx === board.compare[0] || idx === board.compare[1]);
+ const isSwap = (idx: number) =>
+ !!board.swapPair && (idx === board.swapPair[0] || idx === board.swapPair[1]);
+ const isPivot = (idx: number) => board.pivotIndex === idx;
+ const isCandL = (idx: number) => board.candL === idx;
+ const isCandR = (idx: number) => board.candR === idx;
+
+ return (
+
+ {Overlay ?
: null}
+
+ {Array.from({ length: n }, (_, i) => {
+ const h = (board.data[i] / max) * 100;
+ const classes = [
+ 'bar',
+ isPivot(i) ? 'pivot' : '',
+ isCompare(i) ? 'compare' : '',
+ isSwap(i) ? 'swap' : '',
+ isCandL(i) ? 'candL' : '',
+ isCandR(i) ? 'candR' : '',
+ board.finished ? 'sorted' : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+ return (
+
+ );
+ })}
+
+ );
+};
+
+/* ---- 見た目(セクション外枠) ---- */
+const boardStyle: React.CSSProperties = {
+ marginTop: 14,
+ padding: 0,
+ borderRadius: 14,
+ background: '#0a1330',
+ border: '1px dashed rgba(255, 255, 255, 0.08)',
+ boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
+};
+
+const boardSummaryStyle: React.CSSProperties = {
+ padding: '12px 14px',
+};
+
+export default SortSection;
diff --git a/src/components/algorithms/Bubble.tsx b/src/components/algorithms/Bubble.tsx
new file mode 100644
index 0000000..e0d3350
--- /dev/null
+++ b/src/components/algorithms/Bubble.tsx
@@ -0,0 +1,18 @@
+// src/components/algorithms/Bubble.tsx
+import React from 'react';
+import { Group, Badge } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+
+export const BubbleLegend: React.FC = () => {
+ const { t } = useTranslation();
+ return (
+
+ }>
+ {t('badge_swap')}
+
+ }>
+ {t('badge_sorted')}
+
+
+ );
+};
diff --git a/src/components/algorithms/Quick.tsx b/src/components/algorithms/Quick.tsx
new file mode 100644
index 0000000..a9ec2d0
--- /dev/null
+++ b/src/components/algorithms/Quick.tsx
@@ -0,0 +1,90 @@
+// src/components/algorithms/Quick.tsx
+import React from 'react';
+import { Group, Badge } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import type { BoardState } from '../SortSection';
+
+export const QuickLegend: React.FC = () => {
+ const { t } = useTranslation();
+ return (
+
+ }>
+ {t('badge_swap')}
+
+ }>
+ {t('badge_pivot')}
+
+ }>
+ {t('badge_boundary')}
+
+ }>
+ {t('badge_pivotline')}
+
+ }>
+ {t('badge_sorted')}
+
+
+ );
+};
+
+export const QuickOverlay: React.FC<{ board: BoardState }> = ({ board }) => {
+ const n = Math.max(board.data.length, 1);
+ const range = board.range;
+ const lo = range?.lo ?? 0;
+ const hi = range?.hi ?? n - 1;
+
+ const showRange = range != null;
+ const leftPct = (lo / n) * 100;
+ const rightPct = ((hi + 1) / n) * 100;
+ const boundaryPct = ((board.boundaryIndex ?? lo) / n) * 100;
+
+ const pivotHeightPct =
+ board.pivotIndex != null && board.data.length
+ ? (board.data[board.pivotIndex] / Math.max(...board.data, 1)) * 100
+ : null;
+
+ return (
+
+ );
+};
diff --git a/src/index.tsx b/src/index.tsx
index 2fee154..67e6914 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -4,7 +4,7 @@ import { MantineProvider, createTheme } from '@mantine/core';
import '@mantine/core/styles.css';
import ReactGA from 'react-ga4';
import App from './App';
-import './i18n';
+import './plugins/i18n';
ReactGA.initialize('G-3W9LXS29S2');
const theme = createTheme({
diff --git a/src/ja/index.html b/src/ja/index.html
index 0ca1145..4fc7262 100644
--- a/src/ja/index.html
+++ b/src/ja/index.html
@@ -5,7 +5,7 @@
@@ -18,7 +18,7 @@
/>
Algorithm Visualizer | 視覚的にアルゴリズムを学ぼう
diff --git a/src/i18n.ts b/src/plugins/i18n.ts
similarity index 94%
rename from src/i18n.ts
rename to src/plugins/i18n.ts
index 8dcdbe9..4020cb5 100644
--- a/src/i18n.ts
+++ b/src/plugins/i18n.ts
@@ -1,8 +1,8 @@
import i18n, { Resource } from 'i18next';
import { initReactI18next } from 'react-i18next';
-import en from './en/locale.json';
-import ja from './ja/locale.json';
+import en from '../en/locale.json';
+import ja from '../ja/locale.json';
function detectInitialLng(): 'en' | 'ja' {
// URL の指定言語を優先 (/ja/... or /en/...)
diff --git a/src/visualizer.ts b/src/plugins/visualizer.ts
similarity index 100%
rename from src/visualizer.ts
rename to src/plugins/visualizer.ts
diff --git a/src/test/setupTests.ts b/src/test/setupTests.ts
index 7a3d045..ef050f9 100644
--- a/src/test/setupTests.ts
+++ b/src/test/setupTests.ts
@@ -34,7 +34,7 @@ if (typeof window !== 'undefined' && !window.matchMedia) {
}
// ===== i18n: テストは日本語UIで固定 =====
-import i18n from '../i18n';
+import i18n from '../plugins/i18n';
// 非同期を「無視」して初期化(eslint no-floating-promises 対策に void)
void i18n.changeLanguage('ja');