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
3 changes: 3 additions & 0 deletions client/src/features/chromatic-circle/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
export { useChromaticCircleData } from "./useChromaticCircleData";
export { useChordState } from "./useChordState";
export { useDragState } from "./useDragState";
export { useChordSelection } from "./useChordSelection";
export { useCustomChordState } from "./useCustomChordState";
11 changes: 11 additions & 0 deletions client/src/features/chromatic-circle/hooks/useChordSelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useState } from "react";

export interface ChordSelectionResult {
selectedChordName: string;
setSelectedChordName: (name: string) => void;
}

export function useChordSelection(initialChordName = "C"): ChordSelectionResult {
const [selectedChordName, setSelectedChordName] = useState(initialChordName);
return { selectedChordName, setSelectedChordName };
}
306 changes: 47 additions & 259 deletions client/src/features/chromatic-circle/hooks/useChordState.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,14 @@
import { useState, useEffect, useCallback } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import type { ChordType } from "@/features/chord/types";
import type { ScaleType } from "@/features/scale/types";
import type { Chord, PrimitiveShape } from "@/features/current-chord";
import {
transposeChord,
CHORD_INTERVALS,
rotateChordNotes,
rotateNamedChordRoot,
dedupePitchClasses,
getPrimitiveNoteIndices,
mirrorChordAboutRoot,
} from "@/features/chord/utils/transpose";
import type { Chord } from "@/features/current-chord";
import { transposeChord, CHORD_INTERVALS } from "@/features/chord/utils/transpose";
import { findNearestChord } from "@/features/chord/utils/findNearestChord";
import { CHORD_NAME_TO_DATA, getChordName } from "@/features/chord/data/chordNames";
import { CENTER } from "../constants/visualConstants";
import type { CustomChordState } from "../types";

const DRAG_THRESHOLD_PX = 8;
import { useDragState } from "./useDragState";
import { useChordSelection } from "./useChordSelection";
import { useCustomChordState } from "./useCustomChordState";

interface UseChordStateOptions {
onCurrentChordChange?: (chord: Chord) => void;
Expand All @@ -32,18 +23,35 @@ export function useChordState({
selectedScale,
pitchClasses,
}: UseChordStateOptions) {
const [selectedChordName, setSelectedChordName] = useState("C");
const [customFromChord, setCustomFromChord] = useState<CustomChordState | null>(null);

// Drag state
const [isDragging, setIsDragging] = useState(false);
const [draggedNoteIndex, setDraggedNoteIndex] = useState<number | null>(null);
const [dragTargetIndex, setDragTargetIndex] = useState<number | null>(null);
const [dragStartPoint, setDragStartPoint] = useState<{ x: number; y: number } | null>(null);
const [didDrag, setDidDrag] = useState(false);
const [suppressNextClick, setSuppressNextClick] = useState(false);
const [moveAnnouncement, setMoveAnnouncement] = useState("");

const { selectedChordName, setSelectedChordName } = useChordSelection();

const {
customFromChord,
setCustomFromChord,
handleRotateChord,
handleMirrorChord,
handleRandomChord,
handleSelectPrimitiveShape,
} = useCustomChordState({
selectedChordName,
setSelectedChordName,
onCurrentChordChange,
onAnnounce: setMoveAnnouncement,
});

const {
isDragging,
draggedNoteIndex,
dragTargetIndex,
didDrag,
startDrag,
updateDragPosition,
resetDrag,
} = useDragState();

const handleNoteDragStart = useCallback(
(noteIndex: number, e: ReactPointerEvent) => {
const currentChordIndices =
Expand All @@ -52,276 +60,56 @@ export function useChordState({
CHORD_INTERVALS[CHORD_NAME_TO_DATA[selectedChordName].type],
CHORD_NAME_TO_DATA[selectedChordName].root,
).map((n) => n.index);

if (!currentChordIndices.includes(noteIndex)) return;

e.stopPropagation();
setIsDragging(true);
setDidDrag(false);
setDraggedNoteIndex(noteIndex);
setDragTargetIndex(noteIndex);
setDragStartPoint({ x: e.clientX, y: e.clientY });

(e.target as HTMLElement).setPointerCapture(e.pointerId);
startDrag(noteIndex, e);
},
[customFromChord, selectedChordName],
[customFromChord, selectedChordName, startDrag],
);

const handleNoteDragMove = useCallback(
(e: ReactPointerEvent) => {
if (!isDragging || draggedNoteIndex === null || dragStartPoint === null) return;

const dx = e.clientX - dragStartPoint.x;
const dy = e.clientY - dragStartPoint.y;
const distance = Math.hypot(dx, dy);
if (distance < DRAG_THRESHOLD_PX) return;
if (!didDrag) setDidDrag(true);

const svgElement = (e.currentTarget as SVGGElement).ownerSVGElement;
if (!svgElement) return;
const rect = svgElement.getBoundingClientRect();
const x = e.clientX - rect.left - CENTER;
const y = e.clientY - rect.top - CENTER;
const angle = Math.atan2(x, -y);
const normalizedAngle = (angle + 2 * Math.PI) % (2 * Math.PI);
const index = Math.round((normalizedAngle / (2 * Math.PI)) * 12) % 12;

setDragTargetIndex(index);
},
[isDragging, draggedNoteIndex, dragStartPoint, didDrag],
);
// updateDragPosition already memoised by useDragState; expose under public name
const handleNoteDragMove = updateDragPosition;

const handleNoteDragEnd = useCallback(() => {
const resetDragState = () => {
setIsDragging(false);
setDidDrag(false);
setDraggedNoteIndex(null);
setDragTargetIndex(null);
setDragStartPoint(null);
};

if (!isDragging || draggedNoteIndex === null || dragTargetIndex === null) {
resetDragState();
return;
}

if (!didDrag) {
resetDragState();
if (!isDragging || draggedNoteIndex === null || dragTargetIndex === null || !didDrag) {
resetDrag();
return;
}

if (dragTargetIndex === draggedNoteIndex) {
setSuppressNextClick(true);
resetDragState();
resetDrag();
return;
}

const currentChordIndices =
customFromChord?.customNotes ??
transposeChord(
CHORD_INTERVALS[CHORD_NAME_TO_DATA[selectedChordName].type],
CHORD_NAME_TO_DATA[selectedChordName].root,
).map((n) => n.index);

const newNotes = currentChordIndices.map((idx) =>
idx === draggedNoteIndex ? dragTargetIndex : idx,
);

const { root: bestRoot, quality: bestQuality, matchScore } = findNearestChord(newNotes);

if (matchScore === 1) {
setCustomFromChord(null);
setSelectedChordName(getChordName(bestRoot, bestQuality));
onCurrentChordChange?.({ root: bestRoot, quality: bestQuality });
} else {
const newChord: CustomChordState = {
root: bestRoot,
quality: bestQuality,
customNotes: newNotes,
};
const newChord: CustomChordState = { root: bestRoot, quality: bestQuality, customNotes: newNotes };
setCustomFromChord(newChord);
onCurrentChordChange?.(newChord);
}

setMoveAnnouncement(
`Moved ${pitchClasses[draggedNoteIndex]} to ${pitchClasses[dragTargetIndex]}`,
);
setMoveAnnouncement(`Moved ${pitchClasses[draggedNoteIndex]} to ${pitchClasses[dragTargetIndex]}`);
setSuppressNextClick(true);
resetDragState();
resetDrag();
}, [
isDragging,
didDrag,
draggedNoteIndex,
dragTargetIndex,
customFromChord,
selectedChordName,
onCurrentChordChange,
pitchClasses,
isDragging, didDrag, draggedNoteIndex, dragTargetIndex,
customFromChord, selectedChordName,
setCustomFromChord, setSelectedChordName,
onCurrentChordChange, pitchClasses, resetDrag,
]);

const handleRotateChord = useCallback(
(direction: "clockwise" | "counterclockwise") => {
const semitones = direction === "clockwise" ? 1 : -1;

if (customFromChord) {
const rotatedCustomNotes = dedupePitchClasses(
rotateChordNotes(customFromChord.customNotes, semitones),
);
if (rotatedCustomNotes.length === 0) return;

const rotatedRoot = rotateNamedChordRoot(customFromChord.root, semitones);

if (customFromChord.primitiveShape) {
const newChord: CustomChordState = {
root: rotatedRoot,
quality: customFromChord.quality,
customNotes: rotatedCustomNotes,
primitiveShape: customFromChord.primitiveShape,
};
setCustomFromChord(newChord);
onCurrentChordChange?.(newChord);
setMoveAnnouncement(`Rotated ${direction} by one semitone`);
return;
}

const {
root: bestRoot,
quality: bestQuality,
matchScore,
} = findNearestChord(rotatedCustomNotes);

if (matchScore === 1) {
setCustomFromChord(null);
setSelectedChordName(getChordName(bestRoot, bestQuality));
} else {
const newChord: CustomChordState = {
root: bestRoot,
quality: bestQuality,
customNotes: rotatedCustomNotes,
};
setCustomFromChord(newChord);
onCurrentChordChange?.(newChord);
}
} else {
const { root, type } = CHORD_NAME_TO_DATA[selectedChordName];
const rotatedRoot = rotateNamedChordRoot(root, semitones);
setSelectedChordName(getChordName(rotatedRoot, type));
}

setMoveAnnouncement(`Rotated ${direction} by one semitone`);
},
[customFromChord, selectedChordName, onCurrentChordChange],
);

const handleMirrorChord = useCallback(() => {
const applyMirroredNotes = (mirroredNotes: number[]) => {
const {
root: bestRoot,
quality: bestQuality,
matchScore,
} = findNearestChord(mirroredNotes);

if (matchScore === 1) {
setCustomFromChord(null);
setSelectedChordName(getChordName(bestRoot, bestQuality));
onCurrentChordChange?.({ root: bestRoot, quality: bestQuality });
} else {
const newChord: CustomChordState = {
root: bestRoot,
quality: bestQuality,
customNotes: mirroredNotes,
};
setCustomFromChord(newChord);
onCurrentChordChange?.(newChord);
}
};

if (customFromChord) {
const mirroredNotes = dedupePitchClasses(
mirrorChordAboutRoot(customFromChord.customNotes, customFromChord.root),
);
if (mirroredNotes.length === 0) return;

if (customFromChord.primitiveShape) {
const newChord: CustomChordState = {
root: customFromChord.root,
quality: customFromChord.quality,
customNotes: mirroredNotes,
primitiveShape: customFromChord.primitiveShape,
};
setCustomFromChord(newChord);
onCurrentChordChange?.(newChord);
} else {
applyMirroredNotes(mirroredNotes);
}
} else {
const { root, type } = CHORD_NAME_TO_DATA[selectedChordName];
const currentNotes = CHORD_INTERVALS[type].map((interval) => (root + interval) % 12);
const mirroredNotes = dedupePitchClasses(mirrorChordAboutRoot(currentNotes, root));
applyMirroredNotes(mirroredNotes);
}

setMoveAnnouncement("Mirrored chord about root");
}, [customFromChord, selectedChordName, onCurrentChordChange]);

const handleRandomChord = useCallback(() => {
const allIndices = Array.from({ length: 12 }, (_, i) => i);
for (let i = 0; i < 3; i++) {
const j = i + Math.floor(Math.random() * (12 - i));
[allIndices[i], allIndices[j]] = [allIndices[j], allIndices[i]];
}
const randomNotes = allIndices.slice(0, 3);
const { root: bestRoot, quality: bestQuality } = findNearestChord(randomNotes);
const newChord: CustomChordState = {
root: bestRoot,
quality: bestQuality,
customNotes: randomNotes,
};
setCustomFromChord(newChord);
onCurrentChordChange?.(newChord);
setMoveAnnouncement("Generated random chord");
}, [onCurrentChordChange]);

const handleSelectPrimitiveShape = useCallback(
(shape: PrimitiveShape) => {
const root = customFromChord?.root ?? CHORD_NAME_TO_DATA[selectedChordName].root;
const quality: ChordType =
shape === "equilateral-triangle"
? "aug"
: shape === "suspended-triangle"
? "major"
: shape === "rectangle"
? "dom7"
: "dim";
const customNotes = getPrimitiveNoteIndices(root, shape);
const newChord: CustomChordState = {
root,
quality,
customNotes,
primitiveShape: shape,
};

setCustomFromChord(newChord);
onCurrentChordChange?.(newChord);
setMoveAnnouncement(
`Selected ${
shape === "equilateral-triangle"
? "equilateral triangle"
: shape === "suspended-triangle"
? "sus4 triangle"
: shape === "rectangle"
? "rectangle"
: "square"
}`,
);
},
[customFromChord, selectedChordName, onCurrentChordChange],
);

const effectiveRoot =
customFromChord?.root ?? CHORD_NAME_TO_DATA[selectedChordName].root;
const effectiveQuality =
customFromChord?.quality ?? CHORD_NAME_TO_DATA[selectedChordName].type;
const effectiveRoot = customFromChord?.root ?? CHORD_NAME_TO_DATA[selectedChordName].root;
const effectiveQuality = customFromChord?.quality ?? CHORD_NAME_TO_DATA[selectedChordName].type;

useEffect(() => {
if (!customFromChord) {
Expand Down
Loading