Similarity UI — Полный код¶
Уровень: L3 (deep-dive) | ← Назад к Embeddings
1. useEmbeddingSearch Hook¶
Файл: frontend/src/embeddings/useEmbeddingSearch.ts
React-хуки для similarity search с управлением состоянием.
import { useState, useEffect } from 'react';
import { similarityService } from './similarityService';
import type { SimilarityResult, SimilaritySearchState } from './embeddingTypes';
/**
* Хук для поиска похожих вещей.
*
* @param itemId - ID вещи-запроса
* @param topK - количество результатов (default: 8)
* @returns {results, isLoading, error}
*/
export function useSimilarItems(
itemId: string | undefined,
topK = 8
): SimilaritySearchState {
const [results, setResults] = useState<SimilarityResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!itemId) {
setResults([]);
setIsLoading(false);
return;
}
let cancelled = false;
setIsLoading(true);
setError(null);
similarityService
.findSimilarItems(itemId, topK)
.then(({ results: r }) => {
if (!cancelled) {
setResults(r);
setIsLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
setIsLoading(false);
}
});
// Cleanup: отмена при смене ID или unmount
return () => {
cancelled = true;
};
}, [itemId, topK]);
return { results, isLoading, error };
}
/**
* Хук для поиска похожих образов.
*
* @param outfitId - ID образа-запроса
* @param topK - количество результатов (default: 6)
*/
export function useSimilarOutfits(
outfitId: string | undefined,
topK = 6
): SimilaritySearchState {
const [results, setResults] = useState<SimilarityResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!outfitId) {
setResults([]);
setIsLoading(false);
return;
}
let cancelled = false;
setIsLoading(true);
setError(null);
similarityService
.findSimilarOutfits(outfitId, topK)
.then(({ results: r }) => {
if (!cancelled) {
setResults(r);
setIsLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [outfitId, topK]);
return { results, isLoading, error };
}
2. SimilarItemsRail¶
Файл: frontend/src/components/common/SimilarItemsRail.tsx
Горизонтальная карусель похожих вещей (scroll snap).
import type { Item } from '../../types/api';
import ItemCard from './ItemCard';
import SkeletonCard from '../ui/SkeletonCard';
import s from './SimilarItemsRail.module.css';
interface SimilarItemsRailProps {
items: Item[]; // Вещи для отображения
isLoading: boolean; // Показать скелетоны
onItemClick: (itemId: string) => void; // Навигация
}
export default function SimilarItemsRail({
items,
isLoading,
onItemClick,
}: SimilarItemsRailProps) {
// Не рендерить если нет данных и не загружается
if (!isLoading && items.length === 0) return null;
return (
<section className={s.section}>
<h2 className={s.sectionTitle}>Похожие вещи</h2>
<div className={s.rail}>
{isLoading
? // Скелетоны во время загрузки (4 карточки)
Array.from({ length: 4 }).map((_, i) => (
<div className={s.card} key={`skel-${i}`}>
<SkeletonCard imageHeight={100} lines={2} />
</div>
))
: // Реальные карточки вещей
items.map((item) => (
<div className={s.card} key={item.id}>
<ItemCard
item={item}
onClick={() => onItemClick(item.id)}
compact // Компактный режим для rail
/>
</div>
))}
</div>
</section>
);
}
CSS Module: SimilarItemsRail.module.css¶
.section {
margin-top: var(--spacing-lg);
}
.sectionTitle {
font-size: var(--font-size-md);
font-weight: 600;
margin-bottom: var(--spacing-sm);
}
.rail {
display: flex;
gap: var(--spacing-sm);
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* Firefox */
}
.rail::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.card {
flex-shrink: 0;
width: 140px;
scroll-snap-align: start;
}
3. SimilarOutfitsGrid¶
Файл: frontend/src/components/common/SimilarOutfitsGrid.tsx
Сетка 2×3 похожих образов.
import type { Outfit } from '../../types/api';
import OutfitCard from './OutfitCard';
import SkeletonCard from '../ui/SkeletonCard';
import s from './SimilarOutfitsGrid.module.css';
interface SimilarOutfitsGridProps {
outfits: Outfit[];
isLoading: boolean;
onOutfitClick: (outfitId: string) => void;
}
const MAX_VISIBLE = 6; // Максимум 6 карточек (2×3 сетка)
export default function SimilarOutfitsGrid({
outfits,
isLoading,
onOutfitClick,
}: SimilarOutfitsGridProps) {
if (!isLoading && outfits.length === 0) return null;
return (
<section className={s.section}>
<h2 className={s.sectionTitle}>Похожие образы</h2>
<div className={s.grid}>
{isLoading
? Array.from({ length: 4 }).map((_, i) => (
<div className={s.cell} key={`skel-${i}`}>
<SkeletonCard imageHeight={130} lines={2} />
</div>
))
: outfits.slice(0, MAX_VISIBLE).map((outfit) => (
<div className={s.cell} key={outfit.id}>
<OutfitCard
outfit={outfit}
onClick={() => onOutfitClick(outfit.id)}
/>
</div>
))}
</div>
</section>
);
}
CSS Module: SimilarOutfitsGrid.module.css¶
.section {
margin-top: var(--spacing-lg);
}
.sectionTitle {
font-size: var(--font-size-md);
font-weight: 600;
margin-bottom: var(--spacing-sm);
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-sm);
}
.cell {
min-width: 0;
}
4. Интеграция в страницы¶
WardrobeItemPage (похожие вещи)¶
// frontend/src/pages/WardrobeItemPage.tsx
import { useSimilarItems } from '../embeddings/useEmbeddingSearch';
import { useLocalItemsByIds } from '../offline/hooks/useLocalItemsByIds';
// Внутри компонента:
const { results: similarResults, isLoading: similarLoading } = useSimilarItems(itemId, 8);
const { data: similarItems = [] } = useLocalItemsByIds(similarResults.map(r => r.id));
// В JSX:
<SimilarItemsRail
items={similarItems}
isLoading={similarLoading}
onItemClick={(id) => navigate(`/wardrobe/${id}`)}
/>
OutfitDetailPage (похожие образы)¶
// frontend/src/pages/OutfitDetailPage.tsx
import { useSimilarOutfits } from '../embeddings/useEmbeddingSearch';
import { useLocalOutfitsByIds } from '../offline/hooks/useLocalOutfitsByIds';
const { results: similarResults, isLoading: similarLoading } = useSimilarOutfits(outfitId, 6);
const { data: similarOutfits = [] } = useLocalOutfitsByIds(similarResults.map(r => r.id));
<SimilarOutfitsGrid
outfits={similarOutfits}
isLoading={similarLoading}
onOutfitClick={(id) => navigate(`/outfits/${id}`)}
/>
Data Flow (полный путь)¶
1. useSimilarItems(itemId) → similarityService.findSimilarItems(itemId)
2. embeddingRepository.get('item', itemId) → query vector from IndexedDB
3. embeddingRepository.getAllByType('item') → all candidates (if cache stale)
4. worker.postMessage({SEARCH}) → cosine similarity in background
5. worker → SEARCH_RESULT → [{id: 'abc', score: 0.92}, ...]
6. useLocalItemsByIds(['abc', ...]) → full Item objects from IndexedDB
7. <SimilarItemsRail items={[...]} /> → render cards