diff --git a/README.md b/README.md index 72035d7..132d204 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # KeyBpmMap ![Tests](https://github.com/samsta/KeyBpmMap/actions/workflows/test.yml/badge.svg) +![Build](https://github.com/samsta/KeyBpmMap/actions/workflows/deploy.yml/badge.svg) KeyBpmMap is a client-only React application for exploring the harmonic and tempo shape of a DJ music library. It reads local Engine DJ SQLite databases and Traktor `collection.nml` files directly in the browser, normalizes supported key formats into Camelot notation, and renders both a polar density map and a BPM-vs-key heatmap for fast library analysis. @@ -11,6 +12,7 @@ It reads local Engine DJ SQLite databases and Traktor `collection.nml` files dir - Browser-only Traktor `collection.nml` loading, including playlist metadata - Engine numeric, Open Key, and standard musical key → Camelot conversion - Polar harmonic density map and rectangular BPM heatmap +- Weighted path finder between start/end tracks with configurable key/tempo costs - Playlist, rating, BPM, and text filters - Region inspection for exact track lists inside a selected cell - Playlist transition summaries derived from playlist ordering diff --git a/package-lock.json b/package-lock.json index 6b73538..baea878 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "keybpmmap", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "keybpmmap", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "d3": "^7.9.0", "fast-xml-parser": "^5.8.0", diff --git a/package.json b/package.json index 747d0b4..7957745 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "keybpmmap", "private": true, - "version": "0.4.0", + "version": "0.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.css b/src/App.css index 7be3506..439cabe 100644 --- a/src/App.css +++ b/src/App.css @@ -109,7 +109,8 @@ } .source-panel, -.filters-panel { +.filters-panel, +.pathfinder-panel { padding: 24px 28px; } @@ -144,6 +145,10 @@ select { box-sizing: border-box; } +input[type='checkbox'] { + width: auto; +} + .stats-grid, .visual-grid, .insights-grid { @@ -253,6 +258,156 @@ select { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } +.pathfinder-panel { + display: grid; + gap: 16px; + order: 999; +} + +.path-weight-grid { + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); +} + +.path-section-heading { + margin: 0 0 8px 0; + font-size: 0.85rem; + font-weight: 600; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.path-weight-grid label { + grid-template-rows: minmax(2.5em, auto) min-content; +} + +.path-weight-label-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.path-weight-label-text { + display: block; + line-height: 1.25; +} + +.path-weight-tooltip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + min-width: 1rem; + color: #94a3b8; + font-size: 0.85rem; + line-height: 1; + cursor: help; +} + +.path-weight-tooltip:focus-visible { + outline: 1px solid rgba(34, 211, 238, 0.7); + outline-offset: 2px; + border-radius: 50%; +} + +.pathfinder-panel input[type='number'], +.pathfinder-panel input[type='search'], +.pathfinder-panel select { + min-height: 44px; +} + +.path-checkbox { + align-self: end; + display: flex; + align-items: center; + gap: 10px; +} + +.path-helper-copy, +.path-status { + color: #94a3b8; +} + +.path-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.path-results-grid { + display: grid; + gap: 16px; +} + +.path-graph-wrapper { + overflow-x: auto; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + padding: 10px; + background: rgba(15, 23, 42, 0.4); +} + +.path-graph { + width: 100%; + min-width: 460px; + height: auto; +} + +.path-graph-row { + cursor: pointer; +} + +.path-graph-row polyline { + fill: none; + stroke: rgba(148, 163, 184, 0.55); + stroke-width: 2; +} + +.path-graph-row circle { + fill: rgba(30, 41, 59, 1); + stroke: rgba(148, 163, 184, 0.55); + stroke-width: 2; +} + +.path-graph-row.is-selected polyline, +.path-graph-row.is-selected circle { + stroke: rgba(34, 211, 238, 0.9); +} + +.path-graph-label, +.path-graph-node-label { + fill: #cbd5e1; + font-size: 0.78rem; +} + +.path-graph-cost { + fill: #38bdf8; + font-size: 0.6rem; +} + +.path-graph-track-label { + fill: #94a3b8; + font-size: 0.63rem; +} + +.path-graph-track-artist { + font-weight: 700; + fill: #cbd5e1; +} + +.path-summary-list { + max-height: 220px; + overflow: auto; + padding-right: 6px; +} + +.path-details { + display: grid; + gap: 10px; +} + .insight-subtitle, .placeholder-text, .error-banner { @@ -323,6 +478,49 @@ select { font-style: normal; } +.track-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.track-action-button { + padding: 0.45rem 0.8rem; + font-size: 0.82rem; +} + +.scope-track-table-wrapper { + overflow: auto; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 14px; +} + +.scope-track-table { + width: 100%; + min-width: 680px; + border-collapse: collapse; + font-size: 0.86rem; +} + +.scope-track-table th, +.scope-track-table td { + padding: 10px 12px; + border-bottom: 1px solid rgba(148, 163, 184, 0.16); + text-align: left; + vertical-align: middle; +} + +.scope-track-table thead th { + font-size: 0.78rem; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.scope-track-table tbody td { + color: #cbd5e1; +} + .simple-list button { width: 100%; padding: 0; @@ -390,6 +588,7 @@ select { .hero-panel, .source-panel, .filters-panel, + .pathfinder-panel, .chart-panel, .insight-panel { padding: 20px; diff --git a/src/App.tsx b/src/App.tsx index 4616997..e70b7f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,14 @@ import { CAMELOT_KEYS, formatKey, type KeyRepresentation } from './lib/camelot' import { downloadSvgAsPng } from './lib/exportSvg' import { loadLibraryFromFile } from './lib/libraryLoader' import { createMockLibrary } from './lib/mockData' +import { + DEFAULT_PATH_FINDER_SETTINGS, + clampPathFinderSettings, + findNavigationPaths, + type NavigationPath, + type PathFinderSettings, + type PathFinderWeights, +} from './lib/pathFinder' import type { LibraryData, SparseCellSummary, TrackRecord } from './types' const initialFilters = { @@ -41,10 +49,58 @@ interface OpenFilePickerConfig { } type PolarKeyMode = 'both' | 'A' | 'B' +type PathSortMode = 'total' | 'average' | 'max' const POLAR_KEY_MODES = ['both', 'A', 'B'] as const +const PATH_SORT_MODES: Array<{ value: PathSortMode; label: string }> = [ + { value: 'total', label: 'Total Cost' }, + { value: 'average', label: 'Average Transition Cost' }, + { value: 'max', label: 'Max Transition Cost' }, +] const KEY_REPRESENTATIONS = ['camelot', 'open-key', 'musical'] as const const KEY_REPRESENTATION_STORAGE_KEY = 'keybpmmap.keyRepresentation' +const PATH_FINDER_SETTINGS_STORAGE_KEY = 'keybpmmap.pathFinderSettings' const APP_VERSION = __APP_VERSION__ +const PATH_WEIGHT_FIELDS: Array<{ + key: keyof PathFinderWeights + label: string + description: string + exampleFrom?: string + exampleTo?: string + exampleText?: string +}> = [ + { key: 'sameKey', label: 'Same Key', description: 'Keep the same harmonic slot.', exampleFrom: '8A', exampleTo: '8A' }, + { key: 'oneUp', label: 'One Up', description: 'Move clockwise by one on the wheel.', exampleFrom: '5A', exampleTo: '6A' }, + { key: 'oneDown', label: 'One Down', description: 'Move counter-clockwise by one on the wheel.', exampleFrom: '6A', exampleTo: '5A' }, + { + key: 'aToB', + label: 'A → B', + description: 'Jump from the inner ring to the outer ring, or from a minor key to its relative major.', + exampleFrom: '8A', + exampleTo: '8B', + }, + { + key: 'bToA', + label: 'B → A', + description: 'Jump from the outer ring to the inner ring, or from a major key to its relative minor.', + exampleFrom: '8B', + exampleTo: '8A', + }, + { key: 'energyBoost', label: 'xA → (x+3)B', description: 'Minor to major energy lift.', exampleFrom: '5A', exampleTo: '8B' }, + { key: 'energyDrop', label: 'xB → (x-3)A', description: 'Major to minor inverse of the energy lift.', exampleFrom: '8B', exampleTo: '5A' }, + { key: 'centerJump', label: 'Center Jump', description: 'Jump across the wheel center.', exampleFrom: '5A', exampleTo: '11A' }, + { + key: 'tempoPercent', + label: 'Tempo Per %', + description: 'Cost per tempo percent difference.', + exampleText: 'Example: 120.0 BPM → 121.2 BPM is 1%.', + }, + { + key: 'keyChangeByTempo', + label: 'Tempo Key Change', + description: 'Cost to shift key by tempo before evaluating transitions.', + exampleText: 'Example: 8A at 120 BPM +5.946% → 3A at 127.1 BPM.', + }, +] function App() { const [library, setLibrary] = useState(() => createMockLibrary()) @@ -57,6 +113,22 @@ function App() { const [keyRepresentation, setKeyRepresentation] = useState( getInitialKeyRepresentation, ) + const [pathFinderSettings, setPathFinderSettings] = useState( + getInitialPathFinderSettings, + ) + const [pathSelection, setPathSelection] = useState({ + startTrack: '', + endTrack: '', + }) + const [pathSearchRequest, setPathSearchRequest] = useState<{ + startTrackId: string + endTrackId: string + settings: PathFinderSettings + } | null>(null) + const [pathSearchStatus, setPathSearchStatus] = useState(null) + const [selectedPathId, setSelectedPathId] = useState(null) + const [pathSortMode, setPathSortMode] = useState('total') + const [showScopeTrackTable, setShowScopeTrackTable] = useState(false) const fileInputRef = useRef(null) const polarRef = useRef(null) const heatmapRef = useRef(null) @@ -69,6 +141,17 @@ function App() { } }, [keyRepresentation]) + useEffect(() => { + try { + window.localStorage.setItem( + PATH_FINDER_SETTINGS_STORAGE_KEY, + JSON.stringify(pathFinderSettings), + ) + } catch { + // Ignore localStorage persistence errors. + } + }, [pathFinderSettings]) + const playlistLookup = useMemo( () => new Map(library.playlists.map((playlist) => [playlist.id, playlist])), [library.playlists], @@ -138,6 +221,90 @@ function App() { ) const summary = useMemo(() => summarizeTracks(filteredTracks), [filteredTracks]) const sparseCells = useMemo(() => findSparseCells(densityCells, bpmBands), [bpmBands, densityCells]) + const pathScopeTracks = useMemo( + () => + [...filteredTracks].sort( + (left, right) => + left.artist.localeCompare(right.artist) || + left.title.localeCompare(right.title) || + left.id.localeCompare(right.id), + ), + [filteredTracks], + ) + const pathTrackOptions = useMemo( + () => + pathScopeTracks.map((track) => ({ + id: track.id, + label: formatTrackSearchLabel(track, keyRepresentation), + })), + [keyRepresentation, pathScopeTracks], + ) + const pathTrackIdByLabel = useMemo( + () => + new Map( + pathTrackOptions.map((option) => [option.label.toLowerCase(), option.id]), + ), + [pathTrackOptions], + ) + const pathTrackLabelById = useMemo( + () => new Map(pathTrackOptions.map((option) => [option.id, option.label])), + [pathTrackOptions], + ) + const pathTrackLookup = useMemo( + () => new Map(pathScopeTracks.map((track) => [track.id, track])), + [pathScopeTracks], + ) + const searchedPathSelection = useMemo(() => { + if (!pathSearchRequest) { + return null + } + + if (!pathTrackLookup.has(pathSearchRequest.startTrackId) || !pathTrackLookup.has(pathSearchRequest.endTrackId)) { + return null + } + + return pathSearchRequest + }, [pathSearchRequest, pathTrackLookup]) + const navigationPaths = useMemo( + () => { + if (!searchedPathSelection) { + return [] + } + + return findNavigationPaths( + pathScopeTracks, + searchedPathSelection.startTrackId, + searchedPathSelection.endTrackId, + searchedPathSelection.settings, + ) + }, + [pathScopeTracks, searchedPathSelection], + ) + const sortedNavigationPaths = useMemo( + () => + [...navigationPaths].sort((left, right) => { + const metricDelta = getPathSortMetric(left, pathSortMode) - getPathSortMetric(right, pathSortMode) + if (metricDelta !== 0) { + return metricDelta + } + + return left.totalCost - right.totalCost || left.steps.length - right.steps.length + }), + [navigationPaths, pathSortMode], + ) + const selectedPath = useMemo( + () => + sortedNavigationPaths.find((path) => path.id === selectedPathId) ?? + sortedNavigationPaths[0] ?? + null, + [selectedPathId, sortedNavigationPaths], + ) + const pathGraphMaxNodes = useMemo( + () => Math.max(...sortedNavigationPaths.map((path) => path.trackIds.length), 0), + [sortedNavigationPaths], + ) + const pathGraphWidth = Math.max(460, pathGraphMaxNodes * 150) + const pathGraphHeight = Math.max(150, sortedNavigationPaths.length * 92 + 24) const formatVisibleKey = useMemo( () => (camelotKey: string) => formatKey(camelotKey, keyRepresentation), [keyRepresentation], @@ -181,6 +348,102 @@ function App() { setSelectedCellId(null) } + const handlePathWeightChange = (weight: keyof PathFinderWeights, value: string) => { + const nextValue = Number(value) + if (!Number.isFinite(nextValue)) { + return + } + + setPathFinderSettings((current) => + clampPathFinderSettings({ + ...current, + weights: { + ...current.weights, + [weight]: nextValue, + }, + }), + ) + } + + const handlePathMaxCostChange = (value: string) => { + const nextValue = Number(value) + if (!Number.isFinite(nextValue)) { + return + } + + setPathFinderSettings((current) => + clampPathFinderSettings({ + ...current, + maxTotalCost: nextValue, + }), + ) + } + + const handlePathMaxStepCostChange = (value: string) => { + const nextValue = Number(value) + if (!Number.isFinite(nextValue)) { + return + } + + setPathFinderSettings((current) => + clampPathFinderSettings({ + ...current, + maxStepCost: nextValue, + }), + ) + } + + const handlePathMaxAverageCostChange = (value: string) => { + const nextValue = Number(value) + if (!Number.isFinite(nextValue)) { + return + } + + setPathFinderSettings((current) => + clampPathFinderSettings({ + ...current, + maxAverageStepCost: nextValue, + }), + ) + } + + const handleSetPathTrack = (target: 'startTrack' | 'endTrack', trackId: string) => { + if (!pathTrackLabelById.has(trackId)) { + return + } + + setPathSelection((current) => ({ ...current, [target]: trackId })) + } + + const handleFindPath = () => { + const startTrackId = pathTrackLabelById.has(pathSelection.startTrack) + ? pathSelection.startTrack + : resolveTrackId(pathSelection.startTrack, pathTrackIdByLabel, pathTrackLookup) + const endTrackId = pathTrackLabelById.has(pathSelection.endTrack) + ? pathSelection.endTrack + : resolveTrackId(pathSelection.endTrack, pathTrackIdByLabel, pathTrackLookup) + + if (!startTrackId || !endTrackId) { + setPathSearchStatus('Select valid Start Track and End Track values from the list.') + setPathSearchRequest(null) + return + } + + if (startTrackId === endTrackId) { + setPathSearchStatus('Start Track and End Track must be different.') + setPathSearchRequest(null) + return + } + + setPathSearchStatus(null) + setPathSearchRequest({ + startTrackId, + endTrackId, + settings: clampPathFinderSettings(pathFinderSettings), + }) + setSelectedPathId(null) + } + const handleOpenDatabase = async () => { const pickerWindow = window as Window & { showOpenFilePicker?: (options?: OpenFilePickerConfig) => Promise @@ -490,6 +753,371 @@ function App() { +
+
+
+

