├── Procfile ├── .dockerignore ├── .gitignore ├── wsgi.py ├── climbdex ├── static │ ├── css │ │ ├── common.css │ │ └── nouislider.css │ ├── media │ │ ├── icon1500.png │ │ ├── icon192.png │ │ ├── icon512.png │ │ ├── ss_wide.png │ │ ├── ss_narrow.png │ │ └── icon.svg │ ├── manifest.json │ └── js │ │ ├── beta.js │ │ ├── swipe.js │ │ ├── common.js │ │ ├── bluetooth.js │ │ ├── climbCreation.js │ │ ├── boardSelection.js │ │ ├── filterSelection.js │ │ ├── results.js │ │ └── nouislider.min.js ├── templates │ ├── heading.html.j2 │ ├── alert.html.j2 │ ├── footer.html.j2 │ ├── head.html.j2 │ ├── beta.html.j2 │ ├── boardSelection.html.j2 │ ├── climbCreation.html.j2 │ ├── filterSelection.html.j2 │ └── results.html.j2 ├── __init__.py ├── api.py ├── views.py └── db.py ├── bin ├── sync_db.sh └── deploy.sh ├── requirements.txt ├── fly.toml ├── LICENSE └── README.md /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn wsgi:app 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | fly.toml 2 | tmp 3 | .venv 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | tmp/ 3 | .DS_STORE 4 | .venv/ 5 | __pycache__/ -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | import climbdex 2 | 3 | app = climbdex.create_app() 4 | -------------------------------------------------------------------------------- /climbdex/static/css/common.css: -------------------------------------------------------------------------------- 1 | .alert { 2 | display: none; 3 | } 4 | 5 | .show-alert { 6 | display: inherit; 7 | } 8 | -------------------------------------------------------------------------------- /climbdex/static/media/icon1500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemeryfertitta/Climbdex/HEAD/climbdex/static/media/icon1500.png -------------------------------------------------------------------------------- /climbdex/static/media/icon192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemeryfertitta/Climbdex/HEAD/climbdex/static/media/icon192.png -------------------------------------------------------------------------------- /climbdex/static/media/icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemeryfertitta/Climbdex/HEAD/climbdex/static/media/icon512.png -------------------------------------------------------------------------------- /climbdex/static/media/ss_wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemeryfertitta/Climbdex/HEAD/climbdex/static/media/ss_wide.png -------------------------------------------------------------------------------- /climbdex/static/media/ss_narrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemeryfertitta/Climbdex/HEAD/climbdex/static/media/ss_narrow.png -------------------------------------------------------------------------------- /climbdex/templates/heading.html.j2: -------------------------------------------------------------------------------- 1 |

Climbdex

2 |

Search Engine for Interactive Climbing Training Boards

