mirror of
https://github.com/captbaritone/webamp
synced 2024-11-22 16:20:49 +00:00
Add an overly optimized way to filter
This commit is contained in:
parent
43515061fb
commit
934d70320e
@ -8,9 +8,18 @@ import { AppState, PlaylistTrack } from "../../types";
|
||||
|
||||
interface StateProps {
|
||||
tracks: PlaylistTrack[];
|
||||
filterTracks: (query: string) => PlaylistTrack[];
|
||||
}
|
||||
|
||||
class TracksTable extends React.Component<StateProps> {
|
||||
interface State {
|
||||
filter: string;
|
||||
}
|
||||
|
||||
class TracksTable extends React.Component<StateProps, State> {
|
||||
constructor(props: StateProps) {
|
||||
super(props);
|
||||
this.state = { filter: "" };
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
@ -28,6 +37,7 @@ class TracksTable extends React.Component<StateProps> {
|
||||
style={{ marginLeft: 12, flexGrow: 1 }}
|
||||
type="text"
|
||||
className="webamp-media-library-item"
|
||||
onChange={e => this.setState({ filter: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@ -51,7 +61,7 @@ class TracksTable extends React.Component<StateProps> {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{this.props.tracks.map(track => {
|
||||
{this.props.filterTracks(this.state.filter).map(track => {
|
||||
return (
|
||||
<tr key={track.id}>
|
||||
<td>{track.artist}</td>
|
||||
@ -86,7 +96,8 @@ class TracksTable extends React.Component<StateProps> {
|
||||
|
||||
const mapStateToProps = (state: AppState): StateProps => {
|
||||
return {
|
||||
tracks: Object.values(Selectors.getTracks(state))
|
||||
tracks: Object.values(Selectors.getTracks(state)),
|
||||
filterTracks: Selectors.getTracksMatchingFilter(state)
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -25,6 +25,7 @@ import * as fromDisplay from "./reducers/display";
|
||||
import * as fromEqualizer from "./reducers/equalizer";
|
||||
import * as fromMedia from "./reducers/media";
|
||||
import * as fromWindows from "./reducers/windows";
|
||||
import * as TrackUtils from "./trackUtils";
|
||||
import { generateGraph } from "./resizeUtils";
|
||||
import { SerializedStateV1 } from "./serializedStates/v1Types";
|
||||
|
||||
@ -47,6 +48,16 @@ export const getEqfData = createSelector(getSliders, sliders => {
|
||||
|
||||
export const getTracks = (state: AppState) => state.tracks;
|
||||
|
||||
export const getTracksMatchingFilter = createSelector(getTracks, tracks => {
|
||||
const tracksArray = Object.values(tracks);
|
||||
const filter = Utils.makeCachingFilterFunction(tracksArray, (track, query) =>
|
||||
TrackUtils.trackFilterContents(track).includes(query)
|
||||
);
|
||||
return (filterString: string): PlaylistTrack[] => {
|
||||
return filter(filterString.toLowerCase());
|
||||
};
|
||||
});
|
||||
|
||||
export const getTrackUrl = (state: AppState) => {
|
||||
return (id: number): string | null => {
|
||||
const track = state.tracks[id];
|
||||
|
46
js/trackUtils.ts
Normal file
46
js/trackUtils.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { PlaylistTrack } from "./types";
|
||||
import * as Utils from "./utils";
|
||||
import * as FileUtils from "./fileUtils";
|
||||
|
||||
export const trackName = Utils.weakMapMemoize(
|
||||
(track: PlaylistTrack): string => {
|
||||
const { artist, title, defaultName, url } = track;
|
||||
if (artist && title) {
|
||||
return `${artist} - ${title}`;
|
||||
} else if (title) {
|
||||
return title;
|
||||
} else if (defaultName) {
|
||||
return defaultName;
|
||||
} else if (url) {
|
||||
const filename = FileUtils.filenameFromUrl(url);
|
||||
if (filename) {
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
return "???";
|
||||
}
|
||||
);
|
||||
|
||||
export const trackFilename = Utils.weakMapMemoize(
|
||||
(track: PlaylistTrack): string => {
|
||||
if (track.url) {
|
||||
const urlFilename = FileUtils.filenameFromUrl(track.url);
|
||||
if (urlFilename != null) {
|
||||
return urlFilename;
|
||||
}
|
||||
}
|
||||
if (track.defaultName) {
|
||||
return track.defaultName;
|
||||
}
|
||||
return "???";
|
||||
}
|
||||
);
|
||||
|
||||
export const trackFilterContents = Utils.weakMapMemoize(
|
||||
(track: PlaylistTrack): string => {
|
||||
return [track.artist, track.title, track.defaultName]
|
||||
.filter(Boolean)
|
||||
.join("|")
|
||||
.toLowerCase();
|
||||
}
|
||||
);
|
@ -11,7 +11,8 @@ import {
|
||||
segment,
|
||||
moveSelected,
|
||||
spliceIn,
|
||||
getFileExtension
|
||||
getFileExtension,
|
||||
makeCachingFilterFunction
|
||||
} from "./utils";
|
||||
|
||||
const fixture = (filename: string) =>
|
||||
@ -273,3 +274,89 @@ describe("spliceIn", () => {
|
||||
expect(spliced).toEqual([1, 200, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeCachingFilterFunction", () => {
|
||||
test("caches exact queries", () => {
|
||||
const values = ["abc", "b", "c"];
|
||||
const includes = jest.fn((v, query) => v.includes(query));
|
||||
const filter = makeCachingFilterFunction(values, includes);
|
||||
expect(filter("c")).toEqual(["abc", "c"]);
|
||||
expect(includes.mock.calls.length).toBe(3);
|
||||
expect(filter("c")).toEqual(["abc", "c"]);
|
||||
expect(includes.mock.calls.length).toBe(3);
|
||||
});
|
||||
|
||||
test("caches sub queries", () => {
|
||||
const values = ["a--", "ab-", "abc"];
|
||||
const includes = jest.fn((v, query) => v.includes(query));
|
||||
let comparisons = 0;
|
||||
const newComparisons = () => {
|
||||
const recent = includes.mock.calls.length - comparisons;
|
||||
comparisons += recent;
|
||||
return recent;
|
||||
};
|
||||
const filter = makeCachingFilterFunction(values, includes);
|
||||
// Intial search
|
||||
expect(filter("ab")).toEqual(["ab-", "abc"]);
|
||||
expect(newComparisons()).toBe(3); // Looks at all elements
|
||||
|
||||
// Second search where original search is a prefix
|
||||
expect(filter("abc")).toEqual(["abc"]);
|
||||
expect(newComparisons()).toBe(2); // Only reconsiders the previous matches
|
||||
|
||||
// Unique search
|
||||
expect(filter("b")).toEqual(["ab-", "abc"]); // Looks at all elements
|
||||
expect(newComparisons()).toBe(3); // Reconsiders all elements
|
||||
|
||||
expect(filter("bc")).toEqual(["abc"]); // Only reconsidres the matches that already include `b`
|
||||
expect(newComparisons()).toBe(2);
|
||||
|
||||
// Go back to the initial serach
|
||||
expect(filter("ab")).toEqual(["ab-", "abc"]);
|
||||
expect(newComparisons()).toBe(0); // Result is cached
|
||||
|
||||
// A variation on the second search
|
||||
expect(filter("abcd")).toEqual([]);
|
||||
expect(newComparisons()).toBe(1); // Only recondsiders the results of `abc`
|
||||
});
|
||||
|
||||
test("big data", () => {
|
||||
const values = [...Array(10000)].map((val, i) => String(i));
|
||||
const includes = jest.fn((v, query) => v.includes(query));
|
||||
let comparisons = 0;
|
||||
const newComparisons = () => {
|
||||
const recent = includes.mock.calls.length - comparisons;
|
||||
comparisons += recent;
|
||||
return recent;
|
||||
};
|
||||
const filter = makeCachingFilterFunction(values, includes);
|
||||
// Intial search
|
||||
expect(filter("").length).toEqual(10000);
|
||||
expect(newComparisons()).toBe(0); // Looks at zero
|
||||
|
||||
expect(filter("1").length).toEqual(3439);
|
||||
expect(newComparisons()).toBe(10000); // Looks at all elements
|
||||
|
||||
expect(filter("12").length).toEqual(299);
|
||||
expect(newComparisons()).toBe(3439);
|
||||
|
||||
expect(filter("123").length).toEqual(20);
|
||||
expect(newComparisons()).toBe(299);
|
||||
|
||||
expect(filter("1234").length).toEqual(1);
|
||||
expect(newComparisons()).toBe(20);
|
||||
|
||||
expect(filter("12345").length).toEqual(0);
|
||||
expect(newComparisons()).toBe(1);
|
||||
|
||||
// A variation on the initial non-empty query
|
||||
expect(filter("11").length).toEqual(280);
|
||||
expect(newComparisons()).toBe(3439);
|
||||
|
||||
expect(filter("111").length).toEqual(19);
|
||||
expect(newComparisons()).toBe(280);
|
||||
|
||||
expect(filter("1111").length).toEqual(1);
|
||||
expect(newComparisons()).toBe(19);
|
||||
});
|
||||
});
|
||||
|
48
js/utils.ts
48
js/utils.ts
@ -367,3 +367,51 @@ export function getWindowSize(): { width: number; height: number } {
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function weakMapMemoize<T extends object, R>(
|
||||
func: (value: T) => R
|
||||
): (value: T) => R {
|
||||
const cache = new WeakMap();
|
||||
return (value: T) => {
|
||||
if (!cache.has(value)) {
|
||||
cache.set(value, func(value));
|
||||
}
|
||||
return cache.get(value);
|
||||
};
|
||||
}
|
||||
|
||||
interface Cache<V> {
|
||||
results?: V[];
|
||||
subCaches: { [char: string]: Cache<V> };
|
||||
}
|
||||
|
||||
// Is this a premature optimizaiton? Probably. But it's my side-project so I can
|
||||
// do what I like. :P
|
||||
export function makeCachingFilterFunction<V>(
|
||||
values: V[],
|
||||
includes: (v: V, query: string) => boolean
|
||||
) {
|
||||
const cache: Cache<V> = {
|
||||
results: values,
|
||||
subCaches: {}
|
||||
};
|
||||
return (query: string): V[] => {
|
||||
let queryCache: Cache<V> = cache;
|
||||
let lastResults: V[] = values;
|
||||
for (const char of query) {
|
||||
let letterCaches = queryCache.subCaches[char];
|
||||
if (!letterCaches) {
|
||||
letterCaches = queryCache.subCaches[char] = { subCaches: {} };
|
||||
} else if (letterCaches.results) {
|
||||
lastResults = letterCaches.results;
|
||||
}
|
||||
queryCache = letterCaches;
|
||||
}
|
||||
|
||||
if (!queryCache.results) {
|
||||
queryCache.results = lastResults.filter(v => includes(v, query));
|
||||
}
|
||||
|
||||
return queryCache.results;
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user