├── hike_and_run ├── tours │ ├── luisin │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ └── 3.jpg │ ├── mettelhorn │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ └── 3.jpg │ ├── monte_soglio_2025 │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ └── 3.jpg │ └── tours.json ├── index.html ├── tours.css └── tours.js ├── LICENSE ├── README.md └── tools └── gpx_processor.py /hike_and_run/tours/luisin/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nst/HikeAndRun/main/hike_and_run/tours/luisin/1.jpg -------------------------------------------------------------------------------- /hike_and_run/tours/luisin/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nst/HikeAndRun/main/hike_and_run/tours/luisin/2.jpg -------------------------------------------------------------------------------- /hike_and_run/tours/luisin/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nst/HikeAndRun/main/hike_and_run/tours/luisin/3.jpg -------------------------------------------------------------------------------- /hike_and_run/tours/mettelhorn/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nst/HikeAndRun/main/hike_and_run/tours/mettelhorn/1.jpg -------------------------------------------------------------------------------- /hike_and_run/tours/mettelhorn/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nst/HikeAndRun/main/hike_and_run/tours/mettelhorn/2.jpg -------------------------------------------------------------------------------- /hike_and_run/tours/mettelhorn/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nst/HikeAndRun/main/hike_and_run/tours/mettelhorn/3.jpg -------------------------------------------------------------------------------- /hike_and_run/tours/monte_soglio_2025/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nst/HikeAndRun/main/hike_and_run/tours/monte_soglio_2025/1.jpg -------------------------------------------------------------------------------- /hike_and_run/tours/monte_soglio_2025/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nst/HikeAndRun/main/hike_and_run/tours/monte_soglio_2025/2.jpg -------------------------------------------------------------------------------- /hike_and_run/tours/monte_soglio_2025/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nst/HikeAndRun/main/hike_and_run/tours/monte_soglio_2025/3.jpg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Nicolas Seriot 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 | -------------------------------------------------------------------------------- /hike_and_run/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hike and Run 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |

Hike and Run

14 |
15 | 16 |
17 |

https://github.com/nst/HikeAndRun

18 |
19 | 20 |
21 | 22 |
23 | 24 | Vaud 25 | Bern 26 | Uri 27 | Bas Valais 28 | Valais Central 29 | Haut Valais 30 | France 31 | Italy 32 | Ticino 33 | 34 |
35 | 36 |
37 |
38 |
39 |
40 | 41 |

Powered by Hike and Run.

