26 | {% include 'heading.html.j2' %}
27 | {% include 'alert.html.j2' %}
28 |
158 |
159 | {% include 'footer.html.j2' %}
160 |
161 |
162 |
165 |
166 |
167 |
184 |
185 |
186 |
--------------------------------------------------------------------------------
/climbdex/static/js/filterSelection.js:
--------------------------------------------------------------------------------
1 | function updateHoldFilterCount(delta) {
2 | const holdFilterCount = document.getElementById("hold-filter-button");
3 | const currentCount = Number(holdFilterCount.getAttribute("data-count"));
4 | holdFilterCount.textContent = `${currentCount + delta} Selected Holds`;
5 | holdFilterCount.setAttribute("data-count", currentCount + delta);
6 | }
7 |
8 | function onFilterCircleClick(circleElement, colorRows) {
9 | const holdId = circleElement.id.split("-")[1];
10 | const mirroredHoldId = circleElement.getAttribute("data-mirror-id");
11 | const currentColor = circleElement.getAttribute("stroke");
12 | const colorIds = colorRows.map((colorRow) => colorRow[0]);
13 | const colors = colorRows.map((colorRow) => colorRow[1]);
14 | let currentIndex = colors.indexOf(currentColor);
15 | let nextIndex = currentIndex + 1;
16 | const holdFilterInput = document.getElementById("input-hold-filter");
17 | const mirroredHoldFilterInput = document.getElementById(
18 | "input-mirrored-hold-filter"
19 | );
20 | if (nextIndex >= colors.length) {
21 | circleElement.setAttribute("stroke-opacity", 0.0);
22 | circleElement.setAttribute("stroke", "black");
23 | holdFilterInput.value = holdFilterInput.value.replace(
24 | `p${holdId}r${colorIds[currentIndex]}`,
25 | ""
26 | );
27 | if (mirroredHoldId) {
28 | mirroredHoldFilterInput.value = mirroredHoldFilterInput.value.replace(
29 | `p${mirroredHoldId}r${colorIds[currentIndex]}`,
30 | ""
31 | );
32 | }
33 | updateHoldFilterCount(-1);
34 | } else {
35 | circleElement.setAttribute("stroke", `${colors[nextIndex]}`);
36 | circleElement.setAttribute("stroke-opacity", 1.0);
37 | if (currentIndex == -1) {
38 | holdFilterInput.value += `p${holdId}r${colorIds[nextIndex]}`;
39 | if (mirroredHoldId) {
40 | mirroredHoldFilterInput.value += `p${mirroredHoldId}r${colorIds[nextIndex]}`;
41 | }
42 | updateHoldFilterCount(1);
43 | } else {
44 | holdFilterInput.value = holdFilterInput.value.replace(
45 | `p${holdId}r${colorIds[currentIndex]}`,
46 | `p${holdId}r${colorIds[nextIndex]}`
47 | );
48 | if (mirroredHoldId) {
49 | mirroredHoldFilterInput.value = mirroredHoldFilterInput.value.replace(
50 | `p${mirroredHoldId}r${colorIds[currentIndex]}`,
51 | `p${mirroredHoldId}r${colorIds[nextIndex]}`
52 | );
53 | }
54 | }
55 | }
56 | }
57 |
58 | function resetHoldFilter() {
59 | const holdFilterInput = document.getElementById("input-hold-filter");
60 | holdFilterInput.value = "";
61 | const circles = document.getElementsByTagNameNS(
62 | "http://www.w3.org/2000/svg",
63 | "circle"
64 | );
65 | for (const circle of circles) {
66 | circle.setAttribute("stroke-opacity", 0.0);
67 | circle.setAttribute("stroke", "black");
68 | }
69 | const holdFilterCount = document.getElementById("hold-filter-button");
70 | holdFilterCount.textContent = `0 Selected Holds`;
71 | holdFilterCount.setAttribute("data-count", 0);
72 | }
73 |
74 | document
75 | .getElementById("div-hold-filter")
76 | .addEventListener("shown.bs.collapse", function (event) {
77 | event.target.scrollIntoView(true);
78 | });
79 |
80 | document
81 | .getElementById("button-reset-hold-filter")
82 | .addEventListener("click", resetHoldFilter);
83 |
84 | document.getElementById('use-min-holds')
85 | .addEventListener('change', function() {
86 | document.getElementById('input-min-hold-number').disabled = !this.checked
87 | })
88 |
89 | document.getElementById('use-max-holds')
90 | .addEventListener('change', function() {
91 | document.getElementById('input-max-hold-number').disabled = !this.checked
92 | })
93 |
94 | document
95 | .getElementById('input-min-hold-number')
96 | .addEventListener('change', function(event) {
97 | const min = event.target
98 | const max = document.getElementById('input-max-hold-number')
99 | if (min.value > max.value && !max.disabled) {
100 | max.value = min.value
101 | }
102 | })
103 |
104 | document
105 | .getElementById('input-max-hold-number')
106 | .addEventListener('change', function(event) {
107 | const max = event.target
108 | const min = document.getElementById('input-min-hold-number')
109 | if (max.value < min.value && !min.disabled) {
110 | min.value = max.value
111 | }
112 | })
113 |
114 | const backAnchor = document.getElementById("anchor-back");
115 | backAnchor.href = location.origin;
116 | if (document.referrer) {
117 | referrerUrl = new URL(document.referrer);
118 | if (referrerUrl.origin == location.origin && referrerUrl.pathname == "/") {
119 | backAnchor.addEventListener("click", function (event) {
120 | event.preventDefault();
121 | history.back();
122 | });
123 | }
124 | }
125 |
126 | function mergeTooltips(slider, threshold, separator) {
127 | const textIsRtl = getComputedStyle(slider).direction === "rtl";
128 | const isRtl = slider.noUiSlider.options.direction === "rtl";
129 | const isVertical = slider.noUiSlider.options.orientation === "vertical";
130 | const tooltips = slider.noUiSlider.getTooltips();
131 | const origins = slider.noUiSlider.getOrigins();
132 |
133 | // Move tooltips into the origin element. The default stylesheet handles this.
134 | tooltips.forEach(function (tooltip, index) {
135 | if (tooltip) {
136 | origins[index].appendChild(tooltip);
137 | }
138 | });
139 |
140 | slider.noUiSlider.on(
141 | "update",
142 | function (values, handle, unencoded, tap, positions) {
143 | const pools = [[]];
144 | const poolPositions = [[]];
145 | const poolValues = [[]];
146 | let atPool = 0;
147 |
148 | // Assign the first tooltip to the first pool, if the tooltip is configured
149 | if (tooltips[0]) {
150 | pools[0][0] = 0;
151 | poolPositions[0][0] = positions[0];
152 | poolValues[0][0] = values[0];
153 | }
154 |
155 | for (
156 | let positionIndex = 1;
157 | positionIndex < positions.length;
158 | positionIndex++
159 | ) {
160 | if (
161 | !tooltips[positionIndex] ||
162 | positions[positionIndex] - positions[positionIndex - 1] > threshold
163 | ) {
164 | atPool++;
165 | pools[atPool] = [];
166 | poolValues[atPool] = [];
167 | poolPositions[atPool] = [];
168 | }
169 |
170 | if (tooltips[positionIndex]) {
171 | pools[atPool].push(positionIndex);
172 | poolValues[atPool].push(values[positionIndex]);
173 | poolPositions[atPool].push(positions[positionIndex]);
174 | }
175 | }
176 |
177 | pools.forEach(function (pool, poolIndex) {
178 | const handlesInPool = pool.length;
179 |
180 | for (let handleIndex = 0; handleIndex < handlesInPool; handleIndex++) {
181 | const handleNumber = pool[handleIndex];
182 |
183 | if (handleIndex === handlesInPool - 1) {
184 | let offset = 0;
185 |
186 | poolPositions[poolIndex].forEach(function (value) {
187 | offset += 1000 - value;
188 | });
189 |
190 | const direction = isVertical ? "bottom" : "right";
191 | const last = isRtl ? 0 : handlesInPool - 1;
192 | const lastOffset = 1000 - poolPositions[poolIndex][last];
193 | offset =
194 | (textIsRtl && !isVertical ? 100 : 0) +
195 | offset / handlesInPool -
196 | lastOffset;
197 |
198 | // Center this tooltip over the affected handles
199 | tooltips[handleNumber].innerHTML =
200 | poolValues[poolIndex].join(separator);
201 | tooltips[handleNumber].style.display = "block";
202 | tooltips[handleNumber].style[direction] = offset + "%";
203 | } else {
204 | // Hide this tooltip
205 | tooltips[handleNumber].style.display = "none";
206 | }
207 | }
208 | });
209 | }
210 | );
211 | }
212 |
213 | function createSlider() {
214 | const format = {
215 | to: function (value) {
216 | return arbitraryValuesForSlider[Math.round(value)];
217 | },
218 | from: function (value) {
219 | return arbitraryValuesForSlider.indexOf(value);
220 | },
221 | };
222 |
223 | const arbitraryValuesSlider = document.getElementById("grade-slider");
224 | const slider = noUiSlider.create(arbitraryValuesSlider, {
225 | // Start values are parsed by 'format'
226 | start: [
227 | arbitraryValuesForSlider[0],
228 | arbitraryValuesForSlider[arbitraryValuesForSlider.length - 1],
229 | ],
230 | range: { min: 0, max: arbitraryValuesForSlider.length - 1 },
231 | step: 1,
232 | connect: true,
233 | tooltips: true,
234 | format: format,
235 | });
236 |
237 | mergeTooltips(arbitraryValuesSlider, 10, " - ");
238 |
239 | // Get Slider values and convert slider values to numeric difficulty before form submit
240 | document
241 | .getElementById("form-search")
242 | .addEventListener("submit", function (e) {
243 | e.preventDefault();
244 | const values = slider.get()
245 | const minGradeValue = values[0];
246 | const maxGradeValue = values[1];
247 | const convertedMinGrade = gradeMapping[minGradeValue];
248 | const convertedMaxGrade = gradeMapping[maxGradeValue];
249 | document.getElementById("slider-minValue").value = convertedMinGrade;
250 | document.getElementById("slider-maxValue").value = convertedMaxGrade;
251 | this.submit();
252 | });
253 | }
254 |
--------------------------------------------------------------------------------
/climbdex/db.py:
--------------------------------------------------------------------------------
1 | import boardlib
2 | import flask
3 | import sqlite3
4 |
5 | QUERIES = {
6 | "angles": """
7 | SELECT angle
8 | FROM products_angles
9 | JOIN layouts
10 | ON layouts.product_id = products_angles.product_id
11 | WHERE layouts.id = $layout_id
12 | ORDER BY angle ASC""",
13 | "beta": """
14 | SELECT
15 | angle,
16 | foreign_username,
17 | link
18 | FROM beta_links
19 | WHERE climb_uuid = $uuid
20 | AND is_listed = 1
21 | AND link like 'https://www.instagram.com%'
22 | ORDER BY angle DESC""",
23 | "climb": """
24 | SELECT name
25 | FROM climbs
26 | WHERE uuid = $uuid""",
27 | "colors": """
28 | SELECT
29 | placement_roles.id,
30 | '#' || placement_roles.screen_color
31 | FROM placement_roles
32 | JOIN layouts
33 | ON layouts.product_id = placement_roles.product_id
34 | WHERE layouts.id = $layout_id""",
35 | "feet_placement_roles": """
36 | SELECT
37 | placement_roles.id
38 | FROM placement_roles
39 | JOIN layouts
40 | on layouts.product_id = placement_roles.product_id
41 | WHERE layouts.id = $layout_id
42 | AND placement_roles.name = 'foot'""",
43 | "grades": """
44 | SELECT
45 | difficulty,
46 | boulder_name
47 | FROM difficulty_grades
48 | WHERE is_listed = 1
49 | ORDER BY difficulty ASC""",
50 | "holds": """
51 | SELECT
52 | placements.id,
53 | mirrored_placements.id,
54 | holes.x,
55 | holes.y
56 | FROM holes
57 | INNER JOIN placements
58 | ON placements.hole_id=holes.id
59 | AND placements.set_id = $set_id
60 | AND placements.layout_id = $layout_id
61 | LEFT JOIN placements mirrored_placements
62 | ON mirrored_placements.hole_id = holes.mirrored_hole_id
63 | AND mirrored_placements.set_id = $set_id
64 | AND mirrored_placements.layout_id = $layout_id""",
65 | "layouts": """
66 | SELECT id, name
67 | FROM layouts
68 | WHERE is_listed=1
69 | AND password IS NULL""",
70 | "layout_is_mirrored": """
71 | SELECT is_mirrored
72 | FROM layouts
73 | WHERE id = $layout_id""",
74 | "layout_name": """
75 | SELECT name
76 | FROM layouts
77 | WHERE id = $layout_id""",
78 | "leds": """
79 | SELECT
80 | placements.id,
81 | leds.position
82 | FROM placements
83 | INNER JOIN leds ON placements.hole_id = leds.hole_id
84 | WHERE placements.layout_id = $layout_id
85 | AND leds.product_size_id = $size_id""",
86 | "led_colors": """
87 | SELECT
88 | placement_roles.id,
89 | placement_roles.led_color
90 | FROM placement_roles
91 | JOIN layouts
92 | ON layouts.product_id = placement_roles.product_id
93 | WHERE layouts.id = $layout_id""",
94 | "image_filename": """
95 | SELECT
96 | image_filename
97 | FROM product_sizes_layouts_sets
98 | WHERE layout_id = $layout_id
99 | AND product_size_id = $size_id
100 | AND set_id = $set_id""",
101 | "search": """
102 | SELECT
103 | climbs.uuid,
104 | climbs.setter_username,
105 | climbs.name,
106 | climbs.description,
107 | climbs.frames,
108 | climb_stats.angle,
109 | climb_stats.ascensionist_count,
110 | (SELECT boulder_name FROM difficulty_grades WHERE difficulty = ROUND(climb_stats.display_difficulty)) AS difficulty,
111 | climb_stats.quality_average,
112 | (SELECT ROUND(climb_stats.difficulty_average - ROUND(climb_stats.display_difficulty), 2)) AS difficulty_error,
113 | climb_stats.benchmark_difficulty
114 | FROM climbs
115 | LEFT JOIN climb_stats
116 | ON climb_stats.climb_uuid = climbs.uuid
117 | INNER JOIN product_sizes
118 | ON product_sizes.id = $size_id
119 | WHERE climbs.frames_count = 1
120 | AND climbs.is_draft = 0
121 | AND climbs.is_listed = 1
122 | AND climbs.layout_id = $layout_id
123 | AND climbs.edge_left > product_sizes.edge_left
124 | AND climbs.edge_right < product_sizes.edge_right
125 | AND climbs.edge_bottom > product_sizes.edge_bottom
126 | AND climbs.edge_top < product_sizes.edge_top
127 | AND climb_stats.ascensionist_count >= $min_ascents
128 | AND ROUND(climb_stats.display_difficulty) BETWEEN $min_grade AND $max_grade
129 | AND climb_stats.quality_average >= $min_rating
130 | AND ABS(ROUND(climb_stats.display_difficulty) - climb_stats.difficulty_average) <= $grade_accuracy
131 | """,
132 | "setters": """
133 | SELECT
134 | setter_username,
135 | COUNT(*) AS count
136 | FROM climbs
137 | WHERE layout_id = $layout_id
138 | GROUP BY setter_username
139 | ORDER BY setter_username""",
140 | "sets": """
141 | SELECT
142 | sets.id,
143 | sets.name
144 | FROM sets
145 | INNER JOIN product_sizes_layouts_sets psls on sets.id = psls.set_id
146 | WHERE psls.product_size_id = $size_id
147 | AND psls.layout_id = $layout_id""",
148 | "size_name": """
149 | SELECT
150 | product_sizes.name
151 | FROM product_sizes
152 | INNER JOIN layouts
153 | ON product_sizes.product_id = layouts.product_id
154 | WHERE layouts.id = $layout_id
155 | AND product_sizes.id = $size_id""",
156 | "sizes": """
157 | SELECT
158 | product_sizes.id,
159 | product_sizes.name,
160 | product_sizes.description
161 | FROM product_sizes
162 | INNER JOIN layouts
163 | ON product_sizes.product_id = layouts.product_id
164 | WHERE layouts.id = $layout_id""",
165 | "size_dimensions": """
166 | SELECT
167 | edge_left,
168 | edge_right,
169 | edge_bottom,
170 | edge_top
171 | FROM product_sizes
172 | WHERE id = $size_id""",
173 | }
174 |
175 |
176 | def get_board_database(board_name):
177 | try:
178 | return flask.g.database
179 | except AttributeError:
180 | flask.g.database = sqlite3.connect(f"data/{board_name}/db.sqlite")
181 | return flask.g.database
182 |
183 |
184 | def get_data(board_name, query_name, binds={}):
185 | database = get_board_database(board_name)
186 | cursor = database.cursor()
187 | cursor.execute(QUERIES[query_name], binds)
188 | return cursor.fetchall()
189 |
190 |
191 | def get_search_count(args):
192 | base_sql, binds = get_search_base_sql_and_binds(args)
193 | database = get_board_database(args.get("board"))
194 | cursor = database.cursor()
195 | cursor.execute(f"SELECT COUNT(*) FROM ({base_sql})", binds)
196 | return cursor.fetchall()[0][0]
197 |
198 |
199 | def get_search_results(args):
200 | base_sql, binds = get_search_base_sql_and_binds(args)
201 | order_by_sql_name = {
202 | "ascents": "climb_stats.ascensionist_count",
203 | "difficulty": "climb_stats.display_difficulty",
204 | "name": "climbs.name",
205 | "quality": "climb_stats.quality_average",
206 | }[args.get("sortBy")]
207 | sort_order = "ASC" if args.get("sortOrder") == "asc" else "DESC"
208 | ordered_sql = f"{base_sql} ORDER BY {order_by_sql_name} {sort_order}"
209 |
210 | limited_sql = f"{ordered_sql} LIMIT $limit OFFSET $offset"
211 | binds["limit"] = int(args.get("pageSize", 10))
212 | binds["offset"] = int(args.get("page", 0)) * int(binds["limit"])
213 |
214 | database = get_board_database(args.get("board"))
215 | cursor = database.cursor()
216 | cursor.execute(limited_sql, binds)
217 | return cursor.fetchall()
218 |
219 |
220 | def get_search_base_sql_and_binds(args):
221 | sql = QUERIES["search"]
222 | binds = {
223 | "layout_id": int(args.get("layout")),
224 | "size_id": int(args.get("size")),
225 | "min_ascents": int(args.get("minAscents")),
226 | "min_grade": int(args.get("minGrade")),
227 | "max_grade": int(args.get("maxGrade")),
228 | "min_rating": float(args.get("minRating")),
229 | "grade_accuracy": float(args.get("gradeAccuracy")),
230 | }
231 |
232 | name = args.get("name")
233 | if name:
234 | sql += " AND climbs.name LIKE :name"
235 | binds["name"] = f"%{name}%"
236 |
237 | only_classics = args.get("onlyClassics")
238 | if only_classics != "0":
239 | sql += " AND climb_stats.benchmark_difficulty IS NOT NULL"
240 |
241 | settername = args.get("settername")
242 | if settername:
243 | sql += " AND setter_username LIKE :settername"
244 | binds["settername"] = f"%{settername}%"
245 |
246 | angle = args.get("angle")
247 | if angle and angle != "any":
248 | sql += " AND climb_stats.angle = $angle"
249 | binds["angle"] = int(angle)
250 |
251 | holds = args.get("holds")
252 | match_roles = args.get("roleMatch") == "strict"
253 | filter_feet = args.get("roleMatch") == "hands"
254 | if filter_feet:
255 | layout_foot_placement_role= layout_feet_placement_roles(args.get("board"), args.get("layout"))
256 | if holds:
257 | sql += " AND ((climbs.frames LIKE $like_string"
258 | binds["like_string"] = get_frames_like_clause(holds, match_roles)
259 | if filter_feet:
260 | hold_count = 1
261 | for placement, role in sorted(iterframes(holds), key=lambda frame: frame[0]):
262 | sql += f" AND climbs.frames NOT LIKE $not_like_feet_string_{hold_count}"
263 | binds[f"not_like_feet_string_{hold_count}"] = f"%p{placement}r{layout_foot_placement_role}%"
264 | hold_count += 1
265 | mirrored_holds = args.get("mirroredHolds")
266 | if mirrored_holds and layout_is_mirrored(args.get("board"), args.get("layout")):
267 | sql += ") OR (climbs.frames LIKE $mirrored_like_string"
268 | binds["mirrored_like_string"] = get_frames_like_clause(
269 | mirrored_holds, match_roles
270 | )
271 | if filter_feet:
272 | hold_count = 1
273 | for placement, role in sorted(iterframes(mirrored_holds), key=lambda frame: frame[0]):
274 | sql += f" AND climbs.frames NOT LIKE $mirrored_not_like_feet_string_{hold_count}"
275 | binds[f"mirrored_not_like_feet_string_{hold_count}"] = f"%p{placement}r{layout_foot_placement_role}%"
276 | hold_count += 1
277 | sql += "))"
278 | maxHolds = args.get("maxHoldNumber")
279 | minHolds = args.get("minHoldNumber")
280 | if maxHolds or minHolds:
281 | sql += " AND ((length(frames) - length(replace(frames, 'r' || (SELECT placement_roles.id FROM placement_roles JOIN layouts on layouts.product_id = placement_roles.product_id WHERE layouts.id = $layout_id AND placement_roles.position = '2'), ''))) / (length((SELECT placement_roles.id FROM placement_roles JOIN layouts on layouts.product_id = placement_roles.product_id WHERE layouts.id = $layout_id AND placement_roles.position = '2')) + 1))"
282 | if maxHolds and minHolds:
283 | sql += " BETWEEN $minHolds and $maxHolds"
284 | binds['maxHolds'] = int(maxHolds)
285 | binds['minHolds'] = int(minHolds)
286 | elif maxHolds:
287 | sql += " <= $maxHolds"
288 | binds['maxHolds'] = int(maxHolds)
289 | elif minHolds:
290 | sql += " >= $minHolds"
291 | binds['minHolds'] = int(minHolds)
292 | return sql, binds
293 |
294 |
295 | def iterframes(frames):
296 | for frame in frames.split("p")[1:]:
297 | placement, role = frame.split("r")
298 | yield int(placement), int(role)
299 |
300 |
301 | def get_frames_like_clause(holds, match_roles):
302 | like_string_center = "%".join(
303 | f"p{placement}r{role if match_roles else ''}"
304 | for placement, role in sorted(iterframes(holds), key=lambda frame: frame[0])
305 | )
306 | return f"%{like_string_center}%"
307 |
308 | def layout_feet_placement_roles(board, layout_id):
309 | return get_data(board, "feet_placement_roles", {"layout_id": layout_id})[0][0]
310 |
311 | def layout_is_mirrored(board, layout_id):
312 | return get_data(board, "layout_is_mirrored", {"layout_id": layout_id})[0][0] == 1
313 |
--------------------------------------------------------------------------------
/climbdex/templates/results.html.j2:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {% include 'head.html.j2'%}
6 |
7 |
10 |
11 |
52 |
53 |
54 |
55 |
56 |
57 | {% include 'heading.html.j2' %}
58 | {% include 'alert.html.j2' %}
59 |
60 |
61 |
66 |
67 |
68 |
69 |
←
70 |
71 |
72 |
74 |
76 |
77 |
78 |
79 |
84 |
85 |
→
86 |
87 |
88 |
90 |
92 |
94 |
95 |
96 |
121 |
122 |
123 |
124 |
129 |
130 |
131 |
132 | {% include 'footer.html.j2' %}
133 |
134 |
135 |
136 |
137 |
138 |
139 |
143 |
186 |
190 |
191 | Ascent logged successfully!
192 |
193 |
194 | An error occurred, please try again later.
195 |
196 |
197 |
198 |
199 |
200 |
295 |
296 |
297 |
310 |
311 |
312 |
322 |
323 |
324 |
325 |
326 |
327 |
--------------------------------------------------------------------------------
/climbdex/static/js/results.js:
--------------------------------------------------------------------------------
1 | const colorMap = colors.reduce((acc, colorRow) => {
2 | acc[colorRow[0]] = colorRow[1];
3 | return acc;
4 | }, {});
5 |
6 | function isMirroredMode() {
7 | return (document.getElementById("button-mirror")!=null) && (document.getElementById("button-mirror").classList.contains("active"));
8 | }
9 |
10 | function mirrorClimb() {
11 | document
12 | .getElementById("svg-climb")
13 | .querySelectorAll('circle[stroke-opacity="1"]')
14 | .forEach((circle) => {
15 | const stroke = circle.getAttribute("stroke");
16 | const strokeOpacity = circle.getAttribute("stroke-opacity");
17 | const mirroredPlacementId = circle.getAttribute("data-mirror-id");
18 | circle.setAttribute("stroke", 0.0);
19 | circle.setAttribute("stroke-opacity", 0.0);
20 | const mirroredCircle = document.getElementById(`hold-${mirroredPlacementId}`);
21 | mirroredCircle.setAttribute("stroke", stroke);
22 | mirroredCircle.setAttribute("stroke-opacity", strokeOpacity);
23 | });
24 | }
25 |
26 | function drawClimb(
27 | uuid,
28 | name,
29 | frames,
30 | setter,
31 | difficultyAngleSpan,
32 | description,
33 | attempts_infotext,
34 | difficulty
35 | ) {
36 | document
37 | .getElementById("svg-climb")
38 | .querySelectorAll('circle[stroke-opacity="1"]')
39 | .forEach((circle) => {
40 | circle.setAttribute("stroke-opacity", 0.0);
41 | });
42 |
43 | let mirroredFrames="";
44 |
45 | for (const frame of frames.split("p")) {
46 | if (frame.length > 0) {
47 | const [placementId, colorId] = frame.split("r");
48 | const circle = document.getElementById(`hold-${placementId}`);
49 | if(circle.hasAttribute("data-mirror-id")) {
50 | const mirroredPlacementId = circle.getAttribute("data-mirror-id");
51 | mirroredFrames = mirroredFrames + "p" + mirroredPlacementId + "r" + colorId;
52 | }
53 | circle.setAttribute("stroke", colorMap[colorId]);
54 | circle.setAttribute("stroke-opacity", 1.0);
55 | }
56 | }
57 |
58 | const anchor = document.createElement("a");
59 | anchor.textContent = name;
60 | anchor.href = `${appUrl}/climbs/${uuid}`;
61 | anchor.target = "_blank";
62 | anchor.rel = "noopener noreferrer";
63 |
64 | const diffForSave = document.getElementById("difficulty");
65 | diffForSave.value = difficulty;
66 | const event = new Event("change");
67 | diffForSave.dispatchEvent(event);
68 |
69 | const climbNameHeader = document.getElementById("header-climb-name");
70 | climbNameHeader.innerHTML = "";
71 | climbNameHeader.appendChild(anchor);
72 |
73 | document.getElementById("div-climb")?.scrollIntoView(true);
74 |
75 | const climbSetterHeader = document.getElementById("header-climb-setter");
76 | climbSetterHeader.textContent = `by ${setter}`;
77 | const climbStatsParagraph = document.getElementById("paragraph-climb-stats");
78 | climbStatsParagraph.innerHTML = difficultyAngleSpan.outerHTML;
79 |
80 | const climbDescriptionParagraph = document.getElementById(
81 | "paragraph-climb-description"
82 | );
83 | const trimmedDescription = description.trim();
84 | if (trimmedDescription === "") {
85 | climbDescriptionParagraph.classList.add("d-none");
86 | } else {
87 | climbDescriptionParagraph.classList.remove("d-none");
88 | climbDescriptionParagraph.innerHTML = `Description: ${trimmedDescription.italics()}`;
89 | }
90 |
91 | const climbedAttempts = document.getElementById("paragraph-climb-attempts");
92 |
93 | if (attempts_infotext === undefined) {
94 | climbedAttempts.classList.add("d-none");
95 | } else {
96 | climbedAttempts.classList.remove("d-none");
97 | climbedAttempts.innerHTML = `${attempts_infotext}`;
98 | }
99 |
100 | const urlParams = new URLSearchParams(window.location.search);
101 | const board = urlParams.get("board");
102 | fetchBetaCount(board, uuid).then((betaCount) => {
103 | const betaCountSpan = document.getElementById("span-beta-count");
104 | betaCountSpan.textContent = betaCount;
105 | });
106 |
107 | const betaAnchor = document.getElementById("anchor-beta");
108 | betaAnchor.href = `/${board}/beta/${uuid}/`;
109 |
110 | document.getElementById("button-illuminate").onclick = function () {
111 | const bluetoothPacket = getBluetoothPacket(
112 | isMirroredMode() ? mirroredFrames : frames,
113 | placementPositions,
114 | ledColors
115 | );
116 | illuminateClimb(board, bluetoothPacket);
117 | };
118 |
119 | const modalclimbNameHeader = document.getElementById("modal-climb-name");
120 | modalclimbNameHeader.innerHTML = name;
121 |
122 | const modalclimbStatsParagraph = document.getElementById("modal-climb-stats");
123 | modalclimbStatsParagraph.innerHTML = difficultyAngleSpan.outerHTML;
124 |
125 | if(isMirroredMode()) {
126 | mirrorClimb();
127 | }
128 | }
129 | const gradeMappingObject = gradeMapping.reduce((acc, [difficulty, grade]) => {
130 | acc[grade] = difficulty;
131 | return acc;
132 | }, {});
133 |
134 | document
135 | .getElementById("button-log-ascent")
136 | .addEventListener("click", function () {
137 | const urlParams = new URLSearchParams(window.location.search);
138 | const board = urlParams.get("board");
139 | const climb_uuid = document
140 | .querySelector("#header-climb-name a")
141 | .href.split("/")
142 | .pop();
143 | const angle = parseInt(
144 | document
145 | .querySelector("#modal-climb-stats span")
146 | .textContent.match(/\d+°/)[0]
147 | );
148 | const is_mirror = isMirroredMode();
149 | const attempt_id = 0;
150 | const bid_count =
151 | document.querySelector('input[name="attemptType"]:checked').id === "flash"
152 | ? 1
153 | : parseInt(document.getElementById("attempts").value);
154 | const quality =
155 | parseInt(document.querySelector(".star-rating input:checked")?.value) ||
156 | 0;
157 | const selectedAttemptType = document.querySelector(
158 | 'input[name="attemptType"]:checked'
159 | ).id;
160 | const difficultyValue = document.getElementById("difficulty").value;
161 | const convertedDifficulty = gradeMappingObject[difficultyValue];
162 |
163 | const finalDifficulty = ["flash", "send"].includes(selectedAttemptType)
164 | ? parseInt(convertedDifficulty)
165 | : 0;
166 |
167 | const is_benchmark = document
168 | .querySelector("#paragraph-climb-stats span")
169 | .textContent.includes("©")
170 | ? true
171 | : false;
172 | const climbed_at = new Date().toLocaleString('sv');
173 | const comment = document.getElementById("comment").value;
174 |
175 | const data = {
176 | board: board,
177 | climb_uuid: climb_uuid,
178 | angle: angle,
179 | is_mirror: is_mirror,
180 | attempt_id: attempt_id,
181 | bid_count: bid_count,
182 | quality: quality,
183 | difficulty: finalDifficulty,
184 | is_benchmark: is_benchmark,
185 | comment: comment,
186 | climbed_at: climbed_at,
187 | };
188 |
189 | fetch("/api/v1/save_ascent", {
190 | method: "POST",
191 | headers: {
192 | "Content-Type": "application/json",
193 | },
194 | body: JSON.stringify(data),
195 | })
196 | .then((response) => {
197 | if (!response.ok) {
198 | throw new Error("Network response was not ok " + response.statusText);
199 | }
200 | return response.json();
201 | })
202 | .then((data) => {
203 | const successAlert = document.querySelector(".alert-success");
204 | successAlert.style.display = "block";
205 |
206 | setTimeout(() => {
207 | successAlert.style.display = "none";
208 | const logModal = document.getElementById("div-log-modal");
209 | const modalInstance = bootstrap.Modal.getInstance(logModal);
210 | if (modalInstance) {
211 | modalInstance.hide();
212 | }
213 | }, 3000);
214 | })
215 | .catch((error) => {
216 | console.error("Error:", error);
217 | const errorAlert = document.querySelector(".alert-danger");
218 | errorAlert.style.display = "block";
219 |
220 | setTimeout(() => {
221 | errorAlert.style.display = "none";
222 | }, 3000);
223 | });
224 | });
225 |
226 | async function fetchBetaCount(board, uuid) {
227 | const response = await fetch(`/api/v1/${board}/beta/${uuid}`);
228 | const responseJson = await response.json();
229 | return responseJson.length;
230 | }
231 |
232 | async function fetchResultsCount() {
233 | const urlParams = new URLSearchParams(window.location.search);
234 | const response = await fetch("/api/v1/search/count?" + urlParams);
235 | const resultsCount = await response.json();
236 |
237 | if (resultsCount["error"] == true) {
238 | alert.querySelector(".alert-content").innerHTML =
239 | resultsCount["description"];
240 | alert.classList.add("show-alert");
241 | } else {
242 | return resultsCount;
243 | }
244 | }
245 |
246 | async function fetchResults(pageNumber, pageSize) {
247 | const urlParams = new URLSearchParams(window.location.search);
248 | urlParams.append("page", pageNumber);
249 | urlParams.append("pageSize", pageSize);
250 | const response = await fetch("/api/v1/search?" + urlParams);
251 | const results = await response.json();
252 |
253 | if (results["error"] == true) {
254 | alert.querySelector(".alert-content").innerHTML = results["description"];
255 | alert.classList.add("show-alert");
256 | } else {
257 | return results;
258 | }
259 | }
260 |
261 | function clickClimbButton(index, pageSize, resultsCount) {
262 | const button = document.querySelector(`button[data-index="${index}"]`);
263 | if (button) {
264 | button.click();
265 | } else if (index > 0 && index < resultsCount - 1) {
266 | const nextPageNumber = Math.floor(index / pageSize);
267 | fetchResults(nextPageNumber, pageSize).then((results) => {
268 | drawResultsPage(results, nextPageNumber, pageSize, resultsCount);
269 | document.querySelector(`button[data-index="${index}"]`)?.click();
270 | });
271 | }
272 | }
273 |
274 | function getTickPath() {
275 | const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
276 | path.setAttribute("d", "M 30,180 90,240 240,30");
277 | path.setAttribute("style", "stroke:#000; stroke-width:25; fill:none");
278 | return path;
279 | }
280 |
281 | function getTickSvg(tickType) {
282 | const tickSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
283 |
284 | const tickSize = 16;
285 | const viewBoxSize = 280;
286 | const upwardShift = 30;
287 |
288 | const centerX = viewBoxSize / 2;
289 | tickSvg.setAttribute(
290 | "viewBox",
291 | `0 +${upwardShift} ${viewBoxSize} ${viewBoxSize - upwardShift}`
292 | );
293 | tickSvg.setAttribute("height", `${tickSize}px`);
294 | tickSvg.setAttribute("width", `${tickSize}px`);
295 |
296 | const normalTick = 0;
297 | const mirrorTick = 1;
298 | const bothTick = 2;
299 |
300 | if (tickType === normalTick || tickType === bothTick) {
301 | const normalPath = getTickPath();
302 | tickSvg.appendChild(normalPath);
303 | }
304 | if (tickType === mirrorTick || tickType === bothTick) {
305 | const mirroredPath = getTickPath();
306 | mirroredPath.setAttribute(
307 | "transform",
308 | `translate(${centerX}, 0) scale(-1, 1) translate(-${centerX})`
309 | );
310 | tickSvg.appendChild(mirroredPath);
311 | }
312 | return tickSvg;
313 | }
314 |
315 | function drawResultsPage(results, pageNumber, pageSize, resultsCount) {
316 | const resultsList = document.getElementById("div-results-list");
317 | for (const [index, result] of results.entries()) {
318 | let listButton = document.createElement("button");
319 | listButton.setAttribute("class", "list-group-item list-group-item-action");
320 | listButton.setAttribute("data-index", pageNumber * pageSize + index);
321 |
322 | // this is the result of db.search
323 | const [
324 | uuid,
325 | setter,
326 | name,
327 | description,
328 | frames,
329 | angle,
330 | ascents,
331 | difficulty,
332 | rating,
333 | difficultyError,
334 | classic,
335 | ] = result;
336 |
337 | const classicSymbol = classic !== null ? "\u00A9" : "";
338 | const difficultyErrorPrefix = Number(difficultyError) > 0 ? "+" : "-";
339 | const difficultyErrorSuffix = String(
340 | Math.abs(difficultyError).toFixed(2)
341 | ).replace(/^0+/, "");
342 | const difficultyAngleText =
343 | difficulty && angle
344 | ? `${difficulty} (${difficultyErrorPrefix}${difficultyErrorSuffix}) at ${angle}\u00B0 ${classicSymbol}`
345 | : "";
346 | const difficultyAngleSpan = document.createElement("span");
347 | difficultyAngleSpan.appendChild(
348 | document.createTextNode(difficultyAngleText)
349 | );
350 |
351 | const show_attempts = attemptedClimbs[`${uuid}-${angle}`];
352 | let attempts_infotext;
353 | if (show_attempts !== undefined) {
354 | listButton.classList.add("bg-warning-subtle");
355 | attempts_infotext =
356 | "You had " +
357 | show_attempts["total_tries"] +
358 | (show_attempts["total_tries"] === 1 ? " try in " : " tries in ") +
359 | show_attempts["total_sessions"] +
360 | " session.
The last session was: " +
361 | show_attempts["last_try"];
362 | } else {
363 | attempts_infotext = "You had no tries so far.";
364 | }
365 |
366 | const tickType = tickedClimbs[`${uuid}-${angle}`];
367 | if (tickType !== undefined) {
368 | listButton.classList.add("bg-success-subtle");
369 | listButton.classList.remove("bg-warning-subtle"); //remove class if a climb used to be a attemped but was ticked later
370 | difficultyAngleSpan.appendChild(document.createTextNode(" "));
371 | difficultyAngleSpan.appendChild(getTickSvg(tickType));
372 | }
373 |
374 | listButton.addEventListener("click", function (event) {
375 | const index = Number(event.currentTarget.getAttribute("data-index"));
376 | const prevButton = document.getElementById("button-prev");
377 | prevButton.onclick = function () {
378 | clickClimbButton(index - 1, pageSize, resultsCount);
379 | };
380 | prevButton.disabled = index <= 0;
381 | const nextButton = document.getElementById("button-next");
382 | nextButton.disabled = index >= resultsCount - 1;
383 | nextButton.onclick = function () {
384 | clickClimbButton(index + 1, pageSize, resultsCount);
385 | };
386 | drawClimb(
387 | uuid,
388 | name,
389 | frames,
390 | setter,
391 | difficultyAngleSpan,
392 | description,
393 | attempts_infotext,
394 | difficulty
395 | );
396 | });
397 | const nameText = document.createElement("p");
398 | nameText.innerHTML = `${name} ${difficultyAngleSpan.outerHTML}`;
399 | const statsText = document.createElement("p");
400 | statsText.textContent =
401 | ascents && rating ? `${ascents} ascents, ${rating.toFixed(2)}\u2605` : "";
402 | statsText.classList.add("fw-light");
403 | listButton.appendChild(nameText);
404 | listButton.appendChild(statsText);
405 | resultsList.appendChild(listButton);
406 | }
407 | resultsList.onscroll = function (event) {
408 | const { scrollHeight, scrollTop, clientHeight } = event.target;
409 | if (
410 | Math.abs(scrollHeight - clientHeight - scrollTop) < 1 &&
411 | pageNumber < resultsCount / pageSize - 1
412 | ) {
413 | fetchResults(pageNumber + 1, pageSize).then((results) => {
414 | drawResultsPage(results, pageNumber + 1, pageSize, resultsCount);
415 | });
416 | }
417 | };
418 | }
419 |
420 | const backAnchor = document.getElementById("anchor-back");
421 | backAnchor.href = location.origin + "/filter" + location.search;
422 | if (document.referrer && new URL(document.referrer).origin == location.origin) {
423 | backAnchor.addEventListener("click", function (event) {
424 | event.preventDefault();
425 | history.back();
426 | });
427 | }
428 |
429 | fetchResultsCount().then((resultsCount) => {
430 | const resultsCountHeader = document.getElementById("header-results-count");
431 | resultsCountHeader.textContent = `Found ${resultsCount} matching climbs`;
432 | fetchResults(0, 10).then((results) => {
433 | drawResultsPage(results, 0, 10, resultsCount);
434 | });
435 | });
436 |
--------------------------------------------------------------------------------
/climbdex/static/js/nouislider.min.js:
--------------------------------------------------------------------------------
1 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).noUiSlider={})}(this,function(ot){"use strict";function n(t){return"object"==typeof t&&"function"==typeof t.to}function st(t){t.parentElement.removeChild(t)}function at(t){return null!=t}function lt(t){t.preventDefault()}function i(t){return"number"==typeof t&&!isNaN(t)&&isFinite(t)}function ut(t,e,r){0
=e[r];)r+=1;return r}function r(t,e,r){if(r>=t.slice(-1)[0])return 100;var n=l(r,t),i=t[n-1],o=t[n],t=e[n-1],n=e[n];return t+(r=r,a(o=[i,o],o[0]<0?r+Math.abs(o[0]):r-o[0],0)/s(t,n))}function o(t,e,r,n){if(100===n)return n;var i=l(n,t),o=t[i-1],s=t[i];return r?(s-o)/2this.xPct[n+1];)n++;else t===this.xPct[this.xPct.length-1]&&(n=this.xPct.length-2);r||t!==this.xPct[n+1]||n++;for(var i,o=1,s=(e=null===e?[]:e)[n],a=0,l=0,u=0,c=r?(t-this.xPct[n])/(this.xPct[n+1]-this.xPct[n]):(this.xPct[n+1]-t)/(this.xPct[n+1]-this.xPct[n]);0= 2) required for mode 'count'.");for(var e=t.values-1,r=100/e,n=[];e--;)n[e]=e*r;return n.push(100),U(n,t.stepped)}(d),m={},t=S.xVal[0],e=S.xVal[S.xVal.length-1],g=!1,v=!1,b=0;return(h=h.slice().sort(function(t,e){return t-e}).filter(function(t){return!this[t]&&(this[t]=!0)},{}))[0]!==t&&(h.unshift(t),g=!0),h[h.length-1]!==e&&(h.push(e),v=!0),h.forEach(function(t,e){var r,n,i,o,s,a,l,u,t=t,c=h[e+1],p=d.mode===ot.PipsMode.Steps,f=(f=p?S.xNumSteps[e]:f)||c-t;for(void 0===c&&(c=t),f=Math.max(f,1e-7),r=t;r<=c;r=Number((r+f).toFixed(7))){for(a=(o=(i=S.toStepping(r))-b)/(d.density||1),u=o/(l=Math.round(a)),n=1;n<=l;n+=1)m[(s=b+n*u).toFixed(5)]=[S.fromStepping(s),0];a=-1ot.PipsType.NoValue&&((t=P(a,!1)).className=p(n,f.cssClasses.value),t.setAttribute("data-value",String(r)),t.style[f.style]=e+"%",t.innerHTML=String(s.to(r))))}),a}function L(){n&&(st(n),n=null)}function T(t){L();var e=D(t),r=t.filter,t=t.format||{to:function(t){return String(Math.round(t))}};return n=d.appendChild(O(e,r,t))}function j(){var t=i.getBoundingClientRect(),e="offset"+["Width","Height"][f.ort];return 0===f.ort?t.width||i[e]:t.height||i[e]}function z(n,i,o,s){function e(t){var e,r=function(e,t,r){var n=0===e.type.indexOf("touch"),i=0===e.type.indexOf("mouse"),o=0===e.type.indexOf("pointer"),s=0,a=0;0===e.type.indexOf("MSPointer")&&(o=!0);if("mousedown"===e.type&&!e.buttons&&!e.touches)return!1;if(n){var l=function(t){t=t.target;return t===r||r.contains(t)||e.composed&&e.composedPath().shift()===r};if("touchstart"===e.type){n=Array.prototype.filter.call(e.touches,l);if(1r.stepAfter.startValue&&(i=r.stepAfter.startValue-n),t=n>r.thisStep.startValue?r.thisStep.step:!1!==r.stepBefore.step&&n-r.stepBefore.highestStep,100===e?i=null:0===e&&(t=null);e=S.countStepDecimals();return null!==i&&!1!==i&&(i=Number(i.toFixed(e))),[t=null!==t&&!1!==t?Number(t.toFixed(e)):t,i]}ft(t=d,f.cssClasses.target),0===f.dir?ft(t,f.cssClasses.ltr):ft(t,f.cssClasses.rtl),0===f.ort?ft(t,f.cssClasses.horizontal):ft(t,f.cssClasses.vertical),ft(t,"rtl"===getComputedStyle(t).direction?f.cssClasses.textDirectionRtl:f.cssClasses.textDirectionLtr),i=P(t,f.cssClasses.base),function(t,e){var r=P(e,f.cssClasses.connects);l=[],(a=[]).push(N(r,t[0]));for(var n=0;n