Skip to content

Commit bd912dc

Browse files
authored
Merge pull request #147 from marcodejongh/add_ascents_heatmap
Add ascents heatmap
2 parents 4c68dfb + c07c070 commit bd912dc

File tree

4 files changed

+166
-121
lines changed

4 files changed

+166
-121
lines changed

app/components/board-renderer/board-heatmap.tsx

Lines changed: 134 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import { scaleLinear } from 'd3-scale';
77
import useHeatmapData from '../search-drawer/use-heatmap';
88
import { usePathname, useSearchParams } from 'next/navigation';
99
import { useUISearchParams } from '@/app/components/queue-control/ui-searchparams-provider';
10+
import { Button, Select, Form, Space, Switch } from 'antd';
1011

11-
const LEGEND_HEIGHT = 80;
12+
const LEGEND_HEIGHT = 96; // Increased from 80
1213
const BLUR_RADIUS = 10; // Increased blur radius
1314
const HEAT_RADIUS_MULTIPLIER = 2; // Increased radius multiplier
1415

@@ -36,18 +37,18 @@ interface BoardHeatmapProps {
3637
boardDetails: BoardDetails;
3738
litUpHoldsMap?: LitUpHoldsMap;
3839
onHoldClick?: (holdId: number) => void;
39-
colorMode?: 'total' | 'starting' | 'hand' | 'foot' | 'finish' | 'difficulty';
4040
}
4141