42 |
43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Run & Hikes 2 | 3 | A static, lightweight web software for displaying and sharing GPX tracks. 4 | 5 | ### Features 6 | 7 | - tours list and details 8 | - GPX file download 9 | - overview map with clickable paths 10 | - 3 photos per tour 11 | 12 | ### Live Demo 13 | 14 | [https://seriot.ch/hike\_and\_run/](https://seriot.ch/hike_and_run/) 15 | 16 | ### Architecture 17 | 18 | No database. Everything runs in the browser. 19 | 20 | ``` 21 | tours.html - HTML container 22 | tours.css - stylesheet 23 | tours.js - logic to displaying maps, lists, and tours 24 | 25 | /tours/tours.json - tours index 26 | /tours/*/ - one directory per tour 27 | ``` 28 | 29 | ### Installation 30 | 31 | Copy the `hike_and_run` directory on your web server. 32 | 33 | There's no step 2. 34 | 35 | ----- 36 | 37 | ### How to Add a New Tour 38 | 39 | #### Step 1: Create the Tour Folder 40 | 41 | Pick a unique ID (eg. `my_new_hike`) and create the tour folder in `/tours/`. 42 | 43 | Example: `/tours/my_new_hike/` 44 | 45 | #### Step 2: Add the GPX File 46 | 47 | The GPX file must be named exactly the same as the folder, with a `.gpx` extension. 48 | 49 | Example: `/tours/my_new_hike/my_new_hike.gpx` 50 | 51 | I use the script [tools/gpx_processor.py](https://github.com/nst/HikeAndRun/blob/main/tools/gpx_processor.py) to: 52 | 53 | * clean a GPX file, removing timestamps 54 | * create the tour directory 55 | * create the cleaned GPX inside the tour directory 56 | * output the polyline to be copied into tours.json 57 | 58 | Depending on what you want to achieve, your workflow may vary. 59 | 60 | #### Step 3: Add Metadata to the GPX File 61 | 62 | Example: 63 | 64 | 65 | Trail del Monte Soglio 66 | https://www.trailmontesoglio.it/ 67 | 68 | Nicolas Seriot 69 | 70 | 71 | 2025 72 | 73 | 2025-06-01 74 | 75 | 76 | 77 | #### Step 4: Add the Photos 78 | 79 | Add exactly three photos, named `1.jpg`, `2.jpg`, and `3.jpg`. 80 | 81 | #### Step 5: Update `tours.json` 82 | 83 | Finally, open the main `/tours/tours.json` file and add an entry for your new tour. 84 | 85 | You will need to provide: 86 | 87 | * `"id"`: The name of the folder you created. 88 | * `"title"`: The full, display-friendly name of the tour. 89 | * `"summary_polyline"`: The encoded polyline string for the overview map. 90 | 91 | Add your new tour to the appropriate geographical category. 92 | 93 | Example: 94 | 95 | ```json 96 | [ 97 | { 98 | "category": "Vaud", 99 | "tours": [ 100 | { 101 | "id": "my_new_hike", 102 | "title": "My New Hike 2500 m", 103 | "summary_polyline": "encoded string for your new tour..." 104 | } 105 | ] 106 | }, 107 | ... 108 | ] 109 | ``` 110 | 111 | Your new tour will then appear on the main list and overview map. 112 | 113 | #### License 114 | 115 | This project is licensed under the MIT License. See the [LICENSE](https://github.com/nst/HikeAndRun/blob/main/LICENSE) file for details. 116 | -------------------------------------------------------------------------------- /hike_and_run/tours/tours.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "category": "Haut Valais", 4 | "tours": [ 5 | { 6 | "id": "mettelhorn", 7 | "title": "Mettelhorn 3406 m", 8 | "summary_polyline": "o}{wGkghn@JbA`AhAmA|BEpBf@nAe@XNl@u@nAX|@|Ar@cCdBfBbBjB@~@x@pB\\ZhA]bAj@OW|@`@KIfA|@dB|Bp@Zr@u@TTf@kAN^j@yDo@mA^BSl@|HZB_@f@b@V{@l@WmA}@|EuEjGcD`Bg@dCw@Xq@zEaAbCtBuAElAaD`Dy@cASpBsChE_AfHc@yCmB`I_@nKm@gCErF{CtQYUkApG{AdD{CDyDbFWU@jCSNe@_@[tDq@u@_@PRtAu@DeAmAQeB[VM_Cf@cCQkBqBq@?e@}@UA]eAGX}@yDuIoAk@}DgFqDeAuCgCcHIgF_D}Hq@cDoAcBRmFsDyCgAg@_AkBIuAhAcBm@q@mBq@_@mAC_AhA{ABy@q@eAG}A{@sAsCV{A[e@w@Q}HuGiIyCqAyMrCwSi@_B}@Wo@gAoBk@FoA_@]i@_EBpAbAhCGrAnBl@zBnBVj@QXPUz@|@q@UuCnSvArM~@qCv@w@S_Df@]LmBOmB]Cr@b@]XXn@UnBc@b@RzCyAlB_@tBbEz@tF`DzEnENpBnBfDlAv@~Ex@Vq@`B]bAXx@~BnAl@`B_AtB?Bh@n@Ct@jAfHjBnCNlChChIl@MPROfAj@bCnBzHXhD`Dr@IpAd@hElF`A`@dEbJuHxHwCvAoIxIoDjHg@zK}CjAcBdBsAZFj@sArCQrDhB|GCbJ{@~I{ClGXp@{@j@_Au@eBp@X^gA`@VVo@NW_@d@zCaAODrAkBwA_@jDw@sAUrCa@{@g@jEYiAm@|CSqAg@jBMs@]pAUg@]tAIg@cBz@eAxAEa@cBf@_BrCSvAt@xHoAxC`@oAKuB?~Cv@sBu@{HLcAdBiDpGwCNh@nAaCJx@d@qBVvAp@aDPhAj@kE^~@VyCz@vAVmDnBzACyAbAJk@qC\\^h@MSSfAc@Yg@lAWK_@~A`A`Ai@]u@~CsGjAqNQmFgBkFPaFZAt@qBMi@t@YzC}DhBXh@s@d@aKzAmDnDoAlANlFmCjEHkEA{BnAq@C}@~@qAM}Bj@iCjEa@fGb@kGrA_Dz@o@zBi@x@TpAcAr@JpAiAjB]zDj@hCG~CeClAGh@hCr@f@n@EScCTe@JdAt@n@RwDh@d@V[MeC\\ZdEcF~@SdBTWy@bAkA`BmIP`@Ja@rC{PJkFj@dC^qKlBaId@xCdAyHhC}DPgBx@fA|@{A~Ao@XuAoA~A_@B@o@|DsNdEqBtA{C`B{Av@}CGy@ZbA~@e@k@Qf@q@]DH]yC\\ZSi@a@^Sg@[m@Pb@wBlBmAmAsAXyA[eBcCoCuA{DsAaBlDH_AqArB{AsBkBn@cA[m@r@m@a@uAD{BfA}Bk@s@GeAaEaC`Bp@d@bA" 9 | } 10 | ] 11 | }, 12 | { 13 | "category": "Bas Valais", 14 | "tours": [ 15 | { 16 | "id": "luisin", 17 | "title": "Le Luisin 2798 m", 18 | "summary_polyline": "qnnxGi`ui@tAtCV|@L`BIfFJb@nAXf@Sh@o@j@Hp@Q|AdA|@`AJx@OfAIPO@APML[hAONMp@MLa@~@@PIZHp@ZD\t@VTBFEBTXRPd@Jp@IZH?RNHCNHLb@J\\I\\V@ZDDEB`@DDNCPNFALNLEJH@AHHAAFZLGXQJRPPZLb@KLRNNb@DXAv@N~@QA?\\EFy@g@U?BTiANJJ@r@DR_@FRb@?\\Il@Pp@?^KRD\\IBJl@A\\G@ENDl@IJ[??\\GT_@PS\\Q`Ae@v@c@N]Zg@t@EVKFAj@KC?^E@C`@Hl@OHEP@j@IHAt@WP_@n@Gf@SVGIA^MAI^Bf@WTL`@{@|@E~ALj@EPIzAUXy@]EJAZK@Sf@Cf@UJs@IWTOCURQl@}@`AEQKFCZYRAPEGHfAJTOA?PqAMDx@CBcAs@IUKJBj@WMM@@VSG?t@SQXbBOPB?M?LJGPDXKDBRCCM^KPEEBRAFKJ@HQ?Hf@G?JLENFZCLOB@RIHNZEZJXCR@KMB?VHCE??RGDBPGRDBEPGF?RHKAJFAEEBHCG@CBFABGIGJKh@IKLVAJ@GDBHZQa@C_@TY@WEC@WF@@eAJIIe@@e@OSHKCQPOC]FOKo@BEDN?OHFCILGQa@PCDKFH?[FCBYFAEG?MD@BMQ[HJH@JSGI@WMa@Ae@PP?u@PD?YJ?TNCi@LMBLPDz@r@E{@ZHBIp@FNCOu@Am@Xk@Lc@HL~@eATi@x@i@r@NPI@i@Zi@H@@k@P@h@`@\\]FeAOoCHe@p@u@NCI[PY@UGMDSROCWHIAKNKH_@NE?QJ@F_@LBPY?a@L}@CIPKKm@DCC]H??c@JB@m@LK@Yl@_A~@a@^q@\\qAl@c@Hy@RNLOEm@Pa@Mw@HAC]HECQFA?a@IO?KB@CLBMIENEQ?AW@KDIASDGUe@^IGUDIIq@EG`@M`@@DCESXIv@l@BC@a@N@Sy@FSGaAc@u@HIKc@OYSQZg@[KGGAKGYG@CSOIFQAIQBSKFEGCCa@e@QQHe@MKKDQOK?Qo@DcAM{@eA]m@WIKOEq@Bm@V]DY^s@D]LOZcAJE?QNELQ?a@J]O}@[e@kB}A}@JG?CQ[Cq@`Aa@NmAa@Me@HgFGeAUkA_@o@" 19 | } 20 | ] 21 | }, 22 | { 23 | "category": "Italy 🇮🇹", 24 | "tours": [ 25 | { 26 | "id": "monte_soglio_2025", 27 | "title": "🏁 2025 - Trail del Monte Soglio", 28 | "summary_polyline": "qrwsGg}im@kDqA}GpZMpEbD~EKdE~BpGFrG`BHnQeI~Ga@lGnIgApJhBpNjBwAzB@PsCpB{@BrDfBk@hEl@jAaKjEqEq@nJdDsAtDvJnDlC~@dC|AMtBvBpDdIjDdDzDdK`DpAtAYA|CeHvRgGzIn@nEiHxGsE|NiPhVoELiF~FoByEiCaA}Cr@uCbHoDJaFvIcBBiE~Cn@nQeFeFgAjByFoCwD}I}EnBeJyAkCfC{Aa@uJrMk@s@pBeKuCmGrDqb@iAuA{@fA}Ix@qFjDz@aJ~EaM_@sBkEo@VeLsFeE`D_BrAeFgCgEoB`@{AeAqC}JiCN{DuDS|BaCyBa@gDeBMcArQh@dCaBlIVfg@oC~HmIvFwBxKg@xWtAbWwBzUs@UhGvMjB`Q_ArEeBoC]tCiAYgAlLaDQ{AmD_A~ByAaAkBnCy@wEiDi@Rh@_HvGqC|FwBnAgKoG}F{Gg@tA`DtH`ClQ`ClDpK`GhB~\\Tlc@Vb@jCyBNxBfAg@d@xAt@m@jHpDrFq@_AhEcEwAaChAmGnN_A|Dh@fEgC~B{@vF_CjCi@lSoC~MbCdKnAtUjL`KpEnVsCbL}FxGzBpQfPz[dZnS`BjFdDVxMxGjGyBhKwJbFyHbBSb@mBfBdH~EjGfOnDdHgHhG{C~AoCx@~@|AuBnKGzFqDhC}GtFiHhCIJ}@|BtBtAgB`CXJoAtFkDfHaBpAkCbG|@tEqAzEv@lDk@tA_AxH{YnM_I`DeFBqPgDyQDiMgBgIdByFo@oAuG_@{AiEu@pBVmGiErJE_RzEuBrAd@dBi@pAiDGwH}CzCqEsC{Cv@aC{CqAt@qDzOOjI`AvHaAfCSdKkAtA}Co@uHcg@AgKeJoNg@]_@x@eBgJf@yEmAoCyAfBoET{A}@YlBmAmCsAToFeGj@iGu@eGpBsEs@iDx@_CfA{B|DsAbAaI|Bk@dB|@hAy@|C_GHuG~FwG`BsE`GxEvCiL`AWtEtHtFg@bF|AxBSlDyECq@oDz@~C_J{DmAyK{Pd@bHu@JkD_KeCo@aAgBqItDkKl@oEvEuElKuGfDuHzAkHqHgD`@qEfEaBhF{JpIO|EqD|JoBxBiDZ_DvH[`EaGxIkC~@Bi@mBRaAdDwADiDqGmSv@_EzB]rGeA?q@vBkAcGuHiKXaAsCe@h@mDm@[z@q@yFQd@yCs@_AqFt@jDcHsAy@bAsAu@SjDgBA}AaCyCqRkHaGsPcDeQ{JU{BxBeD\\eReUqDt@gFlHk@u@{@~@{BgDo@d@a@gBgJwFgFeK_IsImEkNeJ{OyMcf@wE}DiHPsEeBcCyCeEmOqHsLjDkRqAeD_Cz@xEeQfEma@sB_PyCqIkE~[}DCsDjBmDhHsF`CeAo@lA_ANeCaBk@J}AwBBaAkBzCeBfCuPeHjGyK@B}AjAg@_C{CbDoA`AeAEsBxByAeDaK~BiHyBeKyB_A_@wDLeBfCu@f@oEmC}JqCgBGwGb@{ApMwGnG~DxAeHdGeBxEo]x@rN|DzUq@`JtHhGtDvPxAv@nD}BfEDlEuKnCwAd@pMfB|DRzGmAjGBzK}D`FExIVhCpDfFv@`GGtDcAtAz@vCoBtL_D~Fr@zDo@tG\bAhD@g@xFrF^WvMh@^nA{@nBfAUnCrAdFlOcIpGqIvCWxDvB~@{@v@|@jEuH{@uFbFlADxBlAyE_BiA?{B}BeEf@{GgCsEbAcDtBtD`ENE|F|ClBzAjDuBpM`AsAvGgA~ClK`DhBeClC_@vDzAIr@nA`J_BrEeEBaCjCn@w@sDvAGLoHvD|Db@mI|CgCS_JrAcCvC}TzFyCpBsEdBK|@`Aj@yAn@qBaC{NCgLfA}AW|ChArH`@_G~EyP|DcB|@zC`Bt@nI}IxAmEVoGpCo@tF~HClFtFrCH`DnFrI@pChEe@JvAp@MfAyD~D}BnD{IfDqCr@eCq@]hJ}AbIsb@t@c@bCxA" 29 | } 30 | ] 31 | } 32 | ] -------------------------------------------------------------------------------- /hike_and_run/tours.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: 100%; 3 | overflow-y: scroll; 4 | -ms-text-size-adjust: 100%; 5 | } 6 | 7 | body { 8 | color: #222; 9 | font-family: Verdana, serif; 10 | font-size: 14px; 11 | line-height: 1.2; 12 | padding: 1em; 13 | margin: auto; 14 | max-width: 48em; 15 | background: #fefefe; 16 | } 17 | 18 | a { 19 | color: #5646ff; 20 | text-decoration: none; 21 | } 22 | 23 | a:visited { 24 | color: #3a10a3; 25 | } 26 | 27 | a:hover { 28 | color: #5646ff; 29 | text-decoration: underline; 30 | } 31 | 32 | a:active { 33 | color: #fdbf00; 34 | text-decoration: underline; 35 | } 36 | 37 | a:focus { 38 | outline: thin dotted; 39 | } 40 | 41 | *::-moz-selection { 42 | background: rgba(255, 255, 0, 0.3); 43 | color: #000; 44 | } 45 | 46 | *::selection { 47 | background: rgba(255, 255, 0, 0.3); 48 | color: #000; 49 | } 50 | 51 | a::-moz-selection { 52 | background: rgba(255, 255, 0, 0.3); 53 | color: #0645ad; 54 | } 55 | 56 | a::selection { 57 | background: rgba(255, 255, 0, 0.3); 58 | color: #0645ad; 59 | } 60 | 61 | p { 62 | margin: 1em 0; 63 | } 64 | 65 | img { 66 | max-width: 100%; 67 | } 68 | 69 | img { 70 | border: 0; 71 | -ms-interpolation-mode: bicubic; 72 | vertical-align: middle; 73 | } 74 | 75 | table { 76 | margin-bottom: 1em; 77 | border-bottom: 1px solid #ddd; 78 | border-right: 1px solid #ddd; 79 | border-spacing: 0; 80 | border-collapse: collapse; 81 | width: 100%; 82 | } 83 | 84 | table th { 85 | padding: .2em 1em; 86 | background-color: #eee; 87 | border-top: 1px solid #ddd; 88 | border-left: 1px solid #ddd; 89 | text-align: left; 90 | } 91 | 92 | table td { 93 | padding: .1em 1em; 94 | border-top: 1px solid #ddd; 95 | border-left: 1px solid #ddd; 96 | vertical-align: top; 97 | } 98 | 99 | /* ========== Base page ========== */ 100 | body { background:#fefefe; } 101 | .container { max-width:48em; margin:20px auto; } 102 | .header { text-align:left; padding:0; margin-bottom:2em; } 103 | .header h1 { font-size:2.5em; font-weight:normal; color:#111; } 104 | 105 | /* ========== Overview map ========== */ 106 | #overview-map { 107 | height:400px; width:100%; 108 | margin-bottom:2em; border-radius:4px; border:1px solid #ccc; 109 | } 110 | 111 | /* ========== Category picker ========== */ 112 | .picker-container { margin-bottom:2em; } 113 | #category-picker g { cursor:pointer; } 114 | #category-picker rect { 115 | fill:#f0f0f0; stroke:#ccc; stroke-width:1px; 116 | transition:all .2s ease-in-out; 117 | } 118 | #category-picker text { 119 | font:10px Verdana, serif; 120 | text-anchor:middle; fill:#555; pointer-events:none; 121 | } 122 | #category-picker g:hover rect { fill:#e9e9e9; } 123 | #category-picker g.selected rect { fill:#d4ebf2; stroke:#4682b4; stroke-width:2px; } 124 | #category-picker g.selected text { fill:#000; font-weight:700; } 125 | 126 | /* ========== Stats table ========== */ 127 | .stats-table { width:auto; border:none; margin-bottom:2em; white-space: pre-line; } 128 | .stats-table td { border:none; padding:.1em .5em; line-height:1.6; } 129 | .stats-table .stat-label { font-weight:700; text-align:right; } 130 | 131 | /* ========== Map + Elevation containers (hidden until data loads) ========== */ 132 | .map-container, .elevation-container { display:none; } 133 | .elevation-container { height:240px; margin-bottom:2em; } 134 | 135 | .info-panel { padding:0; } 136 | .loading, .error { text-align:center; padding:40px; color:#666; } 137 | .error { color:#dc3545; background:#f8d7da; border-radius:6px; } 138 | 139 | .map-container { 140 | width: 100%; 141 | height: 400px; 142 | } 143 | 144 | #map { 145 | width: 100%; 146 | height: 100%; 147 | border:1px solid #ccc; 148 | } 149 | 150 | /* ========== Fullscreen support (Safari/iOS + standard) ========== */ 151 | .leaflet-pseudo-fullscreen { 152 | position:fixed !important; inset:0 !important; 153 | width:100vw !important; height:100vh !important; height:100dvh !important; 154 | z-index:99999 !important; 155 | } 156 | .leaflet-container:fullscreen, 157 | .leaflet-container:-webkit-full-screen { 158 | width:100vw !important; height:100vh !important; height:100dvh !important; 159 | } 160 | 161 | /* ========== Fullscreen control (match Leaflet zoom buttons) ========== */ 162 | /* Support BOTH variants: integrated with zoom bar and separate control */ 163 | .leaflet-bar a.leaflet-control-zoom-fullscreen, 164 | .leaflet-bar a.leaflet-control-zoom-fullscreen.fullscreen-icon, 165 | .leaflet-control-fullscreen a, 166 | a.leaflet-control-fullscreen-button { 167 | display:block; 168 | box-sizing:content-box; 169 | width:30px; height:30px; line-height:28px; 170 | padding:0; text-align:center; 171 | 172 | /* same visuals as zoom buttons */ 173 | background:#fff; background-image:none; color:#000; 174 | } 175 | 176 | /* Enter fullscreen icon */ 177 | .leaflet-bar a.leaflet-control-zoom-fullscreen::before, 178 | .leaflet-bar a.leaflet-control-zoom-fullscreen.fullscreen-icon::before, 179 | .leaflet-control-fullscreen a::before, 180 | a.leaflet-control-fullscreen-button::before { 181 | font-size: 24px; 182 | content: "⛶"; /* or "⤢" */ 183 | } 184 | 185 | /* Hover effect to match Leaflet zoom buttons */ 186 | .leaflet-control-fullscreen a, 187 | a.leaflet-control-fullscreen-button, 188 | .leaflet-bar a.leaflet-control-zoom-fullscreen { 189 | transition: background-color .15s ease-in-out; 190 | cursor: pointer; 191 | } 192 | 193 | .leaflet-control-fullscreen a:hover, 194 | a.leaflet-control-fullscreen-button:hover, 195 | .leaflet-bar a.leaflet-control-zoom-fullscreen:hover { 196 | background-color: #f4f4f4; /* same as .leaflet-bar a:hover */ 197 | } 198 | 199 | /* (optional) pressed + keyboard focus states for parity */ 200 | .leaflet-control-fullscreen a:active { background-color: #eee; } 201 | .leaflet-control-fullscreen a:focus { outline: 2px solid #666; outline-offset: -2px; } 202 | 203 | /* ========== Photos ========== */ 204 | 205 | .photos-container { 206 | display: flex; 207 | justify-content: space-between; /* Aligns children (the tags) in a row */ 208 | gap: 10px; /* Adds a small space between the photos */ 209 | margin-top: 0.5em; /* Reduced space above the photos */ 210 | margin-bottom: 1em; /* Defines the space below the photos */ 211 | } 212 | 213 | /* Style for the link wrapping each image */ 214 | .photos-container a { 215 | flex-basis: calc(33.333% - 10px); /* Sets the width for each of the 3 items, accounting for the gap */ 216 | display: block; 217 | aspect-ratio: 1 / 1; /* Enforces a square shape */ 218 | } 219 | 220 | /* Style for the image itself */ 221 | .photos-container img { 222 | width: 100%; 223 | height: 100%; 224 | object-fit: cover; /* Ensures the image fills the square without being distorted */ 225 | border-radius: 6px; /* Defines rounded corners */ 226 | border: 1px solid #ddd; /* Adds a light border */ 227 | } 228 | 229 | -------------------------------------------------------------------------------- /tools/gpx_processor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Program to clean and merge multiple GPX files. 4 | 5 | This script processes one or more GPX files, cleans and merges them, 6 | and then prints a final JSON object to the console containing the track ID 7 | and a simplified polyline. All informational output is sent to stderr. 8 | """ 9 | 10 | import os 11 | import glob 12 | import xml.etree.ElementTree as ET 13 | import sys 14 | from datetime import datetime 15 | import json 16 | from rdp import rdp 17 | import polyline 18 | 19 | class GPXProcessor: 20 | """ 21 | A class to read, clean, and merge GPX files. 22 | """ 23 | def __init__(self): 24 | self.tracks = [] 25 | self.first_timestamp = None 26 | self.source_file_order = [] 27 | 28 | def read_gpx_file(self, filepath): 29 | """Read a GPX file and extract track information, including time.""" 30 | try: 31 | tree = ET.parse(filepath) 32 | root = tree.getroot() 33 | namespaces = {'gpx': 'http://www.topografix.com/GPX/1/1'} 34 | 35 | tracks_in_file = [] 36 | for trk in root.findall('.//gpx:trk', namespaces): 37 | track_data = {'name': None, 'segments': [], 'source_file': os.path.basename(filepath)} 38 | name_elem = trk.find('gpx:name', namespaces) 39 | track_data['name'] = name_elem.text if name_elem is not None and name_elem.text else os.path.splitext(os.path.basename(filepath))[0] 40 | 41 | for trkseg in trk.findall('gpx:trkseg', namespaces): 42 | segment_points = [] 43 | for trkpt in trkseg.findall('gpx:trkpt', namespaces): 44 | time_elem = trkpt.find('gpx:time', namespaces) 45 | timestamp = None 46 | if time_elem is not None and time_elem.text: 47 | try: 48 | timestamp = datetime.fromisoformat(time_elem.text.replace('Z', '+00:00')) 49 | except ValueError: 50 | print(f"Warning: Could not parse timestamp '{time_elem.text}'", file=sys.stderr) 51 | 52 | segment_points.append({ 53 | 'latitude': float(trkpt.get('lat')), 54 | 'longitude': float(trkpt.get('lon')), 55 | 'elevation': float(trkpt.find('gpx:ele', namespaces).text) if trkpt.find('gpx:ele', namespaces) is not None else None, 56 | 'time': timestamp 57 | }) 58 | if segment_points: 59 | track_data['segments'].append(segment_points) 60 | if track_data['segments']: 61 | tracks_in_file.append(track_data) 62 | 63 | if not tracks_in_file: 64 | waypoints = [] 65 | for wpt in root.findall('.//gpx:wpt', namespaces): 66 | time_elem = wpt.find('gpx:time', namespaces) 67 | timestamp = None 68 | if time_elem is not None and time_elem.text: 69 | try: 70 | timestamp = datetime.fromisoformat(time_elem.text.replace('Z', '+00:00')) 71 | except ValueError: 72 | print(f"Warning: Could not parse timestamp '{time_elem.text}'", file=sys.stderr) 73 | 74 | waypoints.append({ 75 | 'latitude': float(wpt.get('lat')), 76 | 'longitude': float(wpt.get('lon')), 77 | 'elevation': float(wpt.find('gpx:ele', namespaces).text) if wpt.find('gpx:ele', namespaces) is not None else None, 78 | 'time': timestamp 79 | }) 80 | if waypoints: 81 | tracks_in_file.append({ 82 | 'name': f"{os.path.splitext(os.path.basename(filepath))[0]} (from waypoints)", 83 | 'segments': [waypoints], 84 | 'source_file': os.path.basename(filepath) 85 | }) 86 | return tracks_in_file 87 | except Exception as e: 88 | print(f"Error reading GPX file {filepath}: {e}", file=sys.stderr) 89 | return [] 90 | 91 | def process_files(self, file_patterns): 92 | """Process GPX files, respecting the command-line order.""" 93 | ordered_gpx_paths = [] 94 | processed_paths = set() 95 | for pattern in file_patterns: 96 | files_from_pattern = sorted(glob.glob(pattern)) 97 | for filepath in files_from_pattern: 98 | if filepath not in processed_paths and filepath.lower().endswith('.gpx'): 99 | ordered_gpx_paths.append(filepath) 100 | processed_paths.add(filepath) 101 | 102 | if not ordered_gpx_paths: 103 | print("No GPX files found with the provided patterns.", file=sys.stderr) 104 | return 105 | 106 | print(f"Processing {len(ordered_gpx_paths)} GPX files...", file=sys.stderr) 107 | for filepath in ordered_gpx_paths: 108 | print(f"Reading {filepath}...", file=sys.stderr) 109 | tracks = self.read_gpx_file(filepath) 110 | 111 | if not tracks: 112 | print(f" → No tracks or waypoints found in {filepath}", file=sys.stderr) 113 | continue 114 | 115 | self.source_file_order.append(os.path.basename(filepath)) 116 | 117 | if self.first_timestamp is None: 118 | for track in tracks: 119 | for segment in track['segments']: 120 | for point in segment: 121 | if point.get('time'): 122 | self.first_timestamp = point['time'] 123 | break 124 | if self.first_timestamp: break 125 | if self.first_timestamp: break 126 | 127 | for track in tracks: 128 | total_points = sum(len(segment) for segment in track['segments']) 129 | print(f" → Track '{track['name']}': {len(track['segments'])} segments, {total_points} points", file=sys.stderr) 130 | self.tracks.extend(tracks) 131 | 132 | total_tracks = len(self.tracks) 133 | total_points = sum(sum(len(segment) for segment in track['segments']) for track in self.tracks) 134 | print(f"\nFound a total of {total_tracks} tracks with {total_points} points.", file=sys.stderr) 135 | 136 | def remove_duplicates_within_tracks(self, tolerance=0.0001): 137 | """Remove duplicate points within each track based on lat/lon tolerance.""" 138 | total_removed = 0 139 | for track in self.tracks: 140 | for segment in track['segments']: 141 | if not segment: continue 142 | unique_points = [] 143 | for point in segment: 144 | is_duplicate = False 145 | if unique_points and (abs(point['latitude'] - unique_points[-1]['latitude']) < tolerance and abs(point['longitude'] - unique_points[-1]['longitude']) < tolerance): 146 | is_duplicate = True 147 | if not is_duplicate: 148 | unique_points.append(point) 149 | removed = len(segment) - len(unique_points) 150 | total_removed += removed 151 | segment[:] = unique_points 152 | if total_removed > 0: 153 | print(f"Removed {total_removed} duplicate points.", file=sys.stderr) 154 | 155 | def export_to_gpx(self, output_file): 156 | """Export the processed tracks to a single GPX file with custom metadata.""" 157 | if not self.tracks: 158 | print("No tracks to export.", file=sys.stderr) 159 | return 160 | 161 | gpx = ET.Element('gpx', {'version': '1.1', 'creator': 'GPX Processor - seriot.ch', 'xmlns': 'http://www.topografix.com/GPX/1/1'}) 162 | 163 | metadata = ET.SubElement(gpx, 'metadata') 164 | ET.SubElement(metadata, 'name') 165 | ET.SubElement(metadata, 'desc') 166 | 167 | author = ET.SubElement(metadata, 'author') 168 | author_name = ET.SubElement(author, 'name') 169 | author_name.text = 'Nicolas Seriot' 170 | 171 | copyright_tag = ET.SubElement(metadata, 'copyright', {'author': 'seriot.ch'}) 172 | copyright_year = ET.SubElement(copyright_tag, 'year') 173 | copyright_year.text = str(datetime.now().year) 174 | 175 | keywords = ET.SubElement(metadata, 'keywords') 176 | keywords.text = self.first_timestamp.strftime('%B %Y') if self.first_timestamp else '' 177 | 178 | for track_data in self.tracks: 179 | trk = ET.SubElement(gpx, 'trk') 180 | trk_name = ET.SubElement(trk, 'name') 181 | trk_name.text = track_data['name'] 182 | for segment in track_data['segments']: 183 | if not segment: continue 184 | trkseg = ET.SubElement(trk, 'trkseg') 185 | for point in segment: 186 | trkpt = ET.SubElement(trkseg, 'trkpt', {'lat': str(point['latitude']), 'lon': str(point['longitude'])}) 187 | if point['elevation'] is not None: 188 | ET.SubElement(trkpt, 'ele').text = str(point['elevation']) 189 | 190 | self._indent(gpx) 191 | tree = ET.ElementTree(gpx) 192 | tree.write(output_file, encoding='utf-8', xml_declaration=True) 193 | print(f"\nSuccessfully exported {len(self.tracks)} track(s) to {output_file}", file=sys.stderr) 194 | 195 | def generate_polyline(self, epsilon=0.0001): 196 | """ 197 | Combines all processed track points, simplifies them, and returns an encoded polyline. 198 | """ 199 | all_points = [] 200 | for track in self.tracks: 201 | for segment in track['segments']: 202 | for point in segment: 203 | all_points.append((point['latitude'], point['longitude'])) 204 | 205 | if not all_points: 206 | return None 207 | 208 | simplified_points = rdp(all_points, epsilon=epsilon) 209 | encoded_polyline = polyline.encode(simplified_points) 210 | return encoded_polyline 211 | 212 | def _indent(self, elem, level=0): 213 | """Add pretty printing indentation to XML for readability.""" 214 | i = "\n" + level * " " 215 | if len(elem): 216 | if not elem.text or not elem.text.strip(): elem.text = i + " " 217 | if not elem.tail or not elem.tail.strip(): elem.tail = i 218 | for sub_elem in elem: self._indent(sub_elem, level + 1) 219 | if not sub_elem.tail or not sub_elem.tail.strip(): sub_elem.tail = i 220 | else: 221 | if level and (not elem.tail or not elem.tail.strip()): elem.tail = i 222 | 223 | def main(): 224 | """Main function to parse arguments and run the processor.""" 225 | if len(sys.argv) < 2: 226 | print("Usage:", file=sys.stderr) 227 | print(" To clean a single GPX file:", file=sys.stderr) 228 | print(" python gpx_processor.py ", file=sys.stderr) 229 | print(" (Output is saved to /.gpx)", file=sys.stderr) 230 | print("\n To merge multiple GPX files:", file=sys.stderr) 231 | print(" python gpx_processor.py ...", file=sys.stderr) 232 | print(" (Output is saved to /.gpx)", file=sys.stderr) 233 | return 234 | 235 | input_patterns = sys.argv[1:] 236 | processor = GPXProcessor() 237 | processor.process_files(input_patterns) 238 | 239 | if not processor.tracks: 240 | print("No valid tracks or waypoints found to process.", file=sys.stderr) 241 | return 242 | 243 | processor.remove_duplicates_within_tracks() 244 | 245 | source_files = processor.source_file_order 246 | if not source_files: 247 | print("Could not determine source files for naming the output file.", file=sys.stderr) 248 | return 249 | 250 | if len(source_files) == 1: 251 | input_basename = source_files[0] 252 | base_name = os.path.splitext(input_basename)[0] 253 | output_directory = base_name 254 | output_filename = input_basename 255 | else: 256 | base_names = [os.path.splitext(f)[0] for f in source_files] 257 | concatenated_name = "_".join(base_names) 258 | output_directory = concatenated_name 259 | output_filename = f"{concatenated_name}.gpx" 260 | 261 | try: 262 | os.makedirs(output_directory, exist_ok=True) 263 | print(f"Output will be saved in directory: '{output_directory}'", file=sys.stderr) 264 | except OSError as e: 265 | print(f"Error creating directory {output_directory}: {e}", file=sys.stderr) 266 | return 267 | 268 | final_output_path = os.path.join(output_directory, output_filename) 269 | processor.export_to_gpx(final_output_path) 270 | 271 | encoded_poly = processor.generate_polyline() 272 | 273 | # --- Generate and Print Final JSON Output to stdout --- 274 | if encoded_poly: 275 | json_object = { 276 | "id": output_directory, 277 | "title": "", 278 | "summary_polyline": encoded_poly 279 | } 280 | print(json.dumps(json_object, indent=4)) 281 | 282 | if __name__ == "__main__": 283 | main() -------------------------------------------------------------------------------- /hike_and_run/tours.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('DOMContentLoaded', () => { 2 | // -- CONFIGURATION -- 3 | const config = { 4 | apiKey: '6170aad10dfd42a38d4d8c709a536f38', 5 | trackColors: ['#ff7f00', '#984ea3', '#000000', '#f781bf', '#a65628', '#4daf4a'] 6 | }; 7 | 8 | const OVERVIEW_INIT = { center: [46.4485501, 7.2854171], zoom: 8 }; 9 | 10 | // -- STATE MANAGEMENT -- 11 | let detailMap = null; 12 | let overviewMap = null; 13 | let allToursData = []; 14 | let selectedCategories = new Set(); 15 | let allOverviewPolylines = {}; 16 | let highlightedTrack = null; 17 | let currentDetailTracks = []; 18 | let currentDetailMarkers = []; 19 | 20 | function debounce(fn, wait = 120) { 21 | let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); }; 22 | } 23 | 24 | function matchesSelectedPrefix(category) { 25 | if (selectedCategories.size === 0) return true; 26 | for (const sel of selectedCategories) { 27 | // case-sensitive; switch to toLowerCase() on both sides if you prefer CI 28 | if (category.startsWith(sel)) return true; 29 | } 30 | return false; 31 | } 32 | 33 | // --- VIEW MANAGEMENT --- 34 | function showView(viewName) { 35 | const elements = { 36 | overview: [document.getElementById('overview-map'), document.querySelector('.intro-text'), document.querySelector('.picker-container'), document.getElementById('trackInfo')], 37 | detail: [document.querySelector('.map-container'), document.querySelector('.elevation-container'), document.querySelector('.info-panel'), document.getElementById('trackInfo')] 38 | }; 39 | [...elements.overview, ...elements.detail].forEach(el => { 40 | if (el) el.style.display = 'none'; 41 | }); 42 | elements[viewName].forEach(el => { 43 | if (el) el.style.display = 'block'; 44 | }); 45 | } 46 | 47 | // --- TOUR LIST & FILTERING --- 48 | function renderTourList() { 49 | const infoPanel = document.getElementById('trackInfo'); 50 | infoPanel.className = ''; 51 | 52 | const categoriesToShow = allToursData.filter(cat => 53 | matchesSelectedPrefix(cat.category) 54 | ); 55 | 56 | let tableHTML = ''; 57 | categoriesToShow.forEach(cat => { 58 | if (cat.tours && cat.tours.length > 0) { 59 | tableHTML += ``; 60 | cat.tours.forEach(tour => { 61 | tableHTML += ``; 62 | }); 63 | } 64 | }); 65 | tableHTML += '
${cat.category}
${tour.title}
'; 66 | infoPanel.innerHTML = tableHTML; 67 | } 68 | 69 | // Debounced recenter to visible polylines; fall back to initial view if none visible 70 | const fitOverviewToVisible = debounce(() => { 71 | if (!overviewMap) return; 72 | const visibleGroups = []; 73 | for (const key in allOverviewPolylines) { 74 | const group = allOverviewPolylines[key]; 75 | if (overviewMap.hasLayer(group)) visibleGroups.push(group); 76 | } 77 | if (!visibleGroups.length) { 78 | overviewMap.setView(OVERVIEW_INIT.center, OVERVIEW_INIT.zoom); 79 | return; 80 | } 81 | const fg = L.featureGroup(visibleGroups); 82 | const bounds = fg.getBounds(); 83 | if (bounds && bounds.isValid()) { 84 | overviewMap.fitBounds(bounds, { padding: [40, 40], maxZoom: 13 }); 85 | } 86 | }, 120); 87 | 88 | function filterOverviewMap() { 89 | if (!overviewMap) return; 90 | 91 | const showAll = selectedCategories.size === 0; 92 | 93 | for (const category in allOverviewPolylines) { 94 | const group = allOverviewPolylines[category]; 95 | 96 | // substring match (change to .startsWith if you prefer prefix-only) 97 | const match = showAll || Array.from(selectedCategories) 98 | .some(sel => category.toLowerCase().includes(sel.toLowerCase())); 99 | 100 | if (match) { 101 | if (!overviewMap.hasLayer(group)) overviewMap.addLayer(group); 102 | } else { 103 | if (overviewMap.hasLayer(group)) overviewMap.removeLayer(group); 104 | } 105 | } 106 | 107 | // Recenter: initial view if no selection; otherwise fit to visible polylines 108 | if (showAll) { 109 | overviewMap.setView(OVERVIEW_INIT.center, OVERVIEW_INIT.zoom); 110 | } else { 111 | fitOverviewToVisible(); 112 | } 113 | } 114 | 115 | function setupCategoryPicker() { 116 | const picker = document.getElementById('category-picker'); 117 | picker.querySelectorAll('g').forEach(g => { 118 | g.addEventListener('click', () => { 119 | const category = g.dataset.category; 120 | g.classList.toggle('selected'); 121 | if (selectedCategories.has(category)) { 122 | selectedCategories.delete(category); 123 | } else { 124 | selectedCategories.add(category); 125 | } 126 | renderTourList(); 127 | filterOverviewMap(); 128 | }); 129 | }); 130 | } 131 | 132 | // --- MAP INITIALIZATION --- 133 | function initializeOverviewMap(tourData) { 134 | if (overviewMap) return; 135 | setTimeout(() => { 136 | overviewMap = L.map('overview-map').setView(OVERVIEW_INIT.center, OVERVIEW_INIT.zoom); 137 | 138 | // Add fullscreen control with Safari fix 139 | overviewMap.addControl(new L.Control.FullScreen({ forceSeparateButton: true, forcePseudoFullscreen: true })); 140 | overviewMap.on('enterFullscreen exitFullscreen', () => { setTimeout(() => overviewMap.invalidateSize(), 100); }); 141 | 142 | const outdoorUrl = `https://tile.thunderforest.com/outdoors/{z}/{x}/{y}{r}.png?apikey=${config.apiKey}`; 143 | const layers = { 144 | "🗺️ Standard": L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }), 145 | "🏔️ Outdoor": L.tileLayer(outdoorUrl, { attribution: '© Thunderforest, OpenStreetMap contributors' }), 146 | "🛰️ Satellite": L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: '© Esri, OpenStreetMap contributors' }) 147 | }; 148 | layers["🏔️ Outdoor"].addTo(overviewMap); 149 | L.control.layers(layers).addTo(overviewMap); 150 | 151 | // --- STYLE DEFINITIONS FOR HIGHLIGHTING --- 152 | const originalStyle = { color: "#FF0000", weight: 3, opacity: 0.7 }; 153 | const highlightStyle = { color: "#1492FF", weight: 5, opacity: 1.0 }; 154 | 155 | allOverviewPolylines = {}; 156 | tourData.forEach(cat => { 157 | const categoryPolylines = []; 158 | cat.tours.forEach(tour => { 159 | if (tour.summary_polyline) { 160 | const track = L.polyline(polyline.decode(tour.summary_polyline), originalStyle); 161 | track.bindPopup(`${tour.title}
View Details`); 162 | 163 | // --- CLICK EVENT FOR HIGHLIGHTING --- 164 | track.on('click', (e) => { 165 | // Reset the previously highlighted track if it exists 166 | if (highlightedTrack) { 167 | highlightedTrack.setStyle(originalStyle); 168 | } 169 | 170 | // Highlight the new track 171 | track.setStyle(highlightStyle); 172 | track.bringToFront(); 173 | highlightedTrack = track; 174 | 175 | // Prevent map click event from firing 176 | L.DomEvent.stopPropagation(e); 177 | }); 178 | 179 | categoryPolylines.push(track); 180 | } 181 | }); 182 | allOverviewPolylines[cat.category] = L.featureGroup(categoryPolylines).addTo(overviewMap); 183 | }); 184 | 185 | // --- MAP CLICK TO DESELECT --- 186 | overviewMap.on('click', () => { 187 | if (highlightedTrack) { 188 | highlightedTrack.setStyle(originalStyle); 189 | highlightedTrack = null; 190 | } 191 | }); 192 | 193 | }, 0); 194 | } 195 | 196 | function initializeDetailMap() { 197 | if (detailMap) return detailMap; 198 | detailMap = L.map('map', { 199 | preferCanvas: true, 200 | zoomControl: true, 201 | attributionControl: true 202 | }).setView(OVERVIEW_INIT.center, OVERVIEW_INIT.zoom); 203 | 204 | // Add fullscreen control with Safari fix 205 | detailMap.addControl(new L.Control.FullScreen({ forceSeparateButton: true, forcePseudoFullscreen: true })); 206 | detailMap.on('enterFullscreen exitFullscreen', () => { setTimeout(() => detailMap.invalidateSize(), 100); }); 207 | 208 | const outdoorUrl = `https://tile.thunderforest.com/outdoors/{z}/{x}/{y}{r}.png?apikey=${config.apiKey}`; 209 | const layers = { 210 | "🗺️ Standard": L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }), 211 | "🏔️ Outdoor": L.tileLayer(outdoorUrl, { attribution: '© Thunderforest, OpenStreetMap contributors' }), 212 | "🛰️ Satellite": L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: '© Esri, OpenStreetMap contributors' }) 213 | }; 214 | layers["🏔️ Outdoor"].addTo(detailMap); 215 | L.control.layers(layers).addTo(detailMap); 216 | return detailMap; 217 | } 218 | 219 | // --- DATA LOADING & PARSING --- 220 | async function loadTourData(tourFolder) { 221 | try { 222 | const tourConfig = {}; 223 | const basePath = `tours/${tourFolder}`; 224 | const gpxFileName = `${tourFolder}.gpx`; 225 | const sanitizedGpxName = sanitizeFileName(gpxFileName); 226 | if (!sanitizedGpxName) throw new Error(`Invalid GPX filename from folder: ${tourFolder}`); 227 | tourConfig.gpxPath = `${basePath}/${sanitizedGpxName}`; 228 | tourConfig.gpx = sanitizedGpxName; 229 | tourConfig.basePath = basePath; 230 | await loadGPXFromConfig(tourConfig); 231 | } catch (error) { 232 | document.getElementById('trackInfo').innerHTML = `
Cannot load tour data
${error.message}
`; 233 | } 234 | } 235 | 236 | async function loadGPXFromConfig(tourConfig) { 237 | try { 238 | const map = initializeDetailMap(); 239 | const response = await fetch(tourConfig.gpxPath); 240 | if (!response.ok) throw new Error(`GPX file not found: ${tourConfig.gpxPath}`); 241 | const gpxContent = await response.text(); 242 | parseAndDisplayGPX(gpxContent, tourConfig, map); 243 | } catch (error) { 244 | document.getElementById('trackInfo').innerHTML = `
Cannot load GPX file
${error.message}
`; 245 | } 246 | } 247 | 248 | function parseAndDisplayGPX(gpxContent, tourConfig, map) { 249 | try { 250 | currentDetailTracks.forEach(track => map.removeLayer(track)); 251 | currentDetailMarkers.forEach(marker => map.removeLayer(marker)); 252 | currentDetailTracks = []; 253 | currentDetailMarkers = []; 254 | 255 | const parser = new DOMParser(); 256 | const gpx = parser.parseFromString(gpxContent, "text/xml"); 257 | if (gpx.querySelector('parsererror')) throw new Error('XML parsing error'); 258 | 259 | const metadata = { 260 | nameNode: gpx.querySelector('metadata > name') || gpx.querySelector('trk > name'), 261 | descNode: gpx.querySelector('metadata > desc'), 262 | keywordsNode: gpx.querySelector('metadata > keywords') 263 | }; 264 | tourConfig.name = metadata.nameNode ? metadata.nameNode.textContent : tourConfig.gpx.replace(/\.gpx$/i, '').replace(/_/g, ' '); 265 | tourConfig.comments = metadata.descNode ? metadata.descNode.textContent : ''; 266 | tourConfig.date = metadata.keywordsNode ? metadata.keywordsNode.textContent : ''; 267 | 268 | const allTrackPoints = Array.from(gpx.querySelectorAll('trk')).map(trackNode => { 269 | return Array.from(trackNode.querySelectorAll('trkpt')).map(pt => { 270 | const lat = parseFloat(pt.getAttribute('lat')); 271 | const lon = parseFloat(pt.getAttribute('lon')); 272 | const eleElement = pt.querySelector('ele'); 273 | if (eleElement) { 274 | const ele = parseFloat(eleElement.textContent); 275 | return [lat, lon, ele]; 276 | } 277 | return null; 278 | }).filter(p => p !== null); 279 | }).filter(track => track.length > 0); 280 | 281 | if (allTrackPoints.length === 0) throw new Error('No valid track points with elevation found in GPX file.'); 282 | 283 | allTrackPoints.forEach((trackPoints, index) => { 284 | const latlngs = trackPoints.map(p => [p[0], p[1]]); 285 | const polyline = L.polyline(latlngs, { color: config.trackColors[index % config.trackColors.length], weight: 4, opacity: 0.85 }).addTo(map); 286 | currentDetailTracks.push(polyline); 287 | }); 288 | 289 | const group = L.featureGroup(currentDetailTracks); 290 | map.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: 16 }); 291 | 292 | const firstTrack = allTrackPoints[0]; 293 | const lastTrack = allTrackPoints[allTrackPoints.length - 1]; 294 | if (firstTrack.length > 0) { 295 | const startIcon = L.divIcon({ html: '🚀', className: 'start-marker', iconSize: [24, 24] }); 296 | const startMarker = L.marker(firstTrack[0], { icon: startIcon }).bindPopup('🚀 Start').addTo(map); 297 | currentDetailMarkers.push(startMarker); 298 | } 299 | if (lastTrack.length > 0) { 300 | const endIcon = L.divIcon({ html: '🏁', className: 'end-marker', iconSize: [24, 24] }); 301 | const endMarker = L.marker(lastTrack[lastTrack.length - 1], { icon: endIcon }).bindPopup('🏁 Finish').addTo(map); 302 | currentDetailMarkers.push(endMarker); 303 | } 304 | 305 | createElevationChart(allTrackPoints); 306 | displayTrackStats(allTrackPoints.flat(), tourConfig); 307 | } catch (error) { 308 | document.getElementById('trackInfo').innerHTML = `
Error processing GPX file
${error.message}
`; 309 | } 310 | } 311 | 312 | // --- UI DISPLAY --- 313 | function displayTrackStats(trackPoints, tourConfig) { 314 | const stats = calculateStats(trackPoints); 315 | document.title = tourConfig.name || 'Tour'; 316 | document.getElementById('tour-title').textContent = tourConfig.name || 'Tour'; 317 | 318 | const statsContainer = document.getElementById('trackInfo'); 319 | statsContainer.className = 'stats'; 320 | 321 | const linkedComments = tourConfig.comments ? linkify(tourConfig.comments) : '-'; 322 | 323 | statsContainer.innerHTML = ` 324 | 325 | 326 | 327 | 328 | 329 | 330 |
Distance:${stats.distance.toFixed(2)} km
Elevation:${stats.elevationGain.toFixed(0)} D+
Date:${tourConfig.date || '-'}
Comments:${linkedComments}
GPX file:${tourConfig.gpx}
331 | `; 332 | 333 | const additionalInfoPanel = document.getElementById('additionalInfo'); 334 | let photoHTML = '
'; 335 | for (let i = 1; i <= 3; i++) { 336 | photoHTML += `Photo ${i}`; 337 | } 338 | photoHTML += '
'; 339 | additionalInfoPanel.innerHTML = photoHTML; 340 | } 341 | 342 | function createElevationChart(allTrackPoints) { 343 | const elevationContainer = document.getElementById('elevation'); 344 | const stats = calculateStats(allTrackPoints.flat()); 345 | 346 | function renderChart() { 347 | const containerWidth = elevationContainer.offsetWidth; 348 | const width = Math.max(300, containerWidth - 30); 349 | const height = 190; 350 | const padding = 45; 351 | 352 | const flatPoints = allTrackPoints.flat(); 353 | const elevations = flatPoints.map(point => point[2]); 354 | const minEle = Math.min(...elevations); 355 | const maxEle = Math.max(...elevations); 356 | const eleRange = maxEle - minEle || 100; 357 | 358 | const numTicks = 6; 359 | const tickStep = Math.ceil(eleRange / numTicks / 50) * 50; 360 | const minTick = Math.floor(minEle / tickStep) * tickStep; 361 | const maxTick = Math.ceil(maxEle / tickStep) * tickStep; 362 | 363 | function getDistanceStep(totalDistance) { 364 | if (totalDistance <= 5) return 1; 365 | else if (totalDistance <= 10) return 2; 366 | else if (totalDistance <= 25) return 5; 367 | else return 10; 368 | } 369 | 370 | const distanceStep = getDistanceStep(stats.distance); 371 | const numDistanceSteps = Math.ceil(stats.distance / distanceStep); 372 | 373 | let allPaths = ''; 374 | let trackSeparators = ''; 375 | const pathPoints = []; 376 | let cumulativeDistance = 0; 377 | 378 | allTrackPoints.forEach((trackPoints, trackIndex) => { 379 | if (trackPoints.length === 0) return; 380 | 381 | const trackColor = config.trackColors[trackIndex % config.trackColors.length]; 382 | let pathData = ''; 383 | const trackPathPoints = []; 384 | 385 | trackPoints.forEach((point, pointIndex) => { 386 | const ele = point[2]; 387 | if (pointIndex > 0) { 388 | const prevPoint = trackPoints[pointIndex - 1]; 389 | cumulativeDistance += calculateDistance(prevPoint[0], prevPoint[1], point[0], point[1]) / 1000; 390 | } 391 | 392 | const x = padding + (cumulativeDistance / stats.distance) * (width - 2 * padding); 393 | const y = height - padding - ((ele - minEle) / eleRange) * (height - 2 * padding); 394 | 395 | trackPathPoints.push({ x, y, trackIndex, elevation: ele, trackPoint: point, distance: cumulativeDistance }); 396 | if (pointIndex === 0) { pathData += `M ${x} ${y}`; } else { pathData += ` L ${x} ${y}`; } 397 | }); 398 | 399 | pathPoints.push(...trackPathPoints); 400 | allPaths += ``; 401 | 402 | if (trackIndex > 0 && trackPathPoints.length > 0) { 403 | const separatorX = trackPathPoints[0].x; 404 | trackSeparators += ``; 405 | } 406 | }); 407 | 408 | let gridLines = ''; 409 | let elevationLabels = ''; 410 | for (let tick = minTick; tick <= maxTick; tick += tickStep) { 411 | if (tick >= minEle && tick <= maxEle) { 412 | const y = height - padding - ((tick - minEle) / eleRange) * (height - 2 * padding); 413 | gridLines += ``; 414 | elevationLabels += `${tick.toFixed(0)}m`; 415 | } 416 | } 417 | 418 | let distanceGridLines = ''; 419 | let distanceLabels = ''; 420 | for (let i = 0; i <= numDistanceSteps; i++) { 421 | const distance = i * distanceStep; 422 | if (distance <= stats.distance) { 423 | const x = padding + (distance / stats.distance) * (width - 2 * padding); 424 | distanceGridLines += ``; 425 | distanceLabels += `${distance.toFixed(0)}km`; 426 | } 427 | } 428 | 429 | elevationContainer.innerHTML = `
${gridLines}${distanceGridLines}${allPaths}${trackSeparators}${elevationLabels}${distanceLabels}Altitude (m)Distance (km)
Elevation +: ${stats.elevationGain.toFixed(0)}mMin/Max: ${minEle.toFixed(0)}m / ${maxEle.toFixed(0)}mTracks: ${allTrackPoints.length}
`; 430 | addChartInteractivity(pathPoints, width, padding); 431 | } 432 | window.renderElevationChart = renderChart; 433 | renderChart(); 434 | } 435 | 436 | function addChartInteractivity(pathPoints, width, padding) { 437 | const svg = document.getElementById('elevation-chart'); 438 | const overlay = document.getElementById('chart-overlay'); 439 | const hoverLine = document.getElementById('hover-line'); 440 | const hoverCircle = document.getElementById('hover-circle'); 441 | const tooltip = document.getElementById('elevation-tooltip'); 442 | if (!svg || !overlay) return; 443 | 444 | let hoverMarker = null; 445 | overlay.addEventListener('mousemove', (e) => { 446 | const rect = svg.getBoundingClientRect(); 447 | const mouseX = e.clientX - rect.left; 448 | if (mouseX >= padding && mouseX <= rect.width - padding) { 449 | let closestPoint = pathPoints[0]; 450 | let minDiff = Infinity; 451 | for (const point of pathPoints) { 452 | const diff = Math.abs(point.x - mouseX); 453 | if (diff < minDiff) { minDiff = diff; closestPoint = point; } 454 | } 455 | if (closestPoint) { 456 | const hoverColor = config.trackColors[closestPoint.trackIndex % config.trackColors.length]; 457 | hoverLine.setAttribute('x1', closestPoint.x); 458 | hoverLine.setAttribute('x2', closestPoint.x); 459 | hoverLine.setAttribute('stroke', hoverColor); 460 | hoverLine.setAttribute('opacity', '0.7'); 461 | hoverCircle.setAttribute('cx', closestPoint.x); 462 | hoverCircle.setAttribute('cy', closestPoint.y); 463 | hoverCircle.setAttribute('fill', hoverColor); 464 | hoverCircle.setAttribute('opacity', '1'); 465 | document.getElementById('tooltip-elevation').textContent = `${closestPoint.elevation.toFixed(0)}m`; 466 | document.getElementById('tooltip-distance').textContent = `${closestPoint.distance.toFixed(2)}km`; 467 | document.getElementById('tooltip-track').textContent = `Track ${closestPoint.trackIndex + 1}`; 468 | tooltip.style.left = Math.min(closestPoint.x, width - 120) + 'px'; 469 | tooltip.style.top = Math.max(closestPoint.y - 70, 10) + 'px'; 470 | tooltip.style.opacity = '1'; 471 | 472 | if (currentDetailTracks.length > 0 && closestPoint.trackPoint) { 473 | if (hoverMarker) detailMap.removeLayer(hoverMarker); 474 | hoverMarker = L.circleMarker([closestPoint.trackPoint[0], closestPoint.trackPoint[1]], { radius: 6, color: hoverColor, fillColor: hoverColor, fillOpacity: 0.8, weight: 2 }).addTo(detailMap); 475 | } 476 | } 477 | } 478 | }); 479 | overlay.addEventListener('mouseleave', () => { 480 | hoverLine.setAttribute('opacity', '0'); 481 | hoverCircle.setAttribute('opacity', '0'); 482 | tooltip.style.opacity = '0'; 483 | if (hoverMarker) { detailMap.removeLayer(hoverMarker); hoverMarker = null; } 484 | }); 485 | } 486 | 487 | // --- UTILITY FUNCTIONS --- 488 | function linkify(text) { 489 | const urlRegex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig; 490 | return text.replace(urlRegex, (url) => { 491 | return `${url}`; 492 | }); 493 | } 494 | 495 | function calculateStats(trackPoints) { 496 | let distance = 0, elevationGain = 0; 497 | let maxElevation = -Infinity, minElevation = Infinity; 498 | for (let i = 0; i < trackPoints.length; i++) { 499 | const ele = trackPoints[i][2]; 500 | if (ele) { maxElevation = Math.max(maxElevation, ele); minElevation = Math.min(minElevation, ele); } 501 | if (i > 0) { 502 | distance += calculateDistance(trackPoints[i - 1][0], trackPoints[i - 1][1], trackPoints[i][0], trackPoints[i][1]); 503 | const eleDiff = trackPoints[i][2] - trackPoints[i - 1][2]; 504 | if (eleDiff > 0) elevationGain += eleDiff; 505 | } 506 | } 507 | return { distance: distance / 1000, elevationGain, maxElevation, minElevation }; 508 | } 509 | 510 | function calculateDistance(lat1, lon1, lat2, lon2) { 511 | const R = 6371e3; 512 | const φ1 = lat1 * Math.PI / 180, φ2 = lat2 * Math.PI / 180; 513 | const Δφ = (lat2 - lat1) * Math.PI / 180, Δλ = (lon2 - lon1) * Math.PI / 180; 514 | const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); 515 | const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 516 | return R * c; 517 | } 518 | 519 | function getTourFolder() { 520 | const urlParams = new URLSearchParams(window.location.search); 521 | const params = Array.from(urlParams.keys()); 522 | return params.length > 0 ? sanitizeFolderName(params[0]) : null; 523 | } 524 | 525 | function sanitizeFolderName(folderName) { 526 | if (!folderName || typeof folderName !== 'string') return null; 527 | const sanitized = folderName.replace(/\.\./g, '').replace(/[/\\]/g, '').replace(/[<>:"|?*]/g, '').trim(); 528 | if (!/^[a-zA-Z0-9_-]+$/.test(sanitized)) return null; 529 | return sanitized.length > 0 ? sanitized : null; 530 | } 531 | 532 | function sanitizeFileName(fileName) { 533 | if (!fileName || typeof fileName !== 'string') return null; 534 | const sanitized = fileName.replace(/\.\./g, '').replace(/[/\\]/g, '').replace(/[<>:"|?*]/g, '').trim(); 535 | if (!/^[a-zA-Z0-9._-]+$/.test(sanitized) || !sanitized.toLowerCase().endsWith('.gpx') || sanitized.length <= 4) return null; 536 | return sanitized; 537 | } 538 | 539 | // --- MAIN EXECUTION LOGIC --- 540 | async function main() { 541 | const tourFolder = getTourFolder(); 542 | if (tourFolder) { 543 | showView('detail'); 544 | loadTourData(tourFolder); 545 | } else { 546 | showView('overview'); 547 | document.title = 'Hike and Run'; 548 | try { 549 | const response = await fetch('tours/tours.json'); 550 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 551 | allToursData = await response.json(); 552 | initializeOverviewMap(allToursData); 553 | renderTourList(); 554 | setupCategoryPicker(); 555 | } catch (error) { 556 | console.error("Could not load and render tour list:", error); 557 | document.getElementById('trackInfo').innerHTML = `
Could not load tour list. Please check that a valid tours/tours.json file exists.
`; 558 | } 559 | } 560 | } 561 | 562 | window.addEventListener('resize', () => { 563 | if (window.renderElevationChart) { setTimeout(() => { window.renderElevationChart(); }, 100); } 564 | if (detailMap) { setTimeout(() => { detailMap.invalidateSize(); }, 100); } 565 | if (overviewMap) { setTimeout(() => { overviewMap.invalidateSize(); }, 100); } 566 | }); 567 | 568 | main(); 569 | }); --------------------------------------------------------------------------------