-------------------------------------------------------------------------------- /bin/sync_db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | mkdir -p data/$1 4 | echo "Syncing database for $1 with user $2" 5 | boardlib database $1 data/$1/db.sqlite -u $2 -------------------------------------------------------------------------------- /bin/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | for BOARD in decoy grasshopper kilter tension touchstone 4 | do 5 | echo "Syncing database for $BOARD with user $1" 6 | bin/sync_db.sh $BOARD $1 7 | echo "Downloading images for $BOARD" 8 | boardlib images $BOARD data/$BOARD/db.sqlite data/$BOARD/images 9 | done 10 | fly deploy 11 | -------------------------------------------------------------------------------- /climbdex/templates/alert.html.j2: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 |
8 |
9 | -------------------------------------------------------------------------------- /climbdex/templates/footer.html.j2: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asgiref==3.7.2 2 | beautifulsoup4==4.12.2 3 | blinker==1.7.0 4 | boardlib==0.13.1 5 | bs4==0.0.1 6 | certifi==2024.7.4 7 | charset-normalizer==3.3.2 8 | click==8.1.7 9 | Flask==3.0.0 10 | gunicorn==22.0.0 11 | idna==3.7 12 | importlib-metadata==7.0.0 13 | itsdangerous==2.1.2 14 | Jinja2==3.1.4 15 | MarkupSafe==2.1.3 16 | packaging==23.2 17 | pandas==2.2.2 18 | requests==2.32.2 19 | soupsieve==2.5 20 | sqlparse==0.5.0 21 | typing_extensions==4.9.0 22 | urllib3==2.2.2 23 | Werkzeug==3.0.6 24 | zipp==3.19.1 25 | flask_parameter_validation==2.3.1 26 | -------------------------------------------------------------------------------- /climbdex/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import flask 3 | import logging 4 | 5 | import climbdex.api 6 | import climbdex.views 7 | 8 | logging.basicConfig( 9 | level=logging.INFO, 10 | format="%(asctime)s [%(levelname)s] %(message)s", 11 | handlers=[logging.StreamHandler(sys.stdout)], 12 | ) 13 | 14 | def create_app(): 15 | app = flask.Flask(__name__, instance_relative_config=True) 16 | app.url_map.strict_slashes = False 17 | app.register_blueprint(climbdex.api.blueprint) 18 | app.register_blueprint(climbdex.views.blueprint) 19 | return app 20 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for climbdex on 2023-12-22T16:48:26-08:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "climbdex" 7 | primary_region = "sjc" 8 | 9 | [build] 10 | builder = "paketobuildpacks/builder:full" 11 | 12 | [env] 13 | PORT = "8080" 14 | 15 | [http_service] 16 | internal_port = 8080 17 | force_https = true 18 | auto_stop_machines = true 19 | auto_start_machines = true 20 | min_machines_running = 0 21 | processes = ["app"] 22 | 23 | [[vm]] 24 | cpu_kind = "shared" 25 | cpus = 1 26 | memory_mb = 1024 -------------------------------------------------------------------------------- /climbdex/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Climbdex", 3 | "description": "Search engine for training board climbs", 4 | "icons": [ 5 | { 6 | "src": "media/icon.svg", 7 | "type": "image/svg+xml", 8 | "sizes": "85x85" 9 | }, 10 | { 11 | "src": "media/icon512.png", 12 | "type": "image/png", 13 | "sizes": "512x512" 14 | }, 15 | { 16 | "src": "media/icon192.png", 17 | "type": "image/png", 18 | "sizes": "192x192" 19 | } 20 | ], 21 | "screenshots": [ 22 | { 23 | "src": "media/ss_wide.png", 24 | "type": "image/png", 25 | "sizes": "2204x1476", 26 | "form_factor": "wide" 27 | }, 28 | { 29 | "src": "media/ss_narrow.png", 30 | "type": "image/png", 31 | "sizes": "1075x1855", 32 | "form_factor": "narrow" 33 | } 34 | ], 35 | "prefer_related_applications": false, 36 | "start_url": "/", 37 | "display": "standalone", 38 | "theme_color": "#0d6efd", 39 | "background_color": "#ffffff" 40 | } 41 | -------------------------------------------------------------------------------- /climbdex/templates/head.html.j2: -------------------------------------------------------------------------------- 1 | Climbdex 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Luke Emery-Fertitta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /climbdex/static/js/beta.js: -------------------------------------------------------------------------------- 1 | function clickBetaButton(index) { 2 | document.querySelector(`button[data-index="${index}"]`)?.click(); 3 | } 4 | 5 | const betaButtons = document.querySelectorAll("[data-link]"); 6 | betaButtons.forEach((button) => { 7 | button.addEventListener("click", (event) => { 8 | const link = event.target.getAttribute("data-link"); 9 | const handle = event.target.getAttribute("data-handle"); 10 | const betaIframeDiv = document.getElementById("div-beta-iframe"); 11 | betaIframeDiv.innerHTML = ""; 12 | const iframe = document.createElement("iframe"); 13 | betaIframeDiv.appendChild(iframe); 14 | iframe.src = link + "embed"; 15 | const handleHeader = document.getElementById("header-handle"); 16 | handleHeader.innerHTML = handle; 17 | document.getElementById("div-beta")?.scrollIntoView(true); 18 | 19 | const index = Number(event.target.getAttribute("data-index")); 20 | const buttonPrev = document.getElementById("button-prev"); 21 | buttonPrev.onclick = () => { 22 | clickBetaButton(index - 1); 23 | }; 24 | buttonPrev.disabled = index <= 0; 25 | 26 | const buttonNext = document.getElementById("button-next"); 27 | buttonNext.onclick = () => { 28 | clickBetaButton(index + 1); 29 | }; 30 | buttonNext.disabled = index >= betaButtons.length - 1; 31 | }); 32 | }); 33 | 34 | const backAnchor = document.getElementById("anchor-back"); 35 | backAnchor.href = location.origin; 36 | if (document.referrer && new URL(document.referrer).origin == location.origin) { 37 | backAnchor.addEventListener("click", function (event) { 38 | event.preventDefault(); 39 | history.back(); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /climbdex/static/js/swipe.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function () { 2 | let startX; 3 | let endX; 4 | const threshold = 150; // Minimum distance of swipe 5 | 6 | // Function to simulate button click and add button hover 7 | function simulateClick(elementId) { 8 | const element = document.getElementById(elementId); 9 | if (element) { 10 | element.click(); 11 | addHoverEffect(elementId); 12 | setTimeout(function () { 13 | removeHoverEffect(elementId); 14 | }, 200); 15 | } 16 | } 17 | 18 | function addHoverEffect(elementId) { 19 | const element = document.getElementById(elementId); 20 | if (element) { 21 | element.classList.add("simulated-button-click"); 22 | } 23 | } 24 | 25 | function removeHoverEffect(elementId) { 26 | const element = document.getElementById(elementId); 27 | if (element) { 28 | element.classList.remove("simulated-button-click"); 29 | } 30 | } 31 | 32 | document.addEventListener( 33 | "touchstart", 34 | function (e) { 35 | startX = e.changedTouches[0].screenX; 36 | }, 37 | false 38 | ); 39 | 40 | document.addEventListener( 41 | "touchend", 42 | function (e) { 43 | endX = e.changedTouches[0].screenX; 44 | 45 | // Check if swipe is right to left (next) 46 | if (startX > endX + threshold) { 47 | simulateClick("button-next"); 48 | } 49 | // Check if swipe is left to right (previous) 50 | else if (startX < endX - threshold) { 51 | simulateClick("button-prev"); 52 | } 53 | }, 54 | false 55 | ); 56 | 57 | // Arrow keys detection 58 | document.addEventListener( 59 | "keydown", 60 | function (e) { 61 | if (e.key === "ArrowLeft") { 62 | simulateClick("button-prev"); 63 | } 64 | if (e.key === "ArrowRight") { 65 | simulateClick("button-next"); 66 | } 67 | }, 68 | false 69 | ); 70 | }); 71 | -------------------------------------------------------------------------------- /climbdex/static/js/common.js: -------------------------------------------------------------------------------- 1 | var alert = document.querySelector('.alert') 2 | 3 | function drawBoard( 4 | svgElementId, 5 | imagesToHolds, 6 | edgeLeft, 7 | edgeRight, 8 | edgeBottom, 9 | edgeTop, 10 | onCircleClick 11 | ) { 12 | const svgElement = document.getElementById(svgElementId); 13 | for (const [imageUrl, holds] of Object.entries(imagesToHolds)) { 14 | const imageElement = document.createElementNS( 15 | "http://www.w3.org/2000/svg", 16 | "image" 17 | ); 18 | imageElement.setAttributeNS( 19 | "http://www.w3.org/1999/xlink", 20 | "xlink:href", 21 | imageUrl 22 | ); 23 | svgElement.appendChild(imageElement); 24 | 25 | const image = new Image(); 26 | image.onload = function () { 27 | svgElement.setAttribute("viewBox", `0 0 ${image.width} ${image.height}`); 28 | let xSpacing = image.width / (edgeRight - edgeLeft); 29 | let ySpacing = image.height / (edgeTop - edgeBottom); 30 | for (const [holdId, mirroredHoldId, x, y] of holds) { 31 | if ( 32 | x <= edgeLeft || 33 | x >= edgeRight || 34 | y <= edgeBottom || 35 | y >= edgeTop 36 | ) { 37 | continue; 38 | } 39 | let xPixel = (x - edgeLeft) * xSpacing; 40 | let yPixel = image.height - (y - edgeBottom) * ySpacing; 41 | let circle = document.createElementNS( 42 | "http://www.w3.org/2000/svg", 43 | "circle" 44 | ); 45 | circle.setAttribute("id", `hold-${holdId}`); 46 | if (mirroredHoldId) { 47 | circle.setAttribute("data-mirror-id", mirroredHoldId); 48 | } 49 | circle.setAttribute("cx", xPixel); 50 | circle.setAttribute("cy", yPixel); 51 | circle.setAttribute("r", xSpacing * 4); 52 | circle.setAttribute("fill-opacity", 0.0); 53 | circle.setAttribute("stroke-opacity", 0.0); 54 | circle.setAttribute("stroke-width", 6); 55 | if (onCircleClick) { 56 | circle.onclick = onCircleClick; 57 | } 58 | svgElement.appendChild(circle); 59 | } 60 | }; 61 | image.src = imageUrl; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /climbdex/templates/beta.html.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% include 'head.html.j2'%} 6 | 14 | 15 | 16 | 17 |
18 | {% include 'heading.html.j2' %} 19 | {% include 'alert.html.j2' %} 20 |
21 |
22 | 23 |

Back to search

24 |
25 | {% for angle, user, link in beta %} 26 | 30 | {% endfor %} 31 |
32 |
33 |
34 |
35 |
36 | 37 |
38 |
39 |

