diff --git a/package-lock.json b/package-lock.json index 93fc41e..af34b30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "keybpmmap", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "keybpmmap", - "version": "0.8.0", + "version": "0.9.0", "dependencies": { "d3": "^7.9.0", "fast-xml-parser": "^5.8.0", diff --git a/package.json b/package.json index 7585e1c..d1d27d9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "keybpmmap", "private": true, - "version": "0.8.0", + "version": "0.10.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.css b/src/App.css index cc5ad3a..458e9c1 100644 --- a/src/App.css +++ b/src/App.css @@ -534,8 +534,13 @@ input[type='checkbox'] { } .track-action-button { - padding: 0.45rem 0.8rem; - font-size: 0.82rem; + padding: 0.32rem 0.58rem; + font-size: 0.74rem; + white-space: nowrap; +} + +.scope-track-table .track-actions { + flex-wrap: nowrap; } .scope-track-table-wrapper { @@ -566,6 +571,53 @@ input[type='checkbox'] { letter-spacing: 0.04em; } +.scope-track-sort-button { + border: 0; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + text-transform: inherit; + letter-spacing: inherit; + background: transparent; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.scope-track-sort-button:hover, +.scope-track-sort-button:focus-visible { + color: #cbd5e1; +} + +.selected-region-sort-controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; + color: #ffffff4f; +} + +.selected-region-sort-button { + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 999px; + padding: 0.25rem 0.52rem; + font: inherit; + font-size: 0.72rem; + color: #94a3b8; + background: rgba(148, 163, 184, 0.08); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.selected-region-sort-button:hover, +.selected-region-sort-button:focus-visible { + color: #cbd5e1; +} + .scope-track-table tbody td { color: #cbd5e1; } diff --git a/src/App.tsx b/src/App.tsx index b7aef02..af873a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,6 +50,10 @@ interface OpenFilePickerConfig { type PolarKeyMode = 'both' | 'A' | 'B' type PathSortMode = 'total' | 'average' | 'max' +type ScopeTrackSortKey = 'artist' | 'title' | 'bpm' | 'key' | 'inMaps' +type ScopeTrackSortDirection = 'asc' | 'desc' +type SelectedRegionSortKey = 'artist' | 'title' | 'bpm' +type SelectedRegionSortDirection = 'asc' | 'desc' const POLAR_KEY_MODES = ['both', 'A', 'B'] as const const PATH_SORT_MODES: Array<{ value: PathSortMode; label: string }> = [ { value: 'total', label: 'Total Cost' }, @@ -128,6 +132,20 @@ function App() { const [pathSearchStatus, setPathSearchStatus] = useState(null) const [selectedPathId, setSelectedPathId] = useState(null) const [pathSortMode, setPathSortMode] = useState('total') + const [scopeTrackSort, setScopeTrackSort] = useState<{ + key: ScopeTrackSortKey + direction: ScopeTrackSortDirection + }>({ + key: 'artist', + direction: 'asc', + }) + const [selectedRegionSort, setSelectedRegionSort] = useState<{ + key: SelectedRegionSortKey + direction: SelectedRegionSortDirection + }>({ + key: 'artist', + direction: 'asc', + }) const [showScopeTrackTable, setShowScopeTrackTable] = useState(false) const fileInputRef = useRef(null) const polarRef = useRef(null) @@ -309,6 +327,123 @@ function App() { () => (camelotKey: string) => formatKey(camelotKey, keyRepresentation), [keyRepresentation], ) + const sortedScopeTrackRows = useMemo(() => { + const compareInMapsRank = (track: TrackRecord) => { + if (track.bpm !== null && polarKeySet.has(track.camelotKey)) { + return 0 + } + if (track.bpm === null) { + return 2 + } + return 1 + } + + return [...pathScopeTracks].sort((left, right) => { + const directionFactor = scopeTrackSort.direction === 'asc' ? 1 : -1 + let comparison = 0 + + switch (scopeTrackSort.key) { + case 'artist': + comparison = left.artist.localeCompare(right.artist) * directionFactor + break + case 'title': + comparison = left.title.localeCompare(right.title) * directionFactor + break + case 'bpm': + comparison = compareNullableNumber(left.bpm, right.bpm, scopeTrackSort.direction) + break + case 'key': + comparison = compareCamelotKeys(left.camelotKey, right.camelotKey, scopeTrackSort.direction) + break + case 'inMaps': + comparison = (compareInMapsRank(left) - compareInMapsRank(right)) * directionFactor + break + } + + if (comparison !== 0) { + return comparison + } + + return ( + left.artist.localeCompare(right.artist) || + left.title.localeCompare(right.title) || + left.id.localeCompare(right.id) + ) + }) + }, [pathScopeTracks, polarKeySet, scopeTrackSort]) + + const handleScopeTrackSort = (key: ScopeTrackSortKey) => { + setScopeTrackSort((current) => + current.key === key + ? { + key, + direction: current.direction === 'asc' ? 'desc' : 'asc', + } + : { + key, + direction: 'asc', + }, + ) + } + + const getScopeTrackSortIndicator = (key: ScopeTrackSortKey) => { + if (scopeTrackSort.key !== key) { + return '↕' + } + return scopeTrackSort.direction === 'asc' ? '↑' : '↓' + } + const sortedSelectedRegionTracks = useMemo(() => { + if (!selectedCell) { + return [] + } + + return [...selectedCell.tracks].sort((left, right) => { + const directionFactor = selectedRegionSort.direction === 'asc' ? 1 : -1 + + switch (selectedRegionSort.key) { + case 'artist': + return ( + left.artist.localeCompare(right.artist) * directionFactor || + left.title.localeCompare(right.title) || + left.id.localeCompare(right.id) + ) + case 'title': + return ( + left.title.localeCompare(right.title) * directionFactor || + left.artist.localeCompare(right.artist) || + left.id.localeCompare(right.id) + ) + case 'bpm': + return ( + compareNullableNumber(left.bpm, right.bpm, selectedRegionSort.direction) || + left.artist.localeCompare(right.artist) || + left.title.localeCompare(right.title) || + left.id.localeCompare(right.id) + ) + } + }) + }, [selectedCell, selectedRegionSort]) + + const handleSelectedRegionSort = (key: SelectedRegionSortKey) => { + setSelectedRegionSort((current) => + current.key === key + ? { + key, + direction: current.direction === 'asc' ? 'desc' : 'asc', + } + : { + key, + direction: 'asc', + }, + ) + } + + const getSelectedRegionSortIndicator = (key: SelectedRegionSortKey) => { + if (selectedRegionSort.key !== key) { + return '↕' + } + return selectedRegionSort.direction === 'asc' ? '↑' : '↓' + } const handleLoadMockData = () => { setLibrary(createMockLibrary()) @@ -857,16 +992,56 @@ function App() { - - - - - + + + + + - {pathScopeTracks.map((track) => { + {sortedScopeTrackRows.map((track) => { const isInPlots = track.bpm !== null && polarKeySet.has(track.camelotKey) return ( @@ -1300,9 +1475,33 @@ function App() { {formatVisibleKey(selectedCell.camelotKey)} · {selectedCell.bandLabel} · {selectedCell.count} track {selectedCell.count === 1 ? '' : 's'}

+
+ Sort By + + + +
    - {selectedCell.tracks.length > 0 ? ( - selectedCell.tracks.map((track) => ( + {sortedSelectedRegionTracks.length > 0 ? ( + sortedSelectedRegionTracks.map((track) => (
  • {track.artist} @@ -1393,6 +1592,51 @@ function formatRating(track: TrackRecord): string { return `${track.rating.toFixed(1)}★` } +function compareNullableNumber( + leftValue: number | null, + rightValue: number | null, + direction: 'asc' | 'desc', +): number { + if (leftValue === rightValue) { + return 0 + } + if (leftValue === null) { + return 1 + } + if (rightValue === null) { + return -1 + } + + return direction === 'asc' ? leftValue - rightValue : rightValue - leftValue +} + +function compareCamelotKeys( + leftValue: string, + rightValue: string, + direction: 'asc' | 'desc', +): number { + const leftMatch = leftValue.match(/^(1[0-2]|[1-9])(A|B)$/) + const rightMatch = rightValue.match(/^(1[0-2]|[1-9])(A|B)$/) + + if (!leftMatch || !rightMatch) { + return leftValue.localeCompare(rightValue) * (direction === 'asc' ? 1 : -1) + } + + const leftNumber = Number(leftMatch[1]) + const rightNumber = Number(rightMatch[1]) + if (leftNumber !== rightNumber) { + return (leftNumber - rightNumber) * (direction === 'asc' ? 1 : -1) + } + + const leftFamily = leftMatch[2] + const rightFamily = rightMatch[2] + if (leftFamily !== rightFamily) { + return (leftFamily === 'A' ? -1 : 1) * (direction === 'asc' ? 1 : -1) + } + + return 0 +} + function getSourceDescription(library: LibraryData): string { if (library.source === 'mock') { return 'Demo dataset' diff --git a/src/components/PolarDensityChart.tsx b/src/components/PolarDensityChart.tsx index b761e55..28830b8 100644 --- a/src/components/PolarDensityChart.tsx +++ b/src/components/PolarDensityChart.tsx @@ -123,8 +123,9 @@ const PolarDensityChart = forwardRef( {keys.map((camelotKey, index) => { const angle = ((index + 0.5) / keys.length) * Math.PI * 2 - Math.PI / 2 const labelRadius = outerRadius + 24 - const x = Math.cos(angle) * labelRadius - const y = Math.sin(angle) * labelRadius + // D3 arc angles are measured from 12 o'clock clockwise, so convert to SVG x/y accordingly. + const x = Math.sin(angle) * labelRadius + const y = -Math.cos(angle) * labelRadius return ( diff --git a/src/lib/exportSvg.ts b/src/lib/exportSvg.ts index db94bd8..35cf842 100644 --- a/src/lib/exportSvg.ts +++ b/src/lib/exportSvg.ts @@ -2,8 +2,10 @@ export async function downloadSvgAsPng( svgElement: SVGSVGElement, fileName: string, ): Promise { + const clonedSvg = svgElement.cloneNode(true) as SVGSVGElement + inlineTextStyles(svgElement, clonedSvg) const serializer = new XMLSerializer() - const source = serializer.serializeToString(svgElement) + const source = serializer.serializeToString(clonedSvg) const blob = new Blob([source], { type: 'image/svg+xml;charset=utf-8' }) const url = URL.createObjectURL(blob) @@ -35,6 +37,38 @@ export async function downloadSvgAsPng( } } +function inlineTextStyles(sourceSvg: SVGSVGElement, targetSvg: SVGSVGElement): void { + const sourceTextNodes = sourceSvg.querySelectorAll('text') + const targetTextNodes = targetSvg.querySelectorAll('text') + if (sourceTextNodes.length !== targetTextNodes.length) { + console.warn('SVG export text node mismatch.', { + sourceCount: sourceTextNodes.length, + targetCount: targetTextNodes.length, + }) + } + const pairCount = Math.min(sourceTextNodes.length, targetTextNodes.length) + + for (let textNodeIndex = 0; textNodeIndex < pairCount; textNodeIndex += 1) { + const sourceTextNode = sourceTextNodes[textNodeIndex] + const targetTextNode = targetTextNodes[textNodeIndex] + const computedStyle = getComputedStyle(sourceTextNode) + setAttributeIfValue(targetTextNode, 'fill', computedStyle.fill) + setAttributeIfValue(targetTextNode, 'font-family', computedStyle.fontFamily) + setAttributeIfValue(targetTextNode, 'font-size', computedStyle.fontSize) + setAttributeIfValue(targetTextNode, 'font-weight', computedStyle.fontWeight) + } +} + +const IGNORED_CSS_KEYWORDS = new Set(['initial', 'inherit', 'unset']) + +function setAttributeIfValue(node: Element, attribute: string, value: string): void { + if (!value || IGNORED_CSS_KEYWORDS.has(value)) { + return + } + + node.setAttribute(attribute, value) +} + function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const image = new Image()
ArtistTitleBPMKeyIn Maps + + + + + + + + + + Path Finder