Add an overly optimized way to filter

This commit is contained in:
Jordan Eldredge 2018-10-10 17:30:45 -07:00
parent 43515061fb
commit 934d70320e
5 changed files with 207 additions and 4 deletions

View File

@ -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)
};
};

View File

@ -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
View 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();
}
);

View File

@ -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);
});
});

View File

@ -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;
};
}