40 |
41 |
42 | 43 |
44 |
45 |
46 |
47 |
48 |
49 | 50 |
51 |
52 |
53 | {% include 'footer.html.j2'%} 54 |
55 |
56 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Climbdex 2 | 3 | Climbdex is a search engine for ["standardized interactive climbing training boards" (SICTBs)](https://gearjunkie.com/climbing/kilter-moon-grasshopper-more-interactive-climbing-training-boards-explained) designed with the goal of adding missing functionality to boards that utilize [Aurora Climbing's](https://auroraclimbing.com/) software, such as [Kilter](https://settercloset.com/pages/the-kilter-board), 4 | [Tension](https://tensionclimbing.com/product/tension-board-sets/), and [Decoy](https://decoy-holds.com/pages/decoy-board). The primary missing feature provided by this engine is a "filter by hold" feature to find climbs with specific sets of holds. There are some additional improvements to other options for filtering and sorting through climbs as well. 5 | 6 | Try it out [here](https://climbdex.com/). 7 | 8 | This app was partially inspired by Tim Parkin's excellent [Moonboard Search Engine](http://mb.timparkin.net/). 9 | 10 | The climb databases are downloaded and synchronized using the [BoardLib](https://github.com/lemeryfertitta/BoardLib) Python library. 11 | 12 | ## Features 13 | 14 | These are the features that Climbdex provides which are currently not supported by the official apps: 15 | 16 | - Hold filtering 17 | - Select holds to require them to be present in the resulting climbs. 18 | - Click multiple times on a hold to change the color. 19 | - If "strict" color matching is selected, the color of the hold must be an exact match. If "any" color matching is selected, the color of the holds will be ignored. If "only hands" color matching is selected, the color of the hold must match one of the hand hold colors (start, middle, or finish). 20 | - On mirrored board layouts, the mirror image of the filtered hold sequence will also be included in the search results. 21 | - Precise quality and difficulty ratings 22 | - The exact average (to the hundredths place) of the grade and star ratings are displayed to give a better sense of the true difficulty and quality of a climb. 23 | - There is a "difficulty accuracy" filter which can be used in combination with minimum ascents to help find benchmark climbs of a grade. 24 | - Bookmarking 25 | - Filters are stored in query params such that a specific search or setup can be bookmarked. 26 | - For example: [Kilter V5s at 40° with at least 500 ascents, sorted by quality](https://climbdex.com/results?minGrade=20&maxGrade=20&name=&angle=40&minAscents=500&sortBy=quality&sortOrder=desc&minRating=1.0&onlyClassics=0&gradeAccuracy=1&settername=&setternameSuggestion=&holds=&mirroredHolds=&board=kilter&layout=1&size=10&set=1&set=20&roleMatch=strict) or the [TB2 mirror layout](https://climbdex.com/filter?board=tension&layout=10&size=6&set=12&set=13). 27 | - Web access 28 | - No mobile app required to search for climbs 29 | - To light up the climbs, the mobile app is needed, but if app links are setup correctly, you can click on the climb name in Climbdex and be taken directly to the climb on the app. 30 | - Climbdex is a [PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) which means the app can be installed on almost any platform and behave similarly to a native mobile or desktop app. 31 | - All of the supported boards on one app. 32 | 33 | ## Development 34 | 35 | To run Climbdex locally, clone the repository and install the Python dependencies ([venv](https://docs.python.org/3/library/venv.html) reccommended): 36 | 37 | ``` 38 | python3 -m pip install -r requirements.txt 39 | ``` 40 | 41 | After the dependencies are installed, start a server: 42 | 43 | ``` 44 | gunicorn wsgi:app 45 | ``` 46 | 47 | To actually use most of the features of Climbdex, at least one of the local SQLite databases are required. To download a database, use the `sync_db` script: 48 | 49 | ``` 50 | bin/sync_db.sh 51 | ``` 52 | 53 | where `` is one of `decoy`, `grasshopper`, `kilter`, `tension` or `touchstone`. 54 | -------------------------------------------------------------------------------- /climbdex/static/media/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 18 | 36 | 40 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /climbdex/templates/boardSelection.html.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% include 'head.html.j2'%} 6 | 11 | 14 | 15 | 16 | 17 |
18 | {% include 'heading.html.j2' %} 19 | {% include 'alert.html.j2' %} 20 |
21 |
22 |
23 |
24 | Board 25 | 36 |
37 |
38 | Layout 39 | 41 |
42 |
43 | Size 44 | 45 |
46 |
47 | 49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | 57 |
58 |
59 | 62 |
63 |
64 |
65 |
66 |
67 |
68 | {% include 'footer.html.j2'%} 69 |
70 |
71 | 72 | 102 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /climbdex/static/js/bluetooth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Based heavily on the excellent blogpost from Philipp Bazun: 3 | * 4 | * https://web.archive.org/web/20240203155713/https://www.bazun.me/blog/kiterboard/#reversing-bluetooth 5 | * 6 | */ 7 | 8 | const MAX_BLUETOOTH_MESSAGE_SIZE = 20; 9 | const MESSAGE_BODY_MAX_LENGTH = 255; 10 | const PACKET_MIDDLE = 81; 11 | const PACKET_FIRST = 82; 12 | const PACKET_LAST = 83; 13 | const PACKET_ONLY = 84; 14 | const SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"; 15 | const CHARACTERISTIC_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; 16 | const BLUETOOTH_UNDEFINED = "navigator.bluetooth is undefined"; 17 | const BLUETOOTH_CANCELLED = "User cancelled the requestDevice() chooser."; 18 | 19 | let bluetoothDevice = null; 20 | 21 | function checksum(data) { 22 | let i = 0; 23 | for (const value of data) { 24 | i = (i + value) & 255; 25 | } 26 | return ~i & 255; 27 | } 28 | 29 | function wrapBytes(data) { 30 | if (data.length > MESSAGE_BODY_MAX_LENGTH) { 31 | return []; 32 | } 33 | 34 | return [1, data.length, checksum(data), 2, ...data, 3]; 35 | } 36 | 37 | function encodePosition(position) { 38 | const position1 = position & 255; 39 | const position2 = (position & 65280) >> 8; 40 | return [position1, position2]; 41 | } 42 | 43 | function encodeColor(color) { 44 | const substring = color.substring(0, 2); 45 | const substring2 = color.substring(2, 4); 46 | 47 | const parsedSubstring = parseInt(substring, 16) / 32; 48 | const parsedSubstring2 = parseInt(substring2, 16) / 32; 49 | const parsedResult = (parsedSubstring << 5) | (parsedSubstring2 << 2); 50 | 51 | const substring3 = color.substring(4, 6); 52 | const parsedSubstring3 = parseInt(substring3, 16) / 64; 53 | const finalParsedResult = parsedResult | parsedSubstring3; 54 | 55 | return finalParsedResult; 56 | } 57 | 58 | function encodePositionAndColor(position, ledColor) { 59 | return [...encodePosition(position), encodeColor(ledColor)]; 60 | } 61 | 62 | function getBluetoothPacket(frames, placementPositions, colors) { 63 | const resultArray = []; 64 | let tempArray = [PACKET_MIDDLE]; 65 | frames.split("p").forEach((frame) => { 66 | if (frame.length > 0) { 67 | const [placement, role] = frame.split("r"); 68 | const encodedFrame = encodePositionAndColor( 69 | Number(placementPositions[placement]), 70 | colors[role] 71 | ); 72 | if (tempArray.length + 3 > MESSAGE_BODY_MAX_LENGTH) { 73 | resultArray.push(tempArray); 74 | tempArray = [PACKET_MIDDLE]; 75 | } 76 | tempArray.push(...encodedFrame); 77 | } 78 | }); 79 | 80 | resultArray.push(tempArray); 81 | 82 | if (resultArray.length === 1) { 83 | resultArray[0][0] = PACKET_ONLY; 84 | } else if (resultArray.length > 1) { 85 | resultArray[0][0] = PACKET_FIRST; 86 | resultArray[resultArray.length - 1][0] = PACKET_LAST; 87 | } 88 | 89 | const finalResultArray = []; 90 | for (const currentArray of resultArray) { 91 | finalResultArray.push(...wrapBytes(currentArray)); 92 | } 93 | 94 | return Uint8Array.from(finalResultArray); 95 | } 96 | 97 | function splitEvery(n, list) { 98 | if (n <= 0) { 99 | throw new Error("First argument to splitEvery must be a positive integer"); 100 | } 101 | var result = []; 102 | var idx = 0; 103 | while (idx < list.length) { 104 | result.push(list.slice(idx, (idx += n))); 105 | } 106 | return result; 107 | } 108 | 109 | function illuminateClimb(board, bluetoothPacket) { 110 | const capitalizedBoard = board[0].toUpperCase() + board.slice(1); 111 | requestDevice(capitalizedBoard) 112 | .then((device) => { 113 | return device.gatt.connect(); 114 | }) 115 | .then((server) => { 116 | return server.getPrimaryService(SERVICE_UUID); 117 | }) 118 | .then((service) => { 119 | return service.getCharacteristic(CHARACTERISTIC_UUID); 120 | }) 121 | .then((characteristic) => { 122 | const splitMessages = (buffer) => 123 | splitEvery(MAX_BLUETOOTH_MESSAGE_SIZE, buffer).map( 124 | (arr) => new Uint8Array(arr) 125 | ); 126 | return writeCharacteristicSeries( 127 | characteristic, 128 | splitMessages(bluetoothPacket) 129 | ); 130 | }) 131 | .then(() => console.log("Climb illuminated")) 132 | .catch((error) => { 133 | if (error.message !== BLUETOOTH_CANCELLED) { 134 | const message = 135 | error.message === BLUETOOTH_UNDEFINED 136 | ? "Web Bluetooth is not supported on this browser. See https://caniuse.com/web-bluetooth for more information." 137 | : `Failed to connect to LEDS: ${error}`; 138 | alert(message); 139 | } 140 | }); 141 | } 142 | 143 | async function writeCharacteristicSeries(characteristic, messages) { 144 | let returnValue = null; 145 | for (const message of messages) { 146 | returnValue = await characteristic.writeValue(message); 147 | } 148 | return returnValue; 149 | } 150 | 151 | async function requestDevice(namePrefix) { 152 | if (!bluetoothDevice) { 153 | bluetoothDevice = await navigator.bluetooth.requestDevice({ 154 | filters: [ 155 | { 156 | namePrefix, 157 | }, 158 | ], 159 | optionalServices: [SERVICE_UUID], 160 | }); 161 | } 162 | return bluetoothDevice; 163 | } 164 | -------------------------------------------------------------------------------- /climbdex/static/js/climbCreation.js: -------------------------------------------------------------------------------- 1 | function onFilterCircleClick(circleElement, colorRows) { 2 | const currentColor = circleElement.getAttribute("stroke"); 3 | const colorIds = colorRows.map((colorRow) => colorRow[0]); 4 | const colors = colorRows.map((colorRow) => colorRow[1]); 5 | let currentIndex = colors.indexOf(currentColor); 6 | let nextIndex = currentIndex + 1; 7 | if (nextIndex >= colors.length) { 8 | circleElement.setAttribute("stroke-opacity", 0.0); 9 | circleElement.setAttribute("stroke", "black"); 10 | } else { 11 | circleElement.setAttribute("stroke", `${colors[nextIndex]}`); 12 | circleElement.setAttribute("stroke-opacity", 1.0); 13 | circleElement.setAttribute("data-color-id", colorIds[nextIndex]); 14 | } 15 | } 16 | 17 | function getFrames() { 18 | const frames = []; 19 | document 20 | .getElementById("svg-climb") 21 | .querySelectorAll('circle[stroke-opacity="1"]') 22 | .forEach((circle) => { 23 | const holdId = circle.id.split("-")[1]; 24 | const colorId = circle.getAttribute("data-color-id"); 25 | frames.push(`p${holdId}r${colorId}`); 26 | }); 27 | return frames.join(""); 28 | } 29 | 30 | function resetHolds() { 31 | const circles = document.getElementsByTagNameNS( 32 | "http://www.w3.org/2000/svg", 33 | "circle" 34 | ); 35 | for (const circle of circles) { 36 | circle.setAttribute("stroke-opacity", 0.0); 37 | circle.setAttribute("stroke", "black"); 38 | } 39 | } 40 | 41 | document.addEventListener('DOMContentLoaded', function () { 42 | // Reset log modal on show 43 | const setModal = document.getElementById('div-set-modal'); 44 | setModal.addEventListener('show.bs.modal', function () { 45 | document.getElementById('name').value = ''; 46 | document.getElementById('description').value = ''; 47 | document.getElementById('draft').checked = true; 48 | document.getElementById('final').checked = false; 49 | document.getElementById('No matching').checked = true; 50 | document.getElementById('matching').checked = false; 51 | document.getElementById("select-angle").value = -1; 52 | document.getElementById('button-publish-climb').disabled = false; 53 | }); 54 | }); 55 | 56 | document.getElementById('button-publish-climb').addEventListener('click', function () { 57 | this.disabled = true; // Disable the button to prevent multiple submissions 58 | }); 59 | 60 | document 61 | .getElementById("button-reset-holds") 62 | .addEventListener("click", resetHolds); 63 | 64 | document 65 | .getElementById("button-illuminate") 66 | .addEventListener("click", function () { 67 | const frames = getFrames(); 68 | let bluetoothPacket = getBluetoothPacket( 69 | frames, 70 | placementPositions, 71 | ledColors 72 | ); 73 | 74 | const urlParams = new URLSearchParams(window.location.search); 75 | const board = urlParams.get("board"); 76 | illuminateClimb(board, bluetoothPacket); 77 | }); 78 | 79 | document 80 | .getElementById("button-publish-climb") 81 | .addEventListener("click", function () { 82 | const urlParams = new URLSearchParams(window.location.search); 83 | const board = urlParams.get("board"); 84 | const layout_id = parseInt(urlParams.get("layout")); 85 | const name = document.getElementById("name").value; 86 | const is_matching = document.querySelector('input[name="is_matching"]:checked').id === "matching"; 87 | const hasDescription = document.getElementById("description").value != ""; 88 | const description = (is_matching ? "" : "No matching.") + (hasDescription ? " " : "") + document.getElementById("description").value; 89 | const is_draft = document.querySelector('input[name="is_draft"]:checked').id === "draft"; 90 | const frames = getFrames(); 91 | const angle = parseInt(document.getElementById("select-angle").value); 92 | 93 | const data = { 94 | board: board, 95 | layout_id: layout_id, 96 | name: name, 97 | description: description, 98 | is_draft: is_draft, 99 | frames: frames, 100 | angle: angle, 101 | }; 102 | 103 | fetch("/api/v1/climbs", { 104 | method: "POST", 105 | headers: { 106 | "Content-Type": "application/json", 107 | }, 108 | body: JSON.stringify(data), 109 | }) 110 | .then((response) => { 111 | if (!response.ok) { 112 | throw new Error("Network response was not ok " + response.statusText); 113 | } 114 | return response.json(); 115 | }) 116 | .then((data) => { 117 | const successAlert = document.querySelector(".alert-success"); 118 | successAlert.style.display = "block"; 119 | 120 | setTimeout(() => { 121 | successAlert.style.display = "none"; 122 | const setModal = document.getElementById("div-set-modal"); 123 | const modalInstance = bootstrap.Modal.getInstance(setModal); 124 | if (modalInstance) { 125 | modalInstance.hide(); 126 | } 127 | }, 3000); 128 | }) 129 | .catch((error) => { 130 | console.error("Error:", error); 131 | const errorAlert = document.querySelector(".alert-danger"); 132 | errorAlert.style.display = "block"; 133 | 134 | setTimeout(() => { 135 | errorAlert.style.display = "none"; 136 | }, 3000); 137 | }); 138 | }); 139 | 140 | const backAnchor = document.getElementById("anchor-back"); 141 | backAnchor.href = location.origin; 142 | if (document.referrer) { 143 | referrerUrl = new URL(document.referrer); 144 | if (referrerUrl.origin == location.origin && referrerUrl.pathname == "/") { 145 | backAnchor.addEventListener("click", function (event) { 146 | event.preventDefault(); 147 | history.back(); 148 | }); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /climbdex/static/js/boardSelection.js: -------------------------------------------------------------------------------- 1 | function populateLayouts(boardName) { 2 | fetch(`/api/v1/${boardName}/layouts`).then((response) => { 3 | response.json().then((layouts) => { 4 | const layoutSelect = document.getElementById("select-layout"); 5 | layoutSelect.innerHTML = ""; 6 | for (const [layoutId, layoutName] of layouts) { 7 | let option = document.createElement("option"); 8 | option.text = layoutName; 9 | option.value = layoutId; 10 | layoutSelect.appendChild(option); 11 | } 12 | layoutSelect.addEventListener("change", populateSizes); 13 | populateSizes(layoutSelect.value); 14 | }); 15 | }); 16 | } 17 | 18 | function populateSizes() { 19 | const boardName = document.getElementById("select-board").value; 20 | const layoutId = document.getElementById("select-layout").value; 21 | fetch(`/api/v1/${boardName}/layouts/${layoutId}/sizes`).then((response) => { 22 | response.json().then((sizes) => { 23 | const sizeSelect = document.getElementById("select-size"); 24 | sizeSelect.innerHTML = ""; 25 | for (const [sizeId, sizeName, sizeDescription] of sizes) { 26 | let option = document.createElement("option"); 27 | option.text = `${sizeName} ${sizeDescription}`; 28 | option.value = sizeId; 29 | sizeSelect.appendChild(option); 30 | } 31 | sizeSelect.addEventListener("change", populateSets); 32 | populateSets(); 33 | }); 34 | }); 35 | } 36 | 37 | function populateSets() { 38 | const boardName = document.getElementById("select-board").value; 39 | const layoutId = document.getElementById("select-layout").value; 40 | const sizeId = document.getElementById("select-size").value; 41 | fetch(`/api/v1/${boardName}/layouts/${layoutId}/sizes/${sizeId}/sets`).then( 42 | (response) => { 43 | response.json().then((sets) => { 44 | const setsDiv = document.getElementById("div-sets"); 45 | setsDiv.innerHTML = ""; 46 | for (const [setId, setName] of sets) { 47 | const inputGroupDiv = document.createElement("div"); 48 | inputGroupDiv.className = "input-group mb-3"; 49 | setsDiv.appendChild(inputGroupDiv); 50 | 51 | const span = document.createElement("span"); 52 | span.className = "input-group-text"; 53 | span.textContent = setName; 54 | inputGroupDiv.appendChild(span); 55 | 56 | const select = document.createElement("select"); 57 | select.className = "form-select"; 58 | select.setAttribute("data-set-id", setId); 59 | select.addEventListener("change", updateSetsInput); 60 | inputGroupDiv.appendChild(select); 61 | 62 | const optionEnabled = document.createElement("option"); 63 | optionEnabled.text = "Enabled"; 64 | optionEnabled.value = true; 65 | optionEnabled.selected = true; 66 | select.appendChild(optionEnabled); 67 | 68 | const optionDisabled = document.createElement("option"); 69 | optionDisabled.text = "Disabled (coming soon)"; 70 | optionDisabled.value = false; 71 | optionDisabled.disabled = true; 72 | select.appendChild(optionDisabled); 73 | } 74 | updateSetsInput(); 75 | }); 76 | } 77 | ); 78 | } 79 | 80 | function updateSetsInput() { 81 | const setsDiv = document.getElementById("div-sets"); 82 | const setsInputsDiv = document.getElementById("div-sets-inputs"); 83 | setsInputsDiv.innerHTML = ""; 84 | let isOneSetEnabled = false; 85 | for (const select of setsDiv.querySelectorAll("select")) { 86 | if (select.value === "true") { 87 | const input = document.createElement("input"); 88 | input.type = "hidden"; 89 | input.name = "set"; 90 | input.value = select.getAttribute("data-set-id"); 91 | setsInputsDiv.appendChild(input); 92 | isOneSetEnabled = true; 93 | } 94 | } 95 | document.getElementById("button-next").disabled = !isOneSetEnabled; 96 | } 97 | 98 | function populateLoginForm(boardName) { 99 | const capitalizedBoardName = 100 | boardName.charAt(0).toUpperCase() + boardName.slice(1); 101 | 102 | const loginButton = document.getElementById("button-login"); 103 | loginButton.disabled = false; 104 | loginButton.textContent = `(Optional) Login to ${capitalizedBoardName}`; 105 | const loginText = document.cookie.includes(`${boardName}_login`) 106 | ? "You're logged in! Log in again to switch users or refresh your token." 107 | : "Log in to allow Climbdex to fetch your ticklist."; 108 | document.getElementById("div-login-text").textContent = loginText; 109 | document.getElementById( 110 | "header-modal-title" 111 | ).textContent = `${capitalizedBoardName} Board Login`; 112 | document.getElementById( 113 | "label-username" 114 | ).textContent = `${capitalizedBoardName} Board Username`; 115 | document.getElementById( 116 | "label-password" 117 | ).textContent = `${capitalizedBoardName} Board Password`; 118 | } 119 | 120 | function handleBoardSelection() { 121 | const boardName = document.getElementById("select-board").value; 122 | populateLayouts(boardName); 123 | populateLoginForm(boardName); 124 | } 125 | 126 | const boardSelect = document.getElementById("select-board"); 127 | boardSelect.addEventListener("change", handleBoardSelection); 128 | handleBoardSelection(); 129 | 130 | const modal = new bootstrap.Modal(document.getElementById("div-modal"), { 131 | keyboard: false, 132 | }); 133 | const loginForm = document.getElementById("form-login"); 134 | loginForm.addEventListener("submit", function (event) { 135 | const username = document.getElementById("input-username").value; 136 | const password = document.getElementById("input-password").value; 137 | const boardName = document.getElementById("select-board").value; 138 | const errorParagraph = document.getElementById("paragraph-login-error"); 139 | errorParagraph.textContent = ""; 140 | fetch("/api/v1/login", { 141 | method: "POST", 142 | body: JSON.stringify({ 143 | board: boardName, 144 | username: username, 145 | password: password, 146 | }), 147 | headers: { 148 | "Content-Type": "application/json", 149 | }, 150 | }).then((response) => { 151 | response.json().then((json) => { 152 | if (!response.ok) { 153 | errorParagraph.textContent = json["error"]; 154 | } else { 155 | document.cookie = `${boardName}_login=${JSON.stringify( 156 | json 157 | )}; SameSite=Strict; Secure;`; 158 | modal.hide(); 159 | populateLoginForm(boardName); 160 | location.reload(); 161 | } 162 | }); 163 | }); 164 | event.preventDefault(); 165 | }); 166 | 167 | const loginButton = document.getElementById("button-login"); 168 | loginButton.addEventListener("click", function () { 169 | modal.show(); 170 | }); 171 | 172 | const closeButton = document.getElementById("button-close"); 173 | closeButton.addEventListener("click", function () { 174 | modal.hide(); 175 | }); 176 | -------------------------------------------------------------------------------- /climbdex/static/css/nouislider.css: -------------------------------------------------------------------------------- 1 | /* Functional styling; 2 | * These styles are required for noUiSlider to function. 3 | * You don't need to change these rules to apply your design. 4 | */ 5 | .noUi-target, 6 | .noUi-target * { 7 | -webkit-touch-callout: none; 8 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 9 | -webkit-user-select: none; 10 | -ms-touch-action: none; 11 | touch-action: none; 12 | -ms-user-select: none; 13 | -moz-user-select: none; 14 | user-select: none; 15 | -moz-box-sizing: border-box; 16 | box-sizing: border-box; 17 | } 18 | .noUi-target { 19 | position: relative; 20 | } 21 | .noUi-base, 22 | .noUi-connects { 23 | width: 100%; 24 | height: 100%; 25 | position: relative; 26 | z-index: 1; 27 | background: #e9ecef!important; 28 | height:8px!important; 29 | } 30 | /* Wrapper for all connect elements. 31 | */ 32 | .noUi-connects { 33 | overflow: hidden; 34 | z-index: 0; 35 | } 36 | .noUi-connect, 37 | .noUi-origin { 38 | will-change: transform; 39 | position: absolute; 40 | z-index: 1; 41 | top: 0; 42 | right: 0; 43 | height: 100%; 44 | width: 100%; 45 | -ms-transform-origin: 0 0; 46 | -webkit-transform-origin: 0 0; 47 | -webkit-transform-style: preserve-3d; 48 | transform-origin: 0 0; 49 | transform-style: flat; 50 | } 51 | /* Offset direction 52 | */ 53 | .noUi-txt-dir-rtl.noUi-horizontal .noUi-origin { 54 | left: 0; 55 | right: auto; 56 | } 57 | /* Give origins 0 height/width so they don't interfere with clicking the 58 | * connect elements. 59 | */ 60 | .noUi-vertical .noUi-origin { 61 | top: -100%; 62 | width: 0; 63 | } 64 | .noUi-horizontal .noUi-origin { 65 | height: 0; 66 | } 67 | .noUi-handle { 68 | -webkit-backface-visibility: hidden; 69 | backface-visibility: hidden; 70 | position: absolute; 71 | } 72 | .noUi-touch-area { 73 | height: 100%; 74 | width: 100%; 75 | } 76 | .noUi-state-tap .noUi-connect, 77 | .noUi-state-tap .noUi-origin { 78 | -webkit-transition: transform 0.3s; 79 | transition: transform 0.3s; 80 | } 81 | .noUi-state-drag * { 82 | cursor: inherit !important; 83 | } 84 | /* Slider size and handle placement; 85 | */ 86 | .noUi-horizontal { 87 | height: 38px; 88 | } 89 | .noUi-horizontal .noUi-handle { 90 | width: 34px; 91 | height: 28px; 92 | right: -17px; 93 | top: -9px; 94 | } 95 | .noUi-vertical { 96 | width: 18px; 97 | } 98 | .noUi-vertical .noUi-handle { 99 | width: 28px; 100 | height: 34px; 101 | right: -6px; 102 | bottom: -17px; 103 | } 104 | .noUi-txt-dir-rtl.noUi-horizontal .noUi-handle { 105 | left: -17px; 106 | right: auto; 107 | } 108 | /* Styling; 109 | * Giving the connect element a border radius causes issues with using transform: scale 110 | */ 111 | 112 | .noUi-connects { 113 | border-radius: 3px; 114 | } 115 | .noUi-connect { 116 | background: #0d6efd!important; 117 | height: 8px!important; 118 | } 119 | /* Handles and cursors; 120 | */ 121 | .noUi-draggable { 122 | cursor: ew-resize; 123 | } 124 | .noUi-vertical .noUi-draggable { 125 | cursor: ns-resize; 126 | } 127 | .noUi-handle { 128 | border: 1px solid #D9D9D9; 129 | border-radius: 3px; 130 | background: #FFF; 131 | cursor: default; 132 | box-shadow: inset 0 0 1px #FFF, inset 0 1px 7px #EBEBEB, 0 3px 6px -3px #BBB; 133 | } 134 | .noUi-active { 135 | box-shadow: inset 0 0 1px #FFF, inset 0 1px 7px #DDD, 0 3px 6px -3px #BBB; 136 | } 137 | /* Handle stripes; 138 | */ 139 | .noUi-handle:before, 140 | .noUi-handle:after { 141 | content: ""; 142 | display: block; 143 | position: absolute; 144 | height: 14px; 145 | width: 1px; 146 | background: #E8E7E6; 147 | left: 14px; 148 | top: 6px; 149 | } 150 | .noUi-handle:after { 151 | left: 17px; 152 | } 153 | .noUi-vertical .noUi-handle:before, 154 | .noUi-vertical .noUi-handle:after { 155 | width: 14px; 156 | height: 1px; 157 | left: 6px; 158 | top: 14px; 159 | } 160 | .noUi-vertical .noUi-handle:after { 161 | top: 17px; 162 | } 163 | /* Disabled state; 164 | */ 165 | [disabled] .noUi-connect { 166 | background: #B8B8B8; 167 | } 168 | [disabled].noUi-target, 169 | [disabled].noUi-handle, 170 | [disabled] .noUi-handle { 171 | cursor: not-allowed; 172 | } 173 | /* Base; 174 | * 175 | */ 176 | .noUi-pips, 177 | .noUi-pips * { 178 | -moz-box-sizing: border-box; 179 | box-sizing: border-box; 180 | } 181 | .noUi-pips { 182 | position: absolute; 183 | color: #999; 184 | } 185 | /* Values; 186 | * 187 | */ 188 | .noUi-value { 189 | position: absolute; 190 | white-space: nowrap; 191 | text-align: center; 192 | } 193 | .noUi-value-sub { 194 | color: #ccc; 195 | font-size: 10px; 196 | } 197 | /* Markings; 198 | * 199 | */ 200 | .noUi-marker { 201 | position: absolute; 202 | background: #CCC; 203 | } 204 | .noUi-marker-sub { 205 | background: #AAA; 206 | } 207 | .noUi-marker-large { 208 | background: #AAA; 209 | } 210 | /* Horizontal layout; 211 | * 212 | */ 213 | .noUi-pips-horizontal { 214 | padding: 10px 0; 215 | height: 80px; 216 | top: 100%; 217 | left: 0; 218 | width: 100%; 219 | } 220 | .noUi-value-horizontal { 221 | -webkit-transform: translate(-50%, 50%); 222 | transform: translate(-50%, 50%); 223 | } 224 | .noUi-rtl .noUi-value-horizontal { 225 | -webkit-transform: translate(50%, 50%); 226 | transform: translate(50%, 50%); 227 | } 228 | .noUi-marker-horizontal.noUi-marker { 229 | margin-left: -1px; 230 | width: 2px; 231 | height: 5px; 232 | } 233 | .noUi-marker-horizontal.noUi-marker-sub { 234 | height: 10px; 235 | } 236 | .noUi-marker-horizontal.noUi-marker-large { 237 | height: 15px; 238 | } 239 | /* Vertical layout; 240 | * 241 | */ 242 | .noUi-pips-vertical { 243 | padding: 0 10px; 244 | height: 100%; 245 | top: 0; 246 | left: 100%; 247 | } 248 | .noUi-value-vertical { 249 | -webkit-transform: translate(0, -50%); 250 | transform: translate(0, -50%); 251 | padding-left: 25px; 252 | } 253 | .noUi-rtl .noUi-value-vertical { 254 | -webkit-transform: translate(0, 50%); 255 | transform: translate(0, 50%); 256 | } 257 | .noUi-marker-vertical.noUi-marker { 258 | width: 5px; 259 | height: 2px; 260 | margin-top: -1px; 261 | } 262 | .noUi-marker-vertical.noUi-marker-sub { 263 | width: 10px; 264 | } 265 | .noUi-marker-vertical.noUi-marker-large { 266 | width: 15px; 267 | } 268 | .noUi-tooltip { 269 | display: block; 270 | position: absolute; 271 | border: 1px solid #D9D9D9; 272 | border-radius: 3px; 273 | font-size:12px; 274 | background: #fff; 275 | color: #000; 276 | padding: 5px; 277 | text-align: center; 278 | white-space: nowrap; 279 | } 280 | .noUi-horizontal .noUi-tooltip { 281 | -webkit-transform: translate(-50%, 0); 282 | transform: translate(-50%, 0); 283 | left: 50%; 284 | bottom: 120%; 285 | } 286 | .noUi-vertical .noUi-tooltip { 287 | -webkit-transform: translate(0, -50%); 288 | transform: translate(0, -50%); 289 | top: 50%; 290 | right: 120%; 291 | } 292 | .noUi-horizontal .noUi-origin > .noUi-tooltip { 293 | -webkit-transform: translate(50%, 0); 294 | transform: translate(50%, 0); 295 | left: auto; 296 | bottom: 10px; 297 | } 298 | .noUi-vertical .noUi-origin > .noUi-tooltip { 299 | -webkit-transform: translate(0, -18px); 300 | transform: translate(0, -18px); 301 | top: auto; 302 | right: 28px; 303 | } 304 | -------------------------------------------------------------------------------- /climbdex/templates/climbCreation.html.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% include 'head.html.j2'%} 6 | 16 | 19 | 20 | 21 | 22 |
23 | {% include 'heading.html.j2' %} 24 | {% include 'alert.html.j2' %} 25 |
26 |
27 |

Setup: {{board.capitalize()}} - {{layout_name}} - {{size_name}}

28 |

Back to setup selection

29 | 30 |
31 | 38 |
39 |
40 | 41 |
42 | {% if login_cookie %} 43 |
44 | 47 |
48 | {% endif %} 49 |
50 |
51 |
52 |
53 | {% include 'footer.html.j2' %} 54 |
55 | 56 | 57 | 113 | 114 | 115 | 116 | 117 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /climbdex/api.py: -------------------------------------------------------------------------------- 1 | import flask 2 | from flask_parameter_validation import ValidateParameters, Json, Query 3 | import boardlib.api.aurora 4 | import logging 5 | import climbdex.db 6 | import requests 7 | 8 | blueprint = flask.Blueprint("api", __name__) 9 | 10 | def parameter_error(e): 11 | code = 400 12 | name = str(type(e).__name__) 13 | description = ( 14 | f"Parameters were missing and/or misconfigured. If the issue persists, please " 15 | f"report it (code: {code})" 16 | ) 17 | 18 | response = { 19 | "error": True, 20 | "code": code, 21 | "name": name, 22 | "description": description, 23 | }, code 24 | 25 | logging.error(response) 26 | return response 27 | 28 | @blueprint.errorhandler(Exception) 29 | def handle_exception(e): 30 | logging.error(f"Unhandled exception: {str(e)}", exc_info=True) 31 | response = e.get_response() 32 | response.data = flask.json.dumps({ 33 | "error": True, 34 | "code": e.code, 35 | "name": e.name, 36 | "description": ( 37 | f"There was a problem while getting results from the server. If the issue persists, " 38 | f"please report it (code: {e.code})" 40 | ), 41 | }) 42 | response.content_type = "application/json" 43 | logging.error(response.data) 44 | return response 45 | 46 | @blueprint.route("/api/v1//layouts") 47 | def layouts(board_name): 48 | return flask.jsonify(climbdex.db.get_data(board_name, "layouts")) 49 | 50 | @blueprint.route("/api/v1//layouts//sizes") 51 | def sizes(board_name, layout_id): 52 | return flask.jsonify( 53 | climbdex.db.get_data(board_name, "sizes", {"layout_id": int(layout_id)}) 54 | ) 55 | 56 | @blueprint.route("/api/v1//layouts//sizes//sets") 57 | def sets(board_name, layout_id, size_id): 58 | return flask.jsonify( 59 | climbdex.db.get_data( 60 | board_name, "sets", {"layout_id": int(layout_id), "size_id": int(size_id)} 61 | ) 62 | ) 63 | 64 | @blueprint.route("/api/v1/search/count") 65 | @ValidateParameters(parameter_error) 66 | def resultsCount( 67 | gradeAccuracy: float = Query(), 68 | layout: int = Query(), 69 | maxGrade: int = Query(), 70 | minAscents: int = Query(), 71 | minGrade: int = Query(), 72 | minRating: float = Query(), 73 | size: int = Query(), 74 | ): 75 | return flask.jsonify(climbdex.db.get_search_count(flask.request.args)) 76 | 77 | @blueprint.route("/api/v1/search") 78 | @ValidateParameters(parameter_error) 79 | def search( 80 | gradeAccuracy: float = Query(), 81 | layout: int = Query(), 82 | maxGrade: int = Query(), 83 | minAscents: int = Query(), 84 | minGrade: int = Query(), 85 | minRating: float = Query(), 86 | size: int = Query(), 87 | ): 88 | return flask.jsonify(climbdex.db.get_search_results(flask.request.args)) 89 | 90 | @blueprint.route("/api/v1//beta/") 91 | def beta(board_name, uuid): 92 | return flask.jsonify(climbdex.db.get_data(board_name, "beta", {"uuid": uuid})) 93 | 94 | @blueprint.route("/api/v1/login/", methods=["POST"]) 95 | def login(): 96 | try: 97 | login_details = boardlib.api.aurora.login( 98 | flask.request.json["board"], 99 | flask.request.json["username"], 100 | flask.request.json["password"], 101 | ) 102 | return flask.jsonify( 103 | {"token": login_details["token"], "user_id": login_details["user_id"]} 104 | ) 105 | except requests.exceptions.HTTPError as e: 106 | if e.response.status_code == requests.codes.unprocessable_entity: 107 | return ( 108 | flask.jsonify({"error": "Invalid username/password combination"}), 109 | e.response.status_code, 110 | ) 111 | else: 112 | return flask.jsonify({"error": str(e)}), e.response.status_code 113 | 114 | @blueprint.route("/api/v1/save_ascent", methods=["POST"]) 115 | @ValidateParameters(parameter_error) 116 | def api_save_ascent( 117 | board: str = Json(), 118 | climb_uuid: str = Json(), 119 | angle: int = Json(), 120 | is_mirror: bool = Json(), 121 | attempt_id: int = Json(), 122 | bid_count: int = Json(), 123 | quality: int = Json(), 124 | difficulty: int = Json(), 125 | is_benchmark: bool = Json(), 126 | comment: str = Json(), 127 | climbed_at: str = Json(), 128 | ): 129 | try: 130 | login_cookie = flask.request.cookies.get(f"{board}_login") 131 | if not login_cookie: 132 | return flask.jsonify({"error": "Login required"}), 401 133 | 134 | login_info = flask.json.loads(login_cookie) 135 | token = login_info["token"] 136 | user_id = login_info["user_id"] 137 | 138 | result = boardlib.api.aurora.save_ascent( 139 | board=board, 140 | token=token, 141 | user_id=user_id, 142 | climb_uuid=climb_uuid, 143 | angle=angle, 144 | is_mirror=is_mirror, 145 | attempt_id=attempt_id, 146 | bid_count=bid_count, 147 | quality=quality, 148 | difficulty=difficulty, 149 | is_benchmark=is_benchmark, 150 | comment=comment, 151 | climbed_at=climbed_at 152 | ) 153 | return flask.jsonify(result) 154 | except Exception as e: 155 | logging.error(f"Error in save_ascent: {str(e)}", exc_info=True) 156 | return flask.jsonify({"error": str(e)}), 500 157 | 158 | @blueprint.route("/api/v1/climbs", methods=["POST"]) 159 | @ValidateParameters(parameter_error) 160 | def api_climbs( 161 | board: str = Json(), 162 | layout_id: int = Json(), 163 | name: str = Json(), 164 | description: str = Json(), 165 | is_draft: bool = Json(), 166 | frames: str = Json(), 167 | angle: int = Json(), 168 | ): 169 | if angle == -1: 170 | angle = None 171 | 172 | try: 173 | login_cookie = flask.request.cookies.get(f"{board}_login") 174 | if not login_cookie: 175 | return flask.jsonify({"error": "Login required"}), 401 176 | 177 | login_info = flask.json.loads(login_cookie) 178 | token = login_info["token"] 179 | setter_id = login_info["user_id"] 180 | 181 | result = boardlib.api.aurora.save_climb( 182 | board=board, 183 | token=token, 184 | layout_id=layout_id, 185 | setter_id=setter_id, 186 | name=name, 187 | description=description, 188 | is_draft=is_draft, 189 | frames=frames, 190 | frames_count=1, 191 | frames_pace=0, 192 | angle=angle, 193 | ) 194 | return flask.jsonify(result) 195 | except Exception as e: 196 | logging.error(f"Error in api_climbs: {str(e)}", exc_info=True) 197 | return flask.jsonify({"error": str(e)}), 500 -------------------------------------------------------------------------------- /climbdex/views.py: -------------------------------------------------------------------------------- 1 | import boardlib.api.aurora 2 | import flask 3 | import climbdex.db 4 | import json 5 | import pandas 6 | from pathlib import Path 7 | 8 | blueprint = flask.Blueprint("view", __name__) 9 | 10 | 11 | @blueprint.route("/board-images//") 12 | def serve_board_image(board_name, filename): 13 | """Serve local board images""" 14 | script_dir = Path(__file__).parent.parent 15 | image_path = script_dir / "data" / board_name / "images" / filename 16 | 17 | if not image_path.exists(): 18 | flask.abort(404) 19 | 20 | return flask.send_file(str(image_path)) 21 | 22 | 23 | @blueprint.route("/") 24 | def index(): 25 | return flask.render_template( 26 | "boardSelection.html.j2", 27 | ) 28 | 29 | 30 | @blueprint.route("/filter") 31 | def filter(): 32 | board_name = flask.request.args.get("board") 33 | layout_id = flask.request.args.get("layout") 34 | size_id = flask.request.args.get("size") 35 | set_ids = flask.request.args.getlist("set") 36 | return flask.render_template( 37 | "filterSelection.html.j2", 38 | params=flask.request.args, 39 | board_name=board_name, 40 | layout_name=climbdex.db.get_data( 41 | board_name, "layout_name", {"layout_id": layout_id} 42 | )[0][0], 43 | size_name=climbdex.db.get_data( 44 | board_name, "size_name", {"layout_id": layout_id, "size_id": size_id} 45 | )[0][0], 46 | angles=climbdex.db.get_data(board_name, "angles", {"layout_id": layout_id}), 47 | grades=climbdex.db.get_data(board_name, "grades"), 48 | setters=climbdex.db.get_data(board_name, "setters", {"layout_id": layout_id}), 49 | colors=climbdex.db.get_data(board_name, "colors", {"layout_id": layout_id}), 50 | **get_draw_board_kwargs(board_name, layout_id, size_id, set_ids), 51 | ) 52 | 53 | 54 | @blueprint.route("/results") 55 | def results(): 56 | board_name = flask.request.args.get("board") 57 | layout_id = flask.request.args.get("layout") 58 | size_id = flask.request.args.get("size") 59 | set_ids = flask.request.args.getlist("set") 60 | login_cookie = flask.request.cookies.get(f"{board_name}_login") 61 | ticked_climbs = get_ticked_climbs(board_name, login_cookie) if login_cookie else [] 62 | attempted_climbs = get_bids(board_name, login_cookie) if login_cookie else [] 63 | placement_positions = get_placement_positions(board_name, layout_id, size_id) 64 | return flask.render_template( 65 | "results.html.j2", 66 | app_url=boardlib.api.aurora.WEB_HOSTS[board_name], 67 | colors=climbdex.db.get_data(board_name, "colors", {"layout_id": layout_id}), 68 | ticked_climbs=ticked_climbs, 69 | attempted_climbs=attempted_climbs, 70 | placement_positions=placement_positions, 71 | grades=climbdex.db.get_data(board_name, "grades"), 72 | led_colors=get_led_colors(board_name, layout_id), 73 | layout_is_mirrored=climbdex.db.layout_is_mirrored(board_name,layout_id), 74 | login_cookie=login_cookie, 75 | **get_draw_board_kwargs( 76 | board_name, 77 | layout_id, 78 | size_id, 79 | set_ids, 80 | ), 81 | ) 82 | 83 | 84 | @blueprint.route("//beta/") 85 | def beta(board_name, uuid): 86 | beta = climbdex.db.get_data(board_name, "beta", {"uuid": uuid}) 87 | climb_name = climbdex.db.get_data(board_name, "climb", {"uuid": uuid})[0][0] 88 | return flask.render_template( 89 | "beta.html.j2", 90 | beta=beta, 91 | climb_name=climb_name, 92 | ) 93 | 94 | 95 | @blueprint.route("/create") 96 | def create(): 97 | board_name = flask.request.args.get("board") 98 | layout_id = flask.request.args.get("layout") 99 | size_id = flask.request.args.get("size") 100 | set_ids = flask.request.args.getlist("set") 101 | colors = climbdex.db.get_data(board_name, "colors", {"layout_id": layout_id}) 102 | app_url = boardlib.api.aurora.WEB_HOSTS[board_name] 103 | placement_positions = get_placement_positions(board_name, layout_id, size_id) 104 | login_cookie = flask.request.cookies.get(f"{board_name}_login") 105 | return flask.render_template( 106 | "climbCreation.html.j2", 107 | app_url=app_url, 108 | board=board_name, 109 | layout_name=climbdex.db.get_data( 110 | board_name, "layout_name", {"layout_id": layout_id} 111 | )[0][0], 112 | size_name=climbdex.db.get_data( 113 | board_name, "size_name", {"layout_id": layout_id, "size_id": size_id} 114 | )[0][0], 115 | colors=colors, 116 | led_colors=get_led_colors(board_name, layout_id), 117 | placement_positions=placement_positions, 118 | angles=climbdex.db.get_data(board_name, "angles", {"layout_id": layout_id}), 119 | login_cookie=login_cookie, 120 | **get_draw_board_kwargs(board_name, layout_id, size_id, set_ids), 121 | ) 122 | 123 | 124 | def get_draw_board_kwargs(board_name, layout_id, size_id, set_ids): 125 | images_to_holds = {} 126 | for set_id in set_ids: 127 | image_filename = climbdex.db.get_data( 128 | board_name, 129 | "image_filename", 130 | {"layout_id": layout_id, "size_id": size_id, "set_id": set_id}, 131 | )[0][0] 132 | # Use local image route instead of Aurora API 133 | image_url = flask.url_for('view.serve_board_image', board_name=board_name, filename=image_filename) 134 | images_to_holds[image_url] = climbdex.db.get_data( 135 | board_name, "holds", {"layout_id": layout_id, "set_id": set_id} 136 | ) 137 | 138 | size_dimensions = climbdex.db.get_data( 139 | board_name, "size_dimensions", {"size_id": size_id} 140 | )[0] 141 | return { 142 | "images_to_holds": images_to_holds, 143 | "edge_left": size_dimensions[0], 144 | "edge_right": size_dimensions[1], 145 | "edge_bottom": size_dimensions[2], 146 | "edge_top": size_dimensions[3], 147 | } 148 | 149 | 150 | def get_ticked_climbs(board, login_cookie): 151 | login_info = flask.json.loads(login_cookie) 152 | logbook = boardlib.api.aurora.get_logbook( 153 | board, login_info["token"], login_info["user_id"] 154 | ) 155 | ticked_climbs = {} 156 | normal_tick = 0 157 | mirror_tick = 1 158 | both_tick = 2 159 | for log in logbook: 160 | key = f'{log["climb_uuid"]}-{log["angle"]}' 161 | tick_type = mirror_tick if log["is_mirror"] else normal_tick 162 | existing_tick = ticked_climbs.get(key) 163 | ticked_climbs[key] = ( 164 | both_tick 165 | if existing_tick is not None and existing_tick != tick_type 166 | else tick_type 167 | ) 168 | return ticked_climbs 169 | 170 | 171 | def get_bids(board, login_cookie): 172 | db_path = f"data/{board}/db.sqlite3" 173 | login_info = json.loads(login_cookie) 174 | full_logbook_df = boardlib.api.aurora.logbook_entries( 175 | board, token=login_info["token"], user_id=login_info["user_id"], db_path=db_path 176 | ) 177 | 178 | if full_logbook_df.empty: 179 | return pandas.DataFrame( 180 | columns=[ 181 | "climb_angle_uuid", 182 | "board", 183 | "climb_name", 184 | "date", 185 | "sessions", 186 | "tries", 187 | ] 188 | ) 189 | 190 | aggregated_logbook = ( 191 | full_logbook_df.groupby(["climb_angle_uuid", "board", "climb_name"]) 192 | .agg( 193 | date=("date", "max"), 194 | sessions=("climb_angle_uuid", "count"), 195 | tries=("tries", "sum"), 196 | ) 197 | .reset_index() 198 | ) 199 | 200 | aggregated_logbook["date"] = aggregated_logbook["date"].dt.strftime("%d/%m/%Y") 201 | 202 | aggregated_json = aggregated_logbook.to_dict(orient="records") 203 | formatted_json = { 204 | entry["climb_angle_uuid"]: { 205 | "total_tries": entry["tries"], 206 | "total_sessions": entry["sessions"], 207 | "last_try": entry["date"], 208 | } 209 | for entry in aggregated_json 210 | } 211 | 212 | return formatted_json 213 | 214 | 215 | def get_placement_positions(board_name, layout_id, size_id): 216 | binds = {"layout_id": layout_id, "size_id": size_id} 217 | return { 218 | placement_id: position 219 | for placement_id, position in climbdex.db.get_data(board_name, "leds", binds) 220 | } 221 | 222 | 223 | def get_led_colors(board_name, layout_id): 224 | binds = {"layout_id": layout_id} 225 | return { 226 | role_id: color 227 | for role_id, color in climbdex.db.get_data(board_name, "led_colors", binds) 228 | } 229 | -------------------------------------------------------------------------------- /climbdex/templates/filterSelection.html.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% include 'head.html.j2'%} 6 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | {% include 'heading.html.j2' %} 27 | {% include 'alert.html.j2' %} 28 |
29 |
30 | 156 |
157 |
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 |
62 |

63 |

Back to filter selection

64 |
65 |
66 |
67 |
68 |
69 | 70 |
71 |
79 |
80 |

81 |
82 |

83 |
84 |
85 | 86 |
87 | 88 | 96 | 121 |
122 |
123 | 124 |
125 |
126 |

127 |
128 |
129 |
130 |
131 |
132 | {% include 'footer.html.j2' %} 133 |
134 | 135 | 136 | 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