mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Compare commits
4 Commits
v5.3.193
...
experiment
Author | SHA1 | Date | |
---|---|---|---|
![]() |
645a6ce900 | ||
![]() |
22b9e710b9 | ||
![]() |
b585ac3d56 | ||
![]() |
3a0a2152d1 |
@@ -32,6 +32,13 @@ import ArrowForwardIos from '@material-ui/icons/ArrowForwardIos';
|
||||
import ThemeContext from '../UI/Theme/ThemeContext';
|
||||
import AnimationPreview from '../ObjectEditor/Editors/SpriteEditor/AnimationPreview';
|
||||
import ScrollView from '../UI/ScrollView';
|
||||
import { BoxSearchResultsNoScroll } from '../UI/Search/BoxSearchResultsNoScroll';
|
||||
import { AssetCard } from './AssetCard';
|
||||
import { NoResultPlaceholder } from './NoResultPlaceholder';
|
||||
import { useSearchItem, SearchFilter } from '../UI/Search/UseSearchItem';
|
||||
import {
|
||||
SimilarAssetStoreSearchFilter,
|
||||
} from './AssetStoreSearchFilter';
|
||||
|
||||
const FIXED_HEIGHT = 250;
|
||||
|
||||
@@ -81,6 +88,7 @@ type Props = {|
|
||||
onClose: () => void,
|
||||
canInstall: boolean,
|
||||
isBeingInstalled: boolean,
|
||||
onOpenDetails: (assetShortHeader: AssetShortHeader) => void,
|
||||
|};
|
||||
|
||||
const getObjectAssetResourcesByName = (
|
||||
@@ -107,6 +115,7 @@ export const AssetDetails = ({
|
||||
onClose,
|
||||
canInstall,
|
||||
isBeingInstalled,
|
||||
onOpenDetails,
|
||||
}: Props) => {
|
||||
const gdevelopTheme = React.useContext(ThemeContext);
|
||||
const { authors, licenses } = React.useContext(AssetStoreContext);
|
||||
@@ -190,7 +199,22 @@ export const AssetDetails = ({
|
||||
? direction.sprites.map(sprite => assetResources[sprite.image])
|
||||
: null;
|
||||
|
||||
const similarAssetFilters = React.useMemo(() => [new SimilarAssetStoreSearchFilter(assetShortHeader)], [assetShortHeader]);
|
||||
const {
|
||||
error: filterError,
|
||||
fetchAssetsAndFilters,
|
||||
useSearchItem,
|
||||
} = React.useContext(AssetStoreContext);
|
||||
const searchResults = useSearchItem(
|
||||
"",
|
||||
null,
|
||||
null,
|
||||
similarAssetFilters
|
||||
);
|
||||
console.log("searchResults: " + (searchResults && searchResults.length));
|
||||
|
||||
return (
|
||||
<Column expand noMargin useFullHeight>
|
||||
<ScrollView>
|
||||
<Column expand noMargin>
|
||||
<Line justifyContent="space-between" noMargin>
|
||||
@@ -425,7 +449,36 @@ export const AssetDetails = ({
|
||||
)}
|
||||
</Column>
|
||||
</ResponsiveLineStackLayout>
|
||||
<Line
|
||||
expand
|
||||
overflow={
|
||||
'hidden' /* Somehow required on Chrome/Firefox to avoid children growing (but not on Safari) */
|
||||
}
|
||||
>
|
||||
<Column>
|
||||
<Text>{"TEST"}</Text>
|
||||
<Text>{"TEST"}</Text>
|
||||
<Text>{"TEST"}</Text>
|
||||
<Text>{"TEST"}</Text>
|
||||
<Text>{"TEST"}</Text>
|
||||
</Column>
|
||||
<BoxSearchResultsNoScroll
|
||||
baseSize={128}
|
||||
onRetry={fetchAssetsAndFilters}
|
||||
error={filterError}
|
||||
searchItems={searchResults}//{searchResults}
|
||||
renderSearchItem={(assetShortHeader, size) => (
|
||||
<AssetCard
|
||||
size={size}
|
||||
onOpenDetails={() => onOpenDetails(assetShortHeader)}
|
||||
assetShortHeader={assetShortHeader}
|
||||
/>
|
||||
)}
|
||||
noResultPlaceholder={<></>}
|
||||
/>
|
||||
</Line>
|
||||
</Column>
|
||||
</ScrollView>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
@@ -20,6 +20,7 @@ import {
|
||||
LicenseAssetStoreSearchFilter,
|
||||
DimensionAssetStoreSearchFilter,
|
||||
} from './AssetStoreSearchFilter';
|
||||
import { type ChosenCategory } from '../UI/Search/FiltersChooser';
|
||||
|
||||
const defaultSearchText = '';
|
||||
|
||||
@@ -50,6 +51,12 @@ type AssetStoreState = {|
|
||||
setSearchText: string => void,
|
||||
filtersState: FiltersState,
|
||||
assetFiltersState: AssetFiltersState,
|
||||
useSearchItem: (
|
||||
searchText: string,
|
||||
chosenCategory: ?ChosenCategory,
|
||||
chosenFilters: Set<string>,
|
||||
searchFilters: Array<SearchFilter<AssetShortHeader>>
|
||||
) => ?Array<AssetShortHeader>,
|
||||
|};
|
||||
|
||||
export const AssetStoreContext = React.createContext<AssetStoreState>({
|
||||
@@ -83,6 +90,12 @@ export const AssetStoreContext = React.createContext<AssetStoreState>({
|
||||
licenseFilter: new LicenseAssetStoreSearchFilter(),
|
||||
setLicenseFilter: filter => {},
|
||||
},
|
||||
useSearchItem: (
|
||||
searchText,
|
||||
chosenCategory,
|
||||
chosenFilters,
|
||||
searchFilters,
|
||||
) => null,
|
||||
});
|
||||
|
||||
type AssetStoreStateProviderProps = {|
|
||||
@@ -265,6 +278,19 @@ export const AssetStoreStateProvider = ({
|
||||
licenseFilter: licenseFilter,
|
||||
setLicenseFilter: setLicenseFilter,
|
||||
},
|
||||
useSearchItem: (
|
||||
searchText,
|
||||
chosenCategory,
|
||||
chosenFilters,
|
||||
searchFilters,
|
||||
) => useSearchItem(
|
||||
assetShortHeadersById,
|
||||
getAssetShortHeaderSearchTerms,
|
||||
searchText,
|
||||
chosenCategory,
|
||||
chosenFilters,
|
||||
searchFilters
|
||||
),
|
||||
}),
|
||||
[
|
||||
searchResults,
|
||||
@@ -288,6 +314,7 @@ export const AssetStoreStateProvider = ({
|
||||
setColorFilter,
|
||||
licenseFilter,
|
||||
setLicenseFilter,
|
||||
assetShortHeadersById,
|
||||
]
|
||||
);
|
||||
|
||||
|
@@ -21,10 +21,11 @@ export class ObjectTypeAssetStoreSearchFilter
|
||||
this.objectTypes = objectTypes;
|
||||
}
|
||||
|
||||
isSatisfiedBy(searchItem: AssetShortHeader): boolean {
|
||||
return (
|
||||
this.objectTypes.size === 0 || this.objectTypes.has(searchItem.objectType)
|
||||
);
|
||||
getPertinence(searchItem: AssetShortHeader): number {
|
||||
return this.objectTypes.size === 0 ||
|
||||
this.objectTypes.has(searchItem.objectType)
|
||||
? 1
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,13 +44,13 @@ export class LicenseAssetStoreSearchFilter
|
||||
this.attributionFreeOnly = attributionFreeOnly;
|
||||
}
|
||||
|
||||
isSatisfiedBy(searchItem: AssetShortHeader): boolean {
|
||||
return (
|
||||
!this.attributionFreeOnly ||
|
||||
getPertinence(searchItem: AssetShortHeader): number {
|
||||
return !this.attributionFreeOnly ||
|
||||
LicenseAssetStoreSearchFilter.noAttributionLicenses.includes(
|
||||
searchItem.license
|
||||
)
|
||||
);
|
||||
? 1
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,13 +67,13 @@ export class AnimatedAssetStoreSearchFilter
|
||||
this.mustHaveSeveralState = mustHaveSeveralState;
|
||||
}
|
||||
|
||||
isSatisfiedBy(searchItem: AssetShortHeader): boolean {
|
||||
getPertinence(searchItem: AssetShortHeader): number {
|
||||
const hasAnimatedState = searchItem.maxFramesCount > 1;
|
||||
const hasSeveralState = searchItem.animationsCount > 1;
|
||||
return (
|
||||
(!this.mustBeAnimated || hasAnimatedState) &&
|
||||
return (!this.mustBeAnimated || hasAnimatedState) &&
|
||||
(!this.mustHaveSeveralState || hasSeveralState)
|
||||
);
|
||||
? 1
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,17 +93,17 @@ export class DimensionAssetStoreSearchFilter
|
||||
this.dimensionMax = dimensionMax;
|
||||
}
|
||||
|
||||
isSatisfiedBy(searchItem: AssetShortHeader): boolean {
|
||||
return (
|
||||
((this.dimensionMin === DimensionAssetStoreSearchFilter.boundMin ||
|
||||
this.dimensionMin <= searchItem.width) &&
|
||||
(this.dimensionMin === DimensionAssetStoreSearchFilter.boundMax ||
|
||||
searchItem.width <= this.dimensionMax)) ||
|
||||
getPertinence(searchItem: AssetShortHeader): number {
|
||||
return ((this.dimensionMin === DimensionAssetStoreSearchFilter.boundMin ||
|
||||
this.dimensionMin <= searchItem.width) &&
|
||||
(this.dimensionMin === DimensionAssetStoreSearchFilter.boundMax ||
|
||||
searchItem.width <= this.dimensionMax)) ||
|
||||
((this.dimensionMin === DimensionAssetStoreSearchFilter.boundMin ||
|
||||
this.dimensionMin <= searchItem.height) &&
|
||||
(this.dimensionMin === DimensionAssetStoreSearchFilter.boundMax ||
|
||||
searchItem.height <= this.dimensionMax))
|
||||
);
|
||||
? 1
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +117,47 @@ const mod = function(x: number, y: number): number {
|
||||
return ((x % y) + y) % y;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param colorA
|
||||
* @param colorB
|
||||
* @returns Return the similitude between the 2 colors
|
||||
* (1 when they are the same).
|
||||
*/
|
||||
const getColorSimilitude = function(
|
||||
colorA: RGBColor,
|
||||
colorB: RGBColor
|
||||
): number {
|
||||
const targetHsl = rgbToHsl(colorA.r, colorA.g, colorA.b);
|
||||
const dominantHsl = rgbToHsl(colorB.r, colorB.g, colorB.b);
|
||||
const targetSaturation = targetHsl[1];
|
||||
const dominantSaturation = dominantHsl[1];
|
||||
if (targetSaturation === 0) {
|
||||
// Hue is not relevant.
|
||||
const deltaSaturation = dominantSaturation - targetSaturation;
|
||||
const deltaLightness = dominantHsl[2] - targetHsl[2];
|
||||
return (
|
||||
1 -
|
||||
(deltaSaturation * deltaSaturation + deltaLightness * deltaLightness) / 2
|
||||
);
|
||||
}
|
||||
// Hue distance can only be up to 0.5 as it's looping.
|
||||
// So, it's multiplied by 2 to cover [0, 1].
|
||||
const deltaHue =
|
||||
dominantSaturation === 0
|
||||
? 1
|
||||
: 2 * Math.abs(mod(dominantHsl[0] - targetHsl[0] + 0.5, 1) - 0.5);
|
||||
const deltaSaturation = dominantSaturation - targetSaturation;
|
||||
const deltaLightness = dominantHsl[2] - targetHsl[2];
|
||||
// Give more importance to hue as it catches human eyes.
|
||||
return (
|
||||
1 -
|
||||
(4 * deltaHue * deltaHue +
|
||||
deltaSaturation * deltaSaturation +
|
||||
deltaLightness * deltaLightness) /
|
||||
6
|
||||
);
|
||||
};
|
||||
|
||||
export class ColorAssetStoreSearchFilter
|
||||
implements SearchFilter<AssetShortHeader> {
|
||||
color: RGBColor | null;
|
||||
@@ -124,44 +166,108 @@ export class ColorAssetStoreSearchFilter
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
isSatisfiedBy(searchItem: AssetShortHeader): boolean {
|
||||
if (!this.color) {
|
||||
return true;
|
||||
getPertinence(searchItem: AssetShortHeader): number {
|
||||
const color = this.color;
|
||||
if (!color) {
|
||||
return 1;
|
||||
}
|
||||
if (searchItem.dominantColors.length === 0) {
|
||||
return false;
|
||||
// Not zero because the item should not be excluded.
|
||||
let scoreMax = Number.MIN_VALUE;
|
||||
for (const dominantColor of searchItem.dominantColors) {
|
||||
const dominantRgb = hexNumberToRGBColor(dominantColor);
|
||||
const score = getColorSimilitude(dominantRgb, color);
|
||||
scoreMax = Math.max(scoreMax, score);
|
||||
}
|
||||
const targetHsl = rgbToHsl(this.color.r, this.color.g, this.color.b);
|
||||
const dominantRgb = hexNumberToRGBColor(searchItem.dominantColors[0]);
|
||||
const dominantHsl = rgbToHsl(dominantRgb.r, dominantRgb.g, dominantRgb.b);
|
||||
const targetSaturation = targetHsl[1];
|
||||
const dominantSaturation = dominantHsl[1];
|
||||
let score = 0;
|
||||
if (targetSaturation === 0) {
|
||||
// Hue is not relevant.
|
||||
const deltaSaturation = dominantSaturation - targetSaturation;
|
||||
const deltaLightness = dominantHsl[2] - targetHsl[2];
|
||||
score =
|
||||
1 -
|
||||
(deltaSaturation * deltaSaturation + deltaLightness * deltaLightness) /
|
||||
2;
|
||||
} else {
|
||||
// Hue distance can only be up to 0.5 as it's looping.
|
||||
// So, it's multiplied by 2 to cover [0, 1].
|
||||
const deltaHue =
|
||||
dominantSaturation === 0
|
||||
? 1
|
||||
: 2 * Math.abs(mod(dominantHsl[0] - targetHsl[0] + 0.5, 1) - 0.5);
|
||||
const deltaSaturation = dominantSaturation - targetSaturation;
|
||||
const deltaLightness = dominantHsl[2] - targetHsl[2];
|
||||
// Give more importance to hue as it catches human eyes.
|
||||
score =
|
||||
1 -
|
||||
(4 * deltaHue * deltaHue +
|
||||
deltaSaturation * deltaSaturation +
|
||||
deltaLightness * deltaLightness) /
|
||||
6;
|
||||
}
|
||||
return score > 0.94;
|
||||
return scoreMax;
|
||||
}
|
||||
}
|
||||
|
||||
export class SimilarAssetStoreSearchFilter
|
||||
implements SearchFilter<AssetShortHeader> {
|
||||
other: AssetShortHeader;
|
||||
|
||||
constructor(other: AssetShortHeader) {
|
||||
this.other = other;
|
||||
console.log(this.other.tags);
|
||||
}
|
||||
|
||||
getPertinence(searchItem: AssetShortHeader): number {
|
||||
if (this.other === searchItem ||
|
||||
this.other.objectType !== searchItem.objectType) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
{
|
||||
const hasAnimatedState = searchItem.maxFramesCount > 1;
|
||||
const hasSeveralState = searchItem.animationsCount > 1;
|
||||
const mustBeAnimated = this.other.maxFramesCount > 1;
|
||||
const mustHaveSeveralState = this.other.animationsCount > 1;
|
||||
const hasBetterAnimationOrState = ((!mustBeAnimated || hasAnimatedState) &&
|
||||
(!mustHaveSeveralState || hasSeveralState));
|
||||
if (!hasBetterAnimationOrState) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const isTopDown = searchItem.tags.includes('top-down');
|
||||
const isIsometric = searchItem.tags.includes('isometric');
|
||||
const isSideView = searchItem.tags.includes('side view');
|
||||
|
||||
const otherIsTopDown = this.other.tags.includes('top-down');
|
||||
const otherIsIsometric = this.other.tags.includes('isometric');
|
||||
const otherIsSideView = this.other.tags.includes('side view');
|
||||
|
||||
const areCompatible =
|
||||
(isTopDown && otherIsTopDown) ||
|
||||
(isIsometric && otherIsIsometric) ||
|
||||
(isSideView && otherIsSideView) ||
|
||||
(!isTopDown &&
|
||||
!isIsometric &&
|
||||
!isSideView &&
|
||||
!otherIsTopDown &&
|
||||
!otherIsIsometric &&
|
||||
!otherIsSideView);
|
||||
if (!areCompatible) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const surface = searchItem.width * searchItem.height;
|
||||
const otherSurface = this.other.width * this.other.height;
|
||||
const smallestSurface = Math.min(surface, otherSurface);
|
||||
const greatestSurface = Math.max(surface, otherSurface);
|
||||
|
||||
if (2 * smallestSurface < greatestSurface) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
{
|
||||
const ratio = searchItem.width / searchItem.height;
|
||||
const otherRatio = this.other.width / this.other.height;
|
||||
const smallestRatio = Math.min(ratio, otherRatio);
|
||||
const greatestRatio = Math.max(ratio, otherRatio);
|
||||
|
||||
if (1.5 * smallestRatio < greatestRatio) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
let colorSimilitude;
|
||||
{
|
||||
let scoreMax = 0;
|
||||
for (const dominantColor of searchItem.dominantColors) {
|
||||
const dominantRgb = hexNumberToRGBColor(dominantColor);
|
||||
for (const otherDominantColor of this.other.dominantColors) {
|
||||
const otherDominantRgb = hexNumberToRGBColor(otherDominantColor);
|
||||
const score = getColorSimilitude(dominantRgb, otherDominantRgb);
|
||||
scoreMax = Math.max(scoreMax, score);
|
||||
}
|
||||
}
|
||||
colorSimilitude = scoreMax;
|
||||
}
|
||||
|
||||
return colorSimilitude;
|
||||
}
|
||||
}
|
||||
|
@@ -109,6 +109,14 @@ export const AssetStore = ({
|
||||
|
||||
const resourcesFetcher = useResourceFetcher();
|
||||
|
||||
const onOpenDetails = (assetShortHeader: AssetShortHeader) => {
|
||||
sendAssetOpened({
|
||||
id: assetShortHeader.id,
|
||||
name: assetShortHeader.name,
|
||||
});
|
||||
setOpenedAssetShortHeader(assetShortHeader);
|
||||
};
|
||||
|
||||
const onInstallAsset = React.useCallback(
|
||||
(assetShortHeader: AssetShortHeader) => {
|
||||
setAssetBeingInstalled(assetShortHeader);
|
||||
@@ -328,13 +336,7 @@ export const AssetStore = ({
|
||||
renderSearchItem={(assetShortHeader, size) => (
|
||||
<AssetCard
|
||||
size={size}
|
||||
onOpenDetails={() => {
|
||||
sendAssetOpened({
|
||||
id: assetShortHeader.id,
|
||||
name: assetShortHeader.name,
|
||||
});
|
||||
setOpenedAssetShortHeader(assetShortHeader);
|
||||
}}
|
||||
onOpenDetails={() => onOpenDetails(assetShortHeader)}
|
||||
assetShortHeader={assetShortHeader}
|
||||
/>
|
||||
)}
|
||||
@@ -361,6 +363,7 @@ export const AssetStore = ({
|
||||
!!assetBeingInstalled &&
|
||||
assetBeingInstalled.id === openedAssetShortHeader.id
|
||||
}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
|
115
newIDE/app/src/UI/Search/BoxSearchResultsNoScroll.js
Normal file
115
newIDE/app/src/UI/Search/BoxSearchResultsNoScroll.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// @flow
|
||||
import { Trans } from '@lingui/macro';
|
||||
import * as React from 'react';
|
||||
import PlaceholderLoader from '../PlaceholderLoader';
|
||||
import PlaceholderError from '../PlaceholderError';
|
||||
import ErrorBoundary from '../ErrorBoundary';
|
||||
import { AutoSizer, Grid } from 'react-virtualized';
|
||||
import EmptyMessage from '../EmptyMessage';
|
||||
import {
|
||||
GridListTile,
|
||||
GridList,
|
||||
makeStyles,
|
||||
createStyles,
|
||||
} from '@material-ui/core';
|
||||
import Text from '../../UI/Text';
|
||||
import { Column, Line, Spacer } from '../../UI/Grid';
|
||||
|
||||
type Props<SearchItem> = {|
|
||||
searchItems: ?Array<SearchItem>,
|
||||
renderSearchItem: (item: SearchItem, size: number) => React.Node,
|
||||
error: ?Error,
|
||||
onRetry: () => void,
|
||||
baseSize: number,
|
||||
noResultPlaceholder?: React.Node,
|
||||
|};
|
||||
|
||||
const styles = {
|
||||
container: { flex: 1 },
|
||||
grid: { overflowX: 'hidden' },
|
||||
};
|
||||
|
||||
export const BoxSearchResultsNoScroll = <SearchItem>({
|
||||
searchItems,
|
||||
renderSearchItem,
|
||||
error,
|
||||
onRetry,
|
||||
baseSize,
|
||||
noResultPlaceholder,
|
||||
}: Props<SearchItem>) => {
|
||||
if (!searchItems) {
|
||||
if (!error) return <PlaceholderLoader />;
|
||||
else {
|
||||
return (
|
||||
<PlaceholderError onRetry={onRetry}>
|
||||
<Trans>
|
||||
Can't load the results. Verify your internet connection or retry
|
||||
later.
|
||||
</Trans>
|
||||
</PlaceholderError>
|
||||
);
|
||||
}
|
||||
} else if (searchItems.length === 0) {
|
||||
return (
|
||||
noResultPlaceholder || (
|
||||
<EmptyMessage>
|
||||
<Trans>
|
||||
No results returned for your search. Try something else, browse the
|
||||
categories or create your object from scratch!
|
||||
</Trans>
|
||||
</EmptyMessage>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoSizer>
|
||||
{({ width, height }) => {
|
||||
if (!width || !height) return null;
|
||||
console.log("Dimensions: " + width + " " + height);
|
||||
|
||||
const columnCount = Math.max(Math.floor((width - 5) / baseSize), 1);
|
||||
const columnWidth = Math.max(Math.floor(width / columnCount), 30);
|
||||
const rowCount = Math.max(
|
||||
1,
|
||||
Math.ceil(searchItems.length / columnCount)
|
||||
);
|
||||
console.log("columnWidth: " + columnWidth);
|
||||
|
||||
return (
|
||||
<GridList
|
||||
style={styles.grid}
|
||||
cellHeight={columnWidth}
|
||||
spacing={0}
|
||||
cols={columnCount}
|
||||
style={{
|
||||
width: width,
|
||||
height: rowCount * columnWidth,
|
||||
}}>
|
||||
>
|
||||
{searchItems.map((searchItem, index) => {
|
||||
console.log("Item: " + index);
|
||||
//return(<Text key={"Similar" + index}>{"ITEMABC"}</Text>);
|
||||
return (<GridListTile
|
||||
//classes={classesForGridListItem}
|
||||
key={"Similar" + index}
|
||||
cols={1} rows={1}
|
||||
tabIndex={0}
|
||||
onKeyPress={(
|
||||
event: SyntheticKeyboardEvent<HTMLLIElement>
|
||||
): void => {
|
||||
// if (shouldValidate(event)) {
|
||||
// }
|
||||
}}
|
||||
onClick={() => {}}
|
||||
>
|
||||
{renderSearchItem(searchItem, columnWidth)}
|
||||
</GridListTile>);
|
||||
}
|
||||
)}
|
||||
</GridList>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
);
|
||||
};
|
@@ -5,7 +5,7 @@ import shuffle from 'lodash/shuffle';
|
||||
import SearchApi from 'js-worker-search';
|
||||
|
||||
export interface SearchFilter<SearchItem> {
|
||||
isSatisfiedBy(searchItem: SearchItem): boolean;
|
||||
getPertinence(searchItem: SearchItem): number;
|
||||
}
|
||||
|
||||
export class TagSearchFilter<SearchItem: { tags: Array<string> }>
|
||||
@@ -16,13 +16,83 @@ export class TagSearchFilter<SearchItem: { tags: Array<string> }>
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
isSatisfiedBy(searchItem: SearchItem): boolean {
|
||||
return (
|
||||
this.tags.size === 0 || searchItem.tags.some(tag => this.tags.has(tag))
|
||||
);
|
||||
getPertinence(searchItem: SearchItem): number {
|
||||
return this.tags.size === 0 ||
|
||||
searchItem.tags.some(tag => this.tags.has(tag))
|
||||
? 1
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximately sort the elements from biggest to smallest.
|
||||
*
|
||||
* It does a quick sort but only on the left side. It means that elements with
|
||||
* a poor pertinence won't be sorted as well as the one with a good pertinence.
|
||||
*
|
||||
* This allows a O(n) complexity in most cases, but still a O(n²) in worst
|
||||
* cases scenario (when all the values are the same).
|
||||
*/
|
||||
export const partialQuickSort = <Element: any>(
|
||||
searchItems: Array<Element>,
|
||||
getValue: (a: Element) => number,
|
||||
valueMin: number,
|
||||
valueMax: number
|
||||
): void => {
|
||||
if (valueMin >= valueMax) {
|
||||
// All values are the same.
|
||||
return;
|
||||
}
|
||||
let indexMax = searchItems.length - 1;
|
||||
// Values are between 0 and pertinenceMax.
|
||||
// Each pass ensures that elements are on the good side of the pivot.
|
||||
// With a pertinenceMax of 1, the pivot takes the values: 0.5, 0.25, 0.125...
|
||||
for (
|
||||
let pivotComplement = 0.5;
|
||||
pivotComplement > 1 / 128 && indexMax > 0;
|
||||
pivotComplement /= 2
|
||||
) {
|
||||
let pivot = valueMin + (valueMax - valueMin) * (1 - pivotComplement);
|
||||
let slidingIndexMin = 0 - 1;
|
||||
let slidingIndexMax = indexMax + 1;
|
||||
while (true) {
|
||||
do {
|
||||
slidingIndexMin++;
|
||||
} while (
|
||||
slidingIndexMin < indexMax &&
|
||||
getValue(searchItems[slidingIndexMin]) > pivot
|
||||
);
|
||||
if (slidingIndexMin === indexMax) {
|
||||
// All the values are on the left side.
|
||||
// They must be sorted.
|
||||
// Let's try with the next pivot value.
|
||||
break;
|
||||
}
|
||||
do {
|
||||
slidingIndexMax--;
|
||||
} while (
|
||||
slidingIndexMax > 0 &&
|
||||
getValue(searchItems[slidingIndexMax]) < pivot
|
||||
);
|
||||
if (slidingIndexMax === 0) {
|
||||
// All the values are on the right side.
|
||||
// As the pivot converge on the maximum value,
|
||||
// The sort is finished.
|
||||
return;
|
||||
}
|
||||
|
||||
if (slidingIndexMin >= slidingIndexMax) {
|
||||
// All values are on the good side of the pivot.
|
||||
indexMax = slidingIndexMax;
|
||||
break;
|
||||
}
|
||||
const swap = searchItems[slidingIndexMin];
|
||||
searchItems[slidingIndexMin] = searchItems[slidingIndexMax];
|
||||
searchItems[slidingIndexMax] = swap;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter a list of items according to the chosen category
|
||||
* and the chosen filters.
|
||||
@@ -30,7 +100,7 @@ export class TagSearchFilter<SearchItem: { tags: Array<string> }>
|
||||
export const filterSearchItems = <SearchItem: { tags: Array<string> }>(
|
||||
searchItems: ?Array<SearchItem>,
|
||||
chosenCategory: ?ChosenCategory,
|
||||
chosenFilters: Set<string>,
|
||||
chosenFilters: ?Set<string>,
|
||||
searchFilters?: Array<SearchFilter<SearchItem>>
|
||||
): ?Array<SearchItem> => {
|
||||
if (!searchItems) return null;
|
||||
@@ -62,20 +132,45 @@ export const filterSearchItems = <SearchItem: { tags: Array<string> }>(
|
||||
})
|
||||
.filter(searchItem => {
|
||||
return (
|
||||
(chosenFilters.size === 0 ||
|
||||
searchItem.tags.some(tag => chosenFilters.has(tag))) &&
|
||||
(!searchFilters ||
|
||||
searchFilters.every(searchFilter =>
|
||||
searchFilter.isSatisfiedBy(searchItem)
|
||||
))
|
||||
!chosenFilters || chosenFilters.size === 0 ||
|
||||
searchItem.tags.some(tag => chosenFilters.has(tag))
|
||||
);
|
||||
});
|
||||
|
||||
let sortedSearchItems = filteredSearchItems;
|
||||
if (searchFilters) {
|
||||
let pertinenceMin = 1;
|
||||
let pertinenceMax = 0;
|
||||
const weightedSearchItems = filteredSearchItems
|
||||
.map(searchItem => {
|
||||
let pertinence = 1;
|
||||
for (const searchFilter of searchFilters) {
|
||||
pertinence *= searchFilter.getPertinence(searchItem);
|
||||
if (pertinence === 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
pertinenceMin = Math.min(pertinenceMin, pertinence);
|
||||
pertinenceMax = Math.max(pertinenceMax, pertinence);
|
||||
return { pertinence: pertinence, searchItem: searchItem };
|
||||
})
|
||||
.filter(Boolean);
|
||||
partialQuickSort(
|
||||
weightedSearchItems,
|
||||
weightedSearchItem => weightedSearchItem.pertinence,
|
||||
pertinenceMin,
|
||||
pertinenceMax
|
||||
);
|
||||
sortedSearchItems = weightedSearchItems.map(
|
||||
weightedSearchItem => weightedSearchItem.searchItem
|
||||
);
|
||||
}
|
||||
|
||||
const totalTime = performance.now() - startTime;
|
||||
console.info(
|
||||
`Filtered items by category/filters in ${totalTime.toFixed(3)}ms.`
|
||||
);
|
||||
return filteredSearchItems;
|
||||
return sortedSearchItems;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -93,7 +188,7 @@ export const useSearchItem = <SearchItem: { tags: Array<string> }>(
|
||||
getItemDescription: SearchItem => string,
|
||||
searchText: string,
|
||||
chosenCategory: ?ChosenCategory,
|
||||
chosenFilters: Set<string>,
|
||||
chosenFilters: ?Set<string>,
|
||||
searchFilters?: Array<SearchFilter<SearchItem>>
|
||||
): ?Array<SearchItem> => {
|
||||
const searchApiRef = React.useRef<?any>(null);
|
||||
|
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import { filterSearchItems } from './UseSearchItem';
|
||||
import { filterSearchItems, partialQuickSort } from './UseSearchItem';
|
||||
|
||||
describe('UseSearchItem', () => {
|
||||
test('sanitizeObjectName', () => {
|
||||
@@ -15,3 +15,55 @@ describe('UseSearchItem', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('partialQuickSort', () => {
|
||||
test('Sort an empty search result', () => {
|
||||
const elements = [];
|
||||
partialQuickSort(elements, a => a, 1, 0);
|
||||
expect(elements).toStrictEqual([]);
|
||||
});
|
||||
test('Sort an unique search result', () => {
|
||||
const elements = [1];
|
||||
partialQuickSort(elements, a => a, 1, 1);
|
||||
expect(elements).toStrictEqual([1]);
|
||||
});
|
||||
[0.4, 0.6, 1].forEach(pertinence => {
|
||||
test(`Sort search results with same pertinence: ${pertinence}`, () => {
|
||||
const elements = [];
|
||||
elements.length = 8;
|
||||
elements.fill(pertinence);
|
||||
partialQuickSort(elements, a => a, pertinence, pertinence);
|
||||
|
||||
const expectedElements = [];
|
||||
expectedElements.length = 8;
|
||||
expectedElements.fill(pertinence);
|
||||
expect(elements).toStrictEqual(expectedElements);
|
||||
});
|
||||
});
|
||||
test('Sort search results', () => {
|
||||
const elements = [0.2, 0.1, 0.6, 0.4, 1, 0.3, 0.8, 0.5, 0.7, 0.9];
|
||||
partialQuickSort(elements, a => a, 0.1, 1);
|
||||
expect(elements).toStrictEqual(
|
||||
//
|
||||
[1, 0.9, 0.8, 0.6, 0.7, 0.3, 0.4, 0.5, 0.1, 0.2]
|
||||
);
|
||||
});
|
||||
test('Sort search results with a lot of small values', () => {
|
||||
const elements = [0.2, 0.1, 0.15, 0.4, 1, 0.3, 0.25, 0.45, 0.35];
|
||||
partialQuickSort(elements, a => a, 0.1, 1);
|
||||
expect(elements).toStrictEqual(
|
||||
//
|
||||
[1, 0.1, 0.15, 0.4, 0.2, 0.3, 0.25, 0.45, 0.35]
|
||||
);
|
||||
});
|
||||
test('Sort search results with duplicated values', () => {
|
||||
const elements = [0.2, 0.6, 0.6, 0.4, 1, 0.8, 0.2, 0.4, 0.8];
|
||||
partialQuickSort(elements, a => a, 0.1, 1);
|
||||
expect(elements).toStrictEqual([1, 0.8, 0.8, 0.6, 0.6, 0.4, 0.2, 0.4, 0.2]);
|
||||
});
|
||||
test('Sort search results with duplicated maximum values', () => {
|
||||
const elements = [1, 1, 1, 1, 1, 0.2, 1, 1, 1];
|
||||
partialQuickSort(elements, a => a, 0.1, 1);
|
||||
expect(elements).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1, 0.2]);
|
||||
});
|
||||
});
|
||||
|
Reference in New Issue
Block a user