diff --git a/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx b/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx index 7c63322a..900ce74c 100644 --- a/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx +++ b/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx @@ -6,6 +6,7 @@ import { getClimb } from '@/app/lib/data/queries'; import ClimbCard from '@/app/components/climb-card/climb-card'; import { Col, Row } from 'antd'; import BetaVideos from '@/app/components/beta-videos/beta-videos'; +import SimilarClimbs from '@/app/components/similar-climbs/similar-climbs'; import { constructClimbInfoUrl, extractUuidFromSlug, @@ -206,6 +207,9 @@ export default async function DynamicResultsPage(props: { params: Promise +
+ +
diff --git a/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/[climb_uuid]/similar/route.ts b/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/[climb_uuid]/similar/route.ts new file mode 100644 index 00000000..fd9189d5 --- /dev/null +++ b/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/[climb_uuid]/similar/route.ts @@ -0,0 +1,27 @@ +import { getSimilarClimbs, SimilarClimbsResult } from '@/app/lib/db/queries/climbs/similar-climbs'; +import { BoardRouteParametersWithUuid, ErrorResponse } from '@/app/lib/types'; +import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; +import { NextResponse } from 'next/server'; + +export async function GET( + req: Request, + props: { params: Promise }, +): Promise> { + const params = await props.params; + + try { + const parsedParams = await parseBoardRouteParamsWithSlugs(params); + + // Get similarity threshold from query params (default 0.9) + const url = new URL(req.url); + const threshold = parseFloat(url.searchParams.get('threshold') || '0.9'); + const limit = parseInt(url.searchParams.get('limit') || '10', 10); + + const result = await getSimilarClimbs(parsedParams, threshold, limit); + + return NextResponse.json(result); + } catch (error) { + console.error('Error fetching similar climbs:', error); + return NextResponse.json({ error: 'Failed to fetch similar climbs' }, { status: 500 }); + } +} diff --git a/app/components/rest-api/api.ts b/app/components/rest-api/api.ts index ef80a7f0..3012709d 100644 --- a/app/components/rest-api/api.ts +++ b/app/components/rest-api/api.ts @@ -14,6 +14,7 @@ import { SearchClimbsResult, } from '@/app/lib/types'; import { BetaLink } from '@/app/lib/api-wrappers/sync-api-types'; +import { LitUpHoldsMap } from '@/app/components/board-renderer/types'; const API_BASE_URL = `/api/v1`; @@ -128,3 +129,44 @@ export const fetchSets = async (board_name: BoardName, layout_id: LayoutId, size return response.json(); }; + +// Fetch similar climbs +export const fetchSimilarClimbs = async ( + routeParameters: ParsedBoardRouteParametersWithUuid, + threshold: number = 0.9, + limit: number = 10, +): Promise => { + const apiUrl = `${API_BASE_URL}/${routeParameters.board_name}/${routeParameters.layout_id}/${routeParameters.size_id}/${routeParameters.set_ids}/${routeParameters.angle}/${routeParameters.climb_uuid}/similar?threshold=${threshold}&limit=${limit}`; + const response = await fetch(apiUrl); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +}; + +export type SimilarClimbsResult = { + exactLargerMatches: SimilarClimbMatch[]; + highSimilarityMatches: SimilarClimbMatch[]; +}; + +export type SimilarClimbMatch = { + uuid: string; + setter_username: string; + name: string; + description: string; + frames: string; + angle: number; + ascensionist_count: number; + difficulty: string; + quality_average: string; + stars: number; + difficulty_error: string; + benchmark_difficulty: string | null; + litUpHoldsMap: LitUpHoldsMap; + matchType: 'exact_larger' | 'high_similarity'; + similarity: number; + matchingSizeId: number; + matchingSizeName: string; +}; diff --git a/app/components/similar-climbs/similar-climbs.tsx b/app/components/similar-climbs/similar-climbs.tsx new file mode 100644 index 00000000..db89a160 --- /dev/null +++ b/app/components/similar-climbs/similar-climbs.tsx @@ -0,0 +1,165 @@ +'use client'; + +import React from 'react'; +import { List, Typography, Tag, Empty, Spin, Card } from 'antd'; +import { CopyrightOutlined } from '@ant-design/icons'; +import useSWR from 'swr'; +import Link from 'next/link'; +import { BoardDetails, ParsedBoardRouteParametersWithUuid } from '@/app/lib/types'; +import { fetchSimilarClimbs, SimilarClimbMatch } from '../rest-api/api'; +import BoardRenderer from '../board-renderer/board-renderer'; +import { constructClimbViewUrlWithSlugs, parseBoardRouteParams, constructClimbViewUrl } from '@/app/lib/url-utils'; + +const { Text, Title } = Typography; + +type SimilarClimbsProps = { + params: ParsedBoardRouteParametersWithUuid; + boardDetails: BoardDetails; +}; + +const SimilarClimbItem: React.FC<{ + climb: SimilarClimbMatch; + boardDetails: BoardDetails; + params: ParsedBoardRouteParametersWithUuid; +}> = ({ climb, boardDetails, params }) => { + // Build the URL for the similar climb + const climbViewUrl = + boardDetails.layout_name && boardDetails.size_name && boardDetails.set_names + ? constructClimbViewUrlWithSlugs( + boardDetails.board_name, + boardDetails.layout_name, + climb.matchingSizeName || boardDetails.size_name, + boardDetails.size_description, + boardDetails.set_names, + climb.angle, + climb.uuid, + climb.name, + ) + : (() => { + const routeParams = parseBoardRouteParams({ + board_name: boardDetails.board_name, + layout_id: boardDetails.layout_id.toString(), + size_id: (climb.matchingSizeId || boardDetails.size_id).toString(), + set_ids: boardDetails.set_ids.join(','), + angle: climb.angle.toString(), + }); + return constructClimbViewUrl(routeParams, climb.uuid, climb.name); + })(); + + return ( + +
+
+ + + +
+
+ + + {climb.name} + {climb.benchmark_difficulty && } + + + + {climb.difficulty && climb.quality_average !== '0' + ? `${climb.difficulty} ${climb.quality_average}★` + : 'project'}{' '} + @ {climb.angle}° + + + by {climb.setter_username} • {climb.ascensionist_count} ascents + +
+ {climb.matchType === 'exact_larger' ? ( + Same climb on {climb.matchingSizeName} + ) : ( + {Math.round(climb.similarity * 100)}% similar + )} +
+
+
+
+ ); +}; + +const SimilarClimbs: React.FC = ({ params, boardDetails }) => { + const { data, error, isLoading } = useSWR( + [`similar-climbs`, params.climb_uuid], + () => fetchSimilarClimbs(params, 0.9, 10), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); + + if (isLoading) { + return ( + +
+ +
+
+ ); + } + + if (error) { + return ( + + Failed to load similar climbs + + ); + } + + const hasExactMatches = data?.exactLargerMatches && data.exactLargerMatches.length > 0; + const hasSimilarityMatches = data?.highSimilarityMatches && data.highSimilarityMatches.length > 0; + + if (!hasExactMatches && !hasSimilarityMatches) { + return ( + + + + ); + } + + return ( + + {hasExactMatches && ( + <> + + Same climb on larger boards + + ( + + )} + /> + + )} + + {hasSimilarityMatches && ( + <> + + Similar climbs (90%+ matching holds) + + ( + + )} + /> + + )} + + ); +}; + +export default SimilarClimbs; diff --git a/app/lib/db/queries/climbs/similar-climbs.ts b/app/lib/db/queries/climbs/similar-climbs.ts new file mode 100644 index 00000000..4c3a7f64 --- /dev/null +++ b/app/lib/db/queries/climbs/similar-climbs.ts @@ -0,0 +1,246 @@ +import { sql } from 'drizzle-orm'; +import { dbz as db } from '@/app/lib/db/db'; +import { ParsedBoardRouteParametersWithUuid, Climb } from '@/app/lib/types'; +import { getBoardTables } from '@/lib/db/queries/util/table-select'; +import { convertLitUpHoldsStringToMap } from '@/app/components/board-renderer/util'; +import { getTableName } from '@/app/lib/data-sync/aurora/getTableName'; + +export type SimilarClimbMatch = Climb & { + matchType: 'exact_larger' | 'high_similarity'; + similarity: number; + matchingSizeId: number; + matchingSizeName: string; +}; + +export type SimilarClimbsResult = { + exactLargerMatches: SimilarClimbMatch[]; + highSimilarityMatches: SimilarClimbMatch[]; +}; + +/** + * Find climbs similar to the given climb based on hold matching. + * + * Two types of similarity: + * 1. Exact match on larger boards: Climbs on larger board sizes that contain ALL the same holds + * as the source climb (the source climb's holds are a subset of the larger climb's holds). + * 2. High similarity (90%+): Climbs where 90% or more of holds match using Jaccard similarity. + */ +export const getSimilarClimbs = async ( + params: ParsedBoardRouteParametersWithUuid, + similarityThreshold: number = 0.9, + limit: number = 10, +): Promise => { + const tables = getBoardTables(params.board_name); + const climbHoldsTable = getTableName(params.board_name, 'climb_holds'); + const climbsTable = getTableName(params.board_name, 'climbs'); + const climbStatsTable = getTableName(params.board_name, 'climb_stats'); + const difficultyGradesTable = getTableName(params.board_name, 'difficulty_grades'); + const productSizesTable = getTableName(params.board_name, 'product_sizes'); + + try { + // First, get the holds for the source climb + const sourceHolds = await db + .select({ holdId: tables.climbHolds.holdId }) + .from(tables.climbHolds) + .where(sql`${tables.climbHolds.climbUuid} = ${params.climb_uuid}`); + + if (sourceHolds.length === 0) { + return { exactLargerMatches: [], highSimilarityMatches: [] }; + } + + const sourceHoldIds = sourceHolds.map(h => h.holdId); + const sourceHoldCount = sourceHoldIds.length; + + // Query 1: Find exact matches on larger board sizes + // These are climbs that contain ALL the holds of the source climb + // and are on a larger board (all edge dimensions are >= the current size's edges) + const exactLargerMatchesQuery = await db.execute(sql` + WITH source_holds AS ( + SELECT hold_id FROM ${sql.identifier(climbHoldsTable)} + WHERE climb_uuid = ${params.climb_uuid} + ), + current_size AS ( + SELECT edge_left, edge_right, edge_bottom, edge_top + FROM ${sql.identifier(productSizesTable)} + WHERE id = ${params.size_id} + ), + larger_sizes AS ( + SELECT ps.id as size_id, ps.name as size_name + FROM ${sql.identifier(productSizesTable)} ps, current_size cs + WHERE ps.id != ${params.size_id} + AND ps.edge_left <= cs.edge_left + AND ps.edge_right >= cs.edge_right + AND ps.edge_bottom <= cs.edge_bottom + AND ps.edge_top >= cs.edge_top + ), + candidate_climbs AS ( + SELECT DISTINCT ch.climb_uuid + FROM ${sql.identifier(climbHoldsTable)} ch + INNER JOIN ${sql.identifier(climbsTable)} c ON c.uuid = ch.climb_uuid + INNER JOIN larger_sizes ls ON true + WHERE c.uuid != ${params.climb_uuid} + AND c.layout_id = ${params.layout_id} + AND c.is_listed = true + AND c.is_draft = false + AND c.frames_count = 1 + -- Climb fits within a larger size + AND c.edge_left > ls.size_id - ls.size_id + (SELECT edge_left FROM current_size) + AND c.edge_right < (SELECT edge_right FROM ${sql.identifier(productSizesTable)} WHERE id = ls.size_id) + AND c.edge_bottom > (SELECT edge_bottom FROM ${sql.identifier(productSizesTable)} WHERE id = ls.size_id) + AND c.edge_top < (SELECT edge_top FROM ${sql.identifier(productSizesTable)} WHERE id = ls.size_id) + ), + climb_hold_counts AS ( + SELECT + ch.climb_uuid, + COUNT(*) as total_holds, + COUNT(*) FILTER (WHERE ch.hold_id IN (SELECT hold_id FROM source_holds)) as matching_holds + FROM ${sql.identifier(climbHoldsTable)} ch + WHERE ch.climb_uuid IN (SELECT climb_uuid FROM candidate_climbs) + GROUP BY ch.climb_uuid + ) + SELECT + c.uuid, + c.setter_username, + c.name, + c.description, + c.frames, + COALESCE(cs.angle, ${params.angle}) as angle, + COALESCE(cs.ascensionist_count, 0) as ascensionist_count, + dg.boulder_name as difficulty, + ROUND(cs.quality_average::numeric, 2) as quality_average, + ROUND(cs.difficulty_average::numeric - cs.display_difficulty::numeric, 2) as difficulty_error, + cs.benchmark_difficulty, + chc.matching_holds, + chc.total_holds, + 1.0 as similarity, + ps.id as matching_size_id, + ps.name as matching_size_name + FROM climb_hold_counts chc + INNER JOIN ${sql.identifier(climbsTable)} c ON c.uuid = chc.climb_uuid + INNER JOIN ${sql.identifier(productSizesTable)} ps ON + c.edge_left > ps.edge_left AND c.edge_right < ps.edge_right + AND c.edge_bottom > ps.edge_bottom AND c.edge_top < ps.edge_top + LEFT JOIN ${sql.identifier(climbStatsTable)} cs ON cs.climb_uuid = c.uuid AND cs.angle = ${params.angle} + LEFT JOIN ${sql.identifier(difficultyGradesTable)} dg ON dg.difficulty = ROUND(cs.display_difficulty::numeric) + WHERE chc.matching_holds = ${sourceHoldCount} + AND ps.id != ${params.size_id} + AND ps.edge_left <= (SELECT edge_left FROM current_size) + AND ps.edge_right >= (SELECT edge_right FROM current_size) + AND ps.edge_bottom <= (SELECT edge_bottom FROM current_size) + AND ps.edge_top >= (SELECT edge_top FROM current_size) + ORDER BY cs.ascensionist_count DESC NULLS LAST + LIMIT ${limit} + `); + + // Query 2: Find high similarity matches using Jaccard similarity + // Jaccard = |A ∩ B| / |A ∪ B| = matching_holds / (source_holds + target_holds - matching_holds) + const highSimilarityMatchesQuery = await db.execute(sql` + WITH source_holds AS ( + SELECT hold_id FROM ${sql.identifier(climbHoldsTable)} + WHERE climb_uuid = ${params.climb_uuid} + ), + source_hold_count AS ( + SELECT COUNT(*) as cnt FROM source_holds + ), + candidate_climbs AS ( + SELECT DISTINCT c.uuid + FROM ${sql.identifier(climbsTable)} c + WHERE c.uuid != ${params.climb_uuid} + AND c.layout_id = ${params.layout_id} + AND c.is_listed = true + AND c.is_draft = false + AND c.frames_count = 1 + ), + climb_similarity AS ( + SELECT + ch.climb_uuid, + COUNT(*) as target_hold_count, + COUNT(*) FILTER (WHERE ch.hold_id IN (SELECT hold_id FROM source_holds)) as matching_holds + FROM ${sql.identifier(climbHoldsTable)} ch + WHERE ch.climb_uuid IN (SELECT uuid FROM candidate_climbs) + GROUP BY ch.climb_uuid + ), + similarity_scores AS ( + SELECT + climb_uuid, + matching_holds, + target_hold_count, + -- Jaccard similarity: intersection / union + matching_holds::float / ( + (SELECT cnt FROM source_hold_count) + target_hold_count - matching_holds + ) as jaccard_similarity + FROM climb_similarity + WHERE matching_holds > 0 + ) + SELECT + c.uuid, + c.setter_username, + c.name, + c.description, + c.frames, + COALESCE(cs.angle, ${params.angle}) as angle, + COALESCE(cs.ascensionist_count, 0) as ascensionist_count, + dg.boulder_name as difficulty, + ROUND(cs.quality_average::numeric, 2) as quality_average, + ROUND(cs.difficulty_average::numeric - cs.display_difficulty::numeric, 2) as difficulty_error, + cs.benchmark_difficulty, + ss.matching_holds, + ss.target_hold_count, + ROUND(ss.jaccard_similarity::numeric, 3) as similarity, + ps.id as matching_size_id, + ps.name as matching_size_name + FROM similarity_scores ss + INNER JOIN ${sql.identifier(climbsTable)} c ON c.uuid = ss.climb_uuid + INNER JOIN ${sql.identifier(productSizesTable)} ps ON + c.edge_left > ps.edge_left AND c.edge_right < ps.edge_right + AND c.edge_bottom > ps.edge_bottom AND c.edge_top < ps.edge_top + LEFT JOIN ${sql.identifier(climbStatsTable)} cs ON cs.climb_uuid = c.uuid AND cs.angle = ${params.angle} + LEFT JOIN ${sql.identifier(difficultyGradesTable)} dg ON dg.difficulty = ROUND(cs.display_difficulty::numeric) + WHERE ss.jaccard_similarity >= ${similarityThreshold} + AND ss.jaccard_similarity < 1.0 -- Exclude exact matches (they're in the first query) + ORDER BY ss.jaccard_similarity DESC, cs.ascensionist_count DESC NULLS LAST + LIMIT ${limit} + `); + + // Transform results + const transformRow = (row: Record, matchType: 'exact_larger' | 'high_similarity'): SimilarClimbMatch => ({ + uuid: row.uuid as string, + setter_username: (row.setter_username as string) || '', + name: (row.name as string) || '', + description: (row.description as string) || '', + frames: (row.frames as string) || '', + angle: Number(row.angle), + ascensionist_count: Number(row.ascensionist_count || 0), + difficulty: (row.difficulty as string) || '', + quality_average: row.quality_average?.toString() || '0', + stars: Math.round((Number(row.quality_average) || 0) * 5), + difficulty_error: row.difficulty_error?.toString() || '0', + benchmark_difficulty: row.benchmark_difficulty?.toString() || null, + litUpHoldsMap: convertLitUpHoldsStringToMap((row.frames as string) || '', params.board_name)[0], + matchType, + similarity: Number(row.similarity), + matchingSizeId: Number(row.matching_size_id), + matchingSizeName: (row.matching_size_name as string) || '', + }); + + const exactLargerMatches = exactLargerMatchesQuery.rows.map(row => + transformRow(row as Record, 'exact_larger') + ); + + const highSimilarityMatches = highSimilarityMatchesQuery.rows.map(row => + transformRow(row as Record, 'high_similarity') + ); + + // Filter out any duplicates (climbs that appear in both lists) + const exactUuids = new Set(exactLargerMatches.map(c => c.uuid)); + const filteredSimilarityMatches = highSimilarityMatches.filter(c => !exactUuids.has(c.uuid)); + + return { + exactLargerMatches, + highSimilarityMatches: filteredSimilarityMatches, + }; + } catch (error) { + console.error('Error in getSimilarClimbs:', error); + throw error; + } +};