4242
const BoardHeatmap: React.FC<BoardHeatmapProps> = ({
4343
boardDetails,
4444
litUpHoldsMap,
4545
onHoldClick,
46-
colorMode = 'total'
4746
}) => {
4847
const pathname = usePathname();
4948
const searchParams = useSearchParams();
5049
const { uiSearchParams } = useUISearchParams();
50+
const [colorMode, setColorMode] = useState<'total' | 'starting' | 'hand' | 'foot' | 'finish' | 'difficulty' | 'ascents'>('ascents');
51+
const [showNumbers, setShowNumbers] = useState(false);
5152

5253
useEffect(() => {
5354
const path = pathname.split('/');
@@ -83,6 +84,7 @@ const BoardHeatmap: React.FC<BoardHeatmapProps> = ({
8384
case 'foot': return data.footUses;
8485
case 'finish': return data.finishUses;
8586
case 'difficulty': return data.averageDifficulty || 0;
87+
case 'ascents': return data.totalAscents || 0;
8688
default: return data.totalUses;
8789
}
8890
};
@@ -157,10 +159,20 @@ const BoardHeatmap: React.FC<BoardHeatmapProps> = ({
157159

158160
const ColorLegend = () => {
159161
const gradientId = "heatmap-gradient";
160-
const legendWidth = 200;
161-
const legendHeight = 20;
162+
const legendWidth = boardWidth * 0.8; // Make legend 80% of board width
163+
const legendHeight = 36; // Increased from 30
162164
const x = (boardWidth - legendWidth) / 2;
163-
const y = boardHeight + 20;
165+
const y = boardHeight + 24; // Increased spacing
166+
167+
// Get the min, max, and middle values from the heatmap data
168+
const values = heatmapData
169+
.map(data => getValue(data))
170+
.filter(val => val > 0)
171+
.sort((a, b) => a - b);
172+
173+
const minValue = values[0] || 0;
174+
const maxValue = values[values.length - 1] || 0;
175+
const midValue = values[Math.floor(values.length / 2)] || 0;
164176

165177
return (
166178
<g transform={`translate(${x}, ${y})`}>
@@ -179,32 +191,36 @@ const BoardHeatmap: React.FC<BoardHeatmapProps> = ({
179191
width={legendWidth}
180192
height={legendHeight}
181193
fill={`url(#${gradientId})`}
182-
rx={4}
194+
rx={8}
183195
/>
184-
<text x="0" y="-5" fontSize="12" textAnchor="start">Low Usage</text>
185-
<text x={legendWidth} y="-5" fontSize="12" textAnchor="end">High Usage</text>
196+
<text x="0" y="-10" fontSize="28" textAnchor="start" fontWeight="500">Low ({minValue})</text>
197+
<text x={legendWidth/2} y="-10" fontSize="28" textAnchor="middle" fontWeight="500">Mid ({midValue})</text>
198+
<text x={legendWidth} y="-10" fontSize="28" textAnchor="end" fontWeight="500">High ({maxValue})</text>
186199
</g>
187200
);
188201
};
189202

203+
const [showHeatmap, setShowHeatmap] = useState(false);
204+
205+
const colorModeOptions = [
206+
{ value: 'ascents', label: 'Ascents' },
207+
{ value: 'total', label: 'Total Problems' },
208+
{ value: 'starting', label: 'Starting Holds' },
209+
{ value: 'hand', label: 'Hand Holds' },
210+
{ value: 'foot', label: 'Foot Holds' },
211+
{ value: 'finish', label: 'Finish Holds' },
212+
{ value: 'difficulty', label: 'Difficulty' },
213+
];
214+
215+
const thresholdOptions = [
216+
{ value: 1, label: 'Show All' },
217+
{ value: 2, label: 'At Least 2 Uses' },
218+
{ value: 5, label: 'At Least 5 Uses' },
219+
{ value: 10, label: 'At Least 10 Uses' },
220+
];
221+
190222
return (
191-
<div className="w-full">
192-
<div className="mb-4">
193-
<label className="block text-sm font-medium text-gray-700 mb-1">
194-
Filter holds by minimum usage:
195-
</label>
196-
<select
197-
value={threshold}
198-
onChange={(e) => setThreshold(Number(e.target.value))}
199-
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
200-
>
201-
<option value={1}>Show All</option>
202-
<option value={2}>At Least 2 Uses</option>
203-
<option value={5}>At Least 5 Uses</option>
204-
<option value={10}>At Least 10 Uses</option>
205-
</select>
206-
</div>
207-
223+
<div className="w-full">
208224
<svg
209225
viewBox={`0 0 ${boardWidth} ${boardHeight + LEGEND_HEIGHT}`}
210226
preserveAspectRatio="xMidYMid meet"
@@ -228,64 +244,68 @@ const BoardHeatmap: React.FC<BoardHeatmapProps> = ({
228244
))}
229245

230246
{/* Heat overlay with blur effect */}
231-
<g style={{ mixBlendMode: 'multiply' }}>
232-
{/* Blurred background layer */}
233-
<g filter="url(#blur)">
247+
{showHeatmap && (
248+
<>
249+
{/* Blurred background layer */}
250+
<g filter="url(#blur)">
251+
{holdsData.map((hold) => {
252+
const data = heatmapMap.get(hold.id);
253+
const value = getValue(data);
254+
255+
if (value === 0 || value < threshold) return null;
256+
257+
return (
258+
<circle
259+
key={`heat-blur-${hold.id}`}
260+
cx={hold.cx}
261+
cy={hold.cy}
262+
r={hold.r * HEAT_RADIUS_MULTIPLIER}
263+
fill={colorScale(value)}
264+
opacity={opacityScale(value) * 0.5}
265+
/>
266+
);
267+
})}
268+
</g>
269+
<filter id="blurMe">
270+
<feGaussianBlur in="SourceGraphic" stdDeviation="20" />
271+
</filter>
272+
273+
{/* Sharp circles with numbers */}
234274
{holdsData.map((hold) => {
235275
const data = heatmapMap.get(hold.id);
236276
const value = getValue(data);
237277

238-
if (value === 0 || value < threshold) return null;
278+
if (value < threshold) return null;
239279

240280
return (
241-
<circle
242-
key={`heat-blur-${hold.id}`}
243-
cx={hold.cx}
244-
cy={hold.cy}
245-
r={hold.r * HEAT_RADIUS_MULTIPLIER}
246-
fill={colorScale(value)}
247-
opacity={opacityScale(value) * 0.7}
248-
/>
281+
<g key={`heat-sharp-${hold.id}`}>
282+
<circle
283+
cx={hold.cx}
284+
cy={hold.cy}
285+
r={hold.r}
286+
fill={colorScale(value)}
287+
opacity={opacityScale(value)}
288+
filter="url(#blurMe)"
289+
/>
290+
{showNumbers && (
291+
<text
292+
x={hold.cx}
293+
y={hold.cy}
294+
textAnchor="middle"
295+
dominantBaseline="middle"
296+
fontSize={Math.max(8, hold.r * 0.6)}
297+
fontWeight="bold"
298+
fill={'#000'}
299+
style={{ userSelect: 'none' }}
300+
>
301+
{value}
302+
</text>
303+
)}
304+
</g>
249305
);
250306
})}
251-
</g>
252-
<filter id="blurMe">
253-
<feGaussianBlur in="SourceGraphic" stdDeviation="20" />
254-
</filter>
255-
256-
{/* Sharp circles with numbers */}
257-
{holdsData.map((hold) => {
258-
const data = heatmapMap.get(hold.id);
259-
const value = getValue(data);
260-
261-
if (value < threshold) return null;
262-
263-
return (
264-
<g key={`heat-sharp-${hold.id}`}>
265-
<circle
266-
cx={hold.cx}
267-
cy={hold.cy}
268-
r={hold.r}
269-
fill={colorScale(value)}
270-
opacity={opacityScale(value)}
271-
filter="url(#blurMe)"
272-
/>
273-
<text
274-
x={hold.cx}
275-
y={hold.cy}
276-
textAnchor="middle"
277-
dominantBaseline="middle"
278-
fontSize={Math.max(8, hold.r * 0.6)}
279-
fontWeight="bold"
280-
fill={'#000'}
281-
style={{ userSelect: 'none' }}
282-
>
283-
{value}
284-
</text>
285-
</g>
286-
);
287-
})}
288-
</g>
307+
</>
308+
)}
289309

290310
{/* Interaction layer */}
291311
{holdsData.map((hold) => (
@@ -318,9 +338,46 @@ const BoardHeatmap: React.FC<BoardHeatmapProps> = ({
318338
);
319339
})}
320340

321-
<ColorLegend />
341+
{showHeatmap && <ColorLegend />}
322342
</g>
323343
</svg>
344+
<Form layout="inline" className="mb-4">
345+
<Form.Item>
346+
<Button
347+
type={showHeatmap ? "primary" : "default"}
348+
onClick={() => setShowHeatmap(!showHeatmap)}
349+
>
350+
{showHeatmap ? 'Hide Heatmap' : 'Show Heatmap'}
351+
</Button>
352+
</Form.Item>
353+
354+
{showHeatmap && (
355+
<>
356+
<Form.Item label="View Mode">
357+
<Select
358+
value={colorMode}
359+
onChange={(value) => setColorMode(value)}
360+
style={{ width: 200 }}
361+
options={colorModeOptions}
362+
/>
363+
</Form.Item>
364+
<Form.Item label="Minimum Usage">
365+
<Select
366+
value={threshold}
367+
onChange={(value) => setThreshold(value)}
368+
style={{ width: 200 }}
369+
options={thresholdOptions}
370+
/>
371+
</Form.Item>
372+
<Form.Item label="Show Numbers">
373+
<Switch
374+
checked={showNumbers}
375+
onChange={setShowNumbers}
376+
/>
377+
</Form.Item>
378+
</>
379+
)}
380+
</Form>
324381
</div>
325382
);
326383
};

app/components/board-renderer/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface HeatmapData {
2525
footUses: number;
2626
finishUses: number;
2727
averageDifficulty: number;
28+
totalAscents: number;
2829
}
2930

3031
// If adding mroe boards be sure to increment the DB version number for indexeddb

app/components/search-drawer/climb-hold-search-form.tsx

Lines changed: 24 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import React from 'react';
22
import { BoardDetails, HoldState } from '@/app/lib/types';
33
import { useUISearchParams } from '@/app/components/queue-control/ui-searchparams-provider';
4-
import { Select } from 'antd';
5-
6-
import BoardRenderer from '../board-renderer/board-renderer';
4+
import { Select, Button, Form } from 'antd';
75
import BoardHeatmap from '../board-renderer/board-heatmap';
86

97
interface ClimbHoldSearchFormProps {
@@ -12,11 +10,7 @@ interface ClimbHoldSearchFormProps {
1210

1311
const ClimbHoldSearchForm: React.FC<ClimbHoldSearchFormProps> = ({ boardDetails }) => {
1412
const { uiSearchParams, updateFilters } = useUISearchParams();
15-
16-
1713
const [selectedState, setSelectedState] = React.useState<HoldState>('ANY');
18-
const [showHeatmap, setShowHeatmap] = React.useState(false);
19-
2014

2115
const handleHoldClick = (holdId: number) => {
2216
const updatedHoldsFilter = { ...uiSearchParams.holdsFilter };
@@ -45,48 +39,36 @@ const ClimbHoldSearchForm: React.FC<ClimbHoldSearchFormProps> = ({ boardDetails
4539

4640
return (
4741
<div className="relative">
48-
<div className="mb-4 flex items-center gap-4">
49-
<p>Select hold type:</p>
50-
<Select
51-
value={selectedState}
52-
onChange={(value) => setSelectedState(value as HoldState)}
53-
style={{ width: 200 }}
54-
options={stateItems}
55-
/>
56-
<button
57-
onClick={() => setShowHeatmap(!showHeatmap)}
58-
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
59-
>
60-
{showHeatmap ? 'Hide Heatmap' : 'Show Heatmap'}
61-
</button>
62-
</div>
42+
<Form layout="horizontal" className="mb-4">
43+
<Form.Item label="Select hold type" className="mb-0">
44+
<Select
45+
value={selectedState}
46+
onChange={(value) => setSelectedState(value as HoldState)}
47+
style={{ width: 200 }}
48+
options={stateItems}
49+
/>
50+
</Form.Item>
51+
</Form>
6352

6453
<p className="mb-4">Click on holds to set them to the selected type</p>
6554

6655
<div className="w-full max-w-2xl mx-auto">
67-
{showHeatmap ? (
68-
<BoardHeatmap
69-
boardDetails={boardDetails}
70-
litUpHoldsMap={uiSearchParams.holdsFilter}
71-
onHoldClick={handleHoldClick}
72-
/>
73-
) : (
74-
<BoardRenderer
75-
boardDetails={boardDetails}
76-
litUpHoldsMap={uiSearchParams.holdsFilter}
77-
mirrored={false}
78-
onHoldClick={handleHoldClick}
79-
/>
80-
)}
56+
<BoardHeatmap
57+
boardDetails={boardDetails}
58+
litUpHoldsMap={uiSearchParams.holdsFilter}
59+
onHoldClick={handleHoldClick}
60+
/>
8161
</div>
8262

8363
{Object.keys(uiSearchParams.holdsFilter || {}).length > 0 && (
84-
<button
85-
onClick={() => updateFilters({ holdsFilter: {} })}
86-
className="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
87-
>
88-
Clear Selected Holds
89-
</button>
64+
<Form.Item className="mt-4">
65+
<Button
66+
danger
67+
onClick={() => updateFilters({ holdsFilter: {} })}
68+
>
69+
Clear Selected Holds
70+
</Button>
71+
</Form.Item>
9072
)}
9173
</div>
9274
);

0 commit comments

Comments
 (0)