Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
460 changes: 37 additions & 423 deletions src/App.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/__tests__/visualizer.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
202 changes: 202 additions & 0 deletions src/components/ControlBar.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({
size,
speed,
playing,
onSizeChange,
onSpeedChange,
onStart,
onPause,
onShuffle,
}) => {
const { t } = useTranslation();

return (
<Group wrap="wrap" gap="md" align="center">
{/* 本数 */}
<Group
gap="xs"
align="center"
style={{
padding: '1px 1px',
background: '#0c1530',
borderRadius: 12,
border: '1px solid rgba(255,255,255,0.06)',
}}
>
<Text size="xs" c="var(--muted)">
{t('count_label')} {size}
</Text>
<Group gap="xs" align="center">
<ActionIcon
variant="default"
aria-label={t('count_dec_aria')}
onClick={() => onSizeChange(size - 1)}
style={stepperBtnStyle}
>
</ActionIcon>
<Slider
value={size}
onChange={onSizeChange}
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)' },
}}
/>
<ActionIcon
variant="default"
aria-label={t('count_inc_aria')}
onClick={() => onSizeChange(size + 1)}
style={stepperBtnStyle}
>
</ActionIcon>
</Group>
</Group>

{/* 速度 */}
<Group
gap="xs"
align="center"
style={{
padding: '1px 1px',
background: '#0c1530',
borderRadius: 12,
border: '1px solid rgba(255,255,255,0.06)',
}}
>
<Text size="xs" c="var(--muted)">
{t('speed_label')} {speed.toFixed(2)}
</Text>
<Group gap="xs" align="center">
<ActionIcon
variant="default"
aria-label={t('speed_down_aria')}
onClick={() => onSpeedChange(Number((speed - 0.05).toFixed(2)))}
style={stepperBtnStyle}
>
</ActionIcon>
<Slider
value={speed}
onChange={onSpeedChange}
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)' },
}}
/>
<ActionIcon
variant="default"
aria-label={t('speed_up_aria')}
onClick={() => onSpeedChange(Number((speed + 0.05).toFixed(2)))}
style={stepperBtnStyle}
>
</ActionIcon>
</Group>
</Group>

{/* 右寄せ:操作ボタン */}
<Box style={{ flex: '0 0 auto', marginLeft: 'auto' }}>
<Group gap="sm" align="center" wrap="nowrap">
<Button
onClick={onStart}
disabled={playing}
style={primaryBtnStyle}
leftSection={<span style={{ fontWeight: 700 }}>▶</span>}
>
{t('play')}
</Button>

<Button
variant="default"
onClick={onPause}
disabled={!playing}
leftSection={<span style={{ fontWeight: 700 }}>⏸</span>}
styles={{ root: pauseBtnStyle }}
>
{t('pause')}
</Button>

<Button variant="default" onClick={onShuffle} style={ghostBtnStyle}>
{t('shuffle')}
</Button>
</Group>
</Box>
</Group>
);
};

/* ---- 局所スタイル ---- */
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;
33 changes: 33 additions & 0 deletions src/components/HeaderBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="xs" gap={3}>
<Stack gap="xs" mb="xs">
<Anchor href="/" underline="never">
<Title
order={1}
style={{ margin: 0, fontWeight: 700, fontSize: 'clamp(20px, 2.4vw, 28px)' }}
>
{t('app_title')}
</Title>
</Anchor>
<Text c="var(--muted)" size="sm">
{t('app_desc')}
</Text>
</Stack>

{/* 右上:言語切替(既存コンポーネントをそのまま使用) */}
<Stack flex="auto" align="flex-end">
<LanguageSwitcher />
</Stack>
</Group>
);
};

export default HeaderBar;
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
120 changes: 120 additions & 0 deletions src/components/SortSection.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ value, titleKey, stepsCount, board, Legend, Overlay }) => {
const { t } = useTranslation();

return (
<Accordion.Item value={value} style={boardStyle}>
<Accordion.Control style={boardSummaryStyle}>
<Group justify="space-between" w="100%">
<Group gap={10} align="center">
<Text style={{ margin: 0, fontSize: 16, color: '#cbd5ff', fontWeight: 600 }}>
{t(titleKey)}
</Text>
</Group>
<Text size="xs" c="#cbd5ff">
{t('steps', { n: stepsCount })}
</Text>
</Group>
</Accordion.Control>

<Accordion.Panel>
<Bars board={board} ariaLabel={t(`bars_aria_${titleKey}`)} Overlay={Overlay} />
<Legend />
</Accordion.Panel>
</Accordion.Item>
);
};

/* ============= 内部: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 (
<div className="bars" aria-label={ariaLabel}>
{Overlay ? <Overlay board={board} /> : 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 (
<div key={i} className={classes} style={{ height: `${h}%` }} data-label={board.ids[i]} />
);
})}
</div>
);
};

/* ---- 見た目(セクション外枠) ---- */
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;
18 changes: 18 additions & 0 deletions src/components/algorithms/Bubble.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Group gap="xs" mt="xs">
<Badge variant="light" leftSection={<span className="legend-box legend-swap" />}>
{t('badge_swap')}
</Badge>
<Badge variant="light" leftSection={<span className="legend-box legend-sorted" />}>
{t('badge_sorted')}
</Badge>
</Group>
);
};
Loading