Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getHoldHeatmapData } from '@/app/lib/db/queries/climbs/holds-heatmap';
import { getHoldHeatmapData, HoldsWithStateFilter } from '@/app/lib/db/queries/climbs/holds-heatmap';
import { getSession } from '@/app/lib/session';
import { BoardRouteParameters, ErrorResponse, SearchRequestPagination } from '@/app/lib/types';
import { urlParamsToSearchParams } from '@/app/lib/url-utils';
Expand Down Expand Up @@ -60,8 +60,19 @@ export async function GET(
userId = session.userId;
}

// Parse holdsWithState filter if provided (JSON stringified object)
let holdsWithState: HoldsWithStateFilter | undefined;
const holdsWithStateParam = query.get('holdsWithState');
if (holdsWithStateParam) {
try {
holdsWithState = JSON.parse(holdsWithStateParam);
} catch {
console.warn('Invalid holdsWithState parameter, ignoring');
}
}

// Get the heatmap data using the query function
const holdStats = await getHoldHeatmapData(parsedParams, searchParams, userId);
const holdStats = await getHoldHeatmapData(parsedParams, searchParams, userId, holdsWithState);

// Return response
return NextResponse.json({
Expand Down
39 changes: 29 additions & 10 deletions app/components/board-renderer/board-heatmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { BoardDetails } from '@/app/lib/types';
import { HeatmapData } from './types';
import { LitUpHoldsMap } from './types';
import { scaleLog } from 'd3-scale';
import useHeatmapData from '../search-drawer/use-heatmap';
import { usePathname, useSearchParams } from 'next/navigation';
import { useUISearchParams } from '@/app/components/queue-control/ui-searchparams-provider';
import useHeatmapData, { HoldsWithStateFilter } from '../search-drawer/use-heatmap';
import { usePathname } from 'next/navigation';
import { useUISearchParamsOptional } from '@/app/components/queue-control/ui-searchparams-provider';
import { Button, Select, Form, Switch } from 'antd';
import { track } from '@vercel/analytics';
import BoardRenderer from './board-renderer';
import { SearchRequestPagination } from '@/app/lib/types';
import { DEFAULT_SEARCH_PARAMS } from '@/app/lib/url-utils';

const LEGEND_HEIGHT = 96; // Increased from 80
const BLUR_RADIUS = 10; // Increased blur radius
Expand Down Expand Up @@ -41,6 +43,9 @@ interface BoardHeatmapProps {
boardDetails: BoardDetails;
litUpHoldsMap?: LitUpHoldsMap;
onHoldClick?: (holdId: number) => void;
holdsWithState?: HoldsWithStateFilter; // Filter for create climb heatmap
angle?: number; // Optional angle override (used by create climb)
filters?: SearchRequestPagination; // Optional filters override (used by create climb)
}

// Define the color mode type including user-specific modes
Expand All @@ -55,17 +60,30 @@ type ColorMode =
| 'userAscents'
| 'userAttempts';

const BoardHeatmap: React.FC<BoardHeatmapProps> = ({ boardDetails, litUpHoldsMap, onHoldClick }) => {
const BoardHeatmap: React.FC<BoardHeatmapProps> = ({
boardDetails,
litUpHoldsMap,
onHoldClick,
holdsWithState,
angle: angleProp,
filters: filtersProp,
}) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const { uiSearchParams } = useUISearchParams();

// Use uiSearchParams context if available and no filters prop provided
const uiSearchParamsContext = useUISearchParamsOptional();
const uiSearchParams = uiSearchParamsContext?.uiSearchParams;

const [colorMode, setColorMode] = useState<ColorMode>('ascents');
const [showNumbers, setShowNumbers] = useState(false);
const [showHeatmap, setShowHeatmap] = useState(false);

// Get angle from pathname - derived directly without needing state
const angle = useMemo(() => getAngleFromPath(pathname), [pathname]);
// Get angle from pathname if not provided as prop
const angleFromPath = useMemo(() => getAngleFromPath(pathname), [pathname]);
const angle = angleProp ?? angleFromPath;

// Use provided filters or fall back to uiSearchParams (context may not be available in create climb screen)
const filters = filtersProp ?? uiSearchParams ?? DEFAULT_SEARCH_PARAMS;

// Only fetch heatmap data when heatmap is enabled
const { data: heatmapData = [], loading: heatmapLoading } = useHeatmapData({
Expand All @@ -74,8 +92,9 @@ const BoardHeatmap: React.FC<BoardHeatmapProps> = ({ boardDetails, litUpHoldsMap
sizeId: boardDetails.size_id,
setIds: boardDetails.set_ids.join(','),
angle,
filters: uiSearchParams,
filters,
enabled: showHeatmap,
holdsWithState,
});

const [threshold, setThreshold] = useState(1);
Expand Down Expand Up @@ -299,7 +318,7 @@ const BoardHeatmap: React.FC<BoardHeatmapProps> = ({ boardDetails, litUpHoldsMap
</div>
)}
<svg
viewBox={`0 0 ${boardWidth} ${boardHeight + LEGEND_HEIGHT}`}
viewBox={`0 0 ${boardWidth} ${boardHeight + (showHeatmap && !heatmapLoading ? LEGEND_HEIGHT : 0)}`}
preserveAspectRatio="xMidYMid meet"
className="w-full h-auto max-h-[55vh]"
>
Expand Down
30 changes: 24 additions & 6 deletions app/components/create-climb/create-climb-form.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
'use client';

import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Form, Input, Switch, Button, Typography, Flex, Space, Tag, Modal, Alert } from 'antd';
import { BulbOutlined, BulbFilled, ExperimentOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { track } from '@vercel/analytics';
import BoardRenderer from '../board-renderer/board-renderer';
import BoardHeatmap from '../board-renderer/board-heatmap';
import { useBoardProvider } from '../board-provider/board-provider-context';
import { useCreateClimb } from './use-create-climb';
import { useBoardBluetooth } from '../board-bluetooth-control/use-board-bluetooth';
import { BoardDetails } from '@/app/lib/types';
import { constructClimbListWithSlugs } from '@/app/lib/url-utils';
import { constructClimbListWithSlugs, DEFAULT_SEARCH_PARAMS } from '@/app/lib/url-utils';
import { HoldsWithStateFilter } from '../search-drawer/use-heatmap';
import { LitUpHoldsMap } from '../board-renderer/types';
import '../board-bluetooth-control/send-climb-to-board-button.css';

// Helper function to convert litUpHoldsMap to holdsWithState filter format
function convertToHoldsWithState(litUpHoldsMap: LitUpHoldsMap): HoldsWithStateFilter {
const holdsWithState: HoldsWithStateFilter = {};
for (const [holdId, hold] of Object.entries(litUpHoldsMap)) {
if (hold.state && hold.state !== 'OFF') {
holdsWithState[holdId] = hold.state;
}
}
return holdsWithState;
}

const { TextArea } = Input;
const { Title, Text } = Typography;

Expand Down Expand Up @@ -43,6 +56,9 @@ export default function CreateClimbForm({ boardDetails, angle }: CreateClimbForm

const { isConnected, loading: bluetoothLoading, connect, sendFramesToBoard } = useBoardBluetooth({ boardDetails });

// Convert litUpHoldsMap to holdsWithState format for heatmap filtering
const holdsWithState = useMemo(() => convertToHoldsWithState(litUpHoldsMap), [litUpHoldsMap]);

const [form] = Form.useForm<CreateClimbFormValues>();
const [loginForm] = Form.useForm<{ username: string; password: string }>();
const [isSaving, setIsSaving] = useState(false);
Expand Down Expand Up @@ -212,17 +228,19 @@ export default function CreateClimbForm({ boardDetails, angle }: CreateClimbForm
</Flex>

<Flex vertical gap={16}>
{/* Board with clickable holds */}
{/* Board with clickable holds and heatmap */}
<div>
<Text type="secondary" style={{ display: 'block', marginBottom: '8px' }}>
Tap holds to set their type. Tap again to cycle through types.
{isConnected && ' Changes are shown live on the board.'}
</Text>
<BoardRenderer
<BoardHeatmap
boardDetails={boardDetails}
litUpHoldsMap={litUpHoldsMap}
mirrored={false}
onHoldClick={handleHoldClick}
holdsWithState={holdsWithState}
angle={angle}
filters={DEFAULT_SEARCH_PARAMS}
/>
</div>

Expand Down
5 changes: 5 additions & 0 deletions app/components/queue-control/ui-searchparams-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,8 @@ export const useUISearchParams = () => {
}
return context;
};

// Optional version that returns null if not in context (for components that can work without it)
export const useUISearchParamsOptional = () => {
return useContext(UISearchParamsContext);
};
24 changes: 22 additions & 2 deletions app/components/search-drawer/use-heatmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { HeatmapData } from '../board-renderer/types';
import { searchParamsToUrlParams } from '@/app/lib/url-utils';
import { useBoardProvider } from '../board-provider/board-provider-context';

// Filter type for holds with their states (used in create climb heatmap)
export interface HoldsWithStateFilter {
[holdId: string]: string; // holdId -> HoldState (STARTING, HAND, FOOT, FINISH)
}

interface UseHeatmapDataProps {
boardName: BoardName;
layoutId: number;
Expand All @@ -12,6 +17,7 @@ interface UseHeatmapDataProps {
angle: number;
filters: SearchRequestPagination;
enabled?: boolean;
holdsWithState?: HoldsWithStateFilter; // Optional filter for create climb heatmap
}

export default function useHeatmapData({
Expand All @@ -22,12 +28,17 @@ export default function useHeatmapData({
angle,
filters,
enabled = true,
holdsWithState,
}: UseHeatmapDataProps) {
const [heatmapData, setHeatmapData] = useState<HeatmapData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const { token, user_id } = useBoardProvider();

// Serialize objects for stable comparison in useEffect
const holdsWithStateKey = holdsWithState ? JSON.stringify(holdsWithState) : '';
const filtersKey = JSON.stringify(filters);

useEffect(() => {
// Don't fetch if not enabled
if (!enabled) {
Expand All @@ -49,8 +60,16 @@ export default function useHeatmapData({
headers['x-user-id'] = user_id.toString();
}

// Build URL with query params
const urlParams = searchParamsToUrlParams(filters);

// Add holdsWithState filter if provided and has entries
if (holdsWithState && Object.keys(holdsWithState).length > 0) {
urlParams.set('holdsWithState', JSON.stringify(holdsWithState));
}

const response = await fetch(
`/api/v1/${boardName}/${layoutId}/${sizeId}/${setIds}/${angle}/heatmap?${searchParamsToUrlParams(filters).toString()}`,
`/api/v1/${boardName}/${layoutId}/${sizeId}/${setIds}/${angle}/heatmap?${urlParams.toString()}`,
{ headers },
);

Expand Down Expand Up @@ -82,7 +101,8 @@ export default function useHeatmapData({
return () => {
cancelled = true;
};
}, [boardName, layoutId, sizeId, setIds, angle, filters, token, user_id, enabled]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [boardName, layoutId, sizeId, setIds, angle, filtersKey, token, user_id, enabled, holdsWithStateKey]);

return { data: heatmapData, loading, error };
}
44 changes: 41 additions & 3 deletions app/lib/db/queries/climbs/holds-heatmap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, eq, sql } from 'drizzle-orm';
import { and, eq, like, sql, SQL } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { dbz as db } from '@/app/lib/db/db';
import { ParsedBoardRouteParameters, SearchRequestPagination } from '@/app/lib/types';
Expand All @@ -19,10 +19,32 @@ export interface HoldHeatmapData {
userAttempts?: number;
}

// Maps hold state names to their codes for each board type
// These must match the format used in frames strings: p{holdId}r{stateCode}
const HOLD_STATE_TO_CODE: Record<string, Record<string, number>> = {
kilter: {
STARTING: 42,
HAND: 43,
FINISH: 44,
FOOT: 45,
},
tension: {
STARTING: 1,
HAND: 2,
FINISH: 3,
FOOT: 4,
},
};

export interface HoldsWithStateFilter {
[holdId: string]: string; // holdId -> HoldState (STARTING, HAND, FOOT, FINISH)
}

export const getHoldHeatmapData = async (
params: ParsedBoardRouteParameters,
searchParams: SearchRequestPagination,
userId?: number,
holdsWithState?: HoldsWithStateFilter,
): Promise<HoldHeatmapData[]> => {
const tables = getBoardTables(params.board_name);
const climbHolds = tables.climbHolds;
Expand All @@ -33,6 +55,22 @@ export const getHoldHeatmapData = async (
// Use the shared filter creator with the PS alias
const filters = createClimbFilters(tables, params, searchParams, ps, userId);

// Build hold state filter conditions if provided
// These filter climbs to only include those that have ALL specified holds in their specified states
const holdStateConditions: SQL[] = [];
if (holdsWithState && Object.keys(holdsWithState).length > 0) {
const stateToCode = HOLD_STATE_TO_CODE[params.board_name];
if (stateToCode) {
for (const [holdId, state] of Object.entries(holdsWithState)) {
const stateCode = stateToCode[state];
if (stateCode !== undefined) {
// Match pattern: p{holdId}r{stateCode} in frames string
holdStateConditions.push(like(tables.climbs.frames, `%p${holdId}r${stateCode}%`));
}
}
}
}

try {
// Check if personal progress filters are active - if so, use user-specific counts
const personalProgressFiltersEnabled =
Expand Down Expand Up @@ -66,7 +104,7 @@ export const getHoldHeatmapData = async (
and(eq(tables.climbStats.climbUuid, climbHolds.climbUuid), eq(tables.climbStats.angle, params.angle)),
)
.where(
and(...filters.getClimbWhereConditions(), ...filters.getSizeConditions(), ...filters.getClimbStatsConditions()),
and(...filters.getClimbWhereConditions(), ...filters.getSizeConditions(), ...filters.getClimbStatsConditions(), ...holdStateConditions),
)
.groupBy(climbHolds.holdId);

Expand All @@ -92,7 +130,7 @@ export const getHoldHeatmapData = async (
and(eq(tables.climbStats.climbUuid, climbHolds.climbUuid), eq(tables.climbStats.angle, params.angle)),
)
.where(
and(...filters.getClimbWhereConditions(), ...filters.getSizeConditions(), ...filters.getClimbStatsConditions()),
and(...filters.getClimbWhereConditions(), ...filters.getSizeConditions(), ...filters.getClimbStatsConditions(), ...holdStateConditions),
)
.groupBy(climbHolds.holdId);

Expand Down