Tracks in Scope

+

+ View a list of tracks matching the above filter criteria. +

+
+ +
+ {showScopeTrackTable ? ( +
+ + + + + + + + + + + + + {pathScopeTracks.map((track) => { + const isInPlots = track.bpm !== null && polarKeySet.has(track.camelotKey) + return ( + + + + + + + + + ) + })} + +
ArtistTitleBPMKeyIn MapsPath Finder
{track.artist}{track.title}{track.bpm === null ? 'No BPM' : track.bpm.toFixed(1)}{formatVisibleKey(track.camelotKey)} + {isInPlots + ? 'Yes' + : (track.bpm === null ? 'No (missing BPM)' : 'No (hidden by key filter)')} + +
+ + +
+
+
+ ) : null} +
+ +
+
+
+

Path Finder

+

+ Set transition costs to guide route quality between tracks. +

+

+ Costs define how expensive each key or tempo move is. Lower values prefer that move. The + search keeps only the 10 best paths under your path limits and runs only when you click Find + Path. +

+
+
+ +
+ + + {pathTrackOptions.map((option) => ( + + + + + {pathTrackOptions.map((option) => ( + + + + + + + + +
+
+ + {pathSearchStatus ?

{pathSearchStatus}

: null} +
+ +

Transition costs

+
+ {PATH_WEIGHT_FIELDS.map((field) => { + const helpText = `${field.description}${ + field.exampleFrom && field.exampleTo + ? ` Example: ${formatVisibleKey(field.exampleFrom)} → ${formatVisibleKey(field.exampleTo)}.` + : ` ${field.exampleText ?? ''}` + }` + + return ( + + ) + })} +
+ + {searchedPathSelection ? ( + sortedNavigationPaths.length > 0 ? ( +
+
+ + {sortedNavigationPaths.map((path, pathIndex) => { + const y = 38 + pathIndex * 92 + const stepWidth = + path.trackIds.length <= 1 ? 0 : (pathGraphWidth - 120) / (path.trackIds.length - 1) + const points = path.trackIds + .map((_, index) => `${60 + index * stepWidth},${y}`) + .join(' ') + const isSelected = selectedPath?.id === path.id + const pathAriaLabel = `Select navigation path ${pathIndex + 1}. Total cost ${path.totalCost.toFixed(2)}. Average cost ${getPathAverageCost(path).toFixed(2)}. Maximum transition cost ${getPathMaxStepCost(path).toFixed(2)}.` + + return ( + setSelectedPathId(path.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setSelectedPathId(path.id) + } + }} + > + + {path.trackIds.map((trackId, nodeIndex) => { + const track = pathTrackLookup.get(trackId) + const x = 60 + nodeIndex * stepWidth + + return ( + + {track ? ( + + {track.artist} - {track.title} (Node {nodeIndex + 1}, Key:{' '} + {formatVisibleKey(track.camelotKey)}) + + ) : null} + + + {nodeIndex + 1} + + {track ? ( + + {track.artist} + {track.title} + + ) : null} + + ) + })} + + #{pathIndex + 1} + + + Tot {path.totalCost.toFixed(2)} · Avg {getPathAverageCost(path).toFixed(2)} · Max{' '} + {getPathMaxStepCost(path).toFixed(2)} + + + ) + })} + +
+ +
    + {sortedNavigationPaths.map((path, pathIndex) => ( +
  • + +
  • + ))} +
+ + {selectedPath ? ( +
+

+ Path total: {selectedPath.totalCost.toFixed(2)} across {selectedPath.steps.length} transition + {selectedPath.steps.length === 1 ? '' : 's'} +

+

+ Path avg/max transition cost: {getPathAverageCost(selectedPath).toFixed(2)} /{' '} + {getPathMaxStepCost(selectedPath).toFixed(2)} +

+
    + {selectedPath.steps.map((step, index) => { + const fromTrack = pathTrackLookup.get(step.fromTrackId) + const toTrack = pathTrackLookup.get(step.toTrackId) + return ( +
  • +
    + Transition {index + 1} + + {fromTrack ? formatTrackLabel(fromTrack) : step.fromTrackId} →{' '} + {toTrack ? formatTrackLabel(toTrack) : step.toTrackId} + +
    +
    + {formatPathStepSummary(step, formatVisibleKey)} + {step.stepCost.toFixed(2)} cost +
    +
  • + ) + })} +
+
+ ) : null} +
+ ) : ( +

+ No path found for the selected tracks and max total cost. Increase Max Total Cost or adjust + costs, then click Find Path again. +

+ ) + ) : ( +

Pick Start Track and End Track, then click Find Path.

+ )} +
+
@@ -596,10 +1224,26 @@ function App() { {track.title}
- {track.bpm ? `${track.bpm.toFixed(1)} BPM` : 'No BPM'} + {track.bpm === null ? 'No BPM' : `${track.bpm.toFixed(1)} BPM`} {formatVisibleKey(track.camelotKey)} {formatRating(track)}
+
+ + +
)) ) : ( @@ -743,6 +1387,159 @@ function formatKeyRepresentationLabel(representation: KeyRepresentation): string } } +function formatTrackLabel(track: TrackRecord): string { + return `${track.artist} — ${track.title}` +} + +function formatTrackSearchLabel(track: TrackRecord, keyRepresentation: KeyRepresentation): string { + const key = formatKey(track.camelotKey, keyRepresentation) + const bpmLabel = track.bpm === null ? 'No BPM' : `${track.bpm.toFixed(1)} BPM` + return `${track.artist} — ${track.title} (${key}, ${bpmLabel})` +} + +function formatWeightLabel( + weight: keyof PathFinderWeights, + fallbackLabel: string, + keyRepresentation: KeyRepresentation, +): string { + if (keyRepresentation === 'musical') { + switch (weight) { + case 'oneUp': + return 'One 5th up' + case 'oneDown': + return 'One 5th down' + case 'aToB': + return 'min → rel maj' + case 'bToA': + return 'maj → rel min' + case 'energyBoost': + return 'min → maj' + case 'energyDrop': + return 'maj → min' + default: + return fallbackLabel + } + } + + const minorLabel = getRepresentationMinorLabel(keyRepresentation) + const majorLabel = getRepresentationMajorLabel(keyRepresentation) + + switch (weight) { + case 'aToB': + return `${minorLabel} → ${majorLabel}` + case 'bToA': + return `${majorLabel} → ${minorLabel}` + case 'energyBoost': + return `x${minorLabel} → (x+3)${majorLabel}` + case 'energyDrop': + return `x${majorLabel} → (x-3)${minorLabel}` + default: + return fallbackLabel + } +} + +function formatPathStepSummary( + step: NavigationPath['steps'][number], + formatVisibleKey: (camelotKey: string) => string, +): string { + const parts = [ + `${formatVisibleKey(step.fromKey)} ${step.fromBpm.toFixed(1)} → ${formatVisibleKey(step.toKey)} ${step.toBpm.toFixed(1)} BPM`, + `${formatPathRule(step.keyRule)} (${step.keyCost.toFixed(2)})`, + `tempo ${step.tempoPercentDelta.toFixed(2)}% (${step.tempoCost.toFixed(2)})`, + ] + + if (step.adjustment !== 'none') { + parts.push(`${formatTempoAdjustment(step.adjustment)} (${step.adjustmentCost.toFixed(2)})`) + } + + return parts.join(' · ') +} + +function formatPathRule(rule: keyof PathFinderWeights): string { + switch (rule) { + case 'sameKey': + return 'Same Key' + case 'oneUp': + return 'One Up' + case 'oneDown': + return 'One Down' + case 'aToB': + return 'A→B' + case 'bToA': + return 'B→A' + case 'energyBoost': + return 'xA→(x+3)B' + case 'energyDrop': + return 'xB→(x-3)A' + case 'centerJump': + return 'Center Jump' + case 'tempoPercent': + return 'Tempo' + case 'keyChangeByTempo': + return 'Tempo Key Change' + default: + return rule + } +} + +function formatTempoAdjustment(adjustment: NavigationPath['steps'][number]['adjustment']): string { + if (adjustment === 'speed-up') { + return 'Speed Up' + } + + if (adjustment === 'slow-down') { + return 'Slow Down' + } + + return 'No Adjustment' +} + +function resolveTrackId( + value: string, + pathTrackIdByLabel: Map, + pathTrackLookup: Map, +): string | null { + const trimmed = value.trim() + if (!trimmed) { + return null + } + + const byLabel = pathTrackIdByLabel.get(trimmed.toLowerCase()) + if (byLabel) { + return byLabel + } + + return pathTrackLookup.has(trimmed) ? trimmed : null +} + +function getPathAverageCost(path: NavigationPath): number { + if (path.steps.length === 0) { + return 0 + } + + return path.totalCost / path.steps.length +} + +function getPathMaxStepCost(path: NavigationPath): number { + return path.steps.reduce((max, step) => Math.max(max, step.stepCost), 0) +} + +function getPathSortMetric(path: NavigationPath, mode: PathSortMode): number { + if (mode === 'average') { + return getPathAverageCost(path) + } + + if (mode === 'max') { + return getPathMaxStepCost(path) + } + + return path.totalCost +} + +function asPathSortMode(value: string): PathSortMode { + return PATH_SORT_MODES.some((mode) => mode.value === value) ? (value as PathSortMode) : 'total' +} + function asKeyRepresentation(value: string): KeyRepresentation { return KEY_REPRESENTATIONS.includes(value as KeyRepresentation) ? (value as KeyRepresentation) @@ -761,4 +1558,29 @@ function getInitialKeyRepresentation(): KeyRepresentation { } } +function getInitialPathFinderSettings(): PathFinderSettings { + if (typeof window === 'undefined') { + return DEFAULT_PATH_FINDER_SETTINGS + } + + try { + const raw = window.localStorage.getItem(PATH_FINDER_SETTINGS_STORAGE_KEY) + if (!raw) { + return DEFAULT_PATH_FINDER_SETTINGS + } + + const parsed = JSON.parse(raw) + return clampPathFinderSettings({ + ...DEFAULT_PATH_FINDER_SETTINGS, + ...parsed, + weights: { + ...DEFAULT_PATH_FINDER_SETTINGS.weights, + ...(parsed as { weights?: Partial }).weights, + }, + }) + } catch { + return DEFAULT_PATH_FINDER_SETTINGS + } +} + export default App diff --git a/src/components/HeatmapChart.tsx b/src/components/HeatmapChart.tsx index 2d8f870..40bd8bf 100644 --- a/src/components/HeatmapChart.tsx +++ b/src/components/HeatmapChart.tsx @@ -92,7 +92,7 @@ const HeatmapChart = forwardRef( key={camelotKey} x={x} y={labelY} - textAnchor={rotateLabel ? 'beginning' : 'middle'} + textAnchor={rotateLabel ? 'start' : 'middle'} className="chart-label" transform={rotateLabel ? `rotate(90 ${x} ${labelY})` : undefined} > diff --git a/src/lib/engineDj.ts b/src/lib/engineDj.ts index 804bb82..a5c8802 100644 --- a/src/lib/engineDj.ts +++ b/src/lib/engineDj.ts @@ -41,8 +41,14 @@ function findTableName(tableNames: string[], target: string): string | null { } function getFirstValue(row: SqlRow, names: string[]): T | null { + const normalizedValues = new Map() for (const [key, value] of Object.entries(row)) { - if (names.some((name) => name.toLowerCase() === key.toLowerCase())) { + normalizedValues.set(normalizeColumnName(key), value) + } + + for (const name of names) { + const value = normalizedValues.get(normalizeColumnName(name)) + if (value !== undefined) { return value as T } } @@ -50,6 +56,10 @@ function getFirstValue(row: SqlRow, names: string[]): T | null { return null } +function normalizeColumnName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]/g, '') +} + function asId(value: unknown): string | null { if (typeof value === 'string') { return value.trim() || null @@ -154,7 +164,9 @@ export async function loadEngineDjLibrary(file: File): Promise { return null } - const bpm = asNumber(getFirstValue(row, ['bpmAnalyzed', 'bpm', 'BPM'])) + const bpm = asNumber( + getFirstValue(row, ['bpmAnalyzed', 'bpm', 'BPM', 'tempo', 'tempoAnalyzed']), + ) const rawKey = asNumber(getFirstValue(row, ['key', 'keyAnalyzed', 'musicalKey'])) return { diff --git a/src/lib/mockData.ts b/src/lib/mockData.ts index 445a995..fa4fc4c 100644 --- a/src/lib/mockData.ts +++ b/src/lib/mockData.ts @@ -111,6 +111,50 @@ const tracks: TrackRecord[] = [ path: '/mock/sunrise-draft.wav', playlists: ['p-3'], }, + { + id: 't-011', + title: 'Arc Signal', + artist: 'Mara Tone', + bpm: 121, + rating: 4, + camelotKey: '8A', + rawKey: null, + path: '/mock/arc-signal.wav', + playlists: ['p-4'], + }, + { + id: 't-012', + title: 'Delta Lift', + artist: 'Mara Tone', + bpm: 122, + rating: 4, + camelotKey: '9A', + rawKey: null, + path: '/mock/delta-lift.wav', + playlists: ['p-4'], + }, + { + id: 't-013', + title: 'Mirror Rail', + artist: 'Mara Tone', + bpm: 123, + rating: 4, + camelotKey: '9B', + rawKey: null, + path: '/mock/mirror-rail.wav', + playlists: ['p-4'], + }, + { + id: 't-014', + title: 'Copper Dawn', + artist: 'Mara Tone', + bpm: 124, + rating: 4, + camelotKey: '10B', + rawKey: null, + path: '/mock/copper-dawn.wav', + playlists: ['p-4'], + }, ] const playlists: PlaylistInfo[] = [ @@ -129,6 +173,11 @@ const playlists: PlaylistInfo[] = [ name: 'Warm-up bridge', trackIds: ['t-009', 't-010', 't-003', 't-004'], }, + { + id: 'p-4', + name: 'Path finder lane', + trackIds: ['t-001', 't-011', 't-012', 't-013', 't-014'], + }, ] const transitions: TransitionRecord[] = playlists.flatMap((playlist) => { diff --git a/src/lib/pathFinder.test.ts b/src/lib/pathFinder.test.ts new file mode 100644 index 0000000..bafcdd8 --- /dev/null +++ b/src/lib/pathFinder.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { + findNavigationPaths, + type PathFinderSettings, +} from './pathFinder' +import type { TrackRecord } from '../types' + +const makeTrack = ( + id: string, + camelotKey: string, + bpm: number, +): TrackRecord => ({ + id, + title: id, + artist: 'artist', + bpm, + rating: null, + camelotKey, + rawKey: null, + path: '', + playlists: [], +}) + +const TEST_PATH_FINDER_SETTINGS: PathFinderSettings = { + weights: { + sameKey: 0, + oneUp: 1, + oneDown: 1, + aToB: 2, + bToA: 2, + energyBoost: 1, + energyDrop: 1, + centerJump: 4, + tempoPercent: 1, + keyChangeByTempo: 3, + }, + allowKeyChangeByTempo: false, + maxTotalCost: 10, + maxStepCost: 4, + maxAverageStepCost: 3, +} + +describe('findNavigationPaths', () => { + it('returns the lowest-cost path first', () => { + const tracks: TrackRecord[] = [ + makeTrack('start', '8A', 120), + makeTrack('end', '8B', 120), + makeTrack('slow-hop', '8A', 122), + makeTrack('fast-hop', '8A', 126), + ] + + const paths = findNavigationPaths(tracks, 'start', 'end', { + ...TEST_PATH_FINDER_SETTINGS, + maxTotalCost: 10, + }) + + expect(paths.length).toBeGreaterThan(0) + expect(paths[0]?.trackIds).toEqual(['start', 'end']) + expect(paths[0]?.totalCost).toBeCloseTo(2, 6) + }) + + it('caps results to 10 best paths and honors maxTotalCost', () => { + const tracks: TrackRecord[] = [ + makeTrack('start', '8A', 120), + makeTrack('end', '8B', 120), + makeTrack('mid-1', '8A', 121), + makeTrack('mid-2', '8A', 122), + makeTrack('mid-3', '8A', 123), + makeTrack('mid-4', '8A', 124), + makeTrack('mid-5', '8A', 125), + makeTrack('mid-6', '8A', 126), + ] + + const allPaths = findNavigationPaths(tracks, 'start', 'end', { + ...TEST_PATH_FINDER_SETTINGS, + maxTotalCost: 100, + }) + expect(allPaths).toHaveLength(10) + + const tightPaths = findNavigationPaths(tracks, 'start', 'end', { + ...TEST_PATH_FINDER_SETTINGS, + maxTotalCost: 2.1, + }) + expect(tightPaths).toHaveLength(1) + expect(tightPaths[0]?.trackIds).toEqual(['start', 'end']) + }) + + it('supports optional key change by tempo', () => { + const tracks: TrackRecord[] = [ + makeTrack('start', '8A', 120), + makeTrack('end', '3A', 127), + ] + + const disabled = findNavigationPaths(tracks, 'start', 'end', { + ...TEST_PATH_FINDER_SETTINGS, + allowKeyChangeByTempo: false, + maxTotalCost: 10, + }) + expect(disabled).toEqual([]) + + const enabled = findNavigationPaths(tracks, 'start', 'end', { + ...TEST_PATH_FINDER_SETTINGS, + allowKeyChangeByTempo: true, + maxTotalCost: 10, + maxAverageStepCost: 10, + }) + + expect(enabled).toHaveLength(1) + expect(enabled[0]?.steps[0]?.adjustment).toBe('speed-up') + expect(enabled[0]?.totalCost).toBeGreaterThan(3) + expect(enabled[0]?.totalCost).toBeLessThan(4) + }) + + it('creates unique ids for paths with the same track sequence but different steps', () => { + const tracks: TrackRecord[] = [ + makeTrack('start', '8A', 120), + makeTrack('end', '9A', 126), + ] + + const paths = findNavigationPaths(tracks, 'start', 'end', { + ...TEST_PATH_FINDER_SETTINGS, + allowKeyChangeByTempo: true, + maxTotalCost: 100, + maxStepCost: 100, + maxAverageStepCost: 100, + }) + + expect(paths).toHaveLength(2) + expect(paths[0]?.trackIds).toEqual(['start', 'end']) + expect(paths[1]?.trackIds).toEqual(['start', 'end']) + expect(paths[0]?.id).not.toBe(paths[1]?.id) + }) + + it('supports xB to (x-3)A transitions', () => { + const tracks: TrackRecord[] = [ + makeTrack('start', '8B', 120), + makeTrack('end', '5A', 120), + ] + + const paths = findNavigationPaths(tracks, 'start', 'end', { + ...TEST_PATH_FINDER_SETTINGS, + maxTotalCost: 10, + }) + + expect(paths).toHaveLength(1) + expect(paths[0]?.steps[0]?.keyRule).toBe('energyDrop') + expect(paths[0]?.totalCost).toBe(1) + }) + + it('enforces max step and average step cost limits', () => { + const tracks: TrackRecord[] = [ + makeTrack('start', '8A', 120), + makeTrack('end', '8B', 120), + makeTrack('mid', '8A', 120), + ] + + const averageLimitedPaths = findNavigationPaths(tracks, 'start', 'end', { + ...TEST_PATH_FINDER_SETTINGS, + maxTotalCost: 10, + maxAverageStepCost: 1.1, + maxStepCost: 4, + }) + expect(averageLimitedPaths).toHaveLength(1) + expect(averageLimitedPaths[0]?.trackIds).toEqual(['start', 'mid', 'end']) + + const stepLimitedPaths = findNavigationPaths(tracks, 'start', 'end', { + ...TEST_PATH_FINDER_SETTINGS, + maxTotalCost: 10, + maxAverageStepCost: 3, + maxStepCost: 1.5, + }) + expect(stepLimitedPaths).toEqual([]) + }) + + it('checks max average step cost on the completed path', () => { + const tracks: TrackRecord[] = [ + makeTrack('start', '8A', 120), + makeTrack('mid', '8B', 120), + makeTrack('end', '8B', 120), + ] + + const paths = findNavigationPaths(tracks, 'start', 'end', { + ...TEST_PATH_FINDER_SETTINGS, + maxTotalCost: 10, + maxStepCost: 4, + maxAverageStepCost: 1.5, + }) + + expect(paths).toHaveLength(1) + expect(paths[0]?.trackIds).toEqual(['start', 'mid', 'end']) + expect(paths[0]?.totalCost).toBe(2) + }) + + it('returns no paths when either endpoint is missing', () => { + const tracks: TrackRecord[] = [makeTrack('start', '8A', 120)] + const settings: PathFinderSettings = { ...TEST_PATH_FINDER_SETTINGS } + + expect(findNavigationPaths(tracks, 'start', 'end', settings)).toEqual([]) + expect(findNavigationPaths(tracks, '', 'end', settings)).toEqual([]) + }) +}) diff --git a/src/lib/pathFinder.ts b/src/lib/pathFinder.ts new file mode 100644 index 0000000..5eca173 --- /dev/null +++ b/src/lib/pathFinder.ts @@ -0,0 +1,491 @@ +import type { TrackRecord } from '../types' + +const EPSILON = 1e-9 +const SPEED_UP_FACTOR = 1.05946 +const SLOW_DOWN_FACTOR = 0.9439 +const MAX_PATH_RESULTS = 10 +const MAX_EXPLORED_STATES = 50_000 + +export interface PathFinderWeights { + sameKey: number + oneUp: number + oneDown: number + aToB: number + bToA: number + energyBoost: number + energyDrop: number + centerJump: number + tempoPercent: number + keyChangeByTempo: number +} + +export interface PathFinderSettings { + weights: PathFinderWeights + allowKeyChangeByTempo: boolean + maxTotalCost: number + maxStepCost: number + maxAverageStepCost: number +} + +export interface PathStep { + fromTrackId: string + toTrackId: string + fromKey: string + toKey: string + fromBpm: number + toBpm: number + keyRule: keyof PathFinderWeights + keyCost: number + tempoPercentDelta: number + tempoCost: number + adjustment: 'none' | 'speed-up' | 'slow-down' + adjustmentCost: number + stepCost: number +} + +export interface NavigationPath { + id: string + trackIds: string[] + steps: PathStep[] + totalCost: number +} + +interface ParsedCamelot { + index: number + mode: 'A' | 'B' +} + +interface TempoAdjustedState { + key: string + bpm: number + adjustment: 'none' | 'speed-up' | 'slow-down' + adjustmentCost: number +} + +interface CandidateState { + trackIds: string[] + steps: PathStep[] + totalCost: number +} + +class MinCostQueue { + private readonly data: CandidateState[] = [] + + get size(): number { + return this.data.length + } + + push(value: CandidateState): void { + this.data.push(value) + this.bubbleUp(this.data.length - 1) + } + + pop(): CandidateState | undefined { + if (this.data.length === 0) { + return undefined + } + + const top = this.data[0] + const tail = this.data.pop() + if (this.data.length > 0 && tail) { + this.data[0] = tail + this.bubbleDown(0) + } + + return top + } + + private bubbleUp(index: number): void { + let currentIndex = index + while (currentIndex > 0) { + const parentIndex = Math.floor((currentIndex - 1) / 2) + if (this.data[parentIndex].totalCost <= this.data[currentIndex].totalCost) { + break + } + + ;[this.data[parentIndex], this.data[currentIndex]] = [this.data[currentIndex], this.data[parentIndex]] + currentIndex = parentIndex + } + } + + private bubbleDown(index: number): void { + let currentIndex = index + + while (true) { + const leftIndex = currentIndex * 2 + 1 + const rightIndex = currentIndex * 2 + 2 + let nextIndex = currentIndex + + if ( + leftIndex < this.data.length && + this.data[leftIndex].totalCost < this.data[nextIndex].totalCost + ) { + nextIndex = leftIndex + } + + if ( + rightIndex < this.data.length && + this.data[rightIndex].totalCost < this.data[nextIndex].totalCost + ) { + nextIndex = rightIndex + } + + if (nextIndex === currentIndex) { + break + } + + ;[this.data[currentIndex], this.data[nextIndex]] = [this.data[nextIndex], this.data[currentIndex]] + currentIndex = nextIndex + } + } +} + +export const DEFAULT_PATH_FINDER_WEIGHTS: PathFinderWeights = { + sameKey: 0, + oneUp: 1, + oneDown: 1, + aToB: 2, + bToA: 2, + energyBoost: 1, + energyDrop: 1, + centerJump: 4, + tempoPercent: 1, + keyChangeByTempo: 3, +} + +export const DEFAULT_PATH_FINDER_SETTINGS: PathFinderSettings = { + weights: DEFAULT_PATH_FINDER_WEIGHTS, + allowKeyChangeByTempo: false, + maxTotalCost: 10, + maxStepCost: 4, + maxAverageStepCost: 3, +} + +export function clampPathFinderSettings(value: PathFinderSettings): PathFinderSettings { + return { + weights: { + sameKey: clampWeight(value.weights.sameKey), + oneUp: clampWeight(value.weights.oneUp), + oneDown: clampWeight(value.weights.oneDown), + aToB: clampWeight(value.weights.aToB), + bToA: clampWeight(value.weights.bToA), + energyBoost: clampWeight(value.weights.energyBoost), + energyDrop: clampWeight(value.weights.energyDrop), + centerJump: clampWeight(value.weights.centerJump), + tempoPercent: clampWeight(value.weights.tempoPercent), + keyChangeByTempo: clampWeight(value.weights.keyChangeByTempo), + }, + allowKeyChangeByTempo: Boolean(value.allowKeyChangeByTempo), + maxTotalCost: clampWeight(value.maxTotalCost), + maxStepCost: clampWeight(value.maxStepCost), + maxAverageStepCost: clampWeight(value.maxAverageStepCost), + } +} + +export function findNavigationPaths( + tracks: TrackRecord[], + startTrackId: string, + endTrackId: string, + rawSettings: PathFinderSettings, +): NavigationPath[] { + if (!startTrackId || !endTrackId || startTrackId === endTrackId) { + return [] + } + + const settings = clampPathFinderSettings(rawSettings) + if (settings.maxTotalCost <= 0) { + return [] + } + + const trackById = new Map(tracks.map((track) => [track.id, track])) + const startTrack = trackById.get(startTrackId) + const endTrack = trackById.get(endTrackId) + + if (!startTrack || !endTrack || startTrack.bpm === null || endTrack.bpm === null) { + return [] + } + + const queue = new MinCostQueue() + queue.push({ + trackIds: [startTrack.id], + steps: [], + totalCost: 0, + }) + const results: NavigationPath[] = [] + const bestArrivalCosts = new Map() + let exploredStates = 0 + + while (queue.size > 0) { + const current = queue.pop() + + if (!current) { + break + } + + exploredStates += 1 + if (exploredStates > MAX_EXPLORED_STATES) { + break + } + + const currentTrackId = current.trackIds.at(-1) + if (!currentTrackId) { + continue + } + + if (currentTrackId === endTrackId && current.steps.length > 0) { + const averageCost = current.totalCost / current.steps.length + if (averageCost - settings.maxAverageStepCost > EPSILON) { + continue + } + + results.push({ + id: createNavigationPathId(current.trackIds, current.steps), + trackIds: current.trackIds, + steps: current.steps, + totalCost: current.totalCost, + }) + + if (results.length >= MAX_PATH_RESULTS) { + break + } + continue + } + + if (current.totalCost + EPSILON >= settings.maxTotalCost) { + continue + } + + const currentTrack = trackById.get(currentTrackId) + if (!currentTrack || currentTrack.bpm === null) { + continue + } + + const candidateStates = getTempoAdjustedStates(currentTrack, settings) + + for (const nextTrack of tracks) { + if (nextTrack.id === currentTrack.id || nextTrack.bpm === null || current.trackIds.includes(nextTrack.id)) { + continue + } + + for (const tempoAdjustedState of candidateStates) { + const transition = scoreTransition(tempoAdjustedState, nextTrack, settings.weights) + if (!transition) { + continue + } + + if (transition.stepCost - settings.maxStepCost > EPSILON) { + continue + } + + const totalCost = current.totalCost + transition.stepCost + if (totalCost - settings.maxTotalCost > EPSILON) { + continue + } + + if (!allowTrackArrival(bestArrivalCosts, nextTrack.id, totalCost)) { + continue + } + + queue.push({ + trackIds: [...current.trackIds, nextTrack.id], + steps: [...current.steps, { + fromTrackId: currentTrack.id, + toTrackId: nextTrack.id, + ...transition, + }], + totalCost, + }) + } + } + } + + return results + .sort((left, right) => left.totalCost - right.totalCost || left.steps.length - right.steps.length) + .slice(0, MAX_PATH_RESULTS) +} + +function scoreTransition( + fromState: TempoAdjustedState, + toTrack: TrackRecord, + weights: PathFinderWeights, +): Omit | null { + const keyTransition = getKeyTransitionCost(fromState.key, toTrack.camelotKey, weights) + if (!keyTransition) { + return null + } + + if (toTrack.bpm === null || fromState.bpm <= 0 || toTrack.bpm <= 0) { + return null + } + + const tempoPercentDelta = Math.abs((toTrack.bpm - fromState.bpm) / fromState.bpm) * 100 + const tempoCost = tempoPercentDelta * weights.tempoPercent + const stepCost = fromState.adjustmentCost + keyTransition.cost + tempoCost + + return { + fromKey: fromState.key, + toKey: toTrack.camelotKey, + fromBpm: fromState.bpm, + toBpm: toTrack.bpm, + keyRule: keyTransition.rule, + keyCost: keyTransition.cost, + tempoPercentDelta, + tempoCost, + adjustment: fromState.adjustment, + adjustmentCost: fromState.adjustmentCost, + stepCost, + } +} + +function getTempoAdjustedStates(track: TrackRecord, settings: PathFinderSettings): TempoAdjustedState[] { + if (track.bpm === null) { + return [] + } + + const states: TempoAdjustedState[] = [ + { + key: track.camelotKey, + bpm: track.bpm, + adjustment: 'none', + adjustmentCost: 0, + }, + ] + + if (!settings.allowKeyChangeByTempo) { + return states + } + + const speedUpKey = shiftCamelotKey(track.camelotKey, -5) + const slowDownKey = shiftCamelotKey(track.camelotKey, 5) + + if (speedUpKey) { + states.push({ + key: speedUpKey, + bpm: track.bpm * SPEED_UP_FACTOR, + adjustment: 'speed-up', + adjustmentCost: settings.weights.keyChangeByTempo, + }) + } + + if (slowDownKey) { + states.push({ + key: slowDownKey, + bpm: track.bpm * SLOW_DOWN_FACTOR, + adjustment: 'slow-down', + adjustmentCost: settings.weights.keyChangeByTempo, + }) + } + + return states +} + +function getKeyTransitionCost( + fromCamelot: string, + toCamelot: string, + weights: PathFinderWeights, +): { rule: keyof PathFinderWeights; cost: number } | null { + const from = parseCamelotKey(fromCamelot) + const to = parseCamelotKey(toCamelot) + + if (!from || !to) { + return null + } + + if (from.index === to.index && from.mode === to.mode) { + return { rule: 'sameKey', cost: weights.sameKey } + } + + const upOffset = (to.index - from.index + 12) % 12 + + if (from.mode === to.mode && upOffset === 1) { + return { rule: 'oneUp', cost: weights.oneUp } + } + + if (from.mode === to.mode && upOffset === 11) { + return { rule: 'oneDown', cost: weights.oneDown } + } + + if (from.index === to.index && from.mode === 'A' && to.mode === 'B') { + return { rule: 'aToB', cost: weights.aToB } + } + + if (from.index === to.index && from.mode === 'B' && to.mode === 'A') { + return { rule: 'bToA', cost: weights.bToA } + } + + if (from.mode === 'A' && to.mode === 'B' && upOffset === 3) { + return { rule: 'energyBoost', cost: weights.energyBoost } + } + + if (from.mode === 'B' && to.mode === 'A' && upOffset === 9) { + return { rule: 'energyDrop', cost: weights.energyDrop } + } + + if (from.mode === to.mode && upOffset === 6) { + return { rule: 'centerJump', cost: weights.centerJump } + } + + return null +} + +function parseCamelotKey(value: string): ParsedCamelot | null { + const match = value.match(/^(1[0-2]|[1-9])(A|B)$/i) + if (!match) { + return null + } + + return { + index: Number(match[1]), + mode: match[2].toUpperCase() as 'A' | 'B', + } +} + +function shiftCamelotKey(value: string, shift: number): string | null { + const parsed = parseCamelotKey(value) + if (!parsed) { + return null + } + + const shifted = (((parsed.index - 1 + shift) % 12) + 12) % 12 + return `${shifted + 1}${parsed.mode}` +} + +function allowTrackArrival(bestArrivalCosts: Map, trackId: string, cost: number): boolean { + const current = bestArrivalCosts.get(trackId) ?? [] + const next = [...current, cost].sort((left, right) => left - right).slice(0, MAX_PATH_RESULTS) + + if (!next.some((value) => Math.abs(value - cost) <= EPSILON)) { + return false + } + + bestArrivalCosts.set(trackId, next) + return true +} + +function clampWeight(value: number): number { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.max(0, value) +} + +function createNavigationPathId(trackIds: string[], steps: PathStep[]): string { + const trackSegment = trackIds.join('→') + const stepSegment = steps + .map((step) => + [ + step.fromTrackId, + step.toTrackId, + step.fromKey, + step.toKey, + step.adjustment, + step.keyRule, + step.fromBpm.toFixed(6), + step.toBpm.toFixed(6), + ].join(':'), + ) + .join('|') + + return `${trackSegment}::${stepSegment}` +}