Перейти к содержанию

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