Initial import of sotlas-frontend v1.9.2
|
@ -0,0 +1,5 @@
|
|||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
|
@ -0,0 +1,22 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
src/compiled-icons
|
|
@ -0,0 +1,2 @@
|
|||
@fortawesome:registry=https://npm.fontawesome.com/
|
||||
//npm.fontawesome.com/:_authToken=$NPM_FONTAWESOME_TOKEN
|
|
@ -0,0 +1,29 @@
|
|||
# sotlas
|
||||
|
||||
## Project setup
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run your tests
|
||||
```
|
||||
npm run test
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/app'
|
||||
]
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"name": "sotlas",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint",
|
||||
"generate-icons": "vsvg -s ./svg-icons -t ./src/compiled-icons",
|
||||
"deploy": "gzip -fk9 dist/js/*.js dist/css/*.css && rsync -av dist/* root@vs5.ksys.ch:/data/www",
|
||||
"deploy:beta": "gzip -fk9 dist/js/*.js dist/css/*.css && rsync -av --delete dist/* root@vs5.ksys.ch:/data/www/beta"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dsb-norge/vue-keycloak-js": "github:manuelkasper/vue-keycloak-js#sotlas",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.11.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
||||
"@fortawesome/pro-regular-svg-icons": "^5.11.2",
|
||||
"@fortawesome/pro-solid-svg-icons": "^5.11.2",
|
||||
"@fortawesome/vue-fontawesome": "^0.1.7",
|
||||
"@mapbox/mapbox-gl-draw": "github:manuelkasper/mapbox-gl-draw#sotlas",
|
||||
"@mapbox/togeojson": "^0.16.0",
|
||||
"@tmcw/togeojson": "^3.2.0",
|
||||
"axios": "^0.19.0",
|
||||
"buefy": "^0.8.15",
|
||||
"cheap-ruler": "^2.5.1",
|
||||
"core-js": "^2.6.10",
|
||||
"filepond": "^4.13.1",
|
||||
"filepond-plugin-file-validate-type": "^1.2.5",
|
||||
"flagpack": "^1.0.4",
|
||||
"frappe-charts": "^1.3.0",
|
||||
"haversine-distance": "^1.1.6",
|
||||
"maidenhead": "^1.0.7",
|
||||
"mapbox-gl": "^1.8.1",
|
||||
"moment": "^2.24.0",
|
||||
"node-vincenty": "0.0.6",
|
||||
"photoswipe": "^4.1.3",
|
||||
"togpx": "^0.5.4",
|
||||
"vue": "^2.6.10",
|
||||
"vue-clipboard2": "^0.3.1",
|
||||
"vue-debounce": "^2.5.0",
|
||||
"vue-filepond": "^6.0.2",
|
||||
"vue-infinite-loading": "^2.4.4",
|
||||
"vue-lazy-youtube-video": "^2.0.0",
|
||||
"vue-mapbox": "github:manuelkasper/vue-mapbox#fix-layer-remove",
|
||||
"vue-match-media": "^1.0.3",
|
||||
"vue-native-websocket": "^2.0.13",
|
||||
"vue-router": "^3.1.3",
|
||||
"vuedraggable": "^2.23.2",
|
||||
"vuex": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^3.12.1",
|
||||
"@vue/cli-plugin-eslint": "^3.12.1",
|
||||
"@vue/cli-service": "^3.12.1",
|
||||
"@vue/eslint-config-standard": "^4.0.0",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-plugin-vue": "^5.2.3",
|
||||
"git-revision-webpack-plugin": "^3.0.4",
|
||||
"node-sass": "^4.13.0",
|
||||
"sass-loader": "^7.3.1",
|
||||
"vue-svgicon": "^3.2.6",
|
||||
"vue-template-compiler": "^2.6.10"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/essential",
|
||||
"@vue/standard"
|
||||
],
|
||||
"rules": {},
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
},
|
||||
"globals": {
|
||||
"VERSION": true,
|
||||
"COMMITHASH": true,
|
||||
"BRANCH": true
|
||||
}
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"autoprefixer": {}
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions"
|
||||
]
|
||||
}
|
Po Szerokość: | Wysokość: | Rozmiar: 2.4 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 10 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 2.4 KiB |
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
Po Szerokość: | Wysokość: | Rozmiar: 724 B |
Po Szerokość: | Wysokość: | Rozmiar: 959 B |
Po Szerokość: | Wysokość: | Rozmiar: 15 KiB |
|
@ -0,0 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="<%= BASE_URL %>apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="<%= BASE_URL %>favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="<%= BASE_URL %>favicon-16x16.png">
|
||||
<link rel="manifest" href="<%= BASE_URL %>site.webmanifest">
|
||||
<link rel="mask-icon" href="<%= BASE_URL %>safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#da532c">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<title>SOTLAS</title>
|
||||
</head>
|
||||
<body class="has-navbar-fixed-top is-size-6 is-size-7-mobile">
|
||||
<noscript>
|
||||
<p>SOTLAS (SOTA Atlas) is an online map of Summits On the Air (SOTA) summits with detailed information about summits, activators, spots and alerts.</p>
|
||||
<strong>We're sorry but SOTLAS doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="oldbrowser" style="display: none; margin: 1em">You are using a browser that is too old to load this website. Please upgrade to a more modern browser.</div>
|
||||
<script type="text/javascript">
|
||||
function checkBrowser() {
|
||||
try {
|
||||
eval("var bar = (x) => x+1");
|
||||
} catch (e) { return false; }
|
||||
|
||||
return true;
|
||||
}
|
||||
if (!checkBrowser()) {
|
||||
document.getElementById('oldbrowser').style.display = 'block';
|
||||
}
|
||||
</script>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
Po Szerokość: | Wysokość: | Rozmiar: 1.9 KiB |
|
@ -0,0 +1,122 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="1152.000000pt" height="1152.000000pt" viewBox="0 0 1152.000000 1152.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,1152.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M5269 11516 c-2 -2 -49 -7 -104 -11 -55 -3 -111 -8 -125 -9 -14 -2
|
||||
-56 -7 -95 -11 -38 -4 -77 -8 -85 -10 -8 -1 -40 -6 -70 -10 -30 -4 -64 -9 -75
|
||||
-11 -35 -7 -69 -13 -110 -20 -22 -3 -44 -7 -50 -9 -13 -3 -76 -15 -95 -19 -8
|
||||
-2 -26 -6 -40 -9 -14 -4 -36 -9 -50 -12 -44 -9 -103 -23 -185 -44 -44 -12 -93
|
||||
-24 -110 -27 -16 -3 -38 -10 -47 -15 -10 -5 -18 -7 -18 -3 0 3 -7 1 -15 -6 -8
|
||||
-7 -15 -10 -15 -7 0 4 -57 -14 -127 -39 -71 -25 -132 -47 -138 -48 -12 -4 -39
|
||||
-14 -85 -34 -19 -8 -40 -16 -47 -18 -28 -6 -411 -201 -423 -215 -3 -3 -16 -12
|
||||
-30 -19 -63 -33 -266 -174 -337 -233 -83 -70 -136 -118 -199 -181 -62 -62 -96
|
||||
-100 -188 -206 -75 -87 -217 -297 -277 -410 -18 -36 -38 -68 -44 -72 -5 -4 -6
|
||||
-8 -1 -8 5 0 4 -6 -2 -12 -29 -36 -113 -267 -152 -417 -36 -136 -43 -167 -55
|
||||
-251 -2 -19 -7 -46 -9 -60 -18 -93 -29 -266 -29 -470 1 -197 8 -318 29 -450 3
|
||||
-19 7 -44 8 -55 3 -22 19 -117 21 -125 1 -3 5 -18 9 -35 23 -97 83 -289 99
|
||||
-320 6 -11 12 -25 13 -31 1 -7 13 -35 28 -63 14 -28 23 -51 20 -51 -3 0 2 -7
|
||||
10 -16 9 -8 16 -22 16 -30 0 -8 5 -14 10 -14 6 0 10 -6 10 -14 0 -7 9 -25 21
|
||||
-40 11 -14 18 -26 14 -26 -3 0 -1 -6 5 -12 6 -7 32 -43 58 -79 51 -72 91 -126
|
||||
107 -143 6 -6 31 -36 56 -66 83 -98 265 -271 368 -350 18 -14 38 -30 45 -36
|
||||
23 -21 243 -164 306 -198 92 -51 345 -174 405 -198 27 -11 66 -26 85 -35 139
|
||||
-60 359 -135 615 -208 92 -26 474 -125 588 -151 9 -2 25 -6 35 -8 28 -7 195
|
||||
-47 217 -52 22 -6 53 -12 88 -18 12 -2 55 -12 96 -21 40 -9 80 -19 90 -20 9
|
||||
-2 16 -4 16 -5 0 -1 11 -3 25 -6 27 -5 111 -21 150 -30 14 -3 39 -8 55 -11 46
|
||||
-8 60 -11 60 -13 0 -1 9 -3 20 -4 11 -1 37 -6 57 -10 21 -4 40 -7 43 -7 2 0
|
||||
19 -5 37 -10 18 -5 53 -12 77 -15 25 -3 48 -7 52 -10 6 -3 135 -32 224 -49 60
|
||||
-12 565 -129 645 -150 61 -15 270 -67 290 -71 20 -4 171 -45 215 -58 6 -2 24
|
||||
-6 40 -11 231 -60 663 -236 888 -362 31 -17 57 -29 57 -25 0 4 4 3 8 -3 4 -5
|
||||
27 -21 51 -34 131 -71 328 -218 444 -330 37 -36 72 -67 77 -69 6 -2 10 -8 10
|
||||
-13 0 -6 16 -27 36 -47 21 -20 70 -82 111 -137 40 -55 78 -105 83 -112 31 -39
|
||||
150 -293 150 -322 0 -9 4 -21 9 -27 19 -21 66 -236 87 -394 13 -106 19 -389
|
||||
10 -485 -3 -22 -7 -72 -10 -110 -4 -39 -9 -84 -12 -100 -19 -114 -28 -157 -45
|
||||
-224 -11 -42 -21 -83 -23 -91 -10 -38 -50 -146 -59 -157 -5 -7 -7 -13 -3 -13
|
||||
4 0 -1 -15 -11 -32 -9 -18 -18 -35 -18 -38 -4 -25 -124 -233 -137 -238 -4 -2
|
||||
-8 -8 -8 -13 0 -15 -100 -153 -188 -259 -45 -53 -209 -220 -272 -274 -81 -70
|
||||
-231 -178 -339 -243 -58 -35 -113 -68 -121 -73 -26 -16 -234 -117 -267 -130
|
||||
-18 -7 -33 -17 -33 -23 0 -6 -2 -8 -5 -5 -3 3 -41 -8 -83 -24 -91 -35 -247
|
||||
-87 -282 -94 -38 -9 -41 -10 -90 -23 -25 -7 -52 -15 -60 -16 -67 -15 -272 -57
|
||||
-299 -61 -18 -3 -43 -8 -55 -10 -40 -8 -199 -31 -273 -39 -106 -12 -143 -15
|
||||
-258 -22 -238 -13 -655 -3 -902 22 -29 3 -79 7 -110 10 -32 3 -76 8 -98 12
|
||||
-22 3 -53 7 -70 9 -36 3 -97 12 -145 20 -19 3 -44 7 -55 9 -81 13 -102 17
|
||||
-119 22 -11 3 -34 7 -50 9 -25 2 -211 42 -276 58 -80 20 -232 65 -270 78 -8 3
|
||||
-51 18 -95 33 -164 57 -448 187 -596 272 -134 78 -364 233 -389 262 -3 3 -27
|
||||
24 -55 46 -48 38 -277 264 -323 318 -12 15 -22 32 -22 37 0 6 -4 10 -8 10 -17
|
||||
0 -234 308 -247 351 -4 10 -11 19 -16 19 -6 0 -8 4 -4 9 3 5 1 12 -5 16 -5 3
|
||||
-33 54 -63 113 -45 90 -72 154 -127 297 -10 26 -50 156 -54 175 -2 8 -6 26 -9
|
||||
40 -4 14 -9 36 -12 50 -2 14 -8 41 -13 60 -5 19 -9 38 -10 43 -3 17 -12 74
|
||||
-17 102 -3 17 -8 50 -10 75 -3 25 -7 61 -10 80 -12 86 -18 231 -19 430 l0 216
|
||||
-339 0 -339 0 4 -223 c1 -123 5 -223 8 -223 3 0 6 -28 8 -63 1 -34 4 -80 7
|
||||
-102 3 -22 8 -62 11 -90 8 -83 26 -197 48 -320 15 -78 71 -302 86 -340 4 -11
|
||||
20 -58 35 -105 68 -208 162 -407 293 -620 26 -41 49 -77 52 -80 3 -3 11 -17
|
||||
19 -33 8 -15 18 -27 23 -27 4 0 8 -5 8 -10 0 -10 37 -67 51 -80 3 -3 18 -23
|
||||
34 -45 17 -22 32 -42 36 -45 3 -3 12 -15 20 -26 60 -89 359 -393 479 -489 41
|
||||
-33 81 -66 89 -73 19 -18 266 -183 311 -208 19 -10 37 -21 40 -24 5 -5 32 -20
|
||||
160 -87 47 -24 89 -49 95 -54 5 -5 15 -9 21 -9 7 0 48 -16 91 -36 176 -82 407
|
||||
-165 593 -215 41 -11 100 -27 130 -35 30 -8 64 -17 75 -19 11 -3 56 -14 100
|
||||
-24 72 -18 310 -68 360 -76 17 -2 78 -12 188 -30 50 -9 82 -13 142 -20 32 -4
|
||||
68 -9 80 -11 11 -2 47 -6 80 -9 33 -2 78 -7 100 -9 43 -6 113 -12 290 -23 237
|
||||
-15 790 -8 965 12 19 2 60 6 90 9 30 4 66 8 80 10 14 3 45 7 70 10 25 4 56 8
|
||||
70 11 14 2 68 11 120 20 52 9 97 18 100 20 3 2 21 6 40 9 59 9 339 75 450 106
|
||||
169 47 317 97 415 140 22 9 60 25 85 36 56 23 406 200 423 214 6 5 62 42 122
|
||||
81 108 71 255 177 286 208 9 9 38 34 65 56 78 63 272 262 357 365 106 129 291
|
||||
399 289 420 0 3 5 12 12 20 15 18 71 133 67 139 -1 2 1 7 6 10 16 11 115 282
|
||||
128 350 2 13 7 31 10 41 3 10 8 27 10 37 3 10 9 36 14 58 6 22 12 56 16 75 3
|
||||
19 7 46 10 60 12 67 17 99 20 140 2 25 4 45 5 45 1 0 5 38 8 85 14 171 6 622
|
||||
-12 715 -2 11 -6 41 -10 66 -29 202 -84 406 -154 574 -38 92 -125 251 -195
|
||||
357 -106 161 -181 252 -322 393 -112 112 -144 140 -230 205 -33 25 -62 47 -65
|
||||
50 -3 3 -34 24 -70 47 -36 24 -70 47 -77 53 -7 5 -15 10 -18 10 -4 0 -26 14
|
||||
-50 30 -24 17 -48 30 -53 30 -6 0 -12 4 -14 9 -3 9 -98 63 -208 118 -41 21
|
||||
-77 41 -78 46 -2 4 -8 5 -13 1 -5 -3 -9 0 -9 5 0 6 -3 10 -7 9 -5 -1 -39 13
|
||||
-78 31 -38 18 -92 42 -120 53 -27 11 -63 25 -80 33 -88 38 -323 118 -485 164
|
||||
-58 16 -126 36 -152 44 -27 8 -48 13 -48 11 0 -3 -13 2 -30 11 -16 8 -31 14
|
||||
-32 13 -3 -2 -92 20 -118 29 -8 3 -22 7 -30 9 -26 5 -144 36 -160 41 -8 4 -21
|
||||
7 -29 9 -50 10 -120 27 -141 34 -22 7 -106 26 -150 34 -8 2 -22 6 -30 9 -8 3
|
||||
-49 13 -90 21 -41 9 -106 23 -145 32 -38 8 -81 17 -95 19 -14 2 -70 13 -125
|
||||
24 -55 12 -111 23 -125 26 -14 3 -45 10 -70 15 -25 5 -56 12 -70 14 -14 2 -59
|
||||
11 -100 20 -41 8 -86 17 -100 20 -14 2 -32 7 -40 10 -8 3 -31 8 -50 11 -66 11
|
||||
-76 14 -285 61 -11 2 -33 7 -50 10 -16 2 -46 9 -65 14 -19 5 -46 11 -60 14
|
||||
-14 2 -119 25 -234 51 -115 26 -216 48 -225 50 -38 8 -217 55 -301 79 -30 9
|
||||
-62 18 -70 20 -33 8 -307 99 -335 111 -16 7 -86 34 -155 60 -148 57 -296 127
|
||||
-409 192 -45 27 -92 53 -104 60 -12 7 -31 19 -42 27 -11 8 -56 40 -100 71 -93
|
||||
66 -193 153 -285 246 -105 106 -200 219 -200 237 0 6 -4 12 -9 14 -16 6 -136
|
||||
210 -130 220 1 2 -7 21 -18 43 -11 22 -26 58 -32 80 -7 22 -16 49 -21 60 -9
|
||||
22 -23 72 -25 95 -1 8 -6 26 -10 40 -5 14 -9 34 -11 45 -1 11 -8 46 -14 78 -6
|
||||
32 -13 85 -14 116 -2 32 -5 62 -8 67 -12 23 0 413 17 534 7 54 18 116 22 135
|
||||
3 11 6 29 8 40 6 34 14 66 20 85 3 10 8 27 10 37 10 48 62 187 100 268 35 76
|
||||
139 255 167 290 5 6 32 42 60 80 84 112 257 295 348 365 25 19 50 38 55 43 45
|
||||
36 129 95 173 121 30 17 75 44 100 59 59 35 279 145 352 175 47 20 266 99 295
|
||||
106 6 1 30 8 55 15 25 7 52 15 60 17 8 2 51 12 95 23 44 10 97 22 119 25 21 4
|
||||
42 8 46 11 4 2 27 7 51 9 24 3 54 7 65 9 42 10 161 28 214 32 19 1 55 5 80 9
|
||||
48 6 106 12 235 22 88 8 489 7 600 -1 97 -6 203 -15 245 -19 19 -3 62 -7 95
|
||||
-11 33 -3 74 -8 90 -11 45 -8 97 -15 128 -18 15 -2 30 -6 34 -10 3 -3 18 -6
|
||||
32 -6 14 0 76 -12 136 -26 61 -14 117 -27 126 -29 220 -49 597 -175 754 -252
|
||||
168 -83 241 -123 345 -192 47 -31 90 -58 95 -60 6 -2 62 -46 125 -97 171 -137
|
||||
322 -292 438 -448 29 -39 59 -78 65 -86 57 -72 216 -365 271 -500 15 -36 34
|
||||
-79 42 -97 8 -17 14 -40 14 -50 0 -11 3 -23 8 -27 4 -4 7 -11 7 -14 1 -4 9
|
||||
-30 18 -58 10 -28 20 -59 22 -70 2 -10 9 -37 15 -59 17 -67 50 -227 55 -270 3
|
||||
-22 8 -58 11 -80 3 -22 8 -60 10 -85 5 -49 16 -183 18 -215 1 -19 10 -19 336
|
||||
-19 l335 0 -1 69 c0 66 -10 194 -19 260 -3 17 -7 48 -9 70 -8 68 -16 119 -21
|
||||
145 -3 14 -7 39 -10 55 -21 123 -88 393 -105 426 -6 10 -8 19 -6 19 6 0 -64
|
||||
204 -83 245 -5 11 -26 58 -47 105 -20 47 -40 89 -45 94 -5 6 -9 18 -9 28 0 10
|
||||
-4 18 -9 18 -5 0 -13 10 -17 22 -3 11 -27 58 -53 102 -26 45 -54 92 -62 106
|
||||
-7 14 -16 27 -19 30 -15 15 -50 71 -50 80 0 5 -4 10 -10 10 -5 0 -10 6 -10 14
|
||||
0 8 -3 16 -7 18 -10 4 -73 85 -73 92 0 3 -8 14 -17 24 -10 10 -38 43 -62 73
|
||||
-56 68 -291 303 -349 350 -24 18 -54 42 -65 53 -12 10 -51 39 -87 64 -36 26
|
||||
-67 49 -70 52 -8 9 -158 110 -163 110 -5 0 -102 62 -122 78 -17 13 -355 182
|
||||
-355 177 0 -2 -22 7 -49 20 -27 14 -54 25 -60 25 -6 0 -19 5 -29 10 -24 15
|
||||
-242 90 -242 84 0 -3 -9 0 -21 6 -11 6 -28 13 -37 15 -9 2 -56 15 -104 29 -49
|
||||
13 -88 22 -88 19 0 -3 -6 -1 -12 4 -11 9 -45 18 -103 29 -5 1 -13 3 -17 4 -5
|
||||
1 -12 4 -18 5 -5 1 -11 3 -12 5 -4 2 -24 6 -72 14 -21 3 -41 8 -45 10 -3 2
|
||||
-22 7 -41 10 -19 3 -46 8 -60 11 -34 8 -117 23 -160 29 -19 3 -44 8 -56 10
|
||||
-12 2 -43 7 -70 10 -27 4 -65 9 -84 11 -19 2 -57 7 -85 11 -27 3 -61 7 -75 9
|
||||
-14 2 -63 6 -110 10 -47 3 -107 8 -135 11 -59 6 -721 15 -726 10z"/>
|
||||
</g>
|
||||
</svg>
|
Po Szerokość: | Wysokość: | Rozmiar: 8.5 KiB |
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "SOTLAS - SOTA Atlas",
|
||||
"short_name": "SOTLAS",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
Po Szerokość: | Wysokość: | Rozmiar: 77 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 120 KiB |
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<NavBar />
|
||||
<keep-alive include="Map">
|
||||
<router-view />
|
||||
</keep-alive>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NavBar from './components/NavBar.vue'
|
||||
|
||||
export default {
|
||||
components: { NavBar }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "~bulma/sass/utilities/_all";
|
||||
|
||||
$link: $blue;
|
||||
|
||||
@import "~bulma";
|
||||
@import "~buefy/src/scss/buefy";
|
||||
@import "~flagpack/dist/flagpack.css";
|
||||
@import '~mapbox-gl/dist/mapbox-gl.css';
|
||||
@import '~@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'
|
||||
</style>
|
|
@ -0,0 +1,160 @@
|
|||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.section {
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
.hero-body {
|
||||
margin-top: 0.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
.title.is-6 {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.subdued {
|
||||
font-size: 0.8em;
|
||||
color: #7a7a7a;
|
||||
}
|
||||
.subtitle {
|
||||
margin-top: 0.2em;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.auto-width .table {
|
||||
width: auto !important;
|
||||
}
|
||||
.th-wrap .is-invisible {
|
||||
display: none;
|
||||
}
|
||||
.message-body .icon svg {
|
||||
height: 2.5rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.message {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.message-body {
|
||||
padding: 0.5em;
|
||||
}
|
||||
.message-body .icon {
|
||||
height: 1.25rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
.message-body .icon svg {
|
||||
height: 1rem;
|
||||
}
|
||||
.message-body .media-left {
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
.section {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.level-left + .level-right {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.level:not(:last-child) {
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
.title.is-4 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.title:not(:last-child).is-4 {
|
||||
margin-bottom: 0.75rem !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
/* Leave enough space so user can press outside menu to dismiss */
|
||||
.dropdown.is-mobile-modal .dropdown-menu {
|
||||
width: calc(100vw - 120px);
|
||||
}
|
||||
}
|
||||
.hero-head {
|
||||
background-color: #ddd;
|
||||
}
|
||||
.hero .navbar-item.is-active {
|
||||
background-color: whitesmoke !important;
|
||||
}
|
||||
.hero-body {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
h4 > .icon {
|
||||
margin-left: 0.3rem;
|
||||
margin-right: 0.7rem;
|
||||
opacity: 0.5;
|
||||
vertical-align: top;
|
||||
}
|
||||
.date-small {
|
||||
font-size: 80%;
|
||||
color: #777;
|
||||
margin-right: 0.3em;
|
||||
min-width: 2em;
|
||||
display: inline-block;
|
||||
}
|
||||
.time-weekday {
|
||||
font-size: 80%;
|
||||
color: #777;
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.table .tag {
|
||||
vertical-align: top;
|
||||
position: relative;
|
||||
top: 0.15em;
|
||||
}
|
||||
}
|
||||
.fp.unknown {
|
||||
background-color: #ddd;
|
||||
}
|
||||
hr {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
font-weight: normal;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.action-button {
|
||||
margin-left: 0.5rem;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.action-button {
|
||||
margin-left: 1rem;
|
||||
display: inline-block;
|
||||
}
|
||||
.action-button button {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
}
|
||||
.modal-card {
|
||||
max-height: calc(90vh - 80px);
|
||||
}
|
||||
.modal-card-head {
|
||||
padding: 15px 30px 15px 15px;
|
||||
}
|
||||
.modal-card-foot {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Fix to keep Iceland flag from appearing in pagination buttons */
|
||||
.pagination .icon.is {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
/* Fix for scale text selection */
|
||||
.mapboxgl-ctrl-scale {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Fix Buefy bug */
|
||||
@media screen and (max-width: 1023px) {
|
||||
.dropdown.is-mobile-modal>.dropdown-menu>.dropdown-content>div>a {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
}
|
Po Szerokość: | Wysokość: | Rozmiar: 1.3 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 5.1 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 1.5 KiB |
|
@ -0,0 +1,124 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="120.58959mm"
|
||||
height="19.343578mm"
|
||||
viewBox="0 0 120.58959 19.343578"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.2 5c3e80d, 2017-08-06"
|
||||
sodipodi:docname="sotlas.svg">
|
||||
<defs
|
||||
id="defs2">
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath5259">
|
||||
<use
|
||||
x="0"
|
||||
y="0"
|
||||
xlink:href="#g5255"
|
||||
id="use5261"
|
||||
width="100%"
|
||||
height="100%" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1.4"
|
||||
inkscape:cx="185.55186"
|
||||
inkscape:cy="66.811867"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="text12"
|
||||
showgrid="false"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1329"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="1"
|
||||
inkscape:window-maximized="1" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-78.247876,-11.392425)">
|
||||
<g
|
||||
aria-label="SOTLAS"
|
||||
style="font-style:normal;font-weight:normal;font-size:10.58333302px;line-height:1.25;font-family:sans-serif;letter-spacing:0.63764584px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332"
|
||||
id="text12">
|
||||
<path
|
||||
d="m 120.40386,17.005825 q -0.0508,-1.1684 -0.508,-2.032 -0.4318,-0.8636 -1.1938,-1.4478 -0.762,-0.5842 -1.8288,-0.8636 -1.0414,-0.3048 -2.286,-0.3048 -0.762,0 -1.651,0.1778 -0.8636,0.1778 -1.6256,0.635 -0.7366,0.4318 -1.2192,1.1938 -0.4826,0.7366 -0.4826,1.8542 0,1.0922 0.5334,1.778 0.5334,0.6858 1.397,1.1176 0.8636,0.4064 1.9812,0.6604 1.1176,0.254 2.2606,0.4826 1.1684,0.2286 2.2606,0.5588 1.1176,0.3048 1.9812,0.8636 0.889,0.5334 1.4224,1.4224 0.5334,0.8636 0.5334,2.1844 0,1.4224 -0.6096,2.3876 -0.6096,0.9652 -1.5494,1.5748 -0.9144,0.6096 -2.0574,0.8636 -1.1176,0.2794 -2.159,0.2794 -1.6002,0 -2.9972,-0.3556 -1.397,-0.3302 -2.4384,-1.0922 -1.0414,-0.7874 -1.651,-2.0066 -0.5842,-1.2192 -0.5588,-2.9718 h 1.1176 q -0.0762,1.4986 0.4318,2.54 0.508,1.016 1.397,1.6764 0.9144,0.6604 2.1336,0.9652 1.2192,0.2794 2.5654,0.2794 0.8128,0 1.7272,-0.2032 0.9398,-0.2032 1.7018,-0.6858 0.7874,-0.4826 1.2954,-1.27 0.5334,-0.7874 0.5334,-1.9812 0,-1.143 -0.5334,-1.8542 -0.5334,-0.7366 -1.4224,-1.1684 -0.8636,-0.4572 -1.9812,-0.7112 -1.0922,-0.2794 -2.2606,-0.508 -1.143,-0.2286 -2.2606,-0.5334 -1.1176,-0.3048 -1.9812,-0.8128 -0.8636,-0.5334 -1.397,-1.3462 -0.5334,-0.8382 -0.5334,-2.1336 0,-1.2954 0.5334,-2.2098 0.5588,-0.9398 1.4224,-1.4986 0.889,-0.5842 1.9812,-0.8382 1.0922,-0.2794 2.159,-0.2794 1.4224,0 2.6416,0.3302 1.2446,0.3048 2.159,0.9906 0.9398,0.6604 1.4986,1.7272 0.5588,1.0668 0.635,2.5654 z"
|
||||
style="fill:#29bdfe;fill-opacity:1"
|
||||
id="path873"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m 141.02509,20.892025 q 0,1.9812 -0.5842,3.7338 -0.5842,1.7272 -1.7018,3.0226 -1.0922,1.27 -2.6924,2.0066 -1.6002,0.7366 -3.6322,0.7366 -2.032,0 -3.6576,-0.7366 -1.6002,-0.7366 -2.7178,-2.0066 -1.0922,-1.2954 -1.6764,-3.0226 -0.5842,-1.7526 -0.5842,-3.7338 0,-1.9812 0.5842,-3.7084 0.5842,-1.7526 1.6764,-3.0226 1.1176,-1.2954 2.7178,-2.032 1.6256,-0.7366 3.6576,-0.7366 2.032,0 3.6322,0.7366 1.6002,0.7366 2.6924,2.032 1.1176,1.27 1.7018,3.0226 0.5842,1.7272 0.5842,3.7084 z m -16.129,0 q 0,1.7526 0.508,3.302 0.508,1.5494 1.4732,2.7178 0.9652,1.143 2.3622,1.8288 1.397,0.6858 3.175,0.6858 1.778,0 3.1496,-0.6858 1.397,-0.6858 2.3622,-1.8288 0.9652,-1.1684 1.4732,-2.7178 0.508,-1.5494 0.508,-3.302 0,-1.7526 -0.508,-3.302 -0.508,-1.5494 -1.4732,-2.6924 -0.9652,-1.1684 -2.3622,-1.8542 -1.3716,-0.6858 -3.1496,-0.6858 -1.778,0 -3.175,0.6858 -1.397,0.6858 -2.3622,1.8542 -0.9652,1.143 -1.4732,2.6924 -0.508,1.5494 -0.508,3.302 z"
|
||||
style="fill:#29bdfe;fill-opacity:1"
|
||||
id="path875"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m 140.42045,12.789425 v -0.9652 h 13.8938 v 0.9652 h -6.4008 v 17.1704 h -1.1176 v -17.1704 z"
|
||||
style="fill:#29bdfe;fill-opacity:1"
|
||||
id="path877"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m 155.59715,11.824225 h 1.1176 v 17.1704 h 10.287 v 0.9652 h -11.4046 z"
|
||||
style="letter-spacing:2.04787493px;fill:#016490;fill-opacity:1"
|
||||
id="path879"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m 175.72983,11.824225 h 1.2192 l 7.1628,18.1356 h -1.1938 l -2.286,-5.8166 h -8.6868 l -2.3114,5.8166 h -1.1938 z m 4.5466,11.3538 -3.8862,-10.2616 h -0.0508 l -4.0386,10.2616 z"
|
||||
style="fill:#29bdfe;fill-opacity:1"
|
||||
id="path881"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m 197.26267,17.005825 q -0.0508,-1.1684 -0.508,-2.032 -0.4318,-0.8636 -1.1938,-1.4478 -0.76199,-0.5842 -1.82879,-0.8636 -1.0414,-0.3048 -2.286,-0.3048 -0.762,0 -1.651,0.1778 -0.8636,0.1778 -1.6256,0.635 -0.7366,0.4318 -1.2192,1.1938 -0.4826,0.7366 -0.4826,1.8542 0,1.0922 0.5334,1.778 0.5334,0.6858 1.397,1.1176 0.8636,0.4064 1.9812,0.6604 1.1176,0.254 2.2606,0.4826 1.1684,0.2286 2.2606,0.5588 1.11759,0.3048 1.98119,0.8636 0.889,0.5334 1.4224,1.4224 0.5334,0.8636 0.5334,2.1844 0,1.4224 -0.6096,2.3876 -0.6096,0.9652 -1.5494,1.5748 -0.9144,0.6096 -2.05739,0.8636 -1.1176,0.2794 -2.159,0.2794 -1.6002,0 -2.9972,-0.3556 -1.397,-0.3302 -2.4384,-1.0922 -1.0414,-0.7874 -1.651,-2.0066 -0.5842,-1.2192 -0.5588,-2.9718 h 1.1176 q -0.0762,1.4986 0.4318,2.54 0.508,1.016 1.397,1.6764 0.9144,0.6604 2.1336,0.9652 1.2192,0.2794 2.5654,0.2794 0.8128,0 1.7272,-0.2032 0.93979,-0.2032 1.70179,-0.6858 0.7874,-0.4826 1.2954,-1.27 0.5334,-0.7874 0.5334,-1.9812 0,-1.143 -0.5334,-1.8542 -0.5334,-0.7366 -1.4224,-1.1684 -0.86359,-0.4572 -1.98119,-0.7112 -1.0922,-0.2794 -2.2606,-0.508 -1.143,-0.2286 -2.2606,-0.5334 -1.1176,-0.3048 -1.9812,-0.8128 -0.8636,-0.5334 -1.397,-1.3462 -0.5334,-0.8382 -0.5334,-2.1336 0,-1.2954 0.5334,-2.2098 0.5588,-0.9398 1.4224,-1.4986 0.889,-0.5842 1.9812,-0.8382 1.0922,-0.2794 2.159,-0.2794 1.4224,0 2.6416,0.3302 1.24459,0.3048 2.15899,0.9906 0.9398,0.6604 1.4986,1.7272 0.5588,1.0668 0.635,2.5654 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:200;font-stretch:normal;font-size:25.39999962px;font-family:'Helvetica Neue';-inkscape-font-specification:'Helvetica Neue Ultra-Light';letter-spacing:0px;stroke-width:0.26458332;fill:#016490;fill-opacity:1"
|
||||
id="path883"
|
||||
inkscape:connector-curvature="0" />
|
||||
<g
|
||||
id="g5255"
|
||||
inkscape:label="Clip">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#29bdfe;fill-opacity:1;stroke-width:0.01132015"
|
||||
d="M 78.247876,15.233355 V 30.05059 c 0,0.484324 0.48903,0.815479 0.938699,0.635783 l 5.906871,-2.688598 V 11.568408 l -5.98474,2.39381 a 1.3693707,1.3693707 0 0 0 -0.86083,1.271137 z"
|
||||
id="path3324" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#29bdfe;fill-opacity:1;stroke-width:0.01132015"
|
||||
d="m 86.46256,27.997775 8.214684,2.738229 V 14.306636 L 86.46256,11.568408 Z"
|
||||
id="path3322" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#29bdfe;fill-opacity:1;stroke-width:0.01132015"
|
||||
d="m 101.95323,11.618038 -5.906873,2.688598 v 16.429368 l 5.984743,-2.393811 a 1.3689428,1.3689428 0 0 0 0.86083,-1.271136 V 12.253821 c 0,-0.484324 -0.48903,-0.815479 -0.9387,-0.635783 z"
|
||||
id="path2" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Po Szerokość: | Wysokość: | Rozmiar: 8.2 KiB |
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="points"><SummitPointsLabel :points="activation.points" :bonus="activation.bonus" /></div>
|
||||
<div class="qsos" @click="$emit('openQsoList', activation.id)">
|
||||
{{ activation.qsos }} QSOs
|
||||
<font-awesome-icon :icon="['far', 'th-list']" class="faicon" />
|
||||
</div>
|
||||
<div class="date">{{ activation.date | formatActivationDate }}</div>
|
||||
<div class="summit"><router-link :class="{ invalid: activation.summit.invalid }" :to="makeSummitLink(activation.summit.code)">{{ activation.summit.name }} ({{ activation.summit.code }})</router-link><font-awesome-icon v-if="hasOwnPhotos(activation.summit)" class="photos-icon" :icon="['far', 'images']" /><font-awesome-icon v-else-if="activation.summit.photoAuthors && activation.summit.photoAuthors.length > 0" class="photos-icon-others" :icon="['far', 'images']" /><AltitudeLabel :altitude="activation.summit.altitude" /><ActivationCount :activationCount="activation.summit.activationCount" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from '../mixins/utils.js'
|
||||
import SummitPointsLabel from '../components/SummitPointsLabel.vue'
|
||||
import ActivationCount from '../components/ActivationCount.vue'
|
||||
import AltitudeLabel from '../components/AltitudeLabel.vue'
|
||||
|
||||
export default {
|
||||
name: 'ActivationCard',
|
||||
components: {
|
||||
SummitPointsLabel, ActivationCount, AltitudeLabel
|
||||
},
|
||||
mixins: [utils],
|
||||
props: {
|
||||
activation: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
ownCallsign: String
|
||||
},
|
||||
methods: {
|
||||
hasOwnPhotos (summit) {
|
||||
if (!summit.photoAuthors || !this.ownCallsign) {
|
||||
return false
|
||||
}
|
||||
|
||||
return summit.photoAuthors.includes(this.ownCallsign)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-content {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.6rem;
|
||||
line-height: 1.3;
|
||||
overflow: auto;
|
||||
}
|
||||
.card-content .date {
|
||||
margin-right: 0.5em;
|
||||
min-width: 3.5em;
|
||||
display: inline-block;
|
||||
}
|
||||
.card-content .points {
|
||||
float: right;
|
||||
}
|
||||
.card-content .qsos {
|
||||
float: right;
|
||||
font-size: 0.75rem;
|
||||
margin-right: 0.7em;
|
||||
color: #3273dc;
|
||||
margin-top: 0.15em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-content .summit {
|
||||
margin-top: 0.1em;
|
||||
}
|
||||
.card-content .invalid {
|
||||
opacity: 0.7;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.card-content .altitude {
|
||||
font-size: 0.75rem;
|
||||
color: #777;
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
.card-content .activation-count {
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.3em;
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
.photos-icon {
|
||||
margin-left: 0.5em;
|
||||
margin-right: 0.1em;
|
||||
color: #777;
|
||||
}
|
||||
.photos-icon-others {
|
||||
margin-left: 0.5em;
|
||||
margin-right: 0.1em;
|
||||
color: #aaa;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,247 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="columns is-5 is-variable">
|
||||
<div class="column is-half">
|
||||
<h4 class="title is-4">Activations per year</h4>
|
||||
<BarChart v-if="activationsPerYear" :data="activationsPerYear" labelField="year" valueField="activations" valueFieldB="bonusActivations" name="Activations" nameB="with Bonus" :xIsSeries="true" />
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<h4 class="title is-4">Activations per association</h4>
|
||||
<PieChart v-if="activationsPerAssociation" :data="activationsPerAssociation" labelField="association" valueField="activations" name="Activations" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="moreStats" class="columns is-5 is-variable">
|
||||
<div class="column is-half">
|
||||
<h4 class="title is-4">Activations by altitude</h4>
|
||||
<BarChart v-if="activationsPerAltitude" :data="activationsPerAltitude" labelField="altitude" valueField="activations" name="Activations" :xIsSeries="true" />
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<h4 class="title is-4 no-margin-bottom">Number of QSOs per activation</h4>
|
||||
<PercentageChart v-if="qsosPerActivation" :data="qsosPerActivation" labelField="qsos" valueField="activations" name="Activations" :maxSlices="11" />
|
||||
|
||||
<template v-if="modes">
|
||||
<h4 class="title is-4 no-margin-bottom">Number of QSOs by mode</h4>
|
||||
<PercentageChart :data="modes" labelField="mode" valueField="qsos" name="QSOs" />
|
||||
</template>
|
||||
|
||||
<template v-if="bands">
|
||||
<h4 class="title is-4 no-margin-bottom">Number of QSOs by band</h4>
|
||||
<PercentageChart :data="bands" labelField="band" valueField="qsos" name="QSOs" />
|
||||
</template>
|
||||
|
||||
<div v-if="!authenticated" class="stats-teaser">
|
||||
<div>
|
||||
<font-awesome-icon :icon="['far', 'chart-bar']" /> Log in for more statistics
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="more-button" v-else>
|
||||
<b-button @click="moreStats = true" type="is-info" icon-left="chart-bar">More stats</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment'
|
||||
import utils from '../mixins/utils.js'
|
||||
import BarChart from '../components/BarChart.vue'
|
||||
import PieChart from '../components/PieChart.vue'
|
||||
import PercentageChart from '../components/PercentageChart.vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
activations: Array
|
||||
},
|
||||
mixins: [utils],
|
||||
components: {
|
||||
BarChart, PieChart, PercentageChart
|
||||
},
|
||||
computed: {
|
||||
activationsPerYear () {
|
||||
if (this.activations.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let years = {}
|
||||
let yearsBonus = {}
|
||||
this.activations.forEach(activation => {
|
||||
let year = moment.utc(activation.date).year()
|
||||
if (!years[year]) {
|
||||
years[year] = 0
|
||||
yearsBonus[year] = 0
|
||||
}
|
||||
years[year]++
|
||||
if (activation.bonus > 0) {
|
||||
yearsBonus[year]++
|
||||
}
|
||||
})
|
||||
|
||||
return Object.keys(years).sort().map(year => {
|
||||
return { year, activations: years[year], bonusActivations: yearsBonus[year] }
|
||||
})
|
||||
},
|
||||
activationsPerAssociation () {
|
||||
if (this.activations.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let associations = {}
|
||||
this.activations.forEach(activation => {
|
||||
let association = activation.summit.code.substring(0, activation.summit.code.indexOf('/'))
|
||||
if (!associations[association]) {
|
||||
associations[association] = 0
|
||||
}
|
||||
associations[association]++
|
||||
})
|
||||
|
||||
return Object.keys(associations).sort().map(association => {
|
||||
return { association, activations: associations[association] }
|
||||
})
|
||||
},
|
||||
activationsPerAltitude () {
|
||||
return this.makeBands(this.activations.map(activation => { return activation.summit.altitude }), 500, ' m', 'altitude', 'activations')
|
||||
},
|
||||
qsosPerActivation () {
|
||||
return this.makeBands(this.activations.map(activation => { return activation.qsos }), 10, '', 'qsos', 'activations', true, 110)
|
||||
},
|
||||
modes () {
|
||||
if (this.activations.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let modes = {}
|
||||
let totalQsos = 0
|
||||
this.activations.forEach(activation => {
|
||||
if (activation.modeQsos) {
|
||||
Object.keys(activation.modeQsos).forEach(mode => {
|
||||
if (!modes[mode]) {
|
||||
modes[mode] = 0
|
||||
}
|
||||
modes[mode] += activation.modeQsos[mode]
|
||||
})
|
||||
}
|
||||
totalQsos += activation.qsos
|
||||
})
|
||||
|
||||
let modesArr = []
|
||||
let modeQsosSum = 0
|
||||
Object.keys(modes).forEach(mode => {
|
||||
modesArr.push({ mode: mode.toUpperCase(), qsos: modes[mode] })
|
||||
modeQsosSum += modes[mode]
|
||||
})
|
||||
if (modesArr.length > 0) {
|
||||
if (modeQsosSum < totalQsos) {
|
||||
modesArr.push({ mode: 'Other', qsos: (totalQsos - modeQsosSum) })
|
||||
}
|
||||
return modesArr
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
bands () {
|
||||
if (this.activations.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let bands = {}
|
||||
this.activations.forEach(activation => {
|
||||
if (activation.bandQsos) {
|
||||
Object.keys(activation.bandQsos).forEach(band => {
|
||||
if (!bands[band]) {
|
||||
bands[band] = 0
|
||||
}
|
||||
bands[band] += activation.bandQsos[band]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let bandsArr = []
|
||||
Object.keys(bands).forEach(band => {
|
||||
bandsArr.push({ band, qsos: bands[band] })
|
||||
})
|
||||
if (bandsArr.length > 0) {
|
||||
bandsArr.sort((a, b) => {
|
||||
return b.qsos - a.qsos
|
||||
})
|
||||
return bandsArr
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
makeBands (data, interval, suffix, bandKey, valueKey, ranges = false, maxBand) {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
let bands = {}
|
||||
let maxValue = 0
|
||||
data.forEach(value => {
|
||||
let band = Math.ceil(value / interval) * interval
|
||||
if (maxBand && band > maxBand) {
|
||||
band = maxBand
|
||||
}
|
||||
if (!bands[band]) {
|
||||
bands[band] = 0
|
||||
}
|
||||
bands[band]++
|
||||
if (maxValue < band) {
|
||||
maxValue = band
|
||||
}
|
||||
})
|
||||
|
||||
for (let value = interval; value < maxValue; value += interval) {
|
||||
if (bands[value] === undefined) {
|
||||
bands[value] = 0
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(bands).sort((a, b) => { return parseInt(a) - parseInt(b) }).map(value => {
|
||||
value = parseInt(value)
|
||||
let label = '< ' + value + suffix
|
||||
if (ranges) {
|
||||
if (value === maxBand) {
|
||||
label = '> ' + (value - interval) + suffix
|
||||
} else {
|
||||
label = (value - interval + 1) + '-' + value + suffix
|
||||
}
|
||||
}
|
||||
return { [bandKey]: label, [valueKey]: bands[value] }
|
||||
})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
moreStats: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.more-button {
|
||||
text-align: center;
|
||||
}
|
||||
.no-margin-bottom {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.stats-teaser {
|
||||
text-align: center;
|
||||
margin-top: 1em;
|
||||
}
|
||||
.stats-teaser div {
|
||||
font-size: 1.5rem;
|
||||
color: #ccc;
|
||||
border: 2px dashed #ccc;
|
||||
display: inline-block;
|
||||
padding: 0.5em 0.75em;
|
||||
border-radius: 0.75em;
|
||||
}
|
||||
.stats-teaser .fa-chart-bar {
|
||||
font-size: 2rem;
|
||||
vertical-align: middle;
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<span :class="activationCountClass" label="activation count" title="activation count">{{ activationCount }}</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ActivationCount',
|
||||
props: {
|
||||
activationCount: Number
|
||||
},
|
||||
computed: {
|
||||
activationCountClass () {
|
||||
if (this.activationCount !== undefined) {
|
||||
return { 'activation-count': true, ['activations-' + this.activationCount]: true }
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.activation-count {
|
||||
color: #ccc;
|
||||
border-radius: 0.2em;
|
||||
padding: 0 0.2em;
|
||||
border: 1px solid #ccc;
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
.activations-0, .activations-1 {
|
||||
font-weight: bold;
|
||||
color: #ccc124;
|
||||
border: 1px solid #ccc124;
|
||||
}
|
||||
.activations-2 {
|
||||
color: #eaa82b;
|
||||
border: 1px solid #eaa82b;
|
||||
}
|
||||
.activations-3 {
|
||||
color: #a98d59;
|
||||
border: 1px solid #a98d59;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,141 @@
|
|||
<template>
|
||||
<div>
|
||||
<CardPagination v-if="!$mq.desktop" :data="cardActivations" :paginated="true" :infinite="infinite">
|
||||
<template v-slot="{ row }">
|
||||
<ActivationCard v-slot="{ row }" :activation="row" :ownCallsign="ownCallsign" @openQsoList="openQsoList" />
|
||||
</template>
|
||||
</CardPagination>
|
||||
<b-table v-else class="auto-width" :narrowed="true" :paginated="true" :striped="true" :default-sort="['date', 'desc']" :per-page="perPage" :data="data" :row-class="rowClass">
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="date" label="Date" sortable>
|
||||
{{ props.row.date | formatActivationDate }}
|
||||
</b-table-column>
|
||||
<b-table-column field="summit.code" label="Summit" class="code" sortable>
|
||||
<CountryFlag v-if="props.row.summit.isoCode" :country="props.row.summit.isoCode" class="flag" />
|
||||
<router-link :to="makeSummitLink(props.row.summit.code)">{{ props.row.summit.code }}</router-link>
|
||||
</b-table-column>
|
||||
<b-table-column field="summit.name" label="Name" class="name" sortable>
|
||||
<router-link :to="makeSummitLink(props.row.summit.code)">{{ props.row.summit.name }}</router-link>
|
||||
<font-awesome-icon v-if="hasOwnPhotos(props.row.summit)" class="photos-icon" :icon="['far', 'images']" />
|
||||
<font-awesome-icon v-else-if="props.row.summit.photoAuthors && props.row.summit.photoAuthors.length > 0" class="photos-icon-others" :icon="['far', 'images']" />
|
||||
</b-table-column>
|
||||
<b-table-column field="summit.altitude" label="Altitude" class="altitude" sortable numeric>
|
||||
<AltitudeLabel :altitude="props.row.summit.altitude" />
|
||||
</b-table-column>
|
||||
<b-table-column field="points" label="Points" sortable>
|
||||
<SummitPointsLabel :points="props.row.points" :bonus="props.row.bonus" />
|
||||
</b-table-column>
|
||||
<b-table-column field="summit.activationCount" label="Activations" sortable numeric>
|
||||
<ActivationCount :activationCount="props.row.summit.activationCount" />
|
||||
</b-table-column>
|
||||
<b-table-column field="callsignUsed" label="Callsign used" sortable>
|
||||
{{ props.row.callsignUsed.toUpperCase() }}
|
||||
</b-table-column>
|
||||
<b-table-column field="qsos" label="QSOs" sortable numeric>
|
||||
<span class="qsos" @click="openQsoList(props.row.id)">{{ props.row.qsos }}</span>
|
||||
<font-awesome-icon :icon="['far', 'th-list']" class="faicon qsos" @click="openQsoList(props.row.id)" />
|
||||
</b-table-column>
|
||||
</template>
|
||||
<template v-slot:bottom-left>
|
||||
<b-select v-model="perPage">
|
||||
<option v-for="option in perPageOptions" :key="option" :value="option">{{ option }} per page</option>
|
||||
</b-select>
|
||||
</template>
|
||||
</b-table>
|
||||
|
||||
<ModalQSOList :activationId="modalActivationId" @modalClosed="modalActivationId = null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from '../mixins/utils.js'
|
||||
import prefs from '../mixins/prefs.js'
|
||||
import SummitPointsLabel from '../components/SummitPointsLabel.vue'
|
||||
import CardPagination from '../components/CardPagination.vue'
|
||||
import ActivationCard from '../components/ActivationCard.vue'
|
||||
import ActivationCount from '../components/ActivationCount.vue'
|
||||
import AltitudeLabel from '../components/AltitudeLabel.vue'
|
||||
import CountryFlag from '../components/CountryFlag.vue'
|
||||
import ModalQSOList from '../components/ModalQSOList.vue'
|
||||
|
||||
export default {
|
||||
name: 'ActivationsList',
|
||||
mixins: [utils, prefs],
|
||||
prefs: {
|
||||
key: 'activationsListPrefs',
|
||||
props: ['perPage']
|
||||
},
|
||||
props: {
|
||||
data: Array,
|
||||
infinite: Boolean,
|
||||
ownCallsign: String
|
||||
},
|
||||
components: {
|
||||
SummitPointsLabel, CardPagination, ActivationCard, ActivationCount, AltitudeLabel, CountryFlag, ModalQSOList
|
||||
},
|
||||
methods: {
|
||||
openQsoList (activationId) {
|
||||
if (!this.authenticated) {
|
||||
this.$buefy.dialog.alert('Please log in to view QSOs.')
|
||||
return
|
||||
}
|
||||
this.modalActivationId = activationId
|
||||
},
|
||||
rowClass (row) {
|
||||
return { invalid: row.summit.invalid }
|
||||
},
|
||||
hasOwnPhotos (summit) {
|
||||
if (!summit.photoAuthors || !this.ownCallsign) {
|
||||
return false
|
||||
}
|
||||
|
||||
return summit.photoAuthors.includes(this.ownCallsign)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cardActivations () {
|
||||
return [...this.data].sort((a, b) => {
|
||||
if (a.date > b.date) {
|
||||
return -1
|
||||
} else if (a.date < b.date) {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
modalActivationId: null,
|
||||
perPage: 15,
|
||||
perPageOptions: [10, 15, 20, 30, 50, 100]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flag {
|
||||
margin-right: 0.4em;
|
||||
}
|
||||
.qsos {
|
||||
color: #3273dc;
|
||||
cursor: pointer;
|
||||
}
|
||||
.faicon {
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
.photos-icon {
|
||||
margin-left: 0.5em;
|
||||
color: #777;
|
||||
}
|
||||
.photos-icon-others {
|
||||
margin-left: 0.5em;
|
||||
color: #aaa;
|
||||
}
|
||||
.invalid .code, .invalid .name {
|
||||
opacity: 0.7;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,126 @@
|
|||
<template>
|
||||
<div class="card">
|
||||
<div v-if="$slots.header" class="card-header"><p class="card-header-title"><slot name="header"></slot></p></div>
|
||||
<div class="card-content">
|
||||
<div class="time" v-html="formatTime(alert.dateActivated)" />
|
||||
<div class="freqmode">{{ alert.frequency }}</div>
|
||||
<div class="callsign">
|
||||
<template v-if="callsignLink">
|
||||
<router-link :to="makeActivatorLink(alert.activatorCallsign)">{{ alert.activatorCallsign }}</router-link>
|
||||
</template>
|
||||
<template v-else>{{ alert.activatorCallsign }}</template>
|
||||
</div>
|
||||
<div v-if="showSummitInfo" class="summit">
|
||||
<div class="summit-title" v-if="alert.summit.name">
|
||||
<CountryFlag v-if="alert.summit.isoCode" :country="alert.summit.isoCode" class="flag" />
|
||||
<router-link :to="makeSummitLink(alert.summit.code)"><span class="summit-name">{{ alert.summit.name }}</span></router-link>
|
||||
</div>
|
||||
<div class="summit-info">{{ alert.summit.code }}<span v-if="alert.summit.altitude">, <AltitudeLabel :altitude="alert.summit.altitude" />, {{ alert.summit.points }}pt<ActivationCount :activationCount="alert.summit.activationCount" /></span></div>
|
||||
</div>
|
||||
<div class="poster">{{ alert.posterCallsign }}</div>
|
||||
<div class="comments">{{ alert.comments }}</div>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from '../mixins/utils.js'
|
||||
import ActivationCount from '../components/ActivationCount.vue'
|
||||
import CountryFlag from '../components/CountryFlag.vue'
|
||||
import AltitudeLabel from '../components/AltitudeLabel.vue'
|
||||
|
||||
export default {
|
||||
name: 'AlertCard',
|
||||
components: {
|
||||
ActivationCount, CountryFlag, AltitudeLabel
|
||||
},
|
||||
mixins: [utils],
|
||||
props: {
|
||||
alert: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showSummitInfo: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
callsignLink: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
background-color: #eee;
|
||||
margin-top: 1em;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.card-header-title {
|
||||
padding: 0.3em 0.5em;
|
||||
}
|
||||
.card-content {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.6rem;
|
||||
line-height: 1.3;
|
||||
overflow: auto;
|
||||
}
|
||||
.card-content .time {
|
||||
margin-right: 0.5em;
|
||||
min-width: 3.5em;
|
||||
display: inline-block;
|
||||
}
|
||||
.card-content .callsign {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
}
|
||||
.card-content .freqmode {
|
||||
float: right;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
margin-left: 0.3em;
|
||||
max-width: calc(100vw - 20em);
|
||||
text-align: right;
|
||||
}
|
||||
.card-content .summit {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.1em;
|
||||
}
|
||||
.card-content .summit-name {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.card-content .comments {
|
||||
font-size: 0.75rem;
|
||||
color: #777;
|
||||
margin-top: 0.1em;
|
||||
}
|
||||
.card-content .poster {
|
||||
float: right;
|
||||
font-style: italic;
|
||||
margin-left: 0.3em;
|
||||
font-size: 0.75rem;
|
||||
clear: right;
|
||||
color: #777;
|
||||
}
|
||||
.card-content .flag {
|
||||
margin-right: 0.5em;
|
||||
margin-left: 0.1em;
|
||||
position: relative;
|
||||
top: -0.05em;
|
||||
}
|
||||
.card-content .activation-count {
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
.summit-title {
|
||||
display: inline-block;
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.summit-info {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,250 @@
|
|||
<template>
|
||||
<div>
|
||||
<CardPagination v-if="!$mq.desktop && cardAlerts.length > 0" :data="cardAlerts" :infinite="infinite" :paginated="paginated">
|
||||
<template v-slot="{ row, prevRow }">
|
||||
<AlertCard :alert="row" :callsignLink="callsignLink" :showSummitInfo="showSummitInfo">
|
||||
<template v-if="needDateHeader(row, prevRow)" v-slot:header>
|
||||
{{ row.dateActivated | formatAlertDate }}
|
||||
</template>
|
||||
<template v-if="canEditAlert(row)" v-slot:actions>
|
||||
<div class="actions">
|
||||
<b-button class="control" size="is-small" outlined icon-left="edit" @click="editAlert(row)">Edit</b-button>
|
||||
<b-button class="control" size="is-small" outlined icon-left="plus" @click="makeSpot(row)">Spot</b-button>
|
||||
<b-button class="control" size="is-small" type="is-danger" outlined icon-left="trash-alt" @click="deleteAlert(row)">Delete</b-button>
|
||||
</div>
|
||||
</template>
|
||||
</AlertCard>
|
||||
</template>
|
||||
</CardPagination>
|
||||
<p v-else-if="!$mq.desktop && cardAlerts.length === 0">No matching alerts found.</p>
|
||||
<b-table v-else default-sort="dateActivated" :narrowed="true" :striped="true" :data="data" :paginated="paginated" :per-page="perPage" :row-class="rowClass">
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="dateActivated" class="timestamp" label="Date/Time" sortable>
|
||||
<span v-html="formatDateTimeRelative(props.row.dateActivated)" />
|
||||
</b-table-column>
|
||||
<b-table-column v-if="showCallsign" field="activatorCallsign" label="Callsign" sortable>
|
||||
<template v-if="callsignLink">
|
||||
<router-link :to="makeActivatorLink(props.row.activatorCallsign)">{{ props.row.activatorCallsign }}</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ props.row.activatorCallsign }}
|
||||
</template>
|
||||
</b-table-column>
|
||||
<b-table-column v-if="showSummitInfo" field="summit.code" label="Summit code" class="nowrap" sortable>
|
||||
<CountryFlag v-if="props.row.summit.isoCode && $mq.fullhd" :country="props.row.summit.isoCode" class="flag" />
|
||||
<router-link v-if="props.row.summit.name" :to="makeSummitLink(props.row.summit.code)">{{ props.row.summit.code }}</router-link>
|
||||
<span v-else>{{ props.row.summit.code }}</span>
|
||||
</b-table-column>
|
||||
<b-table-column v-if="showSummitInfo" field="summit.name" label="Summit name" sortable>
|
||||
<router-link :to="makeSummitLink(props.row.summit.code)">{{ props.row.summit.name }}</router-link>
|
||||
</b-table-column>
|
||||
<b-table-column v-if="showSummitInfo" field="summit.altitude" label="Altitude" sortable numeric>
|
||||
<template v-if="props.row.summit.altitude"><AltitudeLabel :altitude="props.row.summit.altitude" /></template>
|
||||
</b-table-column>
|
||||
<b-table-column v-if="showSummitInfo" field="summit.points" label="Points" sortable numeric>
|
||||
<SummitPointsLabel v-if="props.row.summit.points" :points="props.row.summit.points" />
|
||||
</b-table-column>
|
||||
<b-table-column v-if="showSummitInfo" field="summit.activationCount" label="Act." sortable numeric>
|
||||
<ActivationCount :activationCount="props.row.summit.activationCount" />
|
||||
</b-table-column>
|
||||
<b-table-column class="comments" label="Frequencies/Comments">
|
||||
<div class="comments-cell">
|
||||
<div>
|
||||
{{ props.row.frequency }}<br />
|
||||
<span class="comments-text">{{ props.row.comments }} ({{ props.row.posterCallsign }})</span>
|
||||
</div>
|
||||
<b-dropdown v-if="canEditAlert(props.row)" class="actions" aria-role="list">
|
||||
<b-button size="is-small" slot="trigger" icon-pack="fas" icon-right="caret-down" outlined>Actions</b-button>
|
||||
|
||||
<b-dropdown-item aria-role="listitem" @click="editAlert(props.row)"><b-icon icon="edit" size="is-small" /><span class="dropdown-label">Edit</span></b-dropdown-item>
|
||||
<b-dropdown-item aria-role="listitem" @click="makeSpot(props.row)"><b-icon icon="plus" size="is-small" /><span class="dropdown-label">Spot</span></b-dropdown-item>
|
||||
<b-dropdown-item aria-role="listitem" @click="deleteAlert(props.row)"><b-icon icon="trash-alt" type="is-danger" size="is-small" /><span class="has-text-danger dropdown-label">Delete</span></b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</div>
|
||||
</b-table-column>
|
||||
</template>
|
||||
<template v-if="paginated" v-slot:bottom-left>
|
||||
<b-select v-model="perPage">
|
||||
<option v-for="option in perPageOptions" :key="option" :value="option">{{ option }} per page</option>
|
||||
</b-select>
|
||||
</template>
|
||||
</b-table>
|
||||
<b-modal v-if="isEditAlertActive" :active="true" has-modal-card :can-cancel="['escape']" @close="isEditAlertActive = false">
|
||||
<EditAlert :alert="alertToEdit" />
|
||||
</b-modal>
|
||||
<b-modal v-if="isEditSpotActive" :active="true" has-modal-card :can-cancel="['escape']" @close="isEditSpotActive = false">
|
||||
<EditSpot :spot="spotToMake" />
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment'
|
||||
import utils from '../mixins/utils.js'
|
||||
import prefs from '../mixins/prefs.js'
|
||||
import nowticker from '../mixins/nowticker.js'
|
||||
import sotawatch from '../mixins/sotawatch.js'
|
||||
import SummitPointsLabel from '../components/SummitPointsLabel.vue'
|
||||
import CardPagination from '../components/CardPagination.vue'
|
||||
import AlertCard from '../components/AlertCard.vue'
|
||||
import ActivationCount from '../components/ActivationCount.vue'
|
||||
import CountryFlag from '../components/CountryFlag.vue'
|
||||
import AltitudeLabel from '../components/AltitudeLabel.vue'
|
||||
import EditAlert from '../components/EditAlert.vue'
|
||||
import EditSpot from '../components/EditSpot.vue'
|
||||
|
||||
export default {
|
||||
name: 'AlertsList',
|
||||
components: {
|
||||
SummitPointsLabel, CardPagination, AlertCard, ActivationCount, CountryFlag, AltitudeLabel, EditAlert, EditSpot
|
||||
},
|
||||
mixins: [utils, prefs, nowticker, sotawatch],
|
||||
prefs: {
|
||||
key: 'alertsListPrefs',
|
||||
props: ['perPage']
|
||||
},
|
||||
props: {
|
||||
data: Array,
|
||||
showCallsign: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showSummitInfo: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
paginated: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
infinite: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
callsignLink: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
filters: {
|
||||
formatAlertDate (date) {
|
||||
return moment.utc(date).format('dddd, DD MMM YYYY')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
needDateHeader (row, prevRow) {
|
||||
return (!prevRow || !moment.utc(prevRow.dateActivated).isSame(moment.utc(row.dateActivated), 'day'))
|
||||
},
|
||||
rowClass (row, index) {
|
||||
if (index === 0 || index === this.data.length) {
|
||||
return ''
|
||||
}
|
||||
if (!moment.utc(this.data[index - 1].dateActivated).isSame(moment.utc(row.dateActivated), 'day')) {
|
||||
return 'date-change'
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
canEditAlert (alert) {
|
||||
if (!this.myCallsign) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (alert.posterCallsign === this.myCallsign)
|
||||
},
|
||||
addAlert () {
|
||||
this.alertToEdit = null
|
||||
this.isEditAlertActive = true
|
||||
},
|
||||
editAlert (alert) {
|
||||
this.alertToEdit = alert
|
||||
this.isEditAlertActive = true
|
||||
},
|
||||
makeSpot (alert) {
|
||||
this.spotToMake = {
|
||||
activatorCallsign: alert.activatorCallsign,
|
||||
summit: alert.summit,
|
||||
comments: alert.comments,
|
||||
mode: ''
|
||||
}
|
||||
this.isEditSpotActive = true
|
||||
},
|
||||
deleteAlert (alert) {
|
||||
this.$buefy.dialog.confirm({
|
||||
message: 'Are you sure you want to delete this alert?',
|
||||
confirmText: 'Delete',
|
||||
type: 'is-danger',
|
||||
onConfirm: () => {
|
||||
this.deleteSotaWatchAlert(alert.id)
|
||||
.then(response => {
|
||||
this.$store.dispatch('reloadAlerts')
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cardAlerts () {
|
||||
return [...this.data].sort((a, b) => {
|
||||
if (a.dateActivated > b.dateActivated) {
|
||||
return 1
|
||||
} else if (a.dateActivated < b.dateActivated) {
|
||||
return -1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
perPage: 15,
|
||||
perPageOptions: [10, 15, 20, 30, 50, 100],
|
||||
isEditAlertActive: false,
|
||||
isEditSpotActive: false,
|
||||
alertToEdit: null,
|
||||
spotToMake: null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@media (min-width: 769px) {
|
||||
.table .comments {
|
||||
font-size: 0.8rem;
|
||||
max-width: 30em;
|
||||
}
|
||||
}
|
||||
.flag {
|
||||
margin-right: 0.4em;
|
||||
}
|
||||
.comments-text {
|
||||
color: #777;
|
||||
}
|
||||
.comments-cell {
|
||||
display: flex;
|
||||
}
|
||||
.actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.card .actions {
|
||||
float: right;
|
||||
clear: right;
|
||||
}
|
||||
.actions .button {
|
||||
margin-left: 1em;
|
||||
}
|
||||
.card .actions {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.date-change td {
|
||||
border-top: 3px solid #dbdbdb;
|
||||
}
|
||||
>>> .dropdown-item .icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dropdown-item .dropdown-label {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<span :class="{ altitude: true }">{{ displayAltitude }}</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AltitudeLabel',
|
||||
props: {
|
||||
altitude: Number,
|
||||
altitudeFt: Number
|
||||
},
|
||||
computed: {
|
||||
displayAltitude () {
|
||||
if (this.$store.state.altitudeUnits === 'ft') {
|
||||
if (this.altitudeFt) {
|
||||
return this.altitudeFt + ' ft'
|
||||
} else {
|
||||
return Math.round(this.altitude * 3.28084) + ' ft'
|
||||
}
|
||||
} else {
|
||||
return this.altitude + ' m'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<div ref="chart"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
data: Array,
|
||||
labelField: String,
|
||||
valueField: String,
|
||||
valueFieldB: String,
|
||||
name: String,
|
||||
nameB: String,
|
||||
xIsSeries: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
stacked: Boolean
|
||||
},
|
||||
methods: {
|
||||
updateChart () {
|
||||
let labels = []
|
||||
let values = []
|
||||
let valuesB = []
|
||||
this.data.forEach(row => {
|
||||
labels.push(row[this.labelField])
|
||||
values.push(row[this.valueField])
|
||||
})
|
||||
|
||||
let datasets = [{
|
||||
values,
|
||||
name: this.name
|
||||
}]
|
||||
|
||||
if (this.valueFieldB) {
|
||||
this.data.forEach(row => {
|
||||
valuesB.push(row[this.valueFieldB])
|
||||
})
|
||||
datasets.push({
|
||||
values: valuesB,
|
||||
name: this.nameB
|
||||
})
|
||||
}
|
||||
|
||||
this.chart = new Chart(this.$refs.chart, {
|
||||
data: {
|
||||
labels,
|
||||
datasets: datasets
|
||||
},
|
||||
type: 'bar',
|
||||
height: 250,
|
||||
barOptions: {
|
||||
spaceRatio: 0.3,
|
||||
stacked: this.stacked
|
||||
},
|
||||
axisOptions: {
|
||||
xAxisMode: 'tick',
|
||||
xIsSeries: this.xIsSeries
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
data () {
|
||||
this.updateChart()
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateChart()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
>>> .graph-svg-tip .title {
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,86 @@
|
|||
<template>
|
||||
<span v-if="homeQth" class="wrapper">
|
||||
<DistanceLabel :distance="distance" />, {{ bearing }}°
|
||||
</span>
|
||||
<span v-else-if="homeQth === null" class="has-text-grey">
|
||||
Loading...
|
||||
</span>
|
||||
<span v-else class="has-text-grey">
|
||||
Set home QTH in <a @click="doAccountManagement">your account</a>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DistanceLabel from './DistanceLabel.vue'
|
||||
import vincenty from 'node-vincenty'
|
||||
|
||||
export default {
|
||||
name: 'Bearing',
|
||||
components: { DistanceLabel },
|
||||
props: {
|
||||
latitude: Number,
|
||||
longitude: Number
|
||||
},
|
||||
computed: {
|
||||
homeQth () {
|
||||
return this.$store.state.homeQth
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.homeQth === null) {
|
||||
this.$keycloak.loadUserProfile()
|
||||
.success(profile => {
|
||||
if (profile.attributes.Lat && profile.attributes.Lat[0] && profile.attributes.Lon && profile.attributes.Lon[0]) {
|
||||
this.$store.commit('setHomeQth', {
|
||||
latitude: parseFloat(profile.attributes.Lat[0]),
|
||||
longitude: parseFloat(profile.attributes.Lon[0])
|
||||
})
|
||||
this.calculate()
|
||||
} else {
|
||||
this.$store.commit('setHomeQth', undefined)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.calculate()
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
latitude () {
|
||||
this.calculate()
|
||||
},
|
||||
longitude () {
|
||||
this.calculate()
|
||||
},
|
||||
homeQth () {
|
||||
this.calculate()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
calculate () {
|
||||
if (!this.homeQth) {
|
||||
this.distance = null
|
||||
this.bearing = null
|
||||
return
|
||||
}
|
||||
let res = vincenty.distVincenty(this.homeQth.latitude, this.homeQth.longitude, this.latitude, this.longitude)
|
||||
this.distance = res.distance
|
||||
this.bearing = (Math.round(res.initialBearing) + 360) % 360
|
||||
},
|
||||
doAccountManagement () {
|
||||
this.$keycloak.accountManagement()
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
distance: null,
|
||||
bearing: null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li v-if="association" :class="{'is-active': !region, association: true}"><CountryFlag v-if="association.isoCode" :country="association.isoCode" /> <router-link :to="'/summits/' + association.code">{{ association.code }} <span class="breadcrumb-label"> {{ association.name }}</span></router-link></li>
|
||||
<li v-if="region" :class="{'is-active': !summit}"><router-link :to="'/summits/' + association.code + '/' + region.code">{{ region.code }} <span class="breadcrumb-label"> {{ region.name }}</span></router-link></li>
|
||||
<li v-if="summit && summit.code" class="is-active summit-number"><router-link :to="'/summits/' + summit.code" aria-current="page">{{ summitNumber }}</router-link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CountryFlag from '../components/CountryFlag.vue'
|
||||
|
||||
export default {
|
||||
name: 'Breadcrumb',
|
||||
components: {
|
||||
CountryFlag
|
||||
},
|
||||
props: {
|
||||
association: Object,
|
||||
region: Object,
|
||||
summit: Object
|
||||
},
|
||||
computed: {
|
||||
summitNumber () {
|
||||
if (!this.summit || !this.summit.code) {
|
||||
return null
|
||||
}
|
||||
return this.summit.code.substring(this.summit.code.indexOf('-') + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb {
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
.breadcrumb li.summit-number:before {
|
||||
content: "–";
|
||||
}
|
||||
.breadcrumb .association a {
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
.breadcrumb-label {
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,93 @@
|
|||
<template>
|
||||
<div class="card-list">
|
||||
<b-pagination v-if="paginated && !infinite" :total="data.length" :current.sync="currentCardPage" :simple="true" size="is-small" :per-page="perPage" aria-next-label="Next page" aria-previous-label="Previous page" aria-page-label="Page" aria-current-label="Current page" />
|
||||
<div v-for="(row, index) in pageData" :key="row[rowKey]">
|
||||
<slot :row="row" :prevRow="(index > 0 ? pageData[index-1] : null)"></slot>
|
||||
</div>
|
||||
<infinite-loading v-if="infinite" :identifier="infiniteIdentifier" @infinite="infiniteHandler">
|
||||
<div slot="no-more"></div>
|
||||
<div slot="no-results"></div>
|
||||
</infinite-loading>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import InfiniteLoading from 'vue-infinite-loading'
|
||||
|
||||
export default {
|
||||
name: 'CardPagination',
|
||||
components: {
|
||||
InfiniteLoading
|
||||
},
|
||||
props: {
|
||||
data: Array,
|
||||
paginated: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
infinite: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
perPage: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
infiniteBatchSize: {
|
||||
type: Number,
|
||||
default: 50
|
||||
},
|
||||
rowKey: {
|
||||
type: String,
|
||||
default: 'id'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentCardPage (newCardPage) {
|
||||
if (newCardPage === 0) {
|
||||
this.currentCardPage = 1
|
||||
}
|
||||
},
|
||||
data () {
|
||||
this.infiniteIdentifier++
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
infiniteHandler ($state) {
|
||||
if (this.data.length > (this.infiniteBatchCount * this.infiniteBatchSize)) {
|
||||
this.infiniteBatchCount++
|
||||
$state.loaded()
|
||||
} else {
|
||||
$state.complete()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
pageData () {
|
||||
if (this.infinite) {
|
||||
return this.data.slice(0, this.infiniteBatchCount * this.infiniteBatchSize)
|
||||
} else if (this.paginated) {
|
||||
return this.data.slice((this.currentCardPage - 1) * this.perPage, this.currentCardPage * this.perPage)
|
||||
} else {
|
||||
return this.data
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
currentCardPage: 1,
|
||||
infiniteBatchCount: 1,
|
||||
infiniteIdentifier: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-list {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
.pagination {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<b-table :narrowed="true" :paginated="true" :striped="true" :default-sort="['activationDate', 'desc']" :per-page="15" :data="data" :mobile-cards="false">
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="activationDate" label="Date" sortable>
|
||||
{{ props.row.activationDate | formatActivationDate }}
|
||||
</b-table-column>
|
||||
<b-table-column field="otherCallsign" label="Activator" sortable>
|
||||
<router-link :to="makeActivatorLink(props.row.otherCallsign.toUpperCase())">{{ props.row.otherCallsign.toUpperCase() }}</router-link>
|
||||
</b-table-column>
|
||||
<b-table-column field="band" label="Band" :custom-sort="sortBand" sortable numeric>
|
||||
{{ bandForFrequency(props.row.band.replace('MHz', '')) }}
|
||||
</b-table-column>
|
||||
<b-table-column field="mode" label="Mode" sortable>
|
||||
<ModeLabel :mode="props.row.mode" />
|
||||
</b-table-column>
|
||||
</template>
|
||||
</b-table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from '../mixins/utils.js'
|
||||
import ModeLabel from '../components/ModeLabel.vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
data: Array
|
||||
},
|
||||
mixins: [utils],
|
||||
components: {
|
||||
ModeLabel
|
||||
},
|
||||
methods: {
|
||||
sortBand (a, b, isAsc) {
|
||||
let fa = parseFloat(a.band.replace('MHz', ''))
|
||||
let fb = parseFloat(b.band.replace('MHz', ''))
|
||||
if (fa < fb) {
|
||||
return (isAsc ? -1 : 1)
|
||||
} else if (fa === fb) {
|
||||
return 0
|
||||
} else {
|
||||
return (isAsc ? 1 : -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,199 @@
|
|||
<template>
|
||||
<span class="wrapper">
|
||||
<span class="coordinates">{{ latitude }}, {{ longitude }}</span>
|
||||
<div class="actions">
|
||||
<b-field>
|
||||
<p class="control">
|
||||
<b-dropdown aria-role="list">
|
||||
<b-button type="is-info" outlined size="is-small" icon-right="angle-down" slot="trigger">
|
||||
Open
|
||||
</b-button>
|
||||
|
||||
<b-dropdown-item v-for="action in filteredActions" :key="action.name" :has-link="true" aria-role="listitem"><a :href="action.url()" target="_blank">{{ action.name }}</a></b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</p>
|
||||
<p class="control">
|
||||
<b-button type="is-info" outlined size="is-small" v-clipboard:copy="latitude + ',' + longitude" v-clipboard:success="onCopySuccess" v-clipboard:error="onCopyError">Copy</b-button>
|
||||
</p>
|
||||
</b-field>
|
||||
</div>
|
||||
<div v-if="showMaidenhead" class="locator">Locator: {{ maidenhead }}</div>
|
||||
<div v-if="showElevation" class="elevation">Elevation: <span v-if="elevation"><AltitudeLabel :altitude="elevation" /> (approx.)</span><font-awesome-icon v-else :icon="['far', 'spinner']" spin /></div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Maidenhead from 'maidenhead'
|
||||
import axios from 'axios'
|
||||
import AltitudeLabel from './AltitudeLabel.vue'
|
||||
|
||||
export default {
|
||||
name: 'Coordinates',
|
||||
components: { AltitudeLabel },
|
||||
props: {
|
||||
latitude: Number,
|
||||
longitude: Number,
|
||||
reference: String,
|
||||
showMaidenhead: Boolean,
|
||||
showElevation: Boolean
|
||||
},
|
||||
computed: {
|
||||
filteredActions () {
|
||||
return this.actions.filter(action => {
|
||||
return (action.url() !== null)
|
||||
})
|
||||
},
|
||||
maidenhead () {
|
||||
let loc = new Maidenhead(this.latitude, this.longitude, 4)
|
||||
return loc.locator
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.loadElevation()
|
||||
},
|
||||
watch: {
|
||||
latitude () {
|
||||
this.loadElevation()
|
||||
},
|
||||
longitude () {
|
||||
this.loadElevation()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onCopySuccess () {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Coordinates copied to clipboard',
|
||||
type: 'is-info'
|
||||
})
|
||||
},
|
||||
onCopyError () {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Could not copy coordinates to clipboard',
|
||||
type: 'is-danger'
|
||||
})
|
||||
},
|
||||
loadElevation () {
|
||||
this.elevation = null
|
||||
if (!this.latitude || !this.longitude || !this.showElevation) {
|
||||
return
|
||||
}
|
||||
axios.post('https://ele.sotl.as/api', [[this.latitude, this.longitude]])
|
||||
.then(result => {
|
||||
this.elevation = Math.round(result.data[0])
|
||||
})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
elevation: null,
|
||||
actions: [
|
||||
{
|
||||
name: 'swisstopo',
|
||||
url: () => {
|
||||
if (this.latitude >= 45.7 && this.latitude <= 47.85 && this.longitude >= 5.9 && this.longitude <= 10.9) {
|
||||
return `https://map.geo.admin.ch/?swisssearch=${this.latitude},${this.longitude}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'BayernAtlas',
|
||||
url: () => {
|
||||
if (this.latitude >= 47.25 && this.latitude <= 50.6 && this.longitude >= 9.6 && this.longitude <= 13.85) {
|
||||
return `https://geoportal.bayern.de/bayernatlas?lon=${this.longitude}&lat=${this.latitude}&zoom=10`
|
||||
}
|
||||
return null
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'CalTopo',
|
||||
url: () => {
|
||||
if (this.latitude >= 14 && this.longitude >= -169 && this.longitude <= -52) {
|
||||
return `https://caltopo.com/map.html#ll=${this.latitude},${this.longitude}&z=15&b=t&o=f16a%2Cr&n=1,0.25`
|
||||
}
|
||||
return null
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'OS Maps',
|
||||
url: () => {
|
||||
if (this.latitude >= 49.8 && this.latitude <= 60 && this.longitude >= -9 && this.longitude <= 2) {
|
||||
return `https://osmaps.ordnancesurvey.co.uk/${this.latitude},${this.longitude},15/pin`
|
||||
}
|
||||
return null
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Google Maps',
|
||||
url: () => {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${this.latitude},${this.longitude}`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'OpenStreetMap',
|
||||
url: () => {
|
||||
return `https://www.openstreetmap.org/?mlat=${this.latitude}&mlon=${this.longitude}&zoom=16`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'OpenTopoMap',
|
||||
url: () => {
|
||||
return `https://www.opentopomap.org/#marker=16/${this.latitude}/${this.longitude}`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'SummitPost',
|
||||
url: () => {
|
||||
return `https://www.summitpost.org/object_list.php?object_type=1&distance_lat_1=${this.latitude}&distance_lon_1=${this.longitude}&map_1=1`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'SOTA Summits',
|
||||
url: () => {
|
||||
if (this.reference) {
|
||||
return `https://summits.sota.org.uk/summit/${this.reference}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'SOTA Map',
|
||||
url: () => {
|
||||
if (this.reference) {
|
||||
return `https://www.sotamaps.org/summit/${this.reference}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'aprs.fi',
|
||||
url: () => {
|
||||
return `https://aprs.fi/#!lat=${this.latitude}&lng=${this.longitude}&z=14`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'APRS Direct',
|
||||
url: () => {
|
||||
return `https://www.aprsdirect.com/center/${this.latitude},${this.longitude}/zoom/14`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
.coordinates {
|
||||
margin-right: 0.75em;
|
||||
}
|
||||
.locator {
|
||||
color: #777;
|
||||
}
|
||||
.actions {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,283 @@
|
|||
<template>
|
||||
<span :class="iconClass" :label="label" :title="label"></span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
let countryMap = {
|
||||
'af': 'Afghanistan',
|
||||
'ax': 'Åland Islands',
|
||||
'al': 'Albania',
|
||||
'dz': 'Algeria',
|
||||
'as': 'American Samoa',
|
||||
'ad': 'Andorra',
|
||||
'ao': 'Angola',
|
||||
'ai': 'Anguilla',
|
||||
'aq': 'Antarctica',
|
||||
'ag': 'Antigua and Barbuda',
|
||||
'ar': 'Argentina',
|
||||
'am': 'Armenia',
|
||||
'aw': 'Aruba',
|
||||
'au': 'Australia',
|
||||
'at': 'Austria',
|
||||
'az': 'Azerbaijan',
|
||||
'bs': 'Bahamas',
|
||||
'bh': 'Bahrain',
|
||||
'bd': 'Bangladesh',
|
||||
'bb': 'Barbados',
|
||||
'by': 'Belarus',
|
||||
'be': 'Belgium',
|
||||
'bz': 'Belize',
|
||||
'bj': 'Benin',
|
||||
'bm': 'Bermuda',
|
||||
'bt': 'Bhutan',
|
||||
'bo': 'Bolivia',
|
||||
'bq': 'Bonaire, Sint Eustatius and Saba',
|
||||
'ba': 'Bosnia and Herzegovina',
|
||||
'bw': 'Botswana',
|
||||
'bv': 'Bouvet Island',
|
||||
'br': 'Brazil',
|
||||
'io': 'British Indian Ocean Territory',
|
||||
'bn': 'Brunei Darussalam',
|
||||
'bg': 'Bulgaria',
|
||||
'bf': 'Burkina Faso',
|
||||
'bi': 'Burundi',
|
||||
'cv': 'Cabo Verde',
|
||||
'kh': 'Cambodia',
|
||||
'cm': 'Cameroon',
|
||||
'ca': 'Canada',
|
||||
'ky': 'Cayman Islands',
|
||||
'cf': 'Central African Republic',
|
||||
'td': 'Chad',
|
||||
'cl': 'Chile',
|
||||
'cn': 'China',
|
||||
'cx': 'Christmas Island',
|
||||
'cc': 'Cocos (Keeling) Islands',
|
||||
'co': 'Colombia',
|
||||
'km': 'Comoros',
|
||||
'cg': 'Congo',
|
||||
'cd': 'Congo, Democratic Republic of the',
|
||||
'ck': 'Cook Islands',
|
||||
'cr': 'Costa Rica',
|
||||
'ci': 'Côte d\'Ivoire',
|
||||
'hr': 'Croatia',
|
||||
'cu': 'Cuba',
|
||||
'cw': 'Curaçao',
|
||||
'cy': 'Cyprus',
|
||||
'cz': 'Czechia',
|
||||
'dk': 'Denmark',
|
||||
'dj': 'Djibouti',
|
||||
'dm': 'Dominica',
|
||||
'do': 'Dominican Republic',
|
||||
'ec': 'Ecuador',
|
||||
'eg': 'Egypt',
|
||||
'sv': 'El Salvador',
|
||||
'gq': 'Equatorial Guinea',
|
||||
'er': 'Eritrea',
|
||||
'ee': 'Estonia',
|
||||
'sz': 'Eswatini',
|
||||
'et': 'Ethiopia',
|
||||
'fk': 'Falkland Islands (Malvinas)',
|
||||
'fo': 'Faroe Islands',
|
||||
'fj': 'Fiji',
|
||||
'fi': 'Finland',
|
||||
'fr': 'France',
|
||||
'gf': 'French Guiana',
|
||||
'pf': 'French Polynesia',
|
||||
'tf': 'French Southern Territories',
|
||||
'ga': 'Gabon',
|
||||
'gm': 'Gambia',
|
||||
'ge': 'Georgia',
|
||||
'de': 'Germany',
|
||||
'gh': 'Ghana',
|
||||
'gi': 'Gibraltar',
|
||||
'gr': 'Greece',
|
||||
'gl': 'Greenland',
|
||||
'gd': 'Grenada',
|
||||
'gp': 'Guadeloupe',
|
||||
'gu': 'Guam',
|
||||
'gt': 'Guatemala',
|
||||
'gg': 'Guernsey',
|
||||
'gn': 'Guinea',
|
||||
'gw': 'Guinea-Bissau',
|
||||
'gy': 'Guyana',
|
||||
'ht': 'Haiti',
|
||||
'hm': 'Heard Island and McDonald Islands',
|
||||
'va': 'Holy See',
|
||||
'hn': 'Honduras',
|
||||
'hk': 'Hong Kong',
|
||||
'hu': 'Hungary',
|
||||
'is': 'Iceland',
|
||||
'in': 'India',
|
||||
'id': 'Indonesia',
|
||||
'ir': 'Iran',
|
||||
'iq': 'Iraq',
|
||||
'ie': 'Ireland',
|
||||
'im': 'Isle of Man',
|
||||
'il': 'Israel',
|
||||
'it': 'Italy',
|
||||
'jm': 'Jamaica',
|
||||
'jp': 'Japan',
|
||||
'je': 'Jersey',
|
||||
'jo': 'Jordan',
|
||||
'kz': 'Kazakhstan',
|
||||
'ke': 'Kenya',
|
||||
'ki': 'Kiribati',
|
||||
'kp': 'Korea (Democratic People\'s Republic of)',
|
||||
'kr': 'Korea, Republic of',
|
||||
'kw': 'Kuwait',
|
||||
'kg': 'Kyrgyzstan',
|
||||
'la': 'Laos',
|
||||
'lv': 'Latvia',
|
||||
'lb': 'Lebanon',
|
||||
'ls': 'Lesotho',
|
||||
'lr': 'Liberia',
|
||||
'ly': 'Libya',
|
||||
'li': 'Liechtenstein',
|
||||
'lt': 'Lithuania',
|
||||
'lu': 'Luxembourg',
|
||||
'mo': 'Macao',
|
||||
'mg': 'Madagascar',
|
||||
'mw': 'Malawi',
|
||||
'my': 'Malaysia',
|
||||
'mv': 'Maldives',
|
||||
'ml': 'Mali',
|
||||
'mt': 'Malta',
|
||||
'mh': 'Marshall Islands',
|
||||
'mq': 'Martinique',
|
||||
'mr': 'Mauritania',
|
||||
'mu': 'Mauritius',
|
||||
'yt': 'Mayotte',
|
||||
'mx': 'Mexico',
|
||||
'fm': 'Micronesia (Federated States of)',
|
||||
'md': 'Moldova',
|
||||
'mc': 'Monaco',
|
||||
'mn': 'Mongolia',
|
||||
'me': 'Montenegro',
|
||||
'ms': 'Montserrat',
|
||||
'ma': 'Morocco',
|
||||
'mz': 'Mozambique',
|
||||
'mm': 'Myanmar',
|
||||
'na': 'Namibia',
|
||||
'nr': 'Nauru',
|
||||
'np': 'Nepal',
|
||||
'nl': 'Netherlands',
|
||||
'nc': 'New Caledonia',
|
||||
'nz': 'New Zealand',
|
||||
'ni': 'Nicaragua',
|
||||
'ne': 'Niger',
|
||||
'ng': 'Nigeria',
|
||||
'nu': 'Niue',
|
||||
'nf': 'Norfolk Island',
|
||||
'mk': 'North Macedonia',
|
||||
'mp': 'Northern Mariana Islands',
|
||||
'no': 'Norway',
|
||||
'om': 'Oman',
|
||||
'pk': 'Pakistan',
|
||||
'pw': 'Palau',
|
||||
'ps': 'Palestine, State of',
|
||||
'pa': 'Panama',
|
||||
'pg': 'Papua New Guinea',
|
||||
'py': 'Paraguay',
|
||||
'pe': 'Peru',
|
||||
'ph': 'Philippines',
|
||||
'pn': 'Pitcairn',
|
||||
'pl': 'Poland',
|
||||
'pt': 'Portugal',
|
||||
'pr': 'Puerto Rico',
|
||||
'qa': 'Qatar',
|
||||
're': 'Réunion',
|
||||
'ro': 'Romania',
|
||||
'ru': 'Russian Federation',
|
||||
'rw': 'Rwanda',
|
||||
'bl': 'Saint Barthélemy',
|
||||
'sh': 'Saint Helena, Ascension and Tristan da Cunha',
|
||||
'kn': 'Saint Kitts and Nevis',
|
||||
'lc': 'Saint Lucia',
|
||||
'mf': 'Saint Martin',
|
||||
'pm': 'Saint Pierre and Miquelon',
|
||||
'vc': 'Saint Vincent and the Grenadines',
|
||||
'ws': 'Samoa',
|
||||
'sm': 'San Marino',
|
||||
'st': 'Sao Tome and Principe',
|
||||
'sa': 'Saudi Arabia',
|
||||
'sn': 'Senegal',
|
||||
'rs': 'Serbia',
|
||||
'sc': 'Seychelles',
|
||||
'sl': 'Sierra Leone',
|
||||
'sg': 'Singapore',
|
||||
'sx': 'Sint Maarten',
|
||||
'sk': 'Slovakia',
|
||||
'si': 'Slovenia',
|
||||
'sb': 'Solomon Islands',
|
||||
'so': 'Somalia',
|
||||
'za': 'South Africa',
|
||||
'gs': 'South Georgia and the South Sandwich Islands',
|
||||
'ss': 'South Sudan',
|
||||
'es': 'Spain',
|
||||
'lk': 'Sri Lanka',
|
||||
'sd': 'Sudan',
|
||||
'sr': 'Suriname',
|
||||
'sj': 'Svalbard and Jan Mayen',
|
||||
'se': 'Sweden',
|
||||
'ch': 'Switzerland',
|
||||
'sy': 'Syrian Arab Republic',
|
||||
'tw': 'Taiwan',
|
||||
'tj': 'Tajikistan',
|
||||
'tz': 'Tanzania',
|
||||
'th': 'Thailand',
|
||||
'tl': 'Timor-Leste',
|
||||
'tg': 'Togo',
|
||||
'tk': 'Tokelau',
|
||||
'to': 'Tonga',
|
||||
'tt': 'Trinidad and Tobago',
|
||||
'tn': 'Tunisia',
|
||||
'tr': 'Turkey',
|
||||
'tm': 'Turkmenistan',
|
||||
'tc': 'Turks and Caicos Islands',
|
||||
'tv': 'Tuvalu',
|
||||
'ug': 'Uganda',
|
||||
'ua': 'Ukraine',
|
||||
'ae': 'United Arab Emirates',
|
||||
'gb': 'United Kingdom',
|
||||
'gb-nir': 'Northern Ireland',
|
||||
'gb-sct': 'Scotland',
|
||||
'gb-eng': 'England',
|
||||
'gb-wls': 'Wales',
|
||||
'us': 'United States of America',
|
||||
'um': 'United States Minor Outlying Islands',
|
||||
'uy': 'Uruguay',
|
||||
'uz': 'Uzbekistan',
|
||||
'vu': 'Vanuatu',
|
||||
've': 'Venezuela',
|
||||
'vn': 'Viet Nam',
|
||||
'vg': 'Virgin Islands (British)',
|
||||
'vi': 'Virgin Islands (U.S.)',
|
||||
'wf': 'Wallis and Futuna',
|
||||
'eh': 'Western Sahara',
|
||||
'ye': 'Yemen',
|
||||
'zm': 'Zambia',
|
||||
'zw': 'Zimbabwe'
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'CountryFlag',
|
||||
props: {
|
||||
country: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
iconClass () {
|
||||
return { 'fp': true, 'fp-rounded': this.rounded, [this.country.toLowerCase()]: true }
|
||||
},
|
||||
label () {
|
||||
return countryMap[this.country.toLowerCase()]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<span>{{ displayDistance }}</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DistanceLabel',
|
||||
props: {
|
||||
distance: Number,
|
||||
highPrecision: Boolean
|
||||
},
|
||||
computed: {
|
||||
displayDistance () {
|
||||
if (this.$store.state.altitudeUnits === 'ft') {
|
||||
if (this.highPrecision && this.distance < (1000 / 3.28084)) {
|
||||
return Math.round(this.distance * 3.28084) + ' ft'
|
||||
} else {
|
||||
return (this.distance * 0.000621371).toFixed(1) + ' mi'
|
||||
}
|
||||
} else {
|
||||
if (this.highPrecision && this.distance < 1000) {
|
||||
return Math.round(this.distance) + ' m'
|
||||
} else {
|
||||
return (this.distance / 1000).toFixed(1) + ' km'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,53 @@
|
|||
<template>
|
||||
<div class="action-button download-button">
|
||||
<b-dropdown>
|
||||
<b-button slot="trigger" type="is-info" size="is-small" outlined icon-left="file-download" icon-right="angle-down">Download</b-button>
|
||||
<b-dropdown-item has-link><a :href="makeUrlForType('gpx')">GPX file</a></b-dropdown-item>
|
||||
<b-dropdown-item has-link><a :href="makeUrlForType('kml')">KML file</a></b-dropdown-item>
|
||||
<b-dropdown-item separator />
|
||||
<b-dropdown-item custom disabled><b>Label options</b></b-dropdown-item>
|
||||
<b-dropdown-item custom><b-checkbox v-model="nameopts" native-value="name">Summit name</b-checkbox></b-dropdown-item>
|
||||
<b-dropdown-item custom><b-checkbox v-model="nameopts" native-value="altitude">Summit altitude</b-checkbox></b-dropdown-item>
|
||||
<b-dropdown-item custom><b-checkbox v-model="nameopts" native-value="points">Summit points</b-checkbox></b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
exportUrlPrefix: String,
|
||||
exportUrlParams: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
makeUrlForType (type) {
|
||||
let params = {}
|
||||
if (this.exportUrlParams !== null) {
|
||||
params = { ...this.exportUrlParams }
|
||||
}
|
||||
if (this.nameopts.length > 0) {
|
||||
params.nameopts = this.nameopts.join(',')
|
||||
}
|
||||
|
||||
let url = this.exportUrlPrefix + '.' + type
|
||||
if (Object.keys(params).length > 0) {
|
||||
url += '?' + Object.entries(params).map(kv => kv.map(encodeURIComponent).join('=')).join('&')
|
||||
}
|
||||
return url
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
nameopts: []
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
>>> .checkbox .control-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,381 @@
|
|||
<template>
|
||||
<div class="modal-card" style="width: auto">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{ this.alert ? 'Edit' : 'Add' }} Alert</p>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<b-field label="Callsign" :message="isOwnCallsign ? '' : 'You are posting an alert for someone else\'s callsign'" :type="isOwnCallsign ? '' : 'is-info'">
|
||||
<b-input type="text" class="callsign" v-model="callsign" pattern="[a-zA-Z0-9/]{3,}" validation-message="Invalid callsign" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required />
|
||||
</b-field>
|
||||
|
||||
<b-field label="Summit reference" :message="summitDisplay" :type="summitType" :class="summitLabelClass" expanded>
|
||||
<b-field>
|
||||
<b-input type="text" ref="summitCode" v-model="summitCode" placeholder="XX/YY-000" :loading="summitLoading" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required />
|
||||
<p class="control">
|
||||
<NearbySummitsList @summitSelected="onSummitSelected" />
|
||||
</p>
|
||||
</b-field>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Activation date" message="dd/mm/yyyy" expanded>
|
||||
<b-datepicker v-model="date" icon="calendar-day" :min-date="minDate" :date-formatter="dateFormatter" :date-parser="dateParser" :mobile-native="false" required />
|
||||
</b-field>
|
||||
|
||||
<b-field label="ETA" message="e.g. 12:15" class="eta" expanded>
|
||||
<b-field>
|
||||
<b-input :type="$mq.mobile ? 'time' : 'text'" pattern="([0-1]{1}[0-9]{1}|20|21|22|23):[0-5]{1}[0-9]{1}" class="time-input" v-model="time" icon="clock" required />
|
||||
<p class="control">
|
||||
<b-radio-button v-model="timeZone" native-value="local">Local</b-radio-button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<b-radio-button v-model="timeZone" native-value="utc">UTC</b-radio-button>
|
||||
</p>
|
||||
</b-field>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Frequency-Mode(s)" :message="freqModeMessage">
|
||||
<b-taginput v-model="freqMode" ref="freqMode" autocomplete rounded :data="freqModeSuggestions" :confirm-key-codes="[9,13,32,188]" @typing="updateFreqModeSuggestions" @input="onFreqModeInput" @blur="onFreqModeBlur" @keydown.native="onFreqModeKeyDown" append-to-body />
|
||||
</b-field>
|
||||
|
||||
<b-field label="Comments">
|
||||
<b-input v-model="comments" type="text" maxlength="60" />
|
||||
</b-field>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<b-button @click="$parent.close()">Cancel</b-button>
|
||||
<b-button type="is-info" :disabled="!isInputValid" :loading="posting" @click="postAlert">{{ this.alert ? 'Edit' : 'Add' }} Alert</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import moment from 'moment'
|
||||
|
||||
import utils from '../mixins/utils.js'
|
||||
import prefs from '../mixins/prefs.js'
|
||||
import sotawatch from '../mixins/sotawatch.js'
|
||||
import NearbySummitsList from './NearbySummitsList.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NearbySummitsList
|
||||
},
|
||||
mixins: [utils, prefs, sotawatch],
|
||||
props: {
|
||||
defaultSummitCode: String,
|
||||
alert: Object
|
||||
},
|
||||
prefs: {
|
||||
key: 'editAlertPrefs',
|
||||
props: ['lastCallsign', 'timeZone', 'defaultComments']
|
||||
},
|
||||
mounted () {
|
||||
if (!this.callsign) {
|
||||
if (this.lastCallsign) {
|
||||
this.callsign = this.lastCallsign
|
||||
} else if (this.myCallsign) {
|
||||
this.callsign = this.myCallsign
|
||||
if (!/\/P$/.test(this.callsign)) {
|
||||
this.callsign += '/P'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.timeZone) {
|
||||
this.timeZone = 'utc'
|
||||
}
|
||||
|
||||
if (!this.comments && this.defaultComments) {
|
||||
this.comments = this.defaultComments
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
minDate () {
|
||||
if (this.timeZone === 'local') {
|
||||
return moment(moment().startOf('day').format('YYYY-MM-DD')).toDate()
|
||||
} else {
|
||||
return moment(moment.utc().startOf('day').format('YYYY-MM-DD')).toDate()
|
||||
}
|
||||
},
|
||||
summitDisplay () {
|
||||
if (this.summit) {
|
||||
if (this.$store.state.altitudeUnits === 'ft') {
|
||||
return this.summit.name + ' (' + Math.round(this.summit.altitude * 3.28084) + ' ft)'
|
||||
} else {
|
||||
return this.summit.name + ' (' + this.summit.altitude + ' m)'
|
||||
}
|
||||
} else if (this.summitInvalid) {
|
||||
return 'Summit not found'
|
||||
} else {
|
||||
return 'You can enter spaces instead of / and -'
|
||||
}
|
||||
},
|
||||
summitType () {
|
||||
if (this.summitInvalid) {
|
||||
return 'is-danger'
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
freqModeMessage () {
|
||||
return 'Format: <em>freq-mode, ...</em> (e.g. <em>7.030-cw, 14.250-ssb</em>)'
|
||||
},
|
||||
isInputValid () {
|
||||
return /^[a-zA-Z0-9/]{3,}$/.test(this.callsign) && this.summit !== null && this.isSummitValid(this.summit) && this.date && /^\d\d:\d\d$/.test(this.time) && this.freqMode.length > 0 && (this.freqMode.join(', ').length <= 40 || this.freqMode.join(',').length <= 40)
|
||||
},
|
||||
summitLabelClass () {
|
||||
if (!this.summit || this.isSummitValid(this.summit)) {
|
||||
return { summitref: true }
|
||||
} else {
|
||||
return { summitref: true, invalid: true }
|
||||
}
|
||||
},
|
||||
isOwnCallsign () {
|
||||
return (!this.callsign || !this.myCallsign || (this.homeCallsign(this.callsign) === this.homeCallsign(this.myCallsign)))
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
defaultSummitCode: {
|
||||
immediate: true,
|
||||
handler () {
|
||||
if (!this.summitCode) {
|
||||
this.summitCode = this.defaultSummitCode
|
||||
}
|
||||
}
|
||||
},
|
||||
summitCode: {
|
||||
immediate: true,
|
||||
handler () {
|
||||
if (this.summitCode) {
|
||||
// Shorthand input
|
||||
let summitRegex = /^([A-Z0-9]{1,8})[/ ]([A-Z]{2})[- ]?([0-9]{3})$/i
|
||||
let matches = this.summitCode.match(summitRegex)
|
||||
if (matches) {
|
||||
this.summitCode = (matches[1] + '/' + matches[2] + '-' + matches[3]).toUpperCase()
|
||||
this.summitLoading = true
|
||||
axios.get('https://api.sotl.as/summits/' + this.summitCode)
|
||||
.then(response => {
|
||||
this.summitLoading = false
|
||||
this.summitInvalid = false
|
||||
this.summit = response.data
|
||||
})
|
||||
.catch(() => {
|
||||
this.summitLoading = false
|
||||
this.summitInvalid = true
|
||||
this.summit = null
|
||||
})
|
||||
} else {
|
||||
this.summit = null
|
||||
this.summitInvalid = false
|
||||
}
|
||||
} else {
|
||||
this.summit = null
|
||||
this.summitInvalid = false
|
||||
}
|
||||
}
|
||||
},
|
||||
alert: {
|
||||
immediate: true,
|
||||
handler () {
|
||||
if (this.alert) {
|
||||
this.callsign = this.alert.activatorCallsign
|
||||
this.summitCode = this.alert.summit.code
|
||||
this.date = moment(this.alert.dateActivated.substring(0, 19)).toDate()
|
||||
this.time = moment(this.alert.dateActivated.substring(0, 19)).format('HH:mm')
|
||||
this.freqMode = this.alert.frequency.split(/\s*,\s*/)
|
||||
this.comments = this.alert.comments
|
||||
}
|
||||
}
|
||||
},
|
||||
timeZone (newTimeZone) {
|
||||
if (!this.date || !this.time) {
|
||||
return
|
||||
}
|
||||
|
||||
if (newTimeZone === 'local') {
|
||||
let conv = this.utcToLocal(this.date, this.time)
|
||||
if (conv) {
|
||||
this.date = conv.date
|
||||
this.time = conv.time
|
||||
}
|
||||
} else {
|
||||
let conv = this.localToUtc(this.date, this.time)
|
||||
if (conv) {
|
||||
this.date = conv.date
|
||||
this.time = conv.time
|
||||
}
|
||||
}
|
||||
},
|
||||
time (newTime) {
|
||||
// Add colon to nnnn style times
|
||||
let matches = newTime.match(/^(\d\d)(\d\d)$/)
|
||||
if (matches) {
|
||||
this.time = matches[1] + ':' + matches[2]
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
postAlert () {
|
||||
this.lastCallsign = this.callsign.toUpperCase()
|
||||
let freqMode = this.freqMode.join(', ')
|
||||
if (freqMode.length > 40) {
|
||||
freqMode = this.freqMode.join(',')
|
||||
}
|
||||
let utcDate = this.date
|
||||
let utcTime = this.time
|
||||
if (this.timeZone === 'local') {
|
||||
let conv = this.localToUtc(this.date, this.time)
|
||||
utcDate = conv.date
|
||||
utcTime = conv.time
|
||||
}
|
||||
let params = {
|
||||
activatingCallsign: this.callsign.toUpperCase(),
|
||||
associationCode: this.summitCode.substring(0, this.summitCode.indexOf('/')),
|
||||
summitCode: this.summitCode.substring(this.summitCode.indexOf('/') + 1),
|
||||
dateActivated: moment(utcDate).format('DD/MM/YYYY'),
|
||||
eta: utcTime.replace(':', ''),
|
||||
frequency: freqMode,
|
||||
comments: this.comments,
|
||||
posterCallsign: this.myCallsign
|
||||
}
|
||||
if (this.alert) {
|
||||
params.id = this.alert.id
|
||||
}
|
||||
this.posting = true
|
||||
this.postSotaWatchAlert(params)
|
||||
.then(response => {
|
||||
this.$store.dispatch('reloadAlerts')
|
||||
this.$parent.close()
|
||||
})
|
||||
.finally(() => {
|
||||
this.posting = false
|
||||
})
|
||||
},
|
||||
dateFormatter (date) {
|
||||
return moment(date).format('DD/MM/YYYY')
|
||||
},
|
||||
dateParser (date) {
|
||||
return moment(date).toDate()
|
||||
},
|
||||
updateFreqModeSuggestions (text) {
|
||||
let matches = text.match(/^([0-9.]+)/)
|
||||
if (matches) {
|
||||
this.freqModeSuggestions = Object.keys(this.allModes()).map(mode => {
|
||||
return matches[1] + '-' + mode
|
||||
}).filter(suggestion => {
|
||||
return suggestion.startsWith(text.toLowerCase())
|
||||
})
|
||||
} else {
|
||||
this.freqModeSuggestions = []
|
||||
}
|
||||
},
|
||||
onFreqModeInput () {
|
||||
let splitFreqModes = []
|
||||
this.freqMode.forEach(fm => {
|
||||
splitFreqModes = splitFreqModes.concat(fm.split(/\s*[, ]\s*/))
|
||||
})
|
||||
this.freqMode = splitFreqModes
|
||||
},
|
||||
onFreqModeBlur () {
|
||||
// Delay to avoid double entry when clicking a tag suggestion
|
||||
setTimeout(() => {
|
||||
this.$refs.freqMode.addTag()
|
||||
}, 100)
|
||||
},
|
||||
onFreqModeKeyDown () {
|
||||
// Hack to allow us to get keep-first behavior on autocomplete despite the fact
|
||||
// that b-taginput sets keepFirst = !allowNew
|
||||
if (this.$refs.freqMode.confirmKeyCodes.indexOf(event.keyCode) >= 0) {
|
||||
event.preventDefault()
|
||||
this.$refs.freqMode.addTag()
|
||||
}
|
||||
},
|
||||
onSummitSelected (summit) {
|
||||
this.summitCode = summit.code
|
||||
this.$nextTick(() => {
|
||||
this.$refs.summitCode.checkHtml5Validity()
|
||||
})
|
||||
},
|
||||
localToUtc (date, time) {
|
||||
let utc = moment(moment(date).format('YYYY-MM-DD') + ' ' + time).utc()
|
||||
if (!utc.isValid()) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
date: moment(moment(utc).startOf('day').format('YYYY-MM-DD')).toDate(),
|
||||
time: utc.format('HH:mm')
|
||||
}
|
||||
},
|
||||
utcToLocal (date, time) {
|
||||
let local = moment.utc(moment(date).format('YYYY-MM-DD') + ' ' + time).local()
|
||||
if (!local.isValid()) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
date: moment(moment(local).startOf('day').format('YYYY-MM-DD')).toDate(),
|
||||
time: local.format('HH:mm')
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
callsign: '',
|
||||
lastCallsign: null,
|
||||
defaultComments: '',
|
||||
summitCode: '',
|
||||
date: new Date(),
|
||||
time: '',
|
||||
freqMode: [],
|
||||
freqModeSuggestions: [],
|
||||
comments: '',
|
||||
summit: null,
|
||||
summitInvalid: false,
|
||||
summitLoading: false,
|
||||
timeZone: 'utc',
|
||||
posting: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.callsign >>> input {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
>>> .datepicker .dropdown.is-mobile-modal .dropdown-menu {
|
||||
width: calc(100vw - 40px);
|
||||
}
|
||||
}
|
||||
>>> .datepicker .dropdown.is-active .dropdown-menu {
|
||||
position: fixed !important;
|
||||
left: 50% !important;
|
||||
top: 50% !important;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 100;
|
||||
}
|
||||
.invalid >>> .help {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.taginput {
|
||||
max-width: 30em;
|
||||
}
|
||||
.eta .field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.eta >>> .time-input {
|
||||
width: 8em;
|
||||
}
|
||||
.summitref .field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Fix from https://github.com/buefy/buefy/issues/1932#issuecomment-551453842 */
|
||||
>>> .field.has-addons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
>>> .field.has-addons .help {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,202 @@
|
|||
<template>
|
||||
<div class="modal-card" style="width: auto">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Edit photo</p>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<div class="thumb-view">
|
||||
<img class="thumb" :src="photoSrc(photo, 'thumb')" />
|
||||
|
||||
<div class="image-info">
|
||||
<div>Original size: <strong>{{ photo.width }} x {{ photo.height }}</strong></div>
|
||||
<div v-if="photo.camera">Camera: <strong>{{ photo.camera }}</strong></div>
|
||||
<div v-if="photo.positioningError">GPS position accuracy: <strong>± {{ photo.positioningError }} m</strong></div>
|
||||
<b-checkbox v-model="isCover">Use as cover photo</b-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-field label="Description">
|
||||
<b-input type="textarea" class="title-area" v-model="title" />
|
||||
</b-field>
|
||||
|
||||
<b-field label="Date/Time">
|
||||
<b-datetimepicker v-model="date" rounded editable placeholder="Click to select..." icon="calendar-day" :max-datetime="new Date()" append-to-body open-on-focus horizontal-time-picker />
|
||||
</b-field>
|
||||
|
||||
<b-field grouped>
|
||||
<b-field label="Latitude">
|
||||
<b-input class="coord" v-model="latitude" placeholder="45.6789" />
|
||||
</b-field>
|
||||
<b-field label="Longitude">
|
||||
<b-input class="coord" v-model="longitude" placeholder="-56.789" />
|
||||
</b-field>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Direction" message="0° = North, 90° = East, 180° = South, 270° = West">
|
||||
<b-field>
|
||||
<b-input class="coord" v-model="direction" expanded />
|
||||
<p class="control">
|
||||
<span class="button is-static">°</span>
|
||||
</p>
|
||||
</b-field>
|
||||
</b-field>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<button class="button" type="button" @click="$parent.close()">Cancel</button>
|
||||
<button class="button is-info" :disabled="!isInputValid" :loading="saving" @click="save">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import photos from '../mixins/photos.js'
|
||||
import moment from 'moment'
|
||||
import api from '../mixins/api.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
summitCode: String,
|
||||
photo: Object
|
||||
},
|
||||
mixins: [photos, api],
|
||||
computed: {
|
||||
isInputValid () {
|
||||
if (this.latitude) {
|
||||
if (!this.longitude) {
|
||||
return false
|
||||
}
|
||||
let lat = parseFloat(this.latitude)
|
||||
if (lat < -90 || lat > 90) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (this.longitude) {
|
||||
if (!this.latitude) {
|
||||
return false
|
||||
}
|
||||
let lon = parseFloat(this.longitude)
|
||||
if (lon < -180 || lon > 180) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (this.direction) {
|
||||
let dir = parseFloat(this.direction)
|
||||
if (dir < 0 || dir >= 360) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
save () {
|
||||
let newData = {
|
||||
title: this.title,
|
||||
date: this.date ? moment(this.date).utcOffset(0, true).toDate() : null,
|
||||
isCover: this.isCover
|
||||
}
|
||||
if (this.latitude && this.longitude) {
|
||||
newData.coordinates = {
|
||||
latitude: parseFloat(this.latitude),
|
||||
longitude: parseFloat(this.longitude)
|
||||
}
|
||||
|
||||
if (!this.photo.coordinates ||
|
||||
Math.abs(newData.coordinates.latitude - this.photo.coordinates.latitude) > 0.000001 ||
|
||||
Math.abs(newData.coordinates.longitude - this.photo.coordinates.longitude) > 0.000001) {
|
||||
// Latitude or longitude changed by user; set positioningError to 0
|
||||
newData.positioningError = 0
|
||||
} else {
|
||||
newData.positioningError = parseFloat(this.photo.positioningError)
|
||||
}
|
||||
|
||||
newData.direction = parseFloat(this.direction)
|
||||
}
|
||||
this.saving = true
|
||||
this.editPhoto(this.summitCode, this.photo.filename, newData)
|
||||
.then(() => {
|
||||
this.$emit('photoEdited')
|
||||
this.$parent.close()
|
||||
})
|
||||
.finally(() => {
|
||||
this.saving = false
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
photo: {
|
||||
handler (newPhoto) {
|
||||
this.title = newPhoto.title
|
||||
if (newPhoto.date) {
|
||||
this.date = moment(newPhoto.date.replace('Z', '')).toDate()
|
||||
} else {
|
||||
this.date = null
|
||||
}
|
||||
if (newPhoto.coordinates) {
|
||||
this.latitude = newPhoto.coordinates.latitude
|
||||
this.longitude = newPhoto.coordinates.longitude
|
||||
} else {
|
||||
this.latitude = null
|
||||
this.longitude = null
|
||||
}
|
||||
this.direction = newPhoto.direction
|
||||
this.isCover = newPhoto.isCover
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
latitude (newLatitude) {
|
||||
// Catch comma separated lat/lon
|
||||
if (newLatitude.includes(',')) {
|
||||
let latlon = newLatitude.split(',')
|
||||
this.latitude = latlon[0].trim()
|
||||
this.longitude = latlon[1].trim()
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
title: null,
|
||||
date: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
direction: null,
|
||||
isCover: null,
|
||||
saving: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.thumb {
|
||||
max-height: 96px;
|
||||
margin-right: 1em;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
.thumb-view {
|
||||
display: flex;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.title-area >>> .textarea {
|
||||
min-height: 5em !important;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
>>> .datepicker .dropdown.is-mobile-modal .dropdown-menu {
|
||||
width: calc(100vw - 40px);
|
||||
}
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.title-area >>> .textarea {
|
||||
min-width: 24em;
|
||||
}
|
||||
.image-info {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
.coord {
|
||||
max-width: 8em;
|
||||
}
|
||||
.image-info .checkbox {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,279 @@
|
|||
<template>
|
||||
<div class="modal-card" style="width: auto">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{ this.spot ? 'Edit' : 'Add' }} Spot</p>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<b-field label="Callsign" :message="isOwnCallsign ? '' : 'You are spotting someone else\'s callsign'" :type="isOwnCallsign ? '' : 'is-info'">
|
||||
<b-input type="text" class="callsign" v-model="callsign" pattern="[a-zA-Z0-9/]{3,}" validation-message="Invalid callsign" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required />
|
||||
</b-field>
|
||||
|
||||
<b-field label="Summit reference" :message="summitDisplay" :type="summitType" :class="summitLabelClass" expanded>
|
||||
<b-field>
|
||||
<b-input type="text" ref="summitCode" v-model="summitCode" placeholder="XX/YY-000" :loading="summitLoading" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required />
|
||||
<p class="control">
|
||||
<NearbySummitsList @summitSelected="onSummitSelected" />
|
||||
</p>
|
||||
</b-field>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Frequency" :message="maybeKhz ? 'Do you really mean ' + frequency + ' MHz, or are you missing a dot?' : ''" :type="maybeKhz ? 'is-warning' : ''">
|
||||
<b-field :type="maybeKhz ? 'is-warning' : ''">
|
||||
<b-input v-model="frequency" type="number" step="any" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required />
|
||||
<p class="control">
|
||||
<span class="button is-static">MHz</span>
|
||||
</p>
|
||||
</b-field>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Mode">
|
||||
<b-field>
|
||||
<b-radio-button v-for="(curModeDisp, curMode) in allModes()" :key="curMode" v-model="mode" :size="$mq.mobile ? 'is-small' : ''" :native-value="curMode">{{ curModeDisp }}</b-radio-button>
|
||||
</b-field>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Comments">
|
||||
<b-input v-model="comments" type="text" maxlength="60" />
|
||||
</b-field>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<b-button @click="$parent.close()">Cancel</b-button>
|
||||
<b-button type="is-info" :disabled="!isInputValid" :loading="posting" @click="postSpot">{{ (this.spot && this.spot.id) ? 'Edit' : 'Add' }} Spot</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import utils from '../mixins/utils.js'
|
||||
import prefs from '../mixins/prefs.js'
|
||||
import sotawatch from '../mixins/sotawatch.js'
|
||||
import NearbySummitsList from './NearbySummitsList.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NearbySummitsList
|
||||
},
|
||||
mixins: [utils, prefs, sotawatch],
|
||||
props: {
|
||||
defaultSummitCode: String,
|
||||
spot: Object
|
||||
},
|
||||
prefs: {
|
||||
key: 'spotPrefs',
|
||||
props: ['lastCallsign', 'lastSummitCode', 'defaultComments']
|
||||
},
|
||||
mounted () {
|
||||
if (!this.callsign) {
|
||||
if (this.lastCallsign) {
|
||||
this.callsign = this.lastCallsign
|
||||
} else if (this.myCallsign) {
|
||||
this.callsign = this.myCallsign
|
||||
if (!/\/P$/.test(this.callsign)) {
|
||||
this.callsign += '/P'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!this.summitCode) {
|
||||
if (this.lastSummitCode) {
|
||||
this.summitCode = this.lastSummitCode
|
||||
}
|
||||
}
|
||||
if (!this.comments && this.defaultComments) {
|
||||
this.comments = this.defaultComments
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
summitDisplay () {
|
||||
if (this.summit) {
|
||||
if (this.$store.state.altitudeUnits === 'ft') {
|
||||
return this.summit.name + ' (' + Math.round(this.summit.altitude * 3.28084) + ' ft)'
|
||||
} else {
|
||||
return this.summit.name + ' (' + this.summit.altitude + ' m)'
|
||||
}
|
||||
} else if (this.summitInvalid) {
|
||||
return 'Summit not found'
|
||||
} else {
|
||||
return 'You can enter spaces instead of / and -'
|
||||
}
|
||||
},
|
||||
summitType () {
|
||||
if (this.summitInvalid) {
|
||||
return 'is-danger'
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
isInputValid () {
|
||||
return /^[a-zA-Z0-9/]{3,}$/.test(this.callsign) && this.summit !== null && this.isSummitValid(this.summit) && this.frequency && this.mode
|
||||
},
|
||||
summitLabelClass () {
|
||||
if (!this.summit || this.isSummitValid(this.summit)) {
|
||||
return { summitref: true }
|
||||
} else {
|
||||
return { summitref: true, invalid: true }
|
||||
}
|
||||
},
|
||||
isOwnCallsign () {
|
||||
return (!this.callsign || !this.myCallsign || (this.homeCallsign(this.callsign) === this.homeCallsign(this.myCallsign)))
|
||||
},
|
||||
maybeKhz () {
|
||||
return (this.frequency && this.frequency > 1500)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
defaultSummitCode: {
|
||||
immediate: true,
|
||||
handler () {
|
||||
if (!this.summitCode) {
|
||||
this.summitCode = this.defaultSummitCode
|
||||
}
|
||||
}
|
||||
},
|
||||
summitCode: {
|
||||
immediate: true,
|
||||
handler () {
|
||||
if (this.summitCode) {
|
||||
// Shorthand input
|
||||
let summitRegex = /^([A-Z0-9]{1,8})[/ ]([A-Z]{2})[- ]?([0-9]{3})$/i
|
||||
let matches = this.summitCode.match(summitRegex)
|
||||
if (matches) {
|
||||
this.summitCode = (matches[1] + '/' + matches[2] + '-' + matches[3]).toUpperCase()
|
||||
this.summitLoading = true
|
||||
axios.get('https://api.sotl.as/summits/' + this.summitCode)
|
||||
.then(response => {
|
||||
this.summitLoading = false
|
||||
this.summitInvalid = false
|
||||
this.summit = response.data
|
||||
})
|
||||
.catch(() => {
|
||||
this.summitLoading = false
|
||||
this.summitInvalid = true
|
||||
this.summit = null
|
||||
})
|
||||
} else {
|
||||
this.summit = null
|
||||
this.summitInvalid = false
|
||||
}
|
||||
} else {
|
||||
this.summit = null
|
||||
this.summitInvalid = false
|
||||
}
|
||||
}
|
||||
},
|
||||
spot: {
|
||||
immediate: true,
|
||||
handler () {
|
||||
if (this.spot) {
|
||||
this.callsign = this.spot.activatorCallsign
|
||||
this.summitCode = this.spot.summit.code
|
||||
this.frequency = this.spot.frequency
|
||||
this.mode = this.spot.mode.toLowerCase()
|
||||
this.comments = (this.spot.comments ? this.spot.comments.replace('[sotl.as]', '').trim() : '')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
postSpot () {
|
||||
this.lastCallsign = this.callsign.toUpperCase()
|
||||
this.lastSummitCode = this.summitCode
|
||||
|
||||
// Advertise in comments :)
|
||||
let commentsTag = '[sotl.as]'
|
||||
let comments = this.comments
|
||||
if ((comments.length + commentsTag.length) < 60 && !comments.endsWith(commentsTag)) {
|
||||
if (comments.length > 0) {
|
||||
comments += ' '
|
||||
}
|
||||
comments += commentsTag
|
||||
}
|
||||
|
||||
let params = {
|
||||
callsign: this.myCallsign,
|
||||
activatorCallsign: this.callsign.toUpperCase(),
|
||||
associationCode: this.summitCode.substring(0, this.summitCode.indexOf('/')),
|
||||
summitCode: this.summitCode.substring(this.summitCode.indexOf('/') + 1),
|
||||
frequency: this.frequency,
|
||||
mode: this.allModes()[this.mode],
|
||||
comments
|
||||
}
|
||||
if (this.spot && this.spot.id) {
|
||||
params.id = this.spot.id
|
||||
}
|
||||
this.posting = true
|
||||
this.postSotaWatchSpot(params)
|
||||
.then(response => {
|
||||
this.$store.commit('updateSpot', {
|
||||
id: (this.spot && this.spot.id) ? this.spot.id : response.data.id,
|
||||
timeStamp: response.data.timeStamp,
|
||||
frequency: response.data.frequency,
|
||||
mode: response.data.mode,
|
||||
summit: this.summit,
|
||||
activatorCallsign: response.data.activatorCallsign,
|
||||
callsign: response.data.callsign,
|
||||
comments: response.data.comments
|
||||
})
|
||||
|
||||
this.$parent.close()
|
||||
})
|
||||
.finally(() => {
|
||||
this.posting = false
|
||||
})
|
||||
},
|
||||
onSummitSelected (summit) {
|
||||
this.summitCode = summit.code
|
||||
this.$nextTick(() => {
|
||||
this.$refs.summitCode.checkHtml5Validity()
|
||||
})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
callsign: '',
|
||||
lastCallsign: null,
|
||||
defaultComments: '',
|
||||
summitCode: '',
|
||||
lastSummitCode: null,
|
||||
frequency: '',
|
||||
mode: '',
|
||||
comments: '',
|
||||
summit: null,
|
||||
summitInvalid: false,
|
||||
summitLoading: false,
|
||||
posting: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.callsign >>> input {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.invalid >>> .help {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
>>> input::-webkit-outer-spin-button,
|
||||
>>> input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
>>> input[type=number] {
|
||||
-moz-appearance:textfield;
|
||||
}
|
||||
.summitref .field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
>>> .help.is-warning {
|
||||
color: #cda400;
|
||||
}
|
||||
|
||||
/* Fix from https://github.com/buefy/buefy/issues/1932#issuecomment-551453842 */
|
||||
>>> .field.has-addons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
>>> .field.has-addons .help {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<b-input :value="value" ref="filter" :placeholder="placeholder" type="search" icon-pack="far" icon="search" :class="{ filter: true, invalid }" :size="size" rounded @input="updateValue" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FilterInput',
|
||||
props: {
|
||||
value: String,
|
||||
size: String,
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Filter'
|
||||
},
|
||||
isRegex: Boolean
|
||||
},
|
||||
computed: {
|
||||
invalid () {
|
||||
if (!this.isRegex) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
RegExp(this.value)
|
||||
return false
|
||||
} catch (e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateValue (value) {
|
||||
this.$emit('input', value)
|
||||
},
|
||||
focus () {
|
||||
this.$refs.filter.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter {
|
||||
max-width: 20em;
|
||||
}
|
||||
.filter.invalid >>> input {
|
||||
background-color: #ffeeee;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<footer class="footer">
|
||||
<div class="content has-text-centered">
|
||||
<p>
|
||||
<strong>SOTA Atlas</strong> by Manuel HB9DQM. <router-link to="/about">About</router-link>
|
||||
</p>
|
||||
<p class="version">
|
||||
{{ version }}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Footer',
|
||||
computed: {
|
||||
version () {
|
||||
return VERSION + '-' + COMMITHASH.substring(0, 7)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.version {
|
||||
font-size: 0.8em;
|
||||
color: #777;
|
||||
}
|
||||
.content p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,97 @@
|
|||
<template>
|
||||
<div ref="chart"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
data: Array,
|
||||
labelField: String,
|
||||
valueField: String,
|
||||
valueFieldB: String,
|
||||
name: String,
|
||||
nameB: String,
|
||||
xIsSeries: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
animate: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
suffixX: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
suffixY: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateChart () {
|
||||
let labels = []
|
||||
let values = []
|
||||
let valuesB = []
|
||||
this.data.forEach(row => {
|
||||
labels.push(row[this.labelField])
|
||||
values.push(row[this.valueField])
|
||||
})
|
||||
|
||||
let datasets = [{
|
||||
values,
|
||||
name: this.name
|
||||
}]
|
||||
|
||||
if (this.valueFieldB) {
|
||||
this.data.forEach(row => {
|
||||
valuesB.push(row[this.valueFieldB])
|
||||
})
|
||||
datasets.push({
|
||||
values: valuesB,
|
||||
name: this.nameB
|
||||
})
|
||||
}
|
||||
|
||||
this.chart = new Chart(this.$refs.chart, {
|
||||
data: {
|
||||
labels,
|
||||
datasets: datasets
|
||||
},
|
||||
type: 'line',
|
||||
height: 250,
|
||||
colors: ['red'],
|
||||
axisOptions: {
|
||||
xAxisMode: 'tick',
|
||||
xIsSeries: this.xIsSeries
|
||||
},
|
||||
lineOptions: {
|
||||
hideDots: true,
|
||||
regionFill: true
|
||||
},
|
||||
tooltipOptions: {
|
||||
formatTooltipX: d => d + this.suffixX,
|
||||
formatTooltipY: d => d + this.suffixY
|
||||
},
|
||||
animate: this.animate
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
data () {
|
||||
this.updateChart()
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateChart()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
>>> .graph-svg-tip .title {
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<div v-if="$mq.mobile" class="liveinfo">
|
||||
LIVE <span :class="{ indicator: true, connected: this.$store.state.socket.isConnected }">●</span>
|
||||
</div>
|
||||
<b-tooltip v-else class="liveinfo" type="is-info" label="New spots appear immediately, no need to refresh the page" position="is-left">
|
||||
<b-taglist attached>
|
||||
<b-tag type="is-dark">Live Feed</b-tag>
|
||||
<b-tag :type="this.$store.state.socket.isConnected ? 'is-success' : 'is-danger'">{{ this.$store.state.socket.isConnected ? 'CONNECTED' : 'DISCONNECTED' }}</b-tag>
|
||||
</b-taglist>
|
||||
</b-tooltip>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.liveinfo {
|
||||
margin-left: 1em;
|
||||
}
|
||||
.indicator {
|
||||
color: #dd0000;
|
||||
}
|
||||
.indicator.connected {
|
||||
color: #00dd00;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<div class="lds-dual-ring"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'LoadingRing'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lds-dual-ring {
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
}
|
||||
.lds-dual-ring:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
margin: 1px;
|
||||
border-radius: 50%;
|
||||
border: 5px solid #aaa;
|
||||
border-color: #aaa transparent #aaa transparent;
|
||||
animation: lds-dual-ring 1.2s linear infinite;
|
||||
}
|
||||
@keyframes lds-dual-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<div class="lds-dual-ring"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lds-dual-ring {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.125em;
|
||||
}
|
||||
.lds-dual-ring:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border-radius: 50%;
|
||||
border: 0.1em solid #a0a0a0;
|
||||
border-color: #a0a0a0 transparent #a0a0a0 transparent;
|
||||
animation: lds-dual-ring 1.2s linear infinite;
|
||||
}
|
||||
@keyframes lds-dual-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,58 @@
|
|||
<template>
|
||||
<div>
|
||||
<b-table :narrowed="true" :paginated="true" :striped="true" :default-sort="['activationDate', 'desc']" :per-page="15" :data="data" :mobile-cards="false">
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="activationDate" label="Date" sortable>
|
||||
{{ props.row.activationDate | formatActivationDate }}
|
||||
</b-table-column>
|
||||
<b-table-column field="ownCallsign" label="Activator" sortable>
|
||||
<router-link :to="makeActivatorLink(props.row.ownCallsign.toUpperCase())">{{ props.row.ownCallsign.toUpperCase() }}</router-link>
|
||||
</b-table-column>
|
||||
<b-table-column field="qsos" label="QSOs" sortable numeric>
|
||||
<span class="qsos" @click="openQsoList(props.row.id)">{{ props.row.qsos }}</span>
|
||||
<font-awesome-icon :icon="['far', 'th-list']" class="faicon qsos" @click="openQsoList(props.row.id)" />
|
||||
</b-table-column>
|
||||
</template>
|
||||
</b-table>
|
||||
|
||||
<ModalQSOList :activationId="modalActivationId" @modalClosed="modalActivationId = null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from '../mixins/utils.js'
|
||||
import sotadb from '../mixins/sotadb.js'
|
||||
import ModalQSOList from '../components/ModalQSOList.vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
data: Array
|
||||
},
|
||||
mixins: [utils, sotadb],
|
||||
components: { ModalQSOList },
|
||||
data () {
|
||||
return {
|
||||
modalActivationId: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openQsoList (activationId) {
|
||||
if (!this.authenticated) {
|
||||
this.$buefy.dialog.alert('Please log in to view QSOs.')
|
||||
return
|
||||
}
|
||||
this.modalActivationId = activationId
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.faicon {
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
.qsos {
|
||||
color: #3273dc;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,61 @@
|
|||
<template>
|
||||
<b-dropdown v-if="authenticated" position="is-bottom-left" aria-role="menu">
|
||||
<a class="navbar-item callsign" slot="trigger" role="button"><span>{{ $keycloak.tokenParsed.callsign ? $keycloak.tokenParsed.callsign : $keycloak.userName }}</span><b-icon icon="angle-down"></b-icon></a>
|
||||
<b-dropdown-item v-if="$keycloak.tokenParsed.callsign" has-link><router-link :to="'/activators/' + $keycloak.tokenParsed.callsign" @click.native="$emit('linkClicked')">My activator page</router-link></b-dropdown-item>
|
||||
<b-dropdown-item @click="doAccountManagement">Manage account</b-dropdown-item>
|
||||
<b-dropdown-item @click="doLogout">Logout</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
<div v-else class="navbar-item">
|
||||
<b-button type="is-info" @click="doLogin">Login</b-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from '../mixins/utils.js'
|
||||
|
||||
export default {
|
||||
mixins: [utils],
|
||||
methods: {
|
||||
doLogin () {
|
||||
this.$emit('linkClicked')
|
||||
if (!this.$keycloak) {
|
||||
sessionStorage.setItem('wantSso', 'true')
|
||||
sessionStorage.setItem('wantSsoLogin', 'true')
|
||||
window.location.reload()
|
||||
} else {
|
||||
this.$keycloak.login()
|
||||
}
|
||||
},
|
||||
doAccountManagement () {
|
||||
this.$emit('linkClicked')
|
||||
this.$keycloak.accountManagement()
|
||||
},
|
||||
doLogout () {
|
||||
this.$emit('linkClicked')
|
||||
localStorage.removeItem('wantSso')
|
||||
sessionStorage.removeItem('wantSso')
|
||||
this.$keycloak.logoutFn()
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
authenticated: {
|
||||
immediate: true,
|
||||
handler (newAuthenticated) {
|
||||
if (newAuthenticated) {
|
||||
// Assume that if they have logged on successfully, they will want to do so every time they come back
|
||||
localStorage.setItem('wantSso', 'true')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.callsign {
|
||||
font-weight: bold;
|
||||
}
|
||||
.dropdown-trigger .icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<div v-if="!$mq.mobile" class="mapboxgl-ctrl-group mapboxgl-ctrl">
|
||||
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-download': true }" type="button" title="Download map" @click="downloadMap" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MapDownloadControl',
|
||||
inject: ['map'],
|
||||
methods: {
|
||||
downloadMap () {
|
||||
let link = document.createElement('a')
|
||||
link.download = 'map.png'
|
||||
link.href = this.map.getCanvas().toDataURL()
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mapbox-gl-download {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.6 11.676v3.613a.52.52 0 01-.52.52H4.89a.52.52 0 01-.52-.52v-3.613H3.33v3.613c0 .86.7 1.56 1.56 1.56h10.19c.86 0 1.56-.7 1.56-1.56v-3.613z'/%3E%3Cpath d='M13.216 10.402l-.735-.735-1.975 1.975V3.54h-1.04v8.103L7.49 9.667l-.735.735 3.23 3.23z'/%3E%3C/svg%3E");
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,477 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="chartData || loading" class="elevation-chart">
|
||||
<div class="elevation-controls">
|
||||
<b-button size="is-small" type="is-text" icon-left="window-close" @click="hideElevationProfile" />
|
||||
</div>
|
||||
<b-loading :active="loading" :is-full-page="false" />
|
||||
<LineChart v-if="chartData" :data="chartData" labelField="distance" valueField="elevation" name="Elevation" :xIsSeries="true" :animate="false" :suffixX="' ' + distanceUnits" :suffixY="' ' + $store.state.altitudeUnits" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MapboxDraw from '@mapbox/mapbox-gl-draw'
|
||||
import haversineDistance from 'haversine-distance'
|
||||
import cheapRuler from 'cheap-ruler'
|
||||
import togpx from 'togpx'
|
||||
import moment from 'moment'
|
||||
import axios from 'axios'
|
||||
import utils from '../mixins/utils.js'
|
||||
import LineChart from './LineChart.vue'
|
||||
import { gpx, kml } from '@tmcw/togeojson'
|
||||
|
||||
export default {
|
||||
components: { LineChart },
|
||||
inject: ['map'],
|
||||
mixins: [utils],
|
||||
mounted () {
|
||||
this.setupDraw()
|
||||
},
|
||||
watch: {
|
||||
map () {
|
||||
this.setupDraw()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
distanceUnits () {
|
||||
return (this.$store.state.altitudeUnits === 'ft' ? 'mi' : 'km')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isDrawing () {
|
||||
return (this.draw && this.draw.getMode() !== 'simple_select')
|
||||
},
|
||||
setupDraw () {
|
||||
if (!this.map || this.draw) {
|
||||
return
|
||||
}
|
||||
|
||||
this.draw = new MapboxDraw({
|
||||
controls: {
|
||||
point: true,
|
||||
line_string: true,
|
||||
trash: true,
|
||||
open: true,
|
||||
save: true
|
||||
},
|
||||
displayControlsDefault: false,
|
||||
styles: [
|
||||
{
|
||||
'id': 'gl-draw-line-midpoint',
|
||||
'type': 'circle',
|
||||
'filter': ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']],
|
||||
'paint': {
|
||||
'circle-radius': 3,
|
||||
'circle-color': '#d20c0c'
|
||||
}
|
||||
},
|
||||
{
|
||||
'id': 'gl-draw-line-inactive',
|
||||
'type': 'line',
|
||||
'filter': ['all', ['==', 'active', 'false'], ['==', '$type', 'LineString'], ['!=', 'mode', 'static']],
|
||||
'layout': {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round'
|
||||
},
|
||||
'paint': {
|
||||
'line-color': '#d20c0c',
|
||||
'line-width': 2
|
||||
}
|
||||
},
|
||||
{
|
||||
'id': 'gl-draw-line-active',
|
||||
'type': 'line',
|
||||
'filter': ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true']],
|
||||
'layout': {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round'
|
||||
},
|
||||
'paint': {
|
||||
'line-color': '#d20c0c',
|
||||
'line-dasharray': [0.2, 2],
|
||||
'line-width': 2
|
||||
}
|
||||
},
|
||||
{
|
||||
'id': 'gl-draw-polygon-and-line-vertex-stroke-inactive',
|
||||
'type': 'circle',
|
||||
'filter': ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
|
||||
'paint': {
|
||||
'circle-radius': 7,
|
||||
'circle-color': '#fff'
|
||||
}
|
||||
},
|
||||
{
|
||||
'id': 'gl-draw-polygon-and-line-vertex-inactive',
|
||||
'type': 'circle',
|
||||
'filter': ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
|
||||
'paint': {
|
||||
'circle-radius': 5,
|
||||
'circle-color': '#d20c0c'
|
||||
}
|
||||
},
|
||||
{
|
||||
'id': 'gl-draw-point-point-stroke-inactive',
|
||||
'type': 'circle',
|
||||
'filter': ['all', ['==', 'active', 'false'], ['==', '$type', 'Point'], ['==', 'meta', 'feature'], ['!=', 'mode', 'static']],
|
||||
'paint': {
|
||||
'circle-radius': 7,
|
||||
'circle-opacity': 1,
|
||||
'circle-color': '#fff'
|
||||
}
|
||||
},
|
||||
{
|
||||
'id': 'gl-draw-point-inactive',
|
||||
'type': 'circle',
|
||||
'filter': ['all', ['==', 'active', 'false'], ['==', '$type', 'Point'], ['==', 'meta', 'feature'], ['!=', 'mode', 'static']],
|
||||
'paint': {
|
||||
'circle-radius': 5,
|
||||
'circle-color': '#d20c0c'
|
||||
}
|
||||
},
|
||||
{
|
||||
'id': 'gl-draw-point-active',
|
||||
'type': 'circle',
|
||||
'filter': ['all', ['==', '$type', 'Point'], ['!=', 'meta', 'midpoint'], ['==', 'active', 'true']],
|
||||
'paint': {
|
||||
'circle-radius': 9,
|
||||
'circle-color': '#d20c0c'
|
||||
}
|
||||
},
|
||||
{
|
||||
'id': 'gl-draw-point-stroke-active',
|
||||
'type': 'circle',
|
||||
'filter': ['all', ['==', '$type', 'Point'], ['==', 'active', 'true'], ['!=', 'meta', 'midpoint']],
|
||||
'paint': {
|
||||
'circle-radius': 7,
|
||||
'circle-color': '#fff'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
this.map.addControl(this.draw, 'top-right')
|
||||
|
||||
this.map.addSource('_measurements', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
})
|
||||
|
||||
this.map.addLayer({
|
||||
id: '_measurements_endpoint',
|
||||
source: '_measurements',
|
||||
type: 'symbol',
|
||||
minzoom: 9,
|
||||
filter: ['all', ['==', 'measType', 'endpoint']],
|
||||
paint: {
|
||||
'text-color': '#d20c0c',
|
||||
'text-halo-color': '#fff',
|
||||
'text-halo-width': 2
|
||||
},
|
||||
layout: {
|
||||
'text-font': ['Open Sans Regular'],
|
||||
'text-field': '{label}',
|
||||
'text-size': { 'stops': [[9, 9], [12, 14]] },
|
||||
'text-allow-overlap': true,
|
||||
'text-ignore-placement': true,
|
||||
'text-offset': [1.5, 1.5]
|
||||
}
|
||||
})
|
||||
|
||||
this.map.addLayer({
|
||||
id: '_measurements_interval',
|
||||
source: '_measurements',
|
||||
type: 'symbol',
|
||||
minzoom: 12.5,
|
||||
filter: ['all', ['==', 'measType', 'interval']],
|
||||
paint: {
|
||||
'text-color': '#d20c0c'
|
||||
},
|
||||
layout: {
|
||||
'text-font': ['Open Sans Regular'],
|
||||
'text-field': '{label}',
|
||||
'text-size': 10,
|
||||
'icon-image': 'us-state_2'
|
||||
}
|
||||
})
|
||||
|
||||
// Update measurements on render
|
||||
this.map.on('draw.render', e => {
|
||||
let labelFeatures = []
|
||||
let all = this.draw.getAll()
|
||||
if (all && all.features) {
|
||||
let selected = this.draw.getSelectedIds()
|
||||
let ruler = cheapRuler(this.map.getCenter().lat, 'meters')
|
||||
all.features.forEach(feature => {
|
||||
if (feature.geometry.type === 'LineString') {
|
||||
let distance = ruler.lineDistance(feature.geometry.coordinates)
|
||||
|
||||
if (distance > 0) {
|
||||
// 1 km (or 1 mi) interval markers along line, unless selected
|
||||
if (!selected.includes(feature.id) && distance < 100000) {
|
||||
let markerOffset = this.$store.state.altitudeUnits === 'ft' ? 1609.3445 : 1000
|
||||
let i = 1
|
||||
while (markerOffset < distance) {
|
||||
if (distance - markerOffset < 200) {
|
||||
break
|
||||
}
|
||||
let intervalCoords = ruler.along(feature.geometry.coordinates, markerOffset)
|
||||
labelFeatures.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: intervalCoords
|
||||
},
|
||||
properties: {
|
||||
label: i,
|
||||
measType: 'interval'
|
||||
}
|
||||
})
|
||||
i++
|
||||
markerOffset = i * (this.$store.state.altitudeUnits === 'ft' ? 1609.3445 : 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Total distance at endpoint
|
||||
labelFeatures.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: feature.geometry.coordinates[feature.geometry.coordinates.length - 1]
|
||||
},
|
||||
properties: {
|
||||
label: this.formatDistance(distance),
|
||||
measType: 'endpoint'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
this.map.getSource('_measurements').setData({
|
||||
type: 'FeatureCollection',
|
||||
features: labelFeatures
|
||||
})
|
||||
this.map.moveLayer('_measurements_interval')
|
||||
})
|
||||
|
||||
this.map.on('draw.open', e => {
|
||||
this.input = document.createElement('input')
|
||||
this.input.setAttribute('type', 'file')
|
||||
this.input.setAttribute('accept', '.gpx,.kml,application/gpx+xml,application/vnd.google-earth.kml+xml')
|
||||
this.input.addEventListener('change', (e) => {
|
||||
for (let i = 0; i < e.target.files.length; i++) {
|
||||
let reader = new FileReader()
|
||||
reader.onload = e => {
|
||||
try {
|
||||
let dom = new DOMParser().parseFromString(e.target.result, 'text/xml')
|
||||
if (!dom) {
|
||||
throw new Error('Bad XML document')
|
||||
}
|
||||
if (dom.documentElement.tagName === 'kml') {
|
||||
this.draw.set(kml(dom))
|
||||
} else {
|
||||
this.draw.set(gpx(dom))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
reader.readAsText(e.target.files[i])
|
||||
}
|
||||
}, false)
|
||||
this.input.click()
|
||||
})
|
||||
|
||||
this.map.on('draw.save', e => {
|
||||
let all = this.draw.getAll()
|
||||
if (all && all.features && all.features.length > 0) {
|
||||
const loadingComponent = this.$buefy.loading.open()
|
||||
this.addElevations(all)
|
||||
.then(() => {
|
||||
loadingComponent.close()
|
||||
let gpx = togpx(all)
|
||||
let blob = new Blob([gpx], { type: 'application/gpx+xml' })
|
||||
let url = window.URL.createObjectURL(blob)
|
||||
let link = document.createElement('a')
|
||||
link.download = 'sotlas-' + moment().format('YYYYMMDD-HHmmss') + '.gpx'
|
||||
link.href = url
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
})
|
||||
} else {
|
||||
alert('Draw at least one line or point before saving your drawing.')
|
||||
}
|
||||
})
|
||||
|
||||
this.map.on('draw.selectionchange', e => {
|
||||
this.updateElevationProfile()
|
||||
})
|
||||
|
||||
this.map.on('draw.delete', e => {
|
||||
this.updateElevationProfile()
|
||||
})
|
||||
|
||||
this.map.on('draw.update', e => {
|
||||
this.updateElevationProfile(true)
|
||||
})
|
||||
},
|
||||
calcDistance (coordinates) {
|
||||
if (coordinates.length < 2) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let distance = 0
|
||||
for (let i = 1; i < coordinates.length; i++) {
|
||||
distance += haversineDistance(
|
||||
{ lng: coordinates[i - 1][0], lat: coordinates[i - 1][1] },
|
||||
{ lng: coordinates[i][0], lat: coordinates[i][1] }
|
||||
)
|
||||
}
|
||||
return distance
|
||||
},
|
||||
formatDistance (distance) {
|
||||
if (distance === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (this.$store.state.altitudeUnits === 'ft') {
|
||||
return (distance * 0.000621371).toFixed(2) + ' mi'
|
||||
} else {
|
||||
if (distance > 1000) {
|
||||
return (distance / 1000).toFixed(2) + ' km'
|
||||
} else {
|
||||
return distance.toFixed(0) + ' m'
|
||||
}
|
||||
}
|
||||
},
|
||||
updateElevationProfile (forceUpdate = false) {
|
||||
let selectedFeatures = this.draw.getSelected()
|
||||
if (selectedFeatures.type !== 'FeatureCollection') {
|
||||
return
|
||||
}
|
||||
selectedFeatures = selectedFeatures.features
|
||||
if (selectedFeatures.length === 1 &&
|
||||
selectedFeatures[0].type === 'Feature' && selectedFeatures[0].geometry.type === 'LineString') {
|
||||
if (forceUpdate || this.selectedFeatureId !== selectedFeatures[0].id) {
|
||||
this.selectedFeatureId = selectedFeatures[0].id
|
||||
this.showElevationProfile(selectedFeatures[0].geometry.coordinates)
|
||||
}
|
||||
} else {
|
||||
this.selectedFeatureId = null
|
||||
this.hideElevationProfile()
|
||||
}
|
||||
},
|
||||
showElevationProfile (coordinates) {
|
||||
if (coordinates.length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
// Make an elevation profile by sampling the line described by the coordinates at 100 m intervals,
|
||||
// or whichever interval size is needed to stay below 300 samples
|
||||
let ruler = cheapRuler(this.map.getCenter().lat, 'meters')
|
||||
let distance = ruler.lineDistance(coordinates)
|
||||
let interval = Math.max(distance / 300, 100)
|
||||
let eleCoordinates = []
|
||||
let distances = []
|
||||
let markerOffset
|
||||
for (markerOffset = 0; markerOffset < distance; markerOffset += interval) {
|
||||
let intervalCoords = ruler.along(coordinates, markerOffset)
|
||||
distances.push(markerOffset)
|
||||
eleCoordinates.push([intervalCoords[1], intervalCoords[0]])
|
||||
}
|
||||
|
||||
// Ensure final point is added as well
|
||||
if ((distance - markerOffset + interval) > 5) {
|
||||
let last = coordinates[coordinates.length - 1]
|
||||
eleCoordinates.push([last[1], last[0]])
|
||||
distances.push(distance)
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
axios.post('https://ele.sotl.as/api', eleCoordinates)
|
||||
.then(result => {
|
||||
this.chartData = result.data.map((elevation, i) => {
|
||||
return {
|
||||
distance: this.renderDistance(distances[i]),
|
||||
elevation: this.renderElevation(elevation)
|
||||
}
|
||||
})
|
||||
this.loading = false
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
hideElevationProfile () {
|
||||
this.chartData = null
|
||||
},
|
||||
renderElevation (elevation) {
|
||||
if (this.$store.state.altitudeUnits === 'ft') {
|
||||
return Math.round(elevation * 3.28084)
|
||||
} else {
|
||||
return Math.round(elevation)
|
||||
}
|
||||
},
|
||||
renderDistance (distance) {
|
||||
if (this.$store.state.altitudeUnits === 'ft') {
|
||||
return (distance * 0.000621371).toFixed(1)
|
||||
} else {
|
||||
return (distance / 1000).toFixed(1)
|
||||
}
|
||||
},
|
||||
addElevations (obj) {
|
||||
if (obj.type !== 'FeatureCollection') {
|
||||
return
|
||||
}
|
||||
return Promise.all(obj.features.map(feature => {
|
||||
if (feature.type !== 'Feature' || feature.geometry.type !== 'LineString') {
|
||||
return
|
||||
}
|
||||
|
||||
let coordsSwapped = feature.geometry.coordinates.map(coord => [coord[1], coord[0]])
|
||||
return axios.post('https://ele.sotl.as/api', coordsSwapped)
|
||||
.then(result => {
|
||||
result.data.forEach((elevation, index) => {
|
||||
if (feature.geometry.coordinates[index].length === 2) {
|
||||
feature.geometry.coordinates[index].push(Math.round(elevation))
|
||||
}
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
chartData: null,
|
||||
loading: false,
|
||||
selectedFeatureId: null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.elevation-chart {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 1.5rem;
|
||||
width: 800px;
|
||||
max-width: 80%;
|
||||
height: 250px;
|
||||
background: white;
|
||||
filter: drop-shadow(10px 10px 16px rgba(0,0,0,0.2));
|
||||
}
|
||||
.elevation-controls {
|
||||
position: absolute;
|
||||
background: white;
|
||||
right: 0px;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<div class="mapboxgl-ctrl-group mapboxgl-ctrl">
|
||||
<button :class="{ 'enlarge-control': true, 'mapboxgl-ctrl-fullscreen': !isEnlarged, 'mapboxgl-ctrl-shrink': isEnlarged }" type="button" :title="isEnlarged ? 'Shrink' : 'Enlarge'" @click="$emit('enlarge')"><span class="mapboxgl-ctrl-icon"></span></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
isEnlarged: Boolean
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@media (max-width: 768px) {
|
||||
.enlarge-control {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,352 @@
|
|||
<template>
|
||||
<div :class="{ 'mapboxgl-ctrl-group': true, 'mapboxgl-ctrl': true, 'mapbox-gl-filter-container': true }">
|
||||
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-filter': true, active: active }" type="button" title="Toggle filter" @click="toggleFilter" />
|
||||
<div v-if="open" class="filter-container">
|
||||
<div class="filter-criterion">
|
||||
<b-field>
|
||||
<b-checkbox v-model="activationsEnabled" size="is-small">Activations</b-checkbox>
|
||||
</b-field>
|
||||
<b-field grouped>
|
||||
<b-input v-model="activationsFrom" class="count" placeholder="min." size="is-small" :disabled="!activationsEnabled" />
|
||||
<div class="tlabel">to</div>
|
||||
<b-input v-model="activationsTo" class="count" placeholder="max." size="is-small" :disabled="!activationsEnabled" />
|
||||
</b-field>
|
||||
<b-field grouped>
|
||||
<b-button class="control" size="is-small" @click="setActivations(0, 0)">Never</b-button>
|
||||
<b-button class="control" size="is-small" @click="setActivations(0, 3)">Rarely</b-button>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="filter-criterion">
|
||||
<b-field>
|
||||
<b-checkbox v-model="pointsEnabled" size="is-small">Points</b-checkbox>
|
||||
</b-field>
|
||||
<b-field grouped>
|
||||
<b-select v-model="pointsFrom" placeholder="min." size="is-small" :disabled="!pointsEnabled">
|
||||
<option v-for="point in points" :key="point" :value="point">{{ point }}</option>
|
||||
</b-select>
|
||||
<div class="tlabel">to</div>
|
||||
<b-select v-model="pointsTo" placeholder="max." size="is-small" :disabled="!pointsEnabled">
|
||||
<option v-for="point in points" :key="point" :value="point">{{ point }}</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="filter-criterion">
|
||||
<b-field>
|
||||
<b-checkbox v-model="altitudeEnabled" size="is-small">Altitude</b-checkbox>
|
||||
</b-field>
|
||||
<b-field grouped>
|
||||
<b-input v-model="altitudeFrom" class="altitude" placeholder="min." size="is-small" :disabled="!altitudeEnabled" />
|
||||
<div class="tlabel">to</div>
|
||||
<b-input v-model="altitudeTo" class="altitude" placeholder="max." size="is-small" :disabled="!altitudeEnabled" />
|
||||
<div class="tlabel">{{ $store.state.altitudeUnits }}</div>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="filter-criterion">
|
||||
<b-field>
|
||||
<b-checkbox v-model="activatedByEnabled" size="is-small">Activated by</b-checkbox>
|
||||
</b-field>
|
||||
<b-field grouped>
|
||||
<b-input v-model="activatedBy" class="callsign" placeholder="Callsign" size="is-small" :disabled="!activatedByEnabled" />
|
||||
<b-button v-if="myCallsign" class="control" size="is-small" @click="activatedBy = myCallsign" :disabled="!activatedByEnabled">Me</b-button>
|
||||
<b-checkbox v-model="activatedByThisYear" size="is-small">This year</b-checkbox>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="filter-criterion">
|
||||
<b-field>
|
||||
<b-checkbox v-model="notActivatedByEnabled" size="is-small">Not activated by</b-checkbox>
|
||||
</b-field>
|
||||
<b-field grouped>
|
||||
<b-input v-model="notActivatedBy" class="callsign" placeholder="Callsign" size="is-small" :disabled="!notActivatedByEnabled" />
|
||||
<b-button v-if="myCallsign" class="control" size="is-small" @click="notActivatedBy = myCallsign" :disabled="!notActivatedByEnabled">Me</b-button>
|
||||
<b-checkbox v-model="notActivatedByThisYear" size="is-small">This year</b-checkbox>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="filter-criterion">
|
||||
<b-field>
|
||||
<b-tooltip label="Only available if logged in" type="is-info" :active="!authenticated"><b-checkbox v-model="completeCandidateEnabled" size="is-small" :disabled="!authenticated">Complete candidate for me</b-checkbox></b-tooltip>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<b-button type="is-link" size="is-small" :loading="filterLoadingCount > 0" @click="updateFilter">Update</b-button>
|
||||
<b-button type="is-danger" size="is-small" @click="clearFilter">Clear</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment'
|
||||
import prefs from '../mixins/prefs.js'
|
||||
import utils from '../mixins/utils.js'
|
||||
import api from '../mixins/api.js'
|
||||
import sotadb from '../mixins/sotadb.js'
|
||||
|
||||
export default {
|
||||
name: 'MapFilterControl',
|
||||
inject: ['map'],
|
||||
mixins: [prefs, utils, api, sotadb],
|
||||
prefs: {
|
||||
key: 'mapFilter',
|
||||
props: ['activationsEnabled', 'activationsFrom', 'activationsTo', 'pointsEnabled', 'pointsFrom', 'pointsTo',
|
||||
'altitudeEnabled', 'altitudeFrom', 'altitudeTo', 'activatedByEnabled', 'activatedBy', 'activatedByThisYear',
|
||||
'notActivatedByEnabled', 'notActivatedBy', 'notActivatedByThisYear', 'completeCandidateEnabled']
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
open: false,
|
||||
active: false,
|
||||
activationsEnabled: false,
|
||||
activationsFrom: null,
|
||||
activationsTo: null,
|
||||
pointsEnabled: false,
|
||||
pointsFrom: null,
|
||||
pointsTo: null,
|
||||
altitudeEnabled: false,
|
||||
altitudeFrom: null,
|
||||
altitudeTo: null,
|
||||
activatedByEnabled: false,
|
||||
activatedBy: null,
|
||||
activatedByThisYear: false,
|
||||
notActivatedByEnabled: false,
|
||||
notActivatedBy: null,
|
||||
notActivatedByThisYear: false,
|
||||
completeCandidateEnabled: false,
|
||||
points: [1, 2, 4, 6, 8, 10],
|
||||
filterLoadingCount: 0
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateFilter()
|
||||
},
|
||||
watch: {
|
||||
filterLoadingCount (newFilterLoadingCount) {
|
||||
if (newFilterLoadingCount > 0) {
|
||||
this.$emit('startFiltering')
|
||||
} else {
|
||||
this.$emit('stopFiltering')
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
close () {
|
||||
this.open = false
|
||||
},
|
||||
toggleFilter () {
|
||||
this.open = !this.open
|
||||
},
|
||||
updateFilter () {
|
||||
let filterPromises = []
|
||||
|
||||
if (this.activationsEnabled) {
|
||||
if (this.activationsFrom && this.activationsTo && this.activationsTo < this.activationsFrom) {
|
||||
let tmp = this.activationsFrom
|
||||
this.activationsFrom = this.activationsTo
|
||||
this.activationsTo = tmp
|
||||
}
|
||||
if (this.activationsFrom !== null && this.activationsFrom !== '') {
|
||||
filterPromises.push(['>=', 'act', parseInt(this.activationsFrom)])
|
||||
}
|
||||
if (this.activationsTo !== null && this.activationsTo !== '') {
|
||||
filterPromises.push(['<=', 'act', parseInt(this.activationsTo)])
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pointsEnabled) {
|
||||
if (this.pointsFrom && this.pointsTo && this.pointsTo < this.pointsFrom) {
|
||||
let tmp = this.pointsFrom
|
||||
this.pointsFrom = this.pointsTo
|
||||
this.pointsTo = tmp
|
||||
}
|
||||
if (this.pointsFrom) {
|
||||
filterPromises.push(['>=', 'points', parseInt(this.pointsFrom)])
|
||||
}
|
||||
if (this.pointsTo) {
|
||||
filterPromises.push(['<=', 'points', parseInt(this.pointsTo)])
|
||||
}
|
||||
}
|
||||
|
||||
if (this.altitudeEnabled) {
|
||||
if (this.altitudeFrom) {
|
||||
this.altitudeFrom = parseInt(this.altitudeFrom)
|
||||
}
|
||||
if (this.altitudeTo) {
|
||||
this.altitudeTo = parseInt(this.altitudeTo)
|
||||
}
|
||||
if (this.altitudeFrom && this.altitudeTo && this.altitudeTo < this.altitudeFrom) {
|
||||
let tmp = this.altitudeFrom
|
||||
this.altitudeFrom = this.altitudeTo
|
||||
this.altitudeTo = tmp
|
||||
}
|
||||
let mul = 1
|
||||
if (this.$store.state.altitudeUnits === 'ft') {
|
||||
mul = 1 / 3.28084
|
||||
}
|
||||
if (this.altitudeFrom) {
|
||||
filterPromises.push(['>=', 'alt', Math.round(parseInt(this.altitudeFrom) * mul)])
|
||||
}
|
||||
if (this.altitudeTo) {
|
||||
filterPromises.push(['<=', 'alt', Math.round(parseInt(this.altitudeTo) * mul)])
|
||||
}
|
||||
}
|
||||
|
||||
filterPromises.push(this.makeActivationsFilter('activatedBy', ['in', 'code']))
|
||||
filterPromises.push(this.makeActivationsFilter('notActivatedBy', ['!in', 'code']))
|
||||
|
||||
if (this.completeCandidateEnabled) {
|
||||
filterPromises.push(this.makeCompleteCandidateFilter())
|
||||
}
|
||||
|
||||
Promise.all(filterPromises).then(filters => {
|
||||
let filtersNoNull = filters.filter(el => {
|
||||
return el !== null
|
||||
})
|
||||
if (filtersNoNull.length > 0) {
|
||||
this.setSummitFilter(['all'].concat(filtersNoNull))
|
||||
this.active = true
|
||||
} else {
|
||||
this.setSummitFilter(null)
|
||||
this.active = false
|
||||
}
|
||||
})
|
||||
},
|
||||
setActivations (from, to) {
|
||||
this.activationsEnabled = true
|
||||
this.activationsFrom = from
|
||||
this.activationsTo = to
|
||||
this.updateFilter()
|
||||
},
|
||||
clearFilter () {
|
||||
this.$options.prefs.props.forEach(key => {
|
||||
this[key] = null
|
||||
})
|
||||
this.updateFilter()
|
||||
},
|
||||
setSummitFilter (filter) {
|
||||
this.map.setFilter('summits_circles', filter)
|
||||
this.map.setFilter('summits_names', filter)
|
||||
this.map.setFilter('summits_activations', filter)
|
||||
this.map.setFilter('summits_inactive_circles', filter)
|
||||
this.map.setFilter('summits_inactive_names', filter)
|
||||
},
|
||||
makeActivationsFilter (paramField, filterTemplate) {
|
||||
if (!this[paramField + 'Enabled'] || !this[paramField]) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.filterLoadingCount++
|
||||
return this.loadActivations(this[paramField].toUpperCase().trim())
|
||||
.then(activations => {
|
||||
this.filterLoadingCount--
|
||||
let filter = filterTemplate
|
||||
if (this[paramField + 'ThisYear']) {
|
||||
let now = moment.utc()
|
||||
activations = activations.filter(activation => {
|
||||
return moment.utc(activation.date).isSame(now, 'year')
|
||||
})
|
||||
}
|
||||
activations.forEach(activation => {
|
||||
filter.push(activation.summit.code)
|
||||
})
|
||||
return filter
|
||||
})
|
||||
.catch(() => {
|
||||
this.filterLoadingCount--
|
||||
})
|
||||
},
|
||||
makeCompleteCandidateFilter () {
|
||||
if (!this.authenticated) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.filterLoadingCount++
|
||||
return this.loadMyChaserUniques()
|
||||
.then(chaserUniques => {
|
||||
return this.loadMyActivatorUniques()
|
||||
.then(activatorUniques => {
|
||||
// Find all summits that have been chased, but not activated
|
||||
let completeCandidates = new Set()
|
||||
chaserUniques.forEach(ent => {
|
||||
completeCandidates.add(ent)
|
||||
})
|
||||
activatorUniques.forEach(ent => {
|
||||
completeCandidates.delete(ent)
|
||||
})
|
||||
this.filterLoadingCount--
|
||||
return ['in', 'code', ...completeCandidates]
|
||||
})
|
||||
})
|
||||
},
|
||||
isActive () {
|
||||
return this.active
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mapbox-gl-filter {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M16.93 3.62C16.86 3.47 16.71 3.37 16.54 3.37H3.15c-0.17 0-0.32 0.1-0.39 0.25-0.07 0.15-0.05 0.33 0.06 0.46l5.15 6.24v5.76c0 0.15 0.08 0.29 0.2 0.37 0.07 0.04 0.15 0.06 0.23 0.06 0.07 0 0.13-0.01 0.19-0.04l2.89-1.43c0.15-0.07 0.24-0.22 0.24-0.39l0.01-4.32 5.15-6.24c0.11-0.13 0.13-0.31 0.06-0.46zm-5.97 6.27c-0.06 0.08-0.1 0.17-0.1 0.27l-0.01 4.21-2.03 1.01v-5.21c0-0.1-0.03-0.2-0.1-0.27L4.07 4.23H15.62Z' stroke-width='0.06'/%3E%3C/svg%3E%0A");
|
||||
}
|
||||
.mapbox-gl-filter.active {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M16.93 3.62C16.86 3.47 16.71 3.37 16.54 3.37H3.15c-0.17 0-0.32 0.1-0.39 0.25-0.07 0.15-0.05 0.33 0.06 0.46l5.15 6.24v5.76c0 0.15 0.08 0.29 0.2 0.37 0.07 0.04 0.15 0.06 0.23 0.06 0.07 0 0.13-0.01 0.19-0.04l2.89-1.43c0.15-0.07 0.24-0.22 0.24-0.39l0.01-4.32 5.15-6.24c0.11-0.13 0.13-0.31 0.06-0.46zm-5.97 6.27c-0.06 0.08-0.1 0.17-0.1 0.27l-0.01 4.21-2.03 1.01v-5.21c0-0.1-0.03-0.2-0.1-0.27L4.07 4.23H15.62Z' stroke-width='0.06' fill='%2333b5e5'/%3E%3C/svg%3E%0A");
|
||||
}
|
||||
.filter-criterion {
|
||||
margin: 0.3em 0;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 0.2em;
|
||||
background-color: #eee;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.filter-criterion div.tlabel {
|
||||
margin-right: 0.75rem;
|
||||
display: inline-block;
|
||||
}
|
||||
.filter-criterion .field {
|
||||
margin: 0.2rem 0.2rem 0.5rem 0.2rem;
|
||||
line-height: 1;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-criterion .field:last-child {
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.filter-criterion .callsign >>> input {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.filter-criterion .count >>> input {
|
||||
width: 4em;
|
||||
}
|
||||
.filter-criterion .altitude >>> input {
|
||||
width: 5em;
|
||||
}
|
||||
.filter-criterion .field input, .filter-criterion .field select {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
/* Fix overrides from mapbox-gl.css */
|
||||
.filter-container button {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding-bottom: calc(.375em - 1px);
|
||||
padding-left: .75em;
|
||||
padding-right: .75em;
|
||||
padding-top: calc(.375em - 1px);
|
||||
}
|
||||
.filter-criterion button {
|
||||
border: 1px solid #dbdbdb;
|
||||
background-color: #fff;
|
||||
}
|
||||
.action-buttons button {
|
||||
float: right;
|
||||
margin-left: 0.5em;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
.mapbox-gl-filter-container .filter-container {
|
||||
display: none;
|
||||
padding: 0 0.5em 0.5em 0;
|
||||
display: inline-block;
|
||||
}
|
||||
.mapbox-gl-filter-container button {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<MglPopup :coordinates="[coordinates.longitude, coordinates.latitude]" :showed="true" @close="$emit('close')">
|
||||
<div class="popup-content">
|
||||
<Coordinates :latitude="latitude" :longitude="longitude" show-maidenhead show-elevation />
|
||||
</div>
|
||||
</MglPopup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { MglPopup } from 'vue-mapbox'
|
||||
import Coordinates from './Coordinates.vue'
|
||||
|
||||
export default {
|
||||
name: 'MapInfoPopup',
|
||||
props: {
|
||||
coordinates: Object
|
||||
},
|
||||
components: {
|
||||
MglPopup, Coordinates
|
||||
},
|
||||
computed: {
|
||||
latitude () {
|
||||
return parseFloat(this.coordinates.latitude.toFixed(5))
|
||||
},
|
||||
longitude () {
|
||||
return parseFloat(this.coordinates.longitude.toFixed(5))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.popup-content {
|
||||
margin-top: 7px;
|
||||
}
|
||||
>>> .coordinates {
|
||||
vertical-align: middle;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,185 @@
|
|||
<template>
|
||||
<div class="mapboxgl-ctrl-group mapboxgl-ctrl">
|
||||
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-areas': true, active: areas }" type="button" title="Toggle areas" @click="toggleAreas" />
|
||||
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-contours': true, active: contours }" type="button" title="Toggle contours" @click="toggleContours" />
|
||||
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-hillshading': true, active: hillshading }" type="button" title="Toggle hillshading" @click="toggleHillshading" />
|
||||
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-difficulty': true, active: difficulty }" type="button" title="Toggle trail difficulty" @click="toggleDifficulty" />
|
||||
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-spots': true, active: spots }" type="button" title="Toggle spots" @click="toggleSpots" />
|
||||
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-inactive': true, active: inactive }" type="button" title="Toggle inactive summits" @click="toggleInactive" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment'
|
||||
import prefs from '../mixins/prefs.js'
|
||||
import mapstyle from '../mixins/mapstyle.js'
|
||||
|
||||
const RECENT_SPOT_AGE = 30 * 60 * 1000
|
||||
|
||||
export default {
|
||||
name: 'MapOptionsControl',
|
||||
inject: ['map'],
|
||||
mixins: [mapstyle, prefs],
|
||||
prefs: {
|
||||
key: 'mapOptions',
|
||||
props: ['areas', 'contours', 'hillshading', 'difficulty', 'spots', 'inactive']
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
areas: false,
|
||||
contours: true,
|
||||
hillshading: true,
|
||||
difficulty: true,
|
||||
spots: false,
|
||||
inactive: false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateRecentSpots()
|
||||
if (this.difficulty === undefined) {
|
||||
this.difficulty = true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
recentSpots () {
|
||||
let now = moment.utc()
|
||||
return this.$store.state.spots.filter(spot => {
|
||||
return (now.diff(spot.timeStamp) < RECENT_SPOT_AGE)
|
||||
}).map(spot => {
|
||||
return spot.summit.code
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
areas: {
|
||||
handler () {
|
||||
this.map.setLayoutProperty('regions_areas', 'visibility', this.areas ? 'visible' : 'none')
|
||||
this.map.setLayoutProperty('regions_labels', 'visibility', this.areas ? 'visible' : 'none')
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
contours: {
|
||||
handler () {
|
||||
this.map.setLayoutProperty('contour', 'visibility', this.contours ? 'visible' : 'none')
|
||||
this.map.setLayoutProperty('contour_index', 'visibility', this.contours ? 'visible' : 'none')
|
||||
this.map.setLayoutProperty('contour_label', 'visibility', this.contours ? 'visible' : 'none')
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
hillshading: {
|
||||
handler () {
|
||||
this.map.setLayoutProperty('hillshading', 'visibility', this.hillshading ? 'visible' : 'none')
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
difficulty: {
|
||||
handler () {
|
||||
this.updateDifficultyLayer()
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
mapServer: {
|
||||
handler () {
|
||||
this.updateDifficultyLayer()
|
||||
}
|
||||
},
|
||||
inactive: {
|
||||
handler () {
|
||||
this.map.setLayoutProperty('summits_inactive_circles', 'visibility', this.inactive ? 'visible' : 'none')
|
||||
this.map.setLayoutProperty('summits_inactive_names', 'visibility', this.inactive ? 'visible' : 'none')
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
recentSpots: {
|
||||
handler () {
|
||||
this.updateRecentSpots()
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
spots () {
|
||||
this.updateRecentSpots()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateDifficultyLayer () {
|
||||
this.map.setLayoutProperty('road_path_pedestrian_sac', 'visibility', this.difficulty ? 'visible' : 'none')
|
||||
this.map.setLayoutProperty('road_path_pedestrian_sac_label', 'visibility', this.difficulty ? 'visible' : 'none')
|
||||
},
|
||||
toggleAreas () {
|
||||
this.areas = !this.areas
|
||||
},
|
||||
toggleContours () {
|
||||
this.contours = !this.contours
|
||||
},
|
||||
toggleHillshading () {
|
||||
this.hillshading = !this.hillshading
|
||||
},
|
||||
toggleDifficulty () {
|
||||
this.difficulty = !this.difficulty
|
||||
},
|
||||
toggleSpots () {
|
||||
this.spots = !this.spots
|
||||
},
|
||||
toggleInactive () {
|
||||
this.inactive = !this.inactive
|
||||
},
|
||||
showInactive () {
|
||||
this.inactive = true
|
||||
},
|
||||
updateRecentSpots () {
|
||||
if (this.spots) {
|
||||
this.map.setFilter('summits_highlight', ['in', 'code', ...this.recentSpots])
|
||||
} else {
|
||||
this.map.setFilter('summits_highlight', ['in', 'code'])
|
||||
}
|
||||
},
|
||||
spotsShown () {
|
||||
return this.spots
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mapbox-gl-areas {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' class='undefined'%3E%3Cstyle%3E.a%7Bfill:none;%7D.b%7Bfill:none;stroke-linejoin:round;stroke-width:1;stroke:%23000;%7D%3C/style%3E%3Crect width='16.86' height='9.24' x='4.32' y='2.54' rx='1.1' ry='1.1' class='a'/%3E%3Crect width='9.58' height='6.86' x='2.71' y='3.05' rx='1.1' ry='1.1' class='a'/%3E%3Crect width='9.66' height='7.88' x='3.56' y='3.73' rx='1.02' ry='1.02' class='b'/%3E%3Crect ry='1.02' rx='1.02' y='8.22' x='6.78' height='7.88' width='9.66' class='b'/%3E%3C/svg%3E%0A");
|
||||
}
|
||||
.mapbox-gl-areas.active {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' class='undefined'%3E%3Cstyle%3E.a%7Bfill:none;%7D.b%7Bfill:none;stroke-linejoin:round;stroke-width:1;stroke:%2333b5e5;%7D%3C/style%3E%3Crect width='16.86' height='9.24' x='4.32' y='2.54' rx='1.1' ry='1.1' class='a'/%3E%3Crect width='9.58' height='6.86' x='2.71' y='3.05' rx='1.1' ry='1.1' class='a'/%3E%3Crect width='9.66' height='7.88' x='3.56' y='3.73' rx='1.02' ry='1.02' class='b'/%3E%3Crect ry='1.02' rx='1.02' y='8.22' x='6.78' height='7.88' width='9.66' class='b'/%3E%3C/svg%3E%0A");
|
||||
}
|
||||
|
||||
.mapbox-gl-contours {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cstyle%3E.a%7Bfill:none;stroke:%23000;%7D%3C/style%3E%3Crect width='16.864' height='9.237' x='-34.746' y='-4.068' rx='1.102' ry='1.102' fill='none'/%3E%3Cpath d='m3.644 12.203c0 0-0.169-6.186 3.898-7.712 4.068-1.525 7.712-2.881 7.712 0.339 0 3.22 2.881 5.678-0.678 7.712-3.559 2.034-3.644 4.237-6.356 4.576-2.712 0.339-4.915 0.593-4.576-0.932 0.339-1.525 0-3.983 0-3.983z' class='a'/%3E%3Cpath d='m5.903 11.25c0 0-0.106-3.859 2.431-4.81 2.537-0.951 4.81-1.797 4.81 0.211 0 2.009 1.797 3.541-0.423 4.81-2.22 1.269-2.273 2.643-3.964 2.854-1.691 0.211-3.066 0.37-2.854-0.581 0.211-0.951 0-2.484 0-2.484z' class='a'/%3E%3Cpath d='m8.206 10.493c0 0-0.044-1.612 1.016-2.009 1.06-0.397 2.009-0.751 2.009 0.088 0 0.839 0.751 1.48-0.177 2.009-0.927 0.53-0.95 1.104-1.656 1.192-0.707 0.088-1.281 0.155-1.192-0.243 0.088-0.397 0-1.038 0-1.038z' style='fill:none;stroke-width:0.71;stroke:%23000'/%3E%3C/svg%3E%0A");
|
||||
}
|
||||
.mapbox-gl-contours.active {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cstyle%3E.a%7Bfill:none;stroke:%2333b5e5;%7D%3C/style%3E%3Crect width='16.864' height='9.237' x='-34.746' y='-4.068' rx='1.102' ry='1.102' fill='none'/%3E%3Cpath d='m3.644 12.203c0 0-0.169-6.186 3.898-7.712 4.068-1.525 7.712-2.881 7.712 0.339 0 3.22 2.881 5.678-0.678 7.712-3.559 2.034-3.644 4.237-6.356 4.576-2.712 0.339-4.915 0.593-4.576-0.932 0.339-1.525 0-3.983 0-3.983z' class='a'/%3E%3Cpath d='m5.903 11.25c0 0-0.106-3.859 2.431-4.81 2.537-0.951 4.81-1.797 4.81 0.211 0 2.009 1.797 3.541-0.423 4.81-2.22 1.269-2.273 2.643-3.964 2.854-1.691 0.211-3.066 0.37-2.854-0.581 0.211-0.951 0-2.484 0-2.484z' class='a'/%3E%3Cpath d='m8.206 10.493c0 0-0.044-1.612 1.016-2.009 1.06-0.397 2.009-0.751 2.009 0.088 0 0.839 0.751 1.48-0.177 2.009-0.927 0.53-0.95 1.104-1.656 1.192-0.707 0.088-1.281 0.155-1.192-0.243 0.088-0.397 0-1.038 0-1.038z' style='fill:none;stroke-width:0.71;stroke:%2333b5e5'/%3E%3C/svg%3E%0A");
|
||||
}
|
||||
|
||||
.mapbox-gl-hillshading {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='-34.7' y='-4.1' width='16.9' height='9.2' rx='1.1' ry='1.1' fill='none'/%3E%3Cpath d='m3.0561 16.464 3.8951-8.9287 2.0974 1.9176 3.2359-5.2134 4.3745 12.284' fill='none' stroke='%23000' stroke-width='1px'/%3E%3Cpath d='m4.8986 16.645 2.7045-6.043 1.6903 1.6058 2.504-3.9553 3.0319 8.308v0.04226' fill='none' stroke='%23808080' stroke-width='.7052px'/%3E%3C/svg%3E%0A");
|
||||
}
|
||||
.mapbox-gl-hillshading.active {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='-34.7' y='-4.1' width='16.9' height='9.2' rx='1.1' ry='1.1' fill='none'/%3E%3Cpath d='m3.0561 16.464 3.8951-8.9287 2.0974 1.9176 3.2359-5.2134 4.3745 12.284' fill='none' stroke='%2333b5e5' stroke-width='1px'/%3E%3Cpath d='m4.8986 16.645 2.7045-6.043 1.6903 1.6058 2.504-3.9553 3.0319 8.308v0.04226' fill='none' stroke='%2389a9fa' stroke-width='.7052px'/%3E%3C/svg%3E%0A");
|
||||
}
|
||||
|
||||
.mapbox-gl-difficulty {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 7.056 7.056' height='20pt' width='20pt'%3E%3Cpath transform='skewX(.587) scale(1 .99995)' fill='none' stroke='%23000' stroke-width='.178' d='M3.977 1.708h2.178v3.633H3.977z'/%3E%3Cg style='line-height:1.25'%3E%3Cpath d='M1.988 5.38h.603V2.11h.884v-.508H1.088v.508h.9z' /%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
.mapbox-gl-difficulty.active {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 7.056 7.056' height='20pt' width='20pt'%3E%3Cpath transform='skewX(.587) scale(1 .99995)' fill='none' stroke='%2333b5e5' stroke-width='.178' d='M3.977 1.708h2.178v3.633H3.977z'/%3E%3Cg style='line-height:1.25'%3E%3Cpath d='M1.988 5.38h.603V2.11h.884v-.508H1.088v.508h.9z' fill='%2333b5e5' /%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.mapbox-gl-spots {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M14.6 7.6C15.7 9.5 15.3 12.1 13.7 13.7 12.1 15.4 9.3 15.7 7.3 14.4 5.4 13.3 4.4 10.9 5 8.7 5.5 6.4 7.7 4.7 10.1 4.8 10.9 4.8 11.7 5 12.4 5.4 12.8 5.1 13.1 4.8 13.4 4.5 11.2 3.1 8.2 3.2 6.1 4.8 4.1 6.2 3.1 8.8 3.6 11.2 4.1 13.8 6.3 16 8.8 16.4 11.3 16.9 13.9 15.8 15.3 13.8 16.8 11.7 16.9 8.8 15.5 6.6 15.2 6.9 14.9 7.3 14.6 7.6 14.6 7.6 14.6 7.6 14.6 7.6'/%3E%3Cpath d='M6.1 10C6.1 11.9 7.6 13.6 9.4 13.9 11.2 14.2 13.1 13 13.7 11.3 14 10.4 14 9.4 13.6 8.5 13.3 8.9 12.9 9.2 12.6 9.6 12.9 11.1 11.5 12.7 10 12.6 8.5 12.6 7.2 11.2 7.4 9.7 7.5 8.3 9 7.1 10.4 7.4 10.8 7.1 11.1 6.7 11.5 6.4 9.8 5.6 7.6 6.4 6.7 8 6.3 8.6 6.1 9.3 6.1 10 6.1 10 6.1 10 6.1 10'/%3E%3Cpath d='M16.4 4.5C16.1 4.2 15.8 3.9 15.5 3.6 13.8 5.3 12.1 7 10.3 8.7 9.4 8.5 8.5 9.4 8.7 10.3 8.9 11.2 10.1 11.6 10.8 11 11.4 10.7 11.1 9.9 11.4 9.5 13.1 7.8 14.8 6.2 16.4 4.5 16.4 4.5 16.4 4.5 16.4 4.5'/%3E%3C/svg%3E%0A");
|
||||
}
|
||||
.mapbox-gl-spots.active {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M14.6 7.6C15.7 9.5 15.3 12.1 13.7 13.7 12.1 15.4 9.3 15.7 7.3 14.4 5.4 13.3 4.4 10.9 5 8.7 5.5 6.4 7.7 4.7 10.1 4.8 10.9 4.8 11.7 5 12.4 5.4 12.8 5.1 13.1 4.8 13.4 4.5 11.2 3.1 8.2 3.2 6.1 4.8 4.1 6.2 3.1 8.8 3.6 11.2 4.1 13.8 6.3 16 8.8 16.4 11.3 16.9 13.9 15.8 15.3 13.8 16.8 11.7 16.9 8.8 15.5 6.6 15.2 6.9 14.9 7.3 14.6 7.6 14.6 7.6 14.6 7.6 14.6 7.6' fill='%2333b5e5'/%3E%3Cpath d='M6.1 10C6.1 11.9 7.6 13.6 9.4 13.9 11.2 14.2 13.1 13 13.7 11.3 14 10.4 14 9.4 13.6 8.5 13.3 8.9 12.9 9.2 12.6 9.6 12.9 11.1 11.5 12.7 10 12.6 8.5 12.6 7.2 11.2 7.4 9.7 7.5 8.3 9 7.1 10.4 7.4 10.8 7.1 11.1 6.7 11.5 6.4 9.8 5.6 7.6 6.4 6.7 8 6.3 8.6 6.1 9.3 6.1 10 6.1 10 6.1 10 6.1 10' fill='%2333b5e5'/%3E%3Cpath d='M16.4 4.5C16.1 4.2 15.8 3.9 15.5 3.6 13.8 5.3 12.1 7 10.3 8.7 9.4 8.5 8.5 9.4 8.7 10.3 8.9 11.2 10.1 11.6 10.8 11 11.4 10.7 11.1 9.9 11.4 9.5 13.1 7.8 14.8 6.2 16.4 4.5 16.4 4.5 16.4 4.5 16.4 4.5' fill='%2333b5e5' /%3E%3C/svg%3E%0A");
|
||||
}
|
||||
|
||||
.mapbox-gl-inactive {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' focusable='false' role='img' viewBox='0 0 20 20' %3E%3Cpath d='M7.98 5.79C7.98 5.79 7.83 5.85 7.83 5.85 7.83 5.85 7.71 5.96 7.71 5.96 7.71 5.96 7.66 6.03 7.66 6.03 7.66 6.03 6.81 7.37 6.81 7.37 6.81 7.37 7.72 7.94 7.72 7.94 7.72 7.94 8.13 7.31 8.13 7.31 8.13 7.31 8.58 7.99 8.58 7.99 8.58 7.99 9.48 7.39 9.48 7.39 9.48 7.39 8.56 6.01 8.56 6.01 8.56 6.01 8.45 5.89 8.45 5.89 8.45 5.89 8.31 5.81 8.31 5.81 8.31 5.81 8.15 5.78 8.15 5.78 8.15 5.78 7.98 5.79 7.98 5.79'/%3E%3Cpath d='M4.51 11.02C4.51 11.02 5.42 11.6 5.42 11.6 5.42 11.6 7.15 8.86 7.15 8.86 7.15 8.86 6.23 8.28 6.23 8.28 6.23 8.28 4.51 11.02 4.51 11.02'/%3E%3Cpath d='M9.18 8.89C9.18 8.89 10.98 11.58 10.98 11.58 10.98 11.58 11.87 10.98 11.87 10.98 11.87 10.98 10.08 8.29 10.08 8.29 10.08 8.29 9.18 8.89 9.18 8.89'/%3E%3Cpath d='M11.57 12.48C11.57 12.48 11.8 12.82 11.8 12.82 11.8 12.82 11.01 12.82 11.01 12.82 11.01 12.82 11 13.9 11 13.9 11 13.9 12.81 13.91 12.81 13.91 12.81 13.91 12.97 13.88 12.97 13.88 12.97 13.88 13.12 13.81 13.12 13.81 13.12 13.81 13.24 13.7 13.24 13.7 13.24 13.7 13.32 13.55 13.32 13.55 13.32 13.55 13.35 13.39 13.35 13.39 13.35 13.39 13.34 13.23 13.34 13.23 13.34 13.23 13.27 13.08 13.27 13.08 13.27 13.08 13.26 13.07 13.26 13.07 13.26 13.07 12.47 11.88 12.47 11.88 12.47 11.88 11.57 12.48 11.57 12.48'/%3E%3Cpath d='M6.68 13.87C6.68 13.87 9.92 13.89 9.92 13.89 9.92 13.89 9.93 12.81 9.93 12.81 9.93 12.81 6.69 12.79 6.69 12.79 6.69 12.79 6.68 13.87 6.68 13.87'/%3E%3Cpath d='M11.86 6.47C11.86 6.47 11.71 6.53 11.71 6.53 11.71 6.53 11.58 6.64 11.58 6.64 11.58 6.64 11.55 6.67 11.55 6.67 11.55 6.67 10.51 8.07 10.51 8.07 10.51 8.07 11.38 8.72 11.38 8.72 11.38 8.72 11.96 7.93 11.96 7.93 11.96 7.93 12.36 8.53 12.36 8.53 12.36 8.53 13.26 7.93 13.26 7.93 13.26 7.93 12.44 6.69 12.44 6.69 12.44 6.69 12.32 6.57 12.32 6.57 12.32 6.57 12.18 6.49 12.18 6.49 12.18 6.49 12.02 6.46 12.02 6.46 12.02 6.46 11.86 6.47 11.86 6.47M12.96 9.43C12.96 9.43 14.76 12.12 14.76 12.12 14.76 12.12 15.66 11.53 15.66 11.53 15.66 11.53 13.86 8.83 13.86 8.83 13.86 8.83 12.96 9.43 12.96 9.43M15.65 12.83C15.65 12.83 13.79 12.83 13.79 12.83 13.79 12.83 13.79 13.91 13.79 13.91 13.79 13.91 16.24 13.91 16.24 13.91 16.24 13.91 16.41 13.89 16.41 13.89 16.41 13.89 16.55 13.81 16.55 13.81 16.55 13.81 16.67 13.7 16.67 13.7 16.67 13.7 16.75 13.56 16.75 13.56 16.75 13.56 16.78 13.4 16.78 13.4 16.78 13.4 16.77 13.23 16.77 13.23 16.77 13.23 16.7 13.08 16.7 13.08 16.7 13.08 16.69 13.07 16.69 13.07 16.69 13.07 16.26 12.42 16.26 12.42 16.26 12.42 15.65 12.83 15.65 12.83'/%3E%3Cpath d='M4.9 12.44C4.9 12.44 4.67 12.78 4.67 12.78 4.67 12.78 5.47 12.78 5.47 12.78 5.47 12.78 5.48 13.86 5.48 13.86 5.48 13.86 3.66 13.87 3.66 13.87 3.66 13.87 3.5 13.84 3.5 13.84 3.5 13.84 3.35 13.77 3.35 13.77 3.35 13.77 3.23 13.66 3.23 13.66 3.23 13.66 3.16 13.52 3.16 13.52 3.16 13.52 3.12 13.35 3.12 13.35 3.12 13.35 3.14 13.19 3.14 13.19 3.14 13.19 3.21 13.04 3.21 13.04 3.21 13.04 3.21 13.03 3.21 13.03 3.21 13.03 4 11.84 4 11.84 4 11.84 4.9 12.44 4.9 12.44'/%3E%3C/svg%3E%0A");
|
||||
}
|
||||
.mapbox-gl-inactive.active {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' focusable='false' role='img' viewBox='0 0 20 20' %3E%3Cpath d='M7.98 5.79C7.98 5.79 7.83 5.85 7.83 5.85 7.83 5.85 7.71 5.96 7.71 5.96 7.71 5.96 7.66 6.03 7.66 6.03 7.66 6.03 6.81 7.37 6.81 7.37 6.81 7.37 7.72 7.94 7.72 7.94 7.72 7.94 8.13 7.31 8.13 7.31 8.13 7.31 8.58 7.99 8.58 7.99 8.58 7.99 9.48 7.39 9.48 7.39 9.48 7.39 8.56 6.01 8.56 6.01 8.56 6.01 8.45 5.89 8.45 5.89 8.45 5.89 8.31 5.81 8.31 5.81 8.31 5.81 8.15 5.78 8.15 5.78 8.15 5.78 7.98 5.79 7.98 5.79' fill='%2333b5e5'/%3E%3Cpath d='M4.51 11.02C4.51 11.02 5.42 11.6 5.42 11.6 5.42 11.6 7.15 8.86 7.15 8.86 7.15 8.86 6.23 8.28 6.23 8.28 6.23 8.28 4.51 11.02 4.51 11.02' fill='%2333b5e5'/%3E%3Cpath d='M9.18 8.89C9.18 8.89 10.98 11.58 10.98 11.58 10.98 11.58 11.87 10.98 11.87 10.98 11.87 10.98 10.08 8.29 10.08 8.29 10.08 8.29 9.18 8.89 9.18 8.89' fill='%2333b5e5'/%3E%3Cpath d='M11.57 12.48C11.57 12.48 11.8 12.82 11.8 12.82 11.8 12.82 11.01 12.82 11.01 12.82 11.01 12.82 11 13.9 11 13.9 11 13.9 12.81 13.91 12.81 13.91 12.81 13.91 12.97 13.88 12.97 13.88 12.97 13.88 13.12 13.81 13.12 13.81 13.12 13.81 13.24 13.7 13.24 13.7 13.24 13.7 13.32 13.55 13.32 13.55 13.32 13.55 13.35 13.39 13.35 13.39 13.35 13.39 13.34 13.23 13.34 13.23 13.34 13.23 13.27 13.08 13.27 13.08 13.27 13.08 13.26 13.07 13.26 13.07 13.26 13.07 12.47 11.88 12.47 11.88 12.47 11.88 11.57 12.48 11.57 12.48' fill='%2333b5e5'/%3E%3Cpath d='M6.68 13.87C6.68 13.87 9.92 13.89 9.92 13.89 9.92 13.89 9.93 12.81 9.93 12.81 9.93 12.81 6.69 12.79 6.69 12.79 6.69 12.79 6.68 13.87 6.68 13.87' fill='%2333b5e5'/%3E%3Cpath d='M11.86 6.47C11.86 6.47 11.71 6.53 11.71 6.53 11.71 6.53 11.58 6.64 11.58 6.64 11.58 6.64 11.55 6.67 11.55 6.67 11.55 6.67 10.51 8.07 10.51 8.07 10.51 8.07 11.38 8.72 11.38 8.72 11.38 8.72 11.96 7.93 11.96 7.93 11.96 7.93 12.36 8.53 12.36 8.53 12.36 8.53 13.26 7.93 13.26 7.93 13.26 7.93 12.44 6.69 12.44 6.69 12.44 6.69 12.32 6.57 12.32 6.57 12.32 6.57 12.18 6.49 12.18 6.49 12.18 6.49 12.02 6.46 12.02 6.46 12.02 6.46 11.86 6.47 11.86 6.47M12.96 9.43C12.96 9.43 14.76 12.12 14.76 12.12 14.76 12.12 15.66 11.53 15.66 11.53 15.66 11.53 13.86 8.83 13.86 8.83 13.86 8.83 12.96 9.43 12.96 9.43M15.65 12.83C15.65 12.83 13.79 12.83 13.79 12.83 13.79 12.83 13.79 13.91 13.79 13.91 13.79 13.91 16.24 13.91 16.24 13.91 16.24 13.91 16.41 13.89 16.41 13.89 16.41 13.89 16.55 13.81 16.55 13.81 16.55 13.81 16.67 13.7 16.67 13.7 16.67 13.7 16.75 13.56 16.75 13.56 16.75 13.56 16.78 13.4 16.78 13.4 16.78 13.4 16.77 13.23 16.77 13.23 16.77 13.23 16.7 13.08 16.7 13.08 16.7 13.08 16.69 13.07 16.69 13.07 16.69 13.07 16.26 12.42 16.26 12.42 16.26 12.42 15.65 12.83 15.65 12.83' fill='%2333b5e5'/%3E%3Cpath d='M4.9 12.44C4.9 12.44 4.67 12.78 4.67 12.78 4.67 12.78 5.47 12.78 5.47 12.78 5.47 12.78 5.48 13.86 5.48 13.86 5.48 13.86 3.66 13.87 3.66 13.87 3.66 13.87 3.5 13.84 3.5 13.84 3.5 13.84 3.35 13.77 3.35 13.77 3.35 13.77 3.23 13.66 3.23 13.66 3.23 13.66 3.16 13.52 3.16 13.52 3.16 13.52 3.12 13.35 3.12 13.35 3.12 13.35 3.14 13.19 3.14 13.19 3.14 13.19 3.21 13.04 3.21 13.04 3.21 13.04 3.21 13.03 3.21 13.03 3.21 13.03 4 11.84 4 11.84 4 11.84 4.9 12.44 4.9 12.44' fill='%2333b5e5'/%3E%3C/svg%3E%0A");
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<div>
|
||||
<MglMarker :coordinates="photoCoordinates">
|
||||
<div slot="marker" class="marker-icon" @click="markerClicked">
|
||||
<font-awesome-layers slot="marker" class="fa-2x fa-fw">
|
||||
<font-awesome-icon v-if="photo.direction" class="direction" :icon="['fas', 'location-arrow']" :transform="{ rotate: photo.direction - 45 }" :style="{ color: photo.highlight ? '#bb0000' : undefined, display: photo.highlight ? 'block' : undefined }" />
|
||||
<font-awesome-icon icon="circle" :style="{ color: photo.highlight ? 'red' : undefined }" />
|
||||
<font-awesome-icon :icon="['fas', 'camera']" transform="shrink-7" :style="{ color: 'white' }" />
|
||||
</font-awesome-layers>
|
||||
</div>
|
||||
<MglPopup :closeButton="false">
|
||||
<div class="thumbwrapper">
|
||||
<img class="thumb" :src="photoSrc(photo, 'thumb')" @click="$emit('photoClicked', photo)" />
|
||||
<div v-if="photo.title" class="caption">{{ photo.title }}</div>
|
||||
</div>
|
||||
</MglPopup>
|
||||
</MglMarker>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { MglMarker, MglPopup } from 'vue-mapbox'
|
||||
import photos from '../mixins/photos.js'
|
||||
|
||||
export default {
|
||||
name: 'MapPhoto',
|
||||
props: {
|
||||
summit: Object,
|
||||
photo: Object
|
||||
},
|
||||
components: {
|
||||
MglMarker, MglPopup
|
||||
},
|
||||
mixins: [photos],
|
||||
computed: {
|
||||
photoCoordinates () {
|
||||
return [this.photo.coordinates.longitude, this.photo.coordinates.latitude]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
markerClicked (e) {
|
||||
e.hitMarker = true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.marker-icon {
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.thumbwrapper {
|
||||
max-width: 172px;
|
||||
}
|
||||
.thumb {
|
||||
max-height: 128px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.caption {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.3rem;
|
||||
line-height: 1.4;
|
||||
color: #555;
|
||||
}
|
||||
.direction {
|
||||
display: none;
|
||||
color: #777;
|
||||
transform: scale(1.8);
|
||||
}
|
||||
.marker-icon:hover .direction {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,202 @@
|
|||
<template>
|
||||
<div>
|
||||
<MglGeojsonLayer v-if="trackSource" :sourceId="sourceId + '_trk'" :source="trackSource" :layerId="sourceId + '_trk'" :layer="trackLayer" before="summits_selected" />
|
||||
<MglGeojsonLayer v-if="waypointSource" :sourceId="sourceId + '_wpt'" :source="waypointSource" :layerId="sourceId + '_wpt'" :layer="waypointLayer" before="summits_selected" />
|
||||
<MglMarker v-if="startCoordinates" :coordinates="startCoordinates">
|
||||
<font-awesome-layers slot="marker" class="fa-2x">
|
||||
<font-awesome-icon icon="circle" :style="{ color: route.highlight ? '#ee0000' : '#0000ee' }" />
|
||||
<font-awesome-icon :icon="['fas', 'hiking']" transform="shrink-6" :style="{ color: 'white' }" />
|
||||
</font-awesome-layers>
|
||||
</MglMarker>
|
||||
<MglMarker v-if="parkingCoordinates" :coordinates="parkingCoordinates">
|
||||
<font-awesome-layers slot="marker" class="fa-2x">
|
||||
<font-awesome-icon icon="square" :style="{ color: 'white' }" />
|
||||
<font-awesome-icon :icon="['fas', 'parking']" :style="{ color: route.highlight ? '#ee0000' : '#0000ee' }" />
|
||||
</font-awesome-layers>
|
||||
</MglMarker>
|
||||
<MglMarker v-if="publicTransportCoordinates" :coordinates="publicTransportCoordinates">
|
||||
<font-awesome-layers slot="marker" class="fa-2x">
|
||||
<font-awesome-icon icon="square" :style="{ color: route.highlight ? '#ee0000' : '#0000ee' }" />
|
||||
<font-awesome-icon :icon="['fas', 'bus']" transform="shrink-6" :style="{ color: 'white' }" />
|
||||
</font-awesome-layers>
|
||||
</MglMarker>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import togeojson from '@mapbox/togeojson'
|
||||
import { MglGeojsonLayer, MglMarker } from 'vue-mapbox'
|
||||
import haversineDistance from 'haversine-distance'
|
||||
import tracks from '../mixins/tracks.js'
|
||||
|
||||
export default {
|
||||
name: 'MapRoute',
|
||||
props: {
|
||||
route: Object
|
||||
},
|
||||
components: {
|
||||
MglGeojsonLayer, MglMarker
|
||||
},
|
||||
mixins: [tracks],
|
||||
computed: {
|
||||
trackLayer () {
|
||||
return {
|
||||
type: 'line',
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': this.route.highlight ? '#ff0000' : '#245acd',
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.75
|
||||
}
|
||||
}
|
||||
},
|
||||
waypointLayer () {
|
||||
return {
|
||||
type: 'symbol',
|
||||
layout: {
|
||||
visibility: this.route.highlight ? 'visible' : 'none',
|
||||
'icon-image': 'information_15',
|
||||
'text-field': '{name}',
|
||||
'text-font': ['Open Sans Regular'],
|
||||
'text-size': {
|
||||
base: 1,
|
||||
stops: [
|
||||
[13, 10],
|
||||
[20, 12]
|
||||
]
|
||||
},
|
||||
'text-anchor': 'bottom',
|
||||
'text-offset': {
|
||||
stops: [
|
||||
[
|
||||
13,
|
||||
[
|
||||
0,
|
||||
2.3
|
||||
]
|
||||
],
|
||||
[
|
||||
20,
|
||||
[
|
||||
0,
|
||||
2.5
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
paint: {
|
||||
'text-color': 'rgba(200, 0, 0, 1)',
|
||||
'text-halo-color': 'rgba(255, 255, 255, 1)',
|
||||
'text-halo-width': 1,
|
||||
'text-halo-blur': 1
|
||||
}
|
||||
}
|
||||
},
|
||||
startCoordinates () {
|
||||
if (!this.route.startPoint) {
|
||||
return null
|
||||
}
|
||||
// If start point coordinates are very close to parking or public transport, then don't display them
|
||||
if ((this.route.parking && this.route.parking.coordinates && haversineDistance(this.route.startPoint.coordinates, this.route.parking.coordinates) < 50) ||
|
||||
(this.route.publicTransport && this.route.publicTransport.coordinates && haversineDistance(this.route.startPoint.coordinates, this.route.publicTransport.coordinates) < 50)) {
|
||||
return null
|
||||
}
|
||||
return [this.route.startPoint.coordinates.longitude, this.route.startPoint.coordinates.latitude]
|
||||
},
|
||||
parkingCoordinates () {
|
||||
if (!this.route.parking || !this.route.parking.coordinates) {
|
||||
return null
|
||||
}
|
||||
return [this.route.parking.coordinates.longitude, this.route.parking.coordinates.latitude]
|
||||
},
|
||||
publicTransportCoordinates () {
|
||||
if (!this.route.publicTransport || !this.route.publicTransport.coordinates) {
|
||||
return null
|
||||
}
|
||||
return [this.route.publicTransport.coordinates.longitude, this.route.publicTransport.coordinates.latitude]
|
||||
},
|
||||
trackSource () {
|
||||
if (this.geoJsonSource === null || this.geoJsonSource.data.type !== 'FeatureCollection') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: this.geoJsonSource.data.features.filter(feature => feature.geometry.type === 'LineString')
|
||||
}
|
||||
}
|
||||
},
|
||||
waypointSource () {
|
||||
if (this.geoJsonSource === null || this.geoJsonSource.data.type !== 'FeatureCollection') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: this.geoJsonSource.data.features.filter(feature => feature.geometry.type === 'Point')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
route: {
|
||||
immediate: true,
|
||||
handler () {
|
||||
if (this.route.track) {
|
||||
if (this.route.track.filename) {
|
||||
this.loadGpx()
|
||||
} else if (this.route.track.points) {
|
||||
this.convertSmpPoints()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadGpx () {
|
||||
this.sourceId = this.route.track.filename
|
||||
axios.get(this.trackUrl(this.route.track))
|
||||
.then(response => {
|
||||
let dom = (new DOMParser()).parseFromString(response.data, 'text/xml')
|
||||
this.geoJsonSource = {
|
||||
type: 'geojson',
|
||||
data: togeojson.gpx(dom)
|
||||
}
|
||||
})
|
||||
},
|
||||
convertSmpPoints () {
|
||||
this.sourceId = this.route.id
|
||||
let geojson = {
|
||||
type: 'LineString',
|
||||
coordinates: this.route.track.points.map(point => {
|
||||
return [point.longitude, point.latitude]
|
||||
})
|
||||
}
|
||||
this.geoJsonSource = {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
geometry: geojson
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
geoJsonSource: null,
|
||||
sourceId: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,256 @@
|
|||
<template>
|
||||
<MglMap v-if="(mapCenter || bounds) && mapStyle" :mapStyle="mapStyle" :bounds="bounds" :fitBoundsOptions="fitBoundsOptions" :center="mapCenter" :zoom="12.5" :dragPan="dragPanEnabled" :dragRotate="false" :attributionControl="false" @load="onMapLoaded" @click="onMapClicked" @contextmenu="onMapRightClicked" @idle="onMapIdle">
|
||||
<MglGeolocateControl v-if="!$mq.mobile || isEnlarged" :positionOptions="{ enableHighAccuracy: true }" :fitBoundsOptions="{ maxZoom: 12.5 }" :trackUserLocation="true" position="top-right" />
|
||||
<MglNavigationControl v-if="!$mq.mobile" position="top-right" :showCompass="false" />
|
||||
<MglScaleControl v-if="!$mq.mobile || isEnlarged" position="bottom-left" />
|
||||
<div v-if="canEnlarge" class="mapboxgl-ctrl-top-left">
|
||||
<MapEnlargeControl :isEnlarged="isEnlarged" @enlarge="$emit('enlarge')" />
|
||||
</div>
|
||||
<MglAttributionControl :compact="true" position="bottom-right" />
|
||||
|
||||
<MapRoute v-for="route in routes" :key="route.id" :route="route" />
|
||||
<MapPhoto v-for="photo in mapPhotos" :key="photo.filename" :summit="summit" :photo="photo" @photoClicked="photo => $emit('photoClicked', photo)" />
|
||||
<MapInfoPopup v-if="infoCoordinates !== null" :coordinates="infoCoordinates" @close="infoCoordinates = null" />
|
||||
<div v-if="zoomWarningVisible" class="zoom-warning">Zoom in to see all activations</div>
|
||||
</MglMap>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { MglMap, MglGeolocateControl, MglNavigationControl, MglScaleControl, MglAttributionControl } from 'vue-mapbox'
|
||||
import MapRoute from './MapRoute.vue'
|
||||
import MapPhoto from './MapPhoto.vue'
|
||||
import MapInfoPopup from './MapInfoPopup.vue'
|
||||
import MapEnlargeControl from './MapEnlargeControl.vue'
|
||||
import mapstyle from '../mixins/mapstyle.js'
|
||||
import utils from '../mixins/utils.js'
|
||||
import longtouch from '../mixins/longtouch.js'
|
||||
|
||||
export default {
|
||||
name: 'MiniMap',
|
||||
props: {
|
||||
summit: Object,
|
||||
routes: {
|
||||
type: Array,
|
||||
default: () => { return [] }
|
||||
},
|
||||
filter: Array,
|
||||
bounds: Array,
|
||||
isEnlarged: Boolean,
|
||||
zoomWarning: Boolean,
|
||||
showInactiveSummits: Boolean,
|
||||
canEnlarge: Boolean
|
||||
},
|
||||
components: {
|
||||
MglMap, MglGeolocateControl, MglNavigationControl, MapEnlargeControl, MglScaleControl, MglAttributionControl, MapRoute, MapPhoto, MapInfoPopup
|
||||
},
|
||||
mixins: [utils, mapstyle, longtouch],
|
||||
watch: {
|
||||
summit: {
|
||||
immediate: true,
|
||||
handler () {
|
||||
this.highlightCurrentSummit()
|
||||
}
|
||||
},
|
||||
showInactiveSummits () {
|
||||
this.showHideInactiveSummits()
|
||||
},
|
||||
dragPanEnabled () {
|
||||
if (this.dragPanEnabled) {
|
||||
this.map.dragPan.enable()
|
||||
} else {
|
||||
this.map.dragPan.disable()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
mapCenter () {
|
||||
if (this.summit && this.summit.coordinates) {
|
||||
return [this.summit.coordinates.longitude, this.summit.coordinates.latitude]
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
mapPhotos () {
|
||||
if (!this.summit || !this.summit.photos) {
|
||||
return []
|
||||
}
|
||||
return this.summit.photos.filter(photo => {
|
||||
if (photo.coordinates === undefined) {
|
||||
return false
|
||||
}
|
||||
if (photo.positioningError && photo.positioningError > 100) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
},
|
||||
dragPanEnabled () {
|
||||
return (!this.$mq.mobile || this.isEnlarged)
|
||||
},
|
||||
fitBoundsOptions () {
|
||||
return { padding: { left: 60, top: 40, right: 60, bottom: 40 }, maxZoom: 12 }
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showHideInactiveSummits () {
|
||||
if (!this.map) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.showInactiveSummits) {
|
||||
this.map.setLayoutProperty('summits_inactive_names', 'visibility', 'visible')
|
||||
this.map.setLayoutProperty('summits_inactive_circles', 'visibility', 'visible')
|
||||
} else {
|
||||
this.map.setLayoutProperty('summits_inactive_names', 'visibility', 'none')
|
||||
this.map.setLayoutProperty('summits_inactive_circles', 'visibility', 'none')
|
||||
}
|
||||
},
|
||||
highlightCurrentSummit () {
|
||||
if (!this.map || !this.summit || !this.summit.code) {
|
||||
return
|
||||
}
|
||||
|
||||
this.map.setFilter('summits_selected', ['==', 'code', this.summit.code])
|
||||
},
|
||||
onMapLoaded (event) {
|
||||
this.map = event.map
|
||||
this.map.touchZoomRotate.disableRotation()
|
||||
this.$nextTick(() => {
|
||||
this.map.resize()
|
||||
});
|
||||
['summits_circles', 'summits_inactive_circles'].forEach(layer => {
|
||||
this.map.on('mouseenter', layer, () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
this.map.on('mouseleave', layer, () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
})
|
||||
|
||||
this.map.setFilter('summits_circles', this.filter)
|
||||
this.map.setFilter('summits_names', this.filter)
|
||||
this.map.setFilter('summits_activations', this.filter)
|
||||
this.map.setFilter('summits_inactive_circles', this.filter)
|
||||
this.map.setFilter('summits_inactive_names', this.filter)
|
||||
|
||||
this.map.setLayoutProperty('contour', 'visibility', 'visible')
|
||||
this.map.setLayoutProperty('contour_index', 'visibility', 'visible')
|
||||
this.map.setLayoutProperty('contour_label', 'visibility', 'visible')
|
||||
this.map.setLayoutProperty('hillshading', 'visibility', 'visible')
|
||||
|
||||
this.updateDifficultyLayer()
|
||||
|
||||
this.installLongTouchHandler(this.map, (e) => {
|
||||
this.infoCoordinates = {
|
||||
latitude: e.lngLat.lat,
|
||||
longitude: e.lngLat.lng
|
||||
}
|
||||
})
|
||||
this.showHideInactiveSummits()
|
||||
this.highlightCurrentSummit()
|
||||
},
|
||||
onMapClicked (event) {
|
||||
if (event.mapboxEvent.originalEvent.hitMarker) {
|
||||
return
|
||||
}
|
||||
|
||||
// Search for summit circles with some padding/fuzz to make it easier to hit on mobile devices
|
||||
let point = event.mapboxEvent.point
|
||||
let bbox = [[point.x - 10, point.y - 10], [point.x + 10, point.y + 10]]
|
||||
let features = this.map.queryRenderedFeatures(bbox, { layers: ['summits_circles', 'summits_inactive_circles'] })
|
||||
|
||||
if (features.length > 0) {
|
||||
// Find the summit closest to where the user tapped
|
||||
let minDistance = null
|
||||
let chosenFeature = null
|
||||
features.forEach(feature => {
|
||||
let projected = this.map.project(feature.geometry.coordinates)
|
||||
let distance = Math.pow(projected.x - point.x, 2) + Math.pow(projected.y - point.y, 2)
|
||||
if (minDistance === null || distance < minDistance) {
|
||||
minDistance = distance
|
||||
chosenFeature = feature
|
||||
}
|
||||
})
|
||||
|
||||
if (chosenFeature && chosenFeature.properties.code) {
|
||||
if (this.summit && chosenFeature.properties.code === this.summit.code) {
|
||||
this.$router.push('/map/summits/' + chosenFeature.properties.code)
|
||||
} else {
|
||||
this.$router.push('/summits/' + chosenFeature.properties.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onMapRightClicked (event) {
|
||||
this.infoCoordinates = {
|
||||
latitude: event.mapboxEvent.lngLat.lat,
|
||||
longitude: event.mapboxEvent.lngLat.lng
|
||||
}
|
||||
},
|
||||
onMapIdle () {
|
||||
if (this.map) {
|
||||
this.zoomWarningVisible = (this.map.getZoom() < 3) && this.zoomWarning
|
||||
}
|
||||
},
|
||||
updateDifficultyLayer () {
|
||||
if (!this.map) {
|
||||
return
|
||||
}
|
||||
|
||||
// Glean main map difficulty visibility setting
|
||||
let mapOptions
|
||||
try {
|
||||
mapOptions = JSON.parse(localStorage.getItem('mapOptions'))
|
||||
if (mapOptions && mapOptions.difficulty === false) {
|
||||
this.map.setLayoutProperty('road_path_pedestrian_sac', 'visibility', 'none')
|
||||
this.map.setLayoutProperty('road_path_pedestrian_sac_label', 'visibility', 'none')
|
||||
} else {
|
||||
this.map.setLayoutProperty('road_path_pedestrian_sac', 'visibility', 'visible')
|
||||
this.map.setLayoutProperty('road_path_pedestrian_sac_label', 'visibility', 'visible')
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
resize () {
|
||||
this.$nextTick(() => {
|
||||
if (this.map) {
|
||||
this.map.resize()
|
||||
}
|
||||
})
|
||||
},
|
||||
easeTo (coordinates, zoom) {
|
||||
if (this.map) {
|
||||
this.map.easeTo({
|
||||
center: coordinates,
|
||||
zoom
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
infoCoordinates: null,
|
||||
zoomWarningVisible: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
>>> .mapboxgl-canvas-container.mapboxgl-interactive {
|
||||
cursor: auto;
|
||||
}
|
||||
.map >>> .mapboxgl-popup {
|
||||
max-width: 400px !important;
|
||||
}
|
||||
.zoom-warning {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 1.5rem;
|
||||
transform: translate(-50%, 0);
|
||||
background-color: #feffd2;
|
||||
padding: 0.2em 0.5em;
|
||||
border-radius: 0.5em;
|
||||
text-align: center;
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,155 @@
|
|||
<template>
|
||||
<b-modal :active.sync="modalActive" :can-cancel="['escape', 'outside']" has-modal-card>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title"><span v-if="activationDetails"><router-link :to="makeActivatorLink(activationDetails.OwnCallsign)">{{ activationDetails.OwnCallsign }}</router-link> on <router-link :to="makeSummitLink(summitCode)">{{ activationDetails.Summit }}</router-link>, <span class="activation-date">{{ niceActivationDate }}</span></span></p>
|
||||
<button class="delete" aria-label="close" @click="modalActive = false"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<QSOList v-if="activationDetails !== null" :data="activationDetails.ActivatorLogs" />
|
||||
<b-loading :is-full-page="false" :active="activationLoading" />
|
||||
</section>
|
||||
<footer class="modal-card-foot"></footer>
|
||||
</div>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment'
|
||||
|
||||
import QSOList from '../components/QSOList.vue'
|
||||
import sotadb from '../mixins/sotadb.js'
|
||||
import utils from '../mixins/utils.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
activationId: Number
|
||||
},
|
||||
mixins: [ sotadb, utils ],
|
||||
components: { QSOList },
|
||||
watch: {
|
||||
activationId: {
|
||||
immediate: true,
|
||||
handler () {
|
||||
if (this.activationId === null) {
|
||||
this.modalActive = false
|
||||
return
|
||||
}
|
||||
|
||||
this.activationDetails = null
|
||||
this.modalActive = true
|
||||
this.activationLoading = true
|
||||
this.loadActivationDetails(this.activationId)
|
||||
.then(activationDetails => {
|
||||
this.activationDetails = activationDetails
|
||||
this.activationLoading = false
|
||||
|
||||
let matches = activationDetails.ActivationDate.match(/^\d\d\/\d\d\/(\d\d\d\d)$/)
|
||||
if (matches) {
|
||||
this.loadS2SLog(activationDetails.UserID, matches[1])
|
||||
.then(response => {
|
||||
this.augmentS2S(response)
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.activationLoading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
modalActive (newModalActive) {
|
||||
if (!newModalActive) {
|
||||
this.$emit('modalClosed')
|
||||
this.qsoList = null
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
niceActivationDate () {
|
||||
return moment(this.activationDetails.ActivationDate, 'DD/MM/YYYY').format('DD MMM YYYY')
|
||||
},
|
||||
summitCode () {
|
||||
return this.activationDetails.Summit.substring(0, this.activationDetails.Summit.indexOf(' '))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
augmentS2S (response) {
|
||||
// Convert date (SOTA API is not consistent with formats)
|
||||
let matches = this.activationDetails.ActivationDate.match(/^(\d\d)\/(\d\d)\/(\d\d\d\d)$/)
|
||||
if (!matches) {
|
||||
return
|
||||
}
|
||||
let activationDate = `${matches[3]}-${matches[2]}-${matches[1]}`
|
||||
|
||||
// Extract summit ref.
|
||||
let summitRef = this.activationDetails.Summit.split(' ')[0]
|
||||
|
||||
// Augment activation details with S2S info, if available
|
||||
response.forEach(s2s => {
|
||||
if (s2s.ActivationDate !== activationDate || s2s.Summit2Code !== summitRef) {
|
||||
return
|
||||
}
|
||||
|
||||
// Find a QSO in the activator log with matching callsign, band, mode and approximate time
|
||||
let otherCallsign = this.homeCallsign(s2s.OtherCallsign)
|
||||
let s2sTime = moment.utc(activationDate + ' ' + s2s.TimeOfDay)
|
||||
let logEntry = this.activationDetails.ActivatorLogs.find(log => {
|
||||
if (this.homeCallsign(log.OtherCallsign) !== otherCallsign ||
|
||||
log.Band !== s2s.Band ||
|
||||
log.Mode !== s2s.Mode) {
|
||||
return false
|
||||
}
|
||||
|
||||
let logTime = moment.utc(activationDate + ' ' + log.TimeOfDay)
|
||||
if (Math.abs(logTime.diff(s2sTime, 'minutes')) > 15) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
if (logEntry) {
|
||||
// Add to notes if it does not already contain the summit ref
|
||||
if (!logEntry.Notes.includes(s2s.SummitCode)) {
|
||||
if (logEntry.Notes.length > 0 && logEntry.Notes !== 'S2S') {
|
||||
logEntry.Notes += ', '
|
||||
}
|
||||
if (logEntry.Notes !== 'S2S') {
|
||||
logEntry.Notes += 'S2S'
|
||||
}
|
||||
logEntry.Notes += ' ' + s2s.SummitCode
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
modalActive: false,
|
||||
activationLoading: false,
|
||||
activationDetails: null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.faicon {
|
||||
color: #3273dc;
|
||||
float: right;
|
||||
}
|
||||
.delete {
|
||||
left: 10px;
|
||||
}
|
||||
.activation-date {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modal-card-body {
|
||||
padding: 10px;
|
||||
min-height: 6rem;
|
||||
}
|
||||
.modal-card-title {
|
||||
font-size: 1rem;
|
||||
flex-shrink: 1;
|
||||
line-height: 1.25;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<b-tag type="is-dark" :class="tagClass">{{ mode.toUpperCase() }}</b-tag>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ModeLabel',
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
tagClass () {
|
||||
return { ['mode-' + this.mode.toLowerCase()]: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tag {
|
||||
min-width: 3em;
|
||||
padding: 0.4em 0.5em;
|
||||
color: #fff;
|
||||
line-height: 1em;
|
||||
height: auto;
|
||||
}
|
||||
.tag.mode-cw {
|
||||
background-color: #2b4970;
|
||||
}
|
||||
.tag.mode-ssb {
|
||||
background-color: #a19a36;
|
||||
}
|
||||
.tag.mode-fm {
|
||||
background-color: #a7385a;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,170 @@
|
|||
<template>
|
||||
<nav class="navbar is-fixed-top">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<div class="navbar-item">
|
||||
<router-link to="/about"><img src="../assets/sotlas.svg" alt="Logo"></router-link>
|
||||
</div>
|
||||
<div class="navbar-item clock">
|
||||
<font-awesome-icon :icon="['far', 'clock']" class="faicon" /> {{ clock }}
|
||||
</div>
|
||||
<a role="button" :class="{ 'navbar-burger': true, 'burger': true, 'is-active': burgerActive }" aria-label="menu" aria-expanded="false" data-target="navbarMenu" @click="burgerClicked">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div id="navbarMenu" :class="{ 'navbar-menu': true, 'is-active': burgerActive }">
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item">
|
||||
<SearchField :query="query" @search="closeBurger" />
|
||||
</div>
|
||||
<router-link v-for="link in links" :key="link.target" :to="link.target" :class="linkClass(link)" :title="link.title" @click.native="closeBurger">
|
||||
<b-icon v-if="link.icon" :pack="link.iconPack" :icon="link.icon" />
|
||||
{{ link.text }}
|
||||
<span v-if="!$mq.desktop">{{ link.mobileText }}</span>
|
||||
</router-link>
|
||||
<LoginButton @linkClicked="closeBurger" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment'
|
||||
import SearchField from '../components/SearchField.vue'
|
||||
import LoginButton from '../components/LoginButton.vue'
|
||||
import utils from '../mixins/utils.js'
|
||||
import EventBus from '../event-bus'
|
||||
|
||||
export default {
|
||||
name: 'NavBar',
|
||||
mixins: [ utils ],
|
||||
components: {
|
||||
SearchField, LoginButton
|
||||
},
|
||||
props: {
|
||||
query: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateClock()
|
||||
this.clockInterval = setInterval(() => {
|
||||
this.updateClock()
|
||||
}, 1000)
|
||||
},
|
||||
watch: {
|
||||
burgerActive () {
|
||||
EventBus.$emit(this.burgerActive ? 'navbarMenuOpened' : 'navbarMenuClosed')
|
||||
}
|
||||
},
|
||||
destroyed () {
|
||||
clearInterval(this.clockInterval)
|
||||
},
|
||||
methods: {
|
||||
linkClass (link) {
|
||||
let classes = { 'navbar-item': true }
|
||||
if (this.$route.path.startsWith(link.target)) {
|
||||
classes['is-active'] = true
|
||||
}
|
||||
return classes
|
||||
},
|
||||
burgerClicked () {
|
||||
this.burgerActive = !this.burgerActive
|
||||
},
|
||||
closeBurger () {
|
||||
this.burgerActive = false
|
||||
},
|
||||
updateClock () {
|
||||
let newClock = this.formatTime(moment.utc())
|
||||
if (newClock !== this.clock) {
|
||||
this.clock = newClock
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
links () {
|
||||
let mapLink = '/map'
|
||||
if (this.$route.path.match(/^\/summits\/\S+\/\S+-\d+$/)) {
|
||||
mapLink = this.$route.path.replace('/summits/', '/map/summits/')
|
||||
}
|
||||
return [
|
||||
{
|
||||
target: mapLink,
|
||||
text: 'Map'
|
||||
},
|
||||
{
|
||||
target: '/summits',
|
||||
text: 'Summits'
|
||||
},
|
||||
{
|
||||
target: '/spots',
|
||||
text: 'Spots'
|
||||
},
|
||||
{
|
||||
target: '/alerts',
|
||||
text: 'Alerts'
|
||||
},
|
||||
{
|
||||
target: '/activators',
|
||||
text: 'Activators'
|
||||
},
|
||||
{
|
||||
target: '/settings',
|
||||
mobileText: 'Settings',
|
||||
title: 'Settings',
|
||||
icon: 'cog',
|
||||
iconPack: 'fas'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
burgerActive: false,
|
||||
clock: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navbar {
|
||||
background-color: #ddd;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
@media print {
|
||||
.navbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 320px) {
|
||||
.navbar-brand img {
|
||||
max-height: 1.5rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
}
|
||||
a.navbar-item.is-active:not(:focus):not(:hover), .navbar-link.is-active:not(:focus):not(:hover) {
|
||||
background-color: whitesmoke;
|
||||
}
|
||||
.clock {
|
||||
opacity: 0.7;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.clock .faicon {
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
.navbar-brand .navbar-item {
|
||||
line-height: 1;
|
||||
}
|
||||
.navbar-menu .navbar-item span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<b-dropdown ref="dropdown" aria-role="list" position="is-bottom-left" class="nearby-summits">
|
||||
<b-button slot="trigger" icon-right="angle-down" :loading="loading" @click.stop="clickButton">Nearby</b-button>
|
||||
<b-dropdown-item v-for="summit in nearbySummits" :key="summit.code" aria-role="listitem" @click="clickSummit(summit)">
|
||||
<div class="summit-title"><div class="summit-name">{{ summit.name }}</div><div class="summit-alt"><AltitudeLabel :altitude="summit.altitude" /></div></div>
|
||||
<div class="summit-info"><div class="distance"><font-awesome-icon :icon="['far', 'arrows-h']" class="faicon" /><DistanceLabel :distance="summit.distance" :high-precision="true" /></div>{{ summit.code }}</div>
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import haversineDistance from 'haversine-distance'
|
||||
import DistanceLabel from './DistanceLabel.vue'
|
||||
import AltitudeLabel from './AltitudeLabel.vue'
|
||||
|
||||
export default {
|
||||
name: 'NearbySummitsList',
|
||||
components: {
|
||||
DistanceLabel, AltitudeLabel
|
||||
},
|
||||
methods: {
|
||||
clickButton () {
|
||||
if (this.$refs.dropdown.isActive) {
|
||||
this.$refs.dropdown.toggle()
|
||||
} else {
|
||||
if (navigator.geolocation) {
|
||||
this.loading = true
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
position => {
|
||||
axios.get('https://api.sotl.as/summits/near', { params: { lat: position.coords.latitude, lon: position.coords.longitude, limit: 5, maxDistance: 100000 } })
|
||||
.then(response => {
|
||||
if (response.data.length === 0) {
|
||||
alert('No summits within 100 km.')
|
||||
} else {
|
||||
response.data.forEach(summit => {
|
||||
summit.distance = haversineDistance(summit.coordinates, position.coords)
|
||||
})
|
||||
this.nearbySummits = response.data
|
||||
this.$refs.dropdown.toggle()
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
error => {
|
||||
this.loading = false
|
||||
alert(error.message)
|
||||
}, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000
|
||||
}
|
||||
)
|
||||
} else {
|
||||
alert('Geolocation is not supported by this browser.')
|
||||
}
|
||||
}
|
||||
},
|
||||
clickSummit (summit) {
|
||||
this.$emit('summitSelected', summit)
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
nearbySummits: [],
|
||||
loading: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summit-title {
|
||||
max-width: 100%;
|
||||
}
|
||||
.summit-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
max-width: calc(100% - 4em);
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.summit-alt {
|
||||
display: inline-block;
|
||||
font-size: 0.9em;
|
||||
color: #777;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.summit-name {
|
||||
max-width: 16em;
|
||||
}
|
||||
}
|
||||
.summit-info {
|
||||
color: #777;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.dropdown-item {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
.distance .faicon {
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
.distance {
|
||||
float: right;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,54 @@
|
|||
<template>
|
||||
<div>
|
||||
<section class="hero is-light">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<div class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<h1 class="title is-size-1 is-size-3-mobile">
|
||||
<slot name="title"></slot>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<slot name="title-right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<slot name="subtitle"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<slot></slot>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Footer from '../components/Footer.vue'
|
||||
|
||||
export default {
|
||||
name: 'PageLayout',
|
||||
components: {
|
||||
Footer
|
||||
},
|
||||
computed: {
|
||||
query () {
|
||||
return this.$route.query.q
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.level {
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.level-left + .level-right {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<div ref="chart"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
data: Array,
|
||||
labelField: String,
|
||||
valueField: String,
|
||||
name: String,
|
||||
maxSlices: {
|
||||
type: Number,
|
||||
default: 8
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateChart () {
|
||||
let labels = []
|
||||
let values = []
|
||||
this.data.forEach(row => {
|
||||
labels.push(row[this.labelField])
|
||||
values.push(row[this.valueField])
|
||||
})
|
||||
|
||||
this.chart = new Chart(this.$refs.chart, {
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
values,
|
||||
name: this.name
|
||||
}]
|
||||
},
|
||||
type: 'percentage',
|
||||
height: 150,
|
||||
maxSlices: this.maxSlices,
|
||||
barOptions: {
|
||||
depth: 1
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
data () {
|
||||
this.updateChart()
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateChart()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
>>> .graph-svg-tip .title {
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,97 @@
|
|||
<template>
|
||||
<file-pond name="photo" ref="filePond" :label-idle="labelIdle" :allow-multiple="true" :allow-replace="false" :allow-revert="false" :allow-paste="false" accepted-file-types="image/jpeg, image/png, image/heic" :server="uploadServer()" @processfile="onProcessFile" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import vueFilePond from 'vue-filepond'
|
||||
import prefs from '../mixins/prefs.js'
|
||||
import { FileStatus } from 'filepond'
|
||||
import 'filepond/dist/filepond.min.css'
|
||||
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type'
|
||||
import api from '../mixins/api.js'
|
||||
import axios from 'axios'
|
||||
|
||||
const FilePond = vueFilePond(FilePondPluginFileValidateType)
|
||||
|
||||
export default {
|
||||
props: {
|
||||
summitCode: String
|
||||
},
|
||||
components: {
|
||||
FilePond
|
||||
},
|
||||
prefs: {
|
||||
key: 'photosUploaderPrefs',
|
||||
props: ['gpsNotificationShown']
|
||||
},
|
||||
mixins: [api, prefs],
|
||||
computed: {
|
||||
labelIdle () {
|
||||
if (this.$mq.mobile) {
|
||||
return '<div>Tap to upload photos</div><div style="font-size: 0.7em">By uploading your photos, you allow SOTLAS to publish them. You can delete your photos at any time.</div>'
|
||||
} else {
|
||||
return '<div>Drag & Drop your photos or <span class="filepond--label-action">Browse</span> to upload</div><div style="font-size: 0.8em">By uploading your photos, you allow SOTLAS to publish them. You can delete your photos at any time.</div>'
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
uploadServer () {
|
||||
return {
|
||||
process: (fieldName, file, metadata, load, error, progress, abort, transfer, options) => {
|
||||
const CancelToken = axios.CancelToken
|
||||
const source = CancelToken.source()
|
||||
this.uploadPhoto(this.summitCode, file, e => progress(e.lengthComputable, e.loaded, e.total), source.token)
|
||||
.then(res => {
|
||||
load(res)
|
||||
|
||||
if (res.data.length > 0 && !res.data[0].coordinates && !this.gpsNotificationShown) {
|
||||
this.$buefy.dialog.alert({
|
||||
title: 'No GPS information',
|
||||
message: '<p>Your photo has been uploaded successfully, but did not contain GPS coordinates in its metadata (Exif header). If possible, try uploading original full-resolution files from your camera. SOTLAS will automatically reduce the resolution if needed, and will use the GPS coordinates to show the photos on the map if the position is accurate enough.</p><p><small>This alert will not appear again for future uploads with missing GPS information.</small></p>',
|
||||
type: 'is-info',
|
||||
hasIcon: true,
|
||||
icon: 'info-circle',
|
||||
iconPack: 'far'
|
||||
})
|
||||
this.gpsNotificationShown = true
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
error(err)
|
||||
})
|
||||
|
||||
return {
|
||||
abort () {
|
||||
source.cancel()
|
||||
abort()
|
||||
}
|
||||
}
|
||||
},
|
||||
fetch: null,
|
||||
revert: null
|
||||
}
|
||||
},
|
||||
onProcessFile () {
|
||||
// Check if any other uploads are pending
|
||||
if (this.$refs.filePond.getFiles().every(file => file.status === FileStatus.PROCESSING_COMPLETE)) {
|
||||
this.$emit('upload')
|
||||
this.$refs.filePond.removeFiles()
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
gpsNotificationShown: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
>>> .filepond--root {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
>>> .filepond--panel-root {
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,172 @@
|
|||
<template>
|
||||
<div>
|
||||
<div ref="container">
|
||||
<draggable v-model="myItems" handle=".handle" @change="dragChange">
|
||||
<figure v-for="(item, index) in myItems" :key="item.src">
|
||||
<a :href="item.src" :title="item.thumbTitle" @click.prevent="open(index)" @mouseover="$emit('mouseoverPicture', item, index)" @mouseleave="$emit('mouseleavePicture', item, index)">
|
||||
<img :src="item.msrc" />
|
||||
</a>
|
||||
<div class="move-button" v-if="item.editable">
|
||||
<b-button class="control handle" size="is-small" icon-left="arrows-alt" title="Drag to reorder"></b-button>
|
||||
</div>
|
||||
<div class="edit-buttons" v-if="item.editable">
|
||||
<b-button class="control" size="is-small" icon-left="edit" @click="$emit('editPicture', item, index)" title="Edit"></b-button>
|
||||
<b-button class="control" size="is-small" type="is-danger" icon-left="trash-alt" @click="$emit('deletePicture', item, index)" title="Delete"></b-button>
|
||||
</div>
|
||||
<font-awesome-icon v-if="item.thumbTitle" class="comment-icon" :icon="['far', 'comment']" />
|
||||
</figure>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<div ref="pswp" class="pswp" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="pswp__bg"></div>
|
||||
<div class="pswp__scroll-wrap">
|
||||
<div class="pswp__container">
|
||||
<div class="pswp__item"></div>
|
||||
<div class="pswp__item"></div>
|
||||
<div class="pswp__item"></div>
|
||||
</div>
|
||||
<div class="pswp__ui pswp__ui--hidden">
|
||||
<div class="pswp__top-bar">
|
||||
<div class="pswp__counter"></div>
|
||||
<button class="pswp__button pswp__button--close" title="Close (Esc)"></button>
|
||||
|
||||
<button class="pswp__button pswp__button--share" title="Share"></button>
|
||||
<button class="pswp__button pswp__button--fs" title="Toggle fullscreen"></button>
|
||||
<button class="pswp__button pswp__button--zoom" title="Zoom in/out"></button>
|
||||
<div class="pswp__preloader">
|
||||
<div class="pswp__preloader__icn">
|
||||
<div class="pswp__preloader__cut">
|
||||
<div class="pswp__preloader__donut"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
|
||||
<div class="pswp__share-tooltip"></div>
|
||||
</div>
|
||||
<button class="pswp__button pswp__button--arrow--left" title="Previous (arrow left)">
|
||||
</button>
|
||||
<button class="pswp__button pswp__button--arrow--right" title="Next (arrow right)">
|
||||
</button>
|
||||
<div class="pswp__caption">
|
||||
<div class="pswp__caption__center"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PhotoSwipe from 'photoswipe/dist/photoswipe'
|
||||
import PhotoSwipeUIDefault from 'photoswipe/dist/photoswipe-ui-default'
|
||||
import 'photoswipe/dist/photoswipe.css'
|
||||
import 'photoswipe/dist/default-skin/default-skin.css'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
draggable
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array
|
||||
},
|
||||
options: {
|
||||
default: () => ({}),
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
open (index, disableAnimation = false) {
|
||||
let that = this
|
||||
let gallery
|
||||
let options = {
|
||||
index,
|
||||
getThumbBoundsFn (index) {
|
||||
let thumbnail = Array.from(that.$refs.container.getElementsByTagName('img')).find(tag => tag.src === that.myItems[index].msrc)
|
||||
let pageYScroll = window.pageYOffset || document.documentElement.scrollTop
|
||||
let rect = thumbnail.getBoundingClientRect()
|
||||
|
||||
return { x: rect.left, y: rect.top + pageYScroll, w: rect.width }
|
||||
},
|
||||
shareButtons: [
|
||||
{ id: 'download', label: 'Download original', url: '{{raw_image_url}}', download: true }
|
||||
],
|
||||
getImageURLForShare: () => {
|
||||
return gallery.currItem.osrc || ''
|
||||
}
|
||||
}
|
||||
|
||||
if (disableAnimation) {
|
||||
options.showAnimationDuration = 0
|
||||
// Hide animation duration not 0 to prevent close click event from bubbling through
|
||||
options.hideAnimationDuration = 1
|
||||
}
|
||||
|
||||
gallery = new PhotoSwipe(this.$refs.pswp, PhotoSwipeUIDefault, this.myItems, Object.assign(options, this.options))
|
||||
gallery.init()
|
||||
},
|
||||
dragChange (event) {
|
||||
// Should not get any other type of event
|
||||
if (event.moved) {
|
||||
this.$emit('movePicture', event.moved.newIndex, event.moved.oldIndex, event.element)
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
myItems: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
items: {
|
||||
handler (newItems) {
|
||||
this.myItems = newItems
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.pswp__top-bar {
|
||||
text-align: right;
|
||||
}
|
||||
.pswp__caption__center {
|
||||
text-align: center
|
||||
}
|
||||
figure {
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
position: relative;
|
||||
}
|
||||
figure img {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.pswp__caption__center {
|
||||
max-width: 90vw;
|
||||
}
|
||||
.move-button {
|
||||
position: absolute;
|
||||
left: 0.5em;
|
||||
top: 0.5em;
|
||||
}
|
||||
.edit-buttons {
|
||||
position: absolute;
|
||||
right: 0.5em;
|
||||
bottom: 0.5em;
|
||||
}
|
||||
.edit-buttons .button {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
.comment-icon {
|
||||
position: absolute;
|
||||
left: 0.5em;
|
||||
bottom: 0.5em;
|
||||
color: white;
|
||||
filter: drop-shadow(0 0 0.15em #000);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,62 @@
|
|||
<template>
|
||||
<div ref="chart"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
data: Array,
|
||||
labelField: String,
|
||||
valueField: String,
|
||||
name: String,
|
||||
maxSlices: {
|
||||
type: Number,
|
||||
default: 8
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateChart () {
|
||||
let labels = []
|
||||
let values = []
|
||||
this.data.forEach(row => {
|
||||
labels.push(row[this.labelField])
|
||||
values.push(row[this.valueField])
|
||||
})
|
||||
|
||||
this.chart = new Chart(this.$refs.chart, {
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
values,
|
||||
name: this.name
|
||||
}]
|
||||
},
|
||||
type: 'pie',
|
||||
height: 250,
|
||||
maxSlices: this.maxSlices
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
data () {
|
||||
this.updateChart()
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateChart()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
>>> .graph-svg-tip .title {
|
||||
color: #fff;
|
||||
}
|
||||
@media (max-width: 1216px) {
|
||||
>>> svg.chart {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,76 @@
|
|||
<template>
|
||||
<b-table class="auto-width" :narrowed="true" :striped="true" :data="data" :mobile-cards="false">
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="TimeOfDay" label="Time" class="nowrap" sortable>
|
||||
{{ props.row.TimeOfDay }}
|
||||
</b-table-column>
|
||||
<b-table-column field="OtherCallsign" label="Callsign" class="nowrap" sortable>
|
||||
<CountryFlag :country="isoCodeForCallsign(props.row.OtherCallsign)" class="flag" />
|
||||
<router-link :to="makeActivatorLink(props.row.OtherCallsign)">{{ props.row.OtherCallsign }}</router-link>
|
||||
</b-table-column>
|
||||
<b-table-column field="Band" label="Band" :custom-sort="sortBand" class="nowrap" sortable numeric>
|
||||
{{ bandForFrequency(props.row.Band.replace('MHz', '')) }}
|
||||
</b-table-column>
|
||||
<b-table-column field="Mode" label="Mode" class="mode nowrap" sortable>
|
||||
<ModeLabel :mode="props.row.Mode" />
|
||||
</b-table-column>
|
||||
<b-table-column field="Notes" label="Notes" class="nowrap">
|
||||
<span v-html="formatNotes(props.row.Notes)" />
|
||||
</b-table-column>
|
||||
</template>
|
||||
</b-table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from '../mixins/utils.js'
|
||||
import prefix from '../prefix.js'
|
||||
import ModeLabel from '../components/ModeLabel.vue'
|
||||
import CountryFlag from '../components/CountryFlag.vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
data: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
components: {
|
||||
ModeLabel, CountryFlag
|
||||
},
|
||||
mixins: [utils],
|
||||
methods: {
|
||||
sortBand (a, b, isAsc) {
|
||||
let fa = parseFloat(a.Band.replace('MHz', ''))
|
||||
let fb = parseFloat(b.Band.replace('MHz', ''))
|
||||
if (fa < fb) {
|
||||
return (isAsc ? -1 : 1)
|
||||
} else if (fa === fb) {
|
||||
return 0
|
||||
} else {
|
||||
return (isAsc ? 1 : -1)
|
||||
}
|
||||
},
|
||||
isoCodeForCallsign (callsign) {
|
||||
return prefix.isoCodeForCallsign(callsign)
|
||||
},
|
||||
formatNotes (notes) {
|
||||
// Detect summit references and link them
|
||||
let doc = new DOMParser().parseFromString(notes, 'text/html')
|
||||
let notesText = doc.body.textContent || ''
|
||||
return notesText.replace(/[A-Z0-9]{1,8}\/[A-Z]{2}-[0-9]{3}/gi, match => {
|
||||
return '<a href="/summits/' + match.toUpperCase() + '">' + match.toUpperCase() + '</a>'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flag {
|
||||
margin-right: 0.4em;
|
||||
}
|
||||
.mode .tag {
|
||||
padding-top: 0.3em;
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,92 @@
|
|||
<template>
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="freqmode">{{ spot.frequency | formatFrequency }} <ModeLabel :mode="spot.mode" /></div>
|
||||
<div class="time" v-html="formatTimeDay(spot.timeStamp)" />
|
||||
<div class="callsign">
|
||||
<CountryFlag :country="country(spot.callsign)" class="flag" />
|
||||
<template v-if="callsignLink">
|
||||
<router-link :to="makeActivatorLink(spot.callsign)">{{ spot.callsign }}</router-link>
|
||||
</template>
|
||||
<template v-else>{{ spot.callsign }}</template>
|
||||
</div>
|
||||
<div class="details">
|
||||
<div class="spotter">@{{ spot.spotter }}</div>
|
||||
<div class="speedsnr">{{ spot.snr }} dB, {{ spot.speed }} wpm</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import prefix from '../prefix.js'
|
||||
import utils from '../mixins/utils.js'
|
||||
import ModeLabel from '../components/ModeLabel.vue'
|
||||
import CountryFlag from '../components/CountryFlag.vue'
|
||||
|
||||
export default {
|
||||
name: 'RBNSpotCard',
|
||||
components: {
|
||||
ModeLabel, CountryFlag
|
||||
},
|
||||
mixins: [utils],
|
||||
props: {
|
||||
spot: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
callsignLink: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
country (callsign) {
|
||||
return prefix.isoCodeForCallsign(callsign)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-content {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.6rem;
|
||||
line-height: 1.3;
|
||||
overflow: auto;
|
||||
}
|
||||
.card-content .time {
|
||||
margin-right: 0.5em;
|
||||
min-width: 3.5em;
|
||||
display: inline-block;
|
||||
}
|
||||
.card-content .callsign {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
}
|
||||
.card-content .freqmode {
|
||||
float: right;
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
.card-content .tag {
|
||||
position: relative;
|
||||
margin-left: 0.3em;
|
||||
top: -0.1em;
|
||||
}
|
||||
.card-content .details {
|
||||
margin-top: 0.1em;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.card-content .spotter {
|
||||
display: inline-block;
|
||||
margin-right: 0.5em;
|
||||
font-weight: bold;
|
||||
width: 6em;
|
||||
}
|
||||
.card-content .speedsnr {
|
||||
display: inline-block;
|
||||
}
|
||||
.card-content .flag {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,126 @@
|
|||
<template>
|
||||
<CardPagination v-if="!$mq.desktop" :data="cardSpots" rowKey="_id" :infinite="infinite" :paginated="paginated">
|
||||
<template v-slot="{ row }">
|
||||
<RBNSpotCard v-slot="{ row }" :class="recentClass(row.timeStamp)" :spot="row" :callsignLink="callsignLink" />
|
||||
</template>
|
||||
</CardPagination>
|
||||
<b-table v-else :default-sort="['timeStamp', 'desc']" :narrowed="true" :striped="true" :data="data" :paginated="paginated" :per-page="perPage" :row-class="rowClass">
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="timeStamp" class="timestamp" label="Time" sortable>
|
||||
<span v-html="formatTimeDay(props.row.timeStamp)" />
|
||||
</b-table-column>
|
||||
<b-table-column field="callsign" label="Callsign" sortable>
|
||||
<CountryFlag :country="country(props.row.callsign)" class="flag" /><template v-if="callsignLink"><router-link :to="makeActivatorLink(props.row.callsign)">{{ props.row.callsign }}</router-link></template><template v-else>{{ props.row.callsign }}</template>
|
||||
</b-table-column>
|
||||
<b-table-column field="frequency" label="Frequency" sortable numeric>
|
||||
{{ props.row.frequency | formatFrequency }}
|
||||
</b-table-column>
|
||||
<b-table-column field="mode" label="Mode" sortable>
|
||||
<ModeLabel :mode="props.row.mode" />
|
||||
</b-table-column>
|
||||
<b-table-column field="snr" label="SNR" sortable numeric>
|
||||
{{ props.row.snr }} dB
|
||||
</b-table-column>
|
||||
<b-table-column field="speed" label="Speed" sortable numeric>
|
||||
{{ props.row.speed }} wpm
|
||||
</b-table-column>
|
||||
<b-table-column field="spotter" label="Spotter" sortable>
|
||||
{{ props.row.spotter }}
|
||||
</b-table-column>
|
||||
</template>
|
||||
<template v-if="paginated" v-slot:bottom-left>
|
||||
<b-select v-model="perPage">
|
||||
<option v-for="option in perPageOptions" :key="option" :value="option">{{ option }} per page</option>
|
||||
</b-select>
|
||||
</template>
|
||||
</b-table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import prefix from '../prefix.js'
|
||||
import utils from '../mixins/utils.js'
|
||||
import prefs from '../mixins/prefs.js'
|
||||
import nowticker from '../mixins/nowticker.js'
|
||||
import ModeLabel from '../components/ModeLabel.vue'
|
||||
import CardPagination from '../components/CardPagination.vue'
|
||||
import RBNSpotCard from '../components/RBNSpotCard.vue'
|
||||
import CountryFlag from '../components/CountryFlag.vue'
|
||||
|
||||
export default {
|
||||
name: 'SpotsList',
|
||||
components: {
|
||||
ModeLabel, CardPagination, RBNSpotCard, CountryFlag
|
||||
},
|
||||
mixins: [utils, prefs, nowticker],
|
||||
prefs: {
|
||||
key: 'rbnSpotListPrefs',
|
||||
props: ['perPage']
|
||||
},
|
||||
props: {
|
||||
data: Array,
|
||||
paginated: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
infinite: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
callsignLink: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cardSpots () {
|
||||
return [...this.data].sort((a, b) => {
|
||||
if (a.timeStamp > b.timeStamp) {
|
||||
return -1
|
||||
} else if (a.timeStamp < b.timeStamp) {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
rowClass (row) {
|
||||
return this.recentClass(row.timeStamp)
|
||||
},
|
||||
country (callsign) {
|
||||
return prefix.isoCodeForCallsign(callsign)
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
perPage: 15,
|
||||
perPageOptions: [10, 15, 20, 30, 50, 100]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
tr .timestamp {
|
||||
border-left: 3px solid #e0e0e0;
|
||||
}
|
||||
tr.recent1 .timestamp {
|
||||
border-left: 3px solid #f28591;
|
||||
}
|
||||
tr.recent2 .timestamp {
|
||||
border-left: 3px solid #fbaf63;
|
||||
}
|
||||
.card {
|
||||
border-left: 3px solid #e0e0e0;
|
||||
}
|
||||
.card.recent1 {
|
||||
border-left: 3px solid #f28591;
|
||||
}
|
||||
.card.recent2 {
|
||||
border-left: 3px solid #fbaf63;
|
||||
}
|
||||
.flag {
|
||||
margin-right: 0.4em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,42 @@
|
|||
<template>
|
||||
<div class="content">
|
||||
<ul class="resources">
|
||||
<li v-for="resource in resources" :key="resource.id"><b-icon v-if="resource.icon" :icon="resource.icon" :pack="resource.iconPack" size="is-small" /><svgicon v-if="resource.svgicon" class="icon" :icon="resource.svgicon" color="#555" /><img v-if="resource.iconImg" class="icon" :src="resource.iconImg" />{{ resource.prefix ? resource.prefix + ': ' : '' }}<a :href="resource.url" target="_blank">{{ resource.title }}</a><span class="subdued author" v-if="resource.author">(by {{ resource.author }} on {{ resource.date | formatSubmittedDate }})</span><span v-if="resource.suffix" class="subdued details">{{ resource.suffix }}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment'
|
||||
|
||||
export default {
|
||||
name: 'ResourceList',
|
||||
props: {
|
||||
resources: Array
|
||||
},
|
||||
filters: {
|
||||
formatSubmittedDate (date) {
|
||||
return moment.unix(date).format('DD MMM YYYY')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.resources {
|
||||
margin-top: 0.5em;
|
||||
list-style: none;
|
||||
margin-left: 1em;
|
||||
}
|
||||
.resources .icon {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
vertical-align: bottom;
|
||||
margin-right: 0.3em;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.author, .details {
|
||||
display: inline-block;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<span class="route-attributes">
|
||||
<b-tooltip v-if="route.parking && route.parking.available" :active="!$mq.mobile" label="Parking available" type="is-info" position="is-bottom">
|
||||
<font-awesome-icon class="fa-icon clickable" :icon="['fas', 'parking']" @click="$emit('mapReposition', route.parking.coordinates)" />
|
||||
</b-tooltip>
|
||||
<b-tooltip v-if="route.publicTransport && route.publicTransport.available" :active="!$mq.mobile" label="Public transport available" type="is-info" position="is-bottom">
|
||||
<font-awesome-layers class="fa-icon clickable" @click="$emit('mapReposition', route.publicTransport.coordinates)">
|
||||
<font-awesome-icon class="fa-icon" icon="square" />
|
||||
<font-awesome-icon class="fa-icon" :icon="['fas', 'bus']" transform="shrink-6" :style="{ color: 'white' }" />
|
||||
</font-awesome-layers>
|
||||
</b-tooltip>
|
||||
<b-tooltip v-if="route.cableCar" :active="!$mq.mobile" label="Cable car/funicular available" type="is-info" position="is-bottom">
|
||||
<svgicon icon="icons8-cable-car" />
|
||||
</b-tooltip>
|
||||
<b-tooltip v-if="route.track" :active="!$mq.mobile" label="GPS track available" type="is-info" position="is-bottom">
|
||||
<TrackLink :route="route" :summit="summit"><font-awesome-icon class="fa-icon" :icon="['far', 'location']" /></TrackLink>
|
||||
</b-tooltip>
|
||||
<b-tooltip v-if="route.accessibleInWinter" :active="!$mq.mobile" label="Accessible in winter" type="is-info" position="is-bottom">
|
||||
<font-awesome-icon class="fa-icon" :icon="['far', 'snowflake']" />
|
||||
</b-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import '../compiled-icons'
|
||||
import TrackLink from './TrackLink.vue'
|
||||
|
||||
export default {
|
||||
name: 'RouteAttributes',
|
||||
props: {
|
||||
route: Object,
|
||||
summit: Object
|
||||
},
|
||||
components: { TrackLink },
|
||||
computed: {
|
||||
icons () {
|
||||
let icons = []
|
||||
if (this.route.accessibleInWinter) {
|
||||
icons.push({
|
||||
icon: 'snowflake',
|
||||
text: 'Accessible in winter'
|
||||
})
|
||||
}
|
||||
if (this.route.parking && this.route.parking.available) {
|
||||
icons.push({
|
||||
icon: 'parking',
|
||||
text: 'Parking available'
|
||||
})
|
||||
}
|
||||
if (this.route.publicTransport && this.route.publicTransport.available) {
|
||||
icons.push({
|
||||
icon: 'bus',
|
||||
text: 'Public transport available'
|
||||
})
|
||||
}
|
||||
return this.attributeList.filter((attribute) => this.route[attribute.attribute] === true)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.b-tooltip {
|
||||
margin-right: 0.25em;
|
||||
display: inline;
|
||||
}
|
||||
.fa-icon, .svg-icon {
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
}
|
||||
.b-tooltip:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
a.track-link {
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<b-tooltip type="is-info" position="is-bottom" :active="searchTooltipActive" always animated multilined label="Enter a (partial) callsign, summit name, reference, region or coordinates here">
|
||||
<b-input class="search-input" ref="query" v-model="myQuery" placeholder="Summit, Callsign, Coords..." type="search" icon-pack="far" icon="search" rounded @keydown.native.enter="doSearch" @focus="searchFocus" @blur="searchBlur" />
|
||||
</b-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import prefs from '../mixins/prefs.js'
|
||||
|
||||
export default {
|
||||
name: 'NavBar',
|
||||
props: {
|
||||
query: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
mixins: [prefs],
|
||||
prefs: {
|
||||
key: 'searchField',
|
||||
props: ['searchTooltipShown']
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
myQuery: this.query,
|
||||
searchTooltipActive: false,
|
||||
searchTooltipShown: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
searchFocus () {
|
||||
if (!this.searchTooltipShown) {
|
||||
this.searchTooltipActive = true
|
||||
}
|
||||
},
|
||||
searchBlur () {
|
||||
if (this.searchTooltipActive) {
|
||||
this.searchTooltipActive = false
|
||||
this.searchTooltipShown = true
|
||||
}
|
||||
},
|
||||
doSearch () {
|
||||
if (this.myQuery.length > 0) {
|
||||
// Coordinates?
|
||||
let coordMatches = this.myQuery.match(/^\s*(-?[0-9.]+)\s*,\s*(-?[0-9.]+)\s*$/)
|
||||
if (coordMatches) {
|
||||
this.$router.push('/map/coordinates/' + coordMatches[1] + ',' + coordMatches[2] + '/16.0?popup=1')
|
||||
} else {
|
||||
// Region?
|
||||
let regionMatches = this.myQuery.match(/^[A-Z0-9]{1,3}\/[A-Z]{2}$/i)
|
||||
if (regionMatches) {
|
||||
this.$router.push('/summits/' + this.myQuery.toUpperCase())
|
||||
} else {
|
||||
this.$router.push('/search?q=' + encodeURIComponent(this.myQuery))
|
||||
}
|
||||
}
|
||||
|
||||
this.myQuery = ''
|
||||
this.$refs.query.$el.querySelector('input').blur()
|
||||
this.$emit('search')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@media screen and (min-width: 1024px) and (max-width: 1215px) {
|
||||
.search-input {
|
||||
max-width: 13rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,122 @@
|
|||
<template>
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="freqmode">{{ spot.frequency | formatFrequency }} <ModeLabel :mode="spot.mode" /></div>
|
||||
<div class="time" v-html="formatTimeDay(spot.timeStamp)" />
|
||||
<div class="callsign">
|
||||
<template v-if="callsignLink">
|
||||
<router-link :to="makeActivatorLink(spot.activatorCallsign)">{{ spot.activatorCallsign }}</router-link>
|
||||
</template>
|
||||
<template v-else>{{ spot.activatorCallsign }}</template>
|
||||
</div>
|
||||
<div class="summit" v-if="showSummitInfo">
|
||||
<div class="summit-title" v-if="spot.summit.name">
|
||||
<CountryFlag v-if="spot.summit.isoCode" :country="spot.summit.isoCode" class="flag" />
|
||||
<router-link :to="makeSummitLink(spot.summit.code)"><span class="summit-name">{{ spot.summit.name }}</span></router-link>
|
||||
</div>
|
||||
<div class="summit-info">{{ spot.summit.code }}<template v-if="spot.summit.altitude">, <AltitudeLabel :altitude="spot.summit.altitude" />, {{ spot.summit.points }}pt<ActivationCount :activationCount="spot.summit.activationCount" /></template></div>
|
||||
</div>
|
||||
<div class="spotter">{{ spot.callsign }}</div>
|
||||
<div class="comments">{{ spot.comments }}</div>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from '../mixins/utils.js'
|
||||
import ModeLabel from '../components/ModeLabel.vue'
|
||||
import ActivationCount from '../components/ActivationCount.vue'
|
||||
import CountryFlag from '../components/CountryFlag.vue'
|
||||
import AltitudeLabel from '../components/AltitudeLabel.vue'
|
||||
|
||||
export default {
|
||||
name: 'SpotCard',
|
||||
components: {
|
||||
ModeLabel, ActivationCount, CountryFlag, AltitudeLabel
|
||||
},
|
||||
mixins: [utils],
|
||||
props: {
|
||||
spot: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
callsignLink: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showSummitInfo: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-content {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.6rem;
|
||||
line-height: 1.3;
|
||||
overflow: auto;
|
||||
}
|
||||
.card-content .time {
|
||||
margin-right: 0.5em;
|
||||
min-width: 3.5em;
|
||||
display: inline-block;
|
||||
}
|
||||
.card-content .callsign {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
}
|
||||
.card-content .freqmode {
|
||||
float: right;
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
.card-content .tag {
|
||||
position: relative;
|
||||
margin-left: 0.3em;
|
||||
top: -0.1em;
|
||||
}
|
||||
.card-content .summit {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.1em;
|
||||
}
|
||||
.card-content .summit-name {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.card-content .comments {
|
||||
font-size: 0.75rem;
|
||||
color: #777;
|
||||
margin-top: 0.1em;
|
||||
}
|
||||
.card-content .spotter {
|
||||
float: right;
|
||||
font-style: italic;
|
||||
margin-left: 0.3em;
|
||||
font-size: 0.75rem;
|
||||
clear: right;
|
||||
color: #777;
|
||||
}
|
||||
.card-content .flag {
|
||||
margin-right: 0.5em;
|
||||
margin-left: 0.1em;
|
||||
position: relative;
|
||||
top: -0.05em;
|
||||
}
|
||||
.card-content .activation-count {
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
.card-content .actions {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.summit-title {
|
||||
display: inline-block;
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.summit-info {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,266 @@
|
|||
<template>
|
||||
<div>
|
||||
<CardPagination v-if="!$mq.desktop" :data="cardSpots" :infinite="infinite" :paginated="paginated">
|
||||
<template v-slot="{ row }">
|
||||
<SpotCard :class="recentClass(row.timeStamp)" :spot="row" :callsignLink="callsignLink" :showSummitInfo="showSummitInfo">
|
||||
<template v-slot:actions>
|
||||
<div v-if="canEditSpot(row)" class="actions">
|
||||
<b-button class="control" size="is-small" outlined icon-left="edit" @click="editSpot(row)">Edit</b-button>
|
||||
<b-button class="control" size="is-small" outlined icon-left="clone" @click="cloneSpot(row)">Clone</b-button>
|
||||
<b-button class="control" size="is-small" type="is-danger" outlined icon-left="trash-alt" @click="deleteSpot(row)">Delete</b-button>
|
||||
</div>
|
||||
</template>
|
||||
</SpotCard>
|
||||
</template>
|
||||
</CardPagination>
|
||||
<b-table v-else :default-sort="['timeStamp', 'desc']" :narrowed="true" :striped="true" :data="data" :paginated="paginated" :per-page="perPage" :row-class="rowClass">
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="timeStamp" class="timestamp" label="Time" sortable>
|
||||
<span v-html="formatTimeDay(props.row.timeStamp)" />
|
||||
</b-table-column>
|
||||
<b-table-column v-if="showCallsign" field="activatorCallsign" label="Callsign" sortable>
|
||||
<template v-if="callsignLink">
|
||||
<router-link :to="makeActivatorLink(props.row.activatorCallsign)">{{ props.row.activatorCallsign }}</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ props.row.activatorCallsign }}
|
||||
</template>
|
||||
</b-table-column>
|
||||
<b-table-column field="frequency" label="Frequency" sortable :custom-sort="sortFrequency" numeric>
|
||||
{{ props.row.frequency | formatFrequency }}
|
||||
</b-table-column>
|
||||
<b-table-column field="mode" label="Mode" sortable>
|
||||
<ModeLabel :mode="props.row.mode" />
|
||||
</b-table-column>
|
||||
<b-table-column v-if="showSummitInfo" field="summit.code" label="Summit code" class="nowrap" sortable>
|
||||
<CountryFlag v-if="props.row.summit.isoCode && $mq.fullhd" :country="props.row.summit.isoCode" class="flag" />
|
||||
<router-link v-if="props.row.summit.name" :to="makeSummitLink(props.row.summit.code)">{{ props.row.summit.code }}</router-link>
|
||||
<span v-else>{{ props.row.summit.code }}</span>
|
||||
</b-table-column>
|
||||
<b-table-column v-if="showSummitInfo" field="summit.name" label="Summit name" sortable>
|
||||
<router-link :to="makeSummitLink(props.row.summit.code)">{{ props.row.summit.name }}</router-link>
|
||||
</b-table-column>
|
||||
<b-table-column v-if="showSummitInfo" field="summit.altitude" label="Altitude" sortable numeric>
|
||||
<template v-if="props.row.summit.altitude"><AltitudeLabel :altitude="props.row.summit.altitude" /></template>
|
||||
</b-table-column>
|
||||
<b-table-column v-if="showSummitInfo" field="summit.points" label="Points" sortable numeric>
|
||||
<SummitPointsLabel v-if="props.row.summit.points" :points="props.row.summit.points" />
|
||||
</b-table-column>
|
||||
<b-table-column v-if="showSummitInfo" field="summit.activationCount" label="Act." sortable numeric>
|
||||
<ActivationCount :activationCount="props.row.summit.activationCount" />
|
||||
</b-table-column>
|
||||
<b-table-column field="callsign" label="Posted by" sortable>
|
||||
{{ props.row.callsign }}
|
||||
</b-table-column>
|
||||
<b-table-column field="comments" class="comments" label="Comments">
|
||||
<div class="comments-cell">
|
||||
<b-tooltip class="comments-tooltip" :label="props.row.comments" position="is-left" multilined :active="!$mq.fullhd"><div>{{ props.row.comments }}</div></b-tooltip>
|
||||
<b-dropdown v-if="canEditSpot(props.row)" class="actions" aria-role="list">
|
||||
<b-button size="is-small" slot="trigger" icon-pack="fas" icon-right="caret-down" outlined>Actions</b-button>
|
||||
|
||||
<b-dropdown-item aria-role="listitem" @click="editSpot(props.row)"><b-icon icon="edit" size="is-small" /><span class="dropdown-label">Edit</span></b-dropdown-item>
|
||||
<b-dropdown-item aria-role="listitem" @click="cloneSpot(props.row)"><b-icon icon="clone" size="is-small" /><span class="dropdown-label">Clone</span></b-dropdown-item>
|
||||
<b-dropdown-item aria-role="listitem" @click="deleteSpot(props.row)"><b-icon icon="trash-alt" type="is-danger" size="is-small" /><span class="has-text-danger dropdown-label">Delete</span></b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</div>
|
||||
</b-table-column>
|
||||
</template>
|
||||
<template v-if="paginated" v-slot:bottom-left>
|
||||
<b-select v-model="perPage">
|
||||
<option v-for="option in perPageOptions" :key="option" :value="option">{{ option }} per page</option>
|
||||
</b-select>
|
||||
</template>
|
||||
</b-table>
|
||||
<b-modal v-if="isEditSpotActive" :active="true" has-modal-card :can-cancel="['escape']" @close="isEditSpotActive = false">
|
||||
<EditSpot :spot="spotToEdit" />
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from '../mixins/utils.js'
|
||||
import prefs from '../mixins/prefs.js'
|
||||
import nowticker from '../mixins/nowticker.js'
|
||||
import sotawatch from '../mixins/sotawatch.js'
|
||||
import ModeLabel from '../components/ModeLabel.vue'
|
||||
import SummitPointsLabel from '../components/SummitPointsLabel.vue'
|
||||
import CardPagination from '../components/CardPagination.vue'
|
||||
import SpotCard from '../components/SpotCard.vue'
|
||||
import ActivationCount from '../components/ActivationCount.vue'
|
||||
import CountryFlag from '../components/CountryFlag.vue'
|
||||
import AltitudeLabel from '../components/AltitudeLabel.vue'
|
||||
import EditSpot from '../components/EditSpot.vue'
|
||||
|
||||
export default {
|
||||
name: 'SpotsList',
|
||||
components: {
|
||||
ModeLabel, SummitPointsLabel, CardPagination, SpotCard, ActivationCount, CountryFlag, AltitudeLabel, EditSpot
|
||||
},
|
||||
mixins: [utils, prefs, nowticker, sotawatch],
|
||||
prefs: {
|
||||
key: 'spotsListPrefs',
|
||||
props: ['perPage']
|
||||
},
|
||||
props: {
|
||||
data: Array,
|
||||
showCallsign: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showSummitInfo: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
paginated: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
infinite: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
callsignLink: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cardSpots () {
|
||||
return [...this.data].sort((a, b) => {
|
||||
if (a.timeStamp > b.timeStamp) {
|
||||
return -1
|
||||
} else if (a.timeStamp < b.timeStamp) {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
rowClass (row) {
|
||||
return this.recentClass(row.timeStamp)
|
||||
},
|
||||
sortFrequency (a, b, isAsc) {
|
||||
let fa = parseFloat(a.frequency)
|
||||
let fb = parseFloat(b.frequency)
|
||||
if (fa < fb) {
|
||||
return (isAsc ? -1 : 1)
|
||||
} else if (fa === fb) {
|
||||
return 0
|
||||
} else {
|
||||
return (isAsc ? 1 : -1)
|
||||
}
|
||||
},
|
||||
canEditSpot (spot) {
|
||||
if (!this.myCallsign) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (spot.callsign === this.myCallsign)
|
||||
},
|
||||
addSpot () {
|
||||
this.spotToEdit = null
|
||||
this.isEditSpotActive = true
|
||||
},
|
||||
editSpot (spot) {
|
||||
this.spotToEdit = spot
|
||||
this.isEditSpotActive = true
|
||||
},
|
||||
cloneSpot (spot) {
|
||||
let newSpot = Object.assign({}, spot)
|
||||
delete newSpot.id
|
||||
delete newSpot.frequency
|
||||
this.spotToEdit = newSpot
|
||||
this.isEditSpotActive = true
|
||||
},
|
||||
deleteSpot (spot) {
|
||||
this.$buefy.dialog.confirm({
|
||||
message: 'Are you sure you want to delete this spot?',
|
||||
confirmText: 'Delete',
|
||||
type: 'is-danger',
|
||||
onConfirm: () => {
|
||||
this.deleteSotaWatchSpot(spot.id)
|
||||
.then(response => {
|
||||
this.$store.commit('deleteSpot', spot)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
perPage: 15,
|
||||
perPageOptions: [10, 15, 20, 30, 50, 100],
|
||||
isEditSpotActive: false,
|
||||
spotToEdit: null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
tr .timestamp {
|
||||
border-left: 3px solid #e0e0e0;
|
||||
}
|
||||
tr.recent1 .timestamp {
|
||||
border-left: 3px solid #f28591;
|
||||
}
|
||||
tr.recent2 .timestamp {
|
||||
border-left: 3px solid #fbaf63;
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.table .comments-tooltip {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.table .comments-tooltip div {
|
||||
padding-top: 0.15em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 10em;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1408px) {
|
||||
.table .comments-tooltip div {
|
||||
overflow: visible;
|
||||
white-space: normal;
|
||||
text-overflow: ellipsis;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
.card {
|
||||
border-left: 3px solid #e0e0e0;
|
||||
}
|
||||
.card.recent1 {
|
||||
border-left: 3px solid #f28591;
|
||||
}
|
||||
.card.recent2 {
|
||||
border-left: 3px solid #fbaf63;
|
||||
}
|
||||
.flag {
|
||||
margin-right: 0.4em;
|
||||
}
|
||||
.comments-cell {
|
||||
display: flex;
|
||||
}
|
||||
.actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.card .actions {
|
||||
float: right;
|
||||
clear: right;
|
||||
}
|
||||
.actions .button {
|
||||
margin-left: 1em;
|
||||
}
|
||||
.card .actions {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
>>> .dropdown-item .icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dropdown-item .dropdown-label {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,226 @@
|
|||
<template>
|
||||
<section class="section" v-if="activations !== null && activations.length > 0">
|
||||
<div class="container">
|
||||
<div class="columns is-5 is-variable">
|
||||
<div class="column is-half">
|
||||
<template v-if="myActivations && myActivations.length > 0">
|
||||
<h4 class="title is-4">
|
||||
My activations
|
||||
</h4>
|
||||
|
||||
<LoggedActivationsList :data="myActivations" />
|
||||
</template>
|
||||
|
||||
<template v-if="myChases && myChases.length > 0">
|
||||
<h4 class="title is-4">
|
||||
My chases
|
||||
</h4>
|
||||
|
||||
<ChasesList :data="myChases" />
|
||||
</template>
|
||||
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<h4 class="title is-4">
|
||||
Logged activations
|
||||
</h4>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<b-field>
|
||||
<FilterInput v-model="filter" size="is-small" :is-regex="true" />
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoggedActivationsList :data="filteredActivations" />
|
||||
</div>
|
||||
<div class="column stats">
|
||||
<h4 class="title is-4">
|
||||
QSOs per band
|
||||
</h4>
|
||||
|
||||
<BarChart v-if="bands" :data="bands" labelField="band" valueField="qsos" name="QSOs" />
|
||||
|
||||
<h4 class="title is-4">
|
||||
Activations per year
|
||||
</h4>
|
||||
<BarChart v-if="activationsPerYear" :data="activationsPerYear" labelField="year" valueField="activations" :xIsSeries="true" name="Activations" />
|
||||
|
||||
<template v-if="activations.length >= 20">
|
||||
<h4 class="title is-4">
|
||||
Activations per month
|
||||
</h4>
|
||||
<BarChart v-if="activationsPerMonth" :data="activationsPerMonth" labelField="month" valueField="activations" :xIsSeries="true" name="Activations" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="column" v-if="enableModes">
|
||||
<h4 class="title is-4">
|
||||
Modes
|
||||
</h4>
|
||||
<b-table :default-sort="['qsos', 'desc']" :narrowed="true" :striped="true" :data="modes" :mobile-cards="false">
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="mode" label="Mode" sortable>
|
||||
{{ props.row.mode.toUpperCase() }}
|
||||
</b-table-column>
|
||||
<b-table-column field="qsos" label="QSOs" sortable numeric>
|
||||
{{ props.row.qsos }}
|
||||
</b-table-column>
|
||||
</template>
|
||||
</b-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import utils from '../mixins/utils.js'
|
||||
|
||||
import FilterInput from '../components/FilterInput.vue'
|
||||
import BarChart from '../components/BarChart.vue'
|
||||
import LoggedActivationsList from '../components/LoggedActivationsList.vue'
|
||||
import ChasesList from '../components/ChasesList.vue'
|
||||
|
||||
export default {
|
||||
name: 'Activations',
|
||||
components: { FilterInput, BarChart, LoggedActivationsList, ChasesList },
|
||||
props: {
|
||||
summitCode: String,
|
||||
activations: Array,
|
||||
myActivations: Array,
|
||||
myChases: Array
|
||||
},
|
||||
mixins: [utils],
|
||||
mounted () {
|
||||
this.loadBandstats()
|
||||
},
|
||||
watch: {
|
||||
summitCode () {
|
||||
this.loadBandstats()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadBandstats () {
|
||||
axios.get('https://api2.sota.org.uk/api/bandstats/' + this.summitCode)
|
||||
.then(response => {
|
||||
this.bandstats = response.data
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredActivations () {
|
||||
if (!this.filter) {
|
||||
return this.activations
|
||||
}
|
||||
try {
|
||||
let regex = new RegExp(this.filter, 'i')
|
||||
return this.activations.filter(activation => {
|
||||
return regex.test(activation.ownCallsign)
|
||||
})
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
},
|
||||
bands () {
|
||||
return this.bandstats.map(bandstat => {
|
||||
return { band: bandstat.wavelength, qsos: bandstat.qsos }
|
||||
})
|
||||
},
|
||||
modes () {
|
||||
let modeStats = {}
|
||||
this.bandstats.forEach(bandstat => {
|
||||
Object.keys(bandstat).forEach(key => {
|
||||
if (!key.match(/^(ssb|cw|fm|data|am|dv|other)$/)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!modeStats[key]) {
|
||||
modeStats[key] = 0
|
||||
}
|
||||
modeStats[key] += bandstat[key]
|
||||
})
|
||||
})
|
||||
let modeStatsArr = []
|
||||
Object.keys(modeStats).sort().forEach(mode => {
|
||||
modeStatsArr.push({
|
||||
mode, qsos: modeStats[mode]
|
||||
})
|
||||
})
|
||||
return modeStatsArr
|
||||
},
|
||||
activationsPerYear () {
|
||||
if (!this.activations) {
|
||||
return null
|
||||
}
|
||||
|
||||
let years = {}
|
||||
let firstYear, lastYear
|
||||
this.activations.forEach(activation => {
|
||||
let year = activation.activationDate.substring(0, 4)
|
||||
if (!years[year]) {
|
||||
years[year] = 0
|
||||
}
|
||||
years[year]++
|
||||
|
||||
if (!firstYear || year < firstYear) {
|
||||
firstYear = year
|
||||
}
|
||||
if (!lastYear || year > lastYear) {
|
||||
lastYear = year
|
||||
}
|
||||
})
|
||||
|
||||
let yearsArr = []
|
||||
for (let year = firstYear; year <= lastYear; year++) {
|
||||
yearsArr.push({
|
||||
year, activations: years[year] || 0
|
||||
})
|
||||
}
|
||||
|
||||
return yearsArr
|
||||
},
|
||||
activationsPerMonth () {
|
||||
if (!this.activations) {
|
||||
return null
|
||||
}
|
||||
|
||||
let months = []
|
||||
this.activations.forEach(activation => {
|
||||
let month = parseInt(activation.activationDate.substring(5, 7))
|
||||
if (!months[month]) {
|
||||
months[month] = 0
|
||||
}
|
||||
months[month]++
|
||||
})
|
||||
|
||||
let monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
let monthsArr = []
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
monthsArr.push({
|
||||
month: monthNames[i - 1],
|
||||
activations: months[i] || 0
|
||||
})
|
||||
}
|
||||
|
||||
return monthsArr
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
bandstats: [],
|
||||
filter: '',
|
||||
enableModes: false // currently no data from SOTA API
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter {
|
||||
width: 10em
|
||||
}
|
||||
.stats >>> .chart-container {
|
||||
margin-bottom: 1em
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,150 @@
|
|||
<template>
|
||||
<div v-if="attributes && Object.keys(attributes).length > 0" class="attribute-list">
|
||||
<ul>
|
||||
<li v-for="item in summitAttributes" :key="item.attribute"><div><svgicon :icon="item.icon" color="#555" />{{ item.text }}</div></li>
|
||||
<li v-if="attributes['mobileSignal'] !== undefined"><div><svgicon :icon="this.signalList[attributes['mobileSignal']].icon" :color="attributes['mobileSignal'] > 0 ? '#555' : '#aaa'" />{{ this.signalList[attributes['mobileSignal']].text }}</div></li>
|
||||
</ul>
|
||||
<div class="pole-support" v-if="attributes.poleSupport && attributes.poleSupport.length > 0">
|
||||
Pole support:
|
||||
<b-tooltip v-for="(val, key) in poleSupport" :key="key" :label="val.text" type="is-info" position="is-bottom"><svgicon :icon="val.icon" color="#555" /></b-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import '../compiled-icons'
|
||||
|
||||
export default {
|
||||
name: 'SummitAttributes',
|
||||
props: ['attributes'],
|
||||
computed: {
|
||||
summitAttributes () {
|
||||
return this.attributeList.filter((attribute) => this.attributes[attribute.attribute] === true)
|
||||
},
|
||||
poleSupport () {
|
||||
return this.attributes.poleSupport.map(el => this.poleSupportList[el])
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
attributeList: [
|
||||
{
|
||||
attribute: 'driveUp',
|
||||
icon: 'icons8-car',
|
||||
text: 'Drive up'
|
||||
},
|
||||
{
|
||||
attribute: 'cableCar',
|
||||
icon: 'icons8-cable-car',
|
||||
text: 'Cable car/Funicular available'
|
||||
},
|
||||
{
|
||||
attribute: 'seating',
|
||||
icon: 'icons8-bench-filled',
|
||||
text: 'Seating/Bench'
|
||||
},
|
||||
{
|
||||
attribute: 'summitCross',
|
||||
icon: 'icons8-cross',
|
||||
text: 'Summit cross'
|
||||
},
|
||||
{
|
||||
attribute: 'summitBook',
|
||||
icon: 'icons8-open-book',
|
||||
text: 'Summit book'
|
||||
},
|
||||
{
|
||||
attribute: 'shelter',
|
||||
icon: 'icons8-home',
|
||||
text: 'Shelter'
|
||||
},
|
||||
{
|
||||
attribute: 'views',
|
||||
icon: 'icons8-binoculars',
|
||||
text: 'Views'
|
||||
}
|
||||
],
|
||||
poleSupportList: {
|
||||
summitCross: {
|
||||
icon: 'icons8-cross',
|
||||
text: 'Summit cross'
|
||||
},
|
||||
bench: {
|
||||
icon: 'icons8-bench-filled',
|
||||
text: 'Bench'
|
||||
},
|
||||
signpost: {
|
||||
icon: 'icons8-signpost',
|
||||
text: 'Signpost'
|
||||
},
|
||||
cairn: {
|
||||
icon: 'icons8-rock',
|
||||
text: 'Cairn'
|
||||
},
|
||||
fencePost: {
|
||||
icon: 'icons8-fence',
|
||||
text: 'Fence post'
|
||||
}
|
||||
},
|
||||
signalList: [
|
||||
{
|
||||
icon: 'icons8-signal0',
|
||||
text: 'No signal'
|
||||
},
|
||||
{
|
||||
icon: 'icons8-signal1',
|
||||
text: 'Weak signal'
|
||||
},
|
||||
{
|
||||
icon: 'icons8-signal2',
|
||||
text: 'Average signal'
|
||||
},
|
||||
{
|
||||
icon: 'icons8-signal3',
|
||||
text: 'Good signal'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.attribute-list {
|
||||
margin: 0.5em 0;
|
||||
background: #f7f7f7;
|
||||
border-radius: 7px;
|
||||
padding: 0.4em 0.5em;
|
||||
margin-left: -0.5em;
|
||||
display: inline-block;
|
||||
}
|
||||
.attribute-list ul li {
|
||||
margin: 0.2em 1em 0.2em 0;
|
||||
display: inline;
|
||||
}
|
||||
.attribute-list ul li:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
.attribute-list ul li div {
|
||||
display: inline-block;
|
||||
}
|
||||
.svg-icon {
|
||||
vertical-align: bottom;
|
||||
margin-right: 0.4em;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
}
|
||||
.pole-support {
|
||||
margin-left: 0.2em;
|
||||
margin-top: 0.5em;
|
||||
border-top: 1px solid #ccc;
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
.pole-support .svg-icon {
|
||||
margin-left: 0.3em;
|
||||
margin-right: 0;
|
||||
}
|
||||
.pole-support .b-tooltip {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,54 @@
|
|||
<template>
|
||||
<div>
|
||||
<section class="hero is-light">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<slot name="title"></slot>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<Breadcrumb :association="association" :region="region" :summit="summit" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<slot name="subtitle"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<slot></slot>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Breadcrumb from '../components/Breadcrumb.vue'
|
||||
import Footer from '../components/Footer.vue'
|
||||
|
||||
export default {
|
||||
name: 'SummitDatabasePageLayout',
|
||||
components: {
|
||||
Breadcrumb, Footer
|
||||
},
|
||||
props: {
|
||||
association: Object,
|
||||
region: Object,
|
||||
summit: Object,
|
||||
query: String
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.level {
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.level-left + .level-right {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,184 @@
|
|||
<template>
|
||||
<b-table :class="{ 'auto-width': autoWidth, summits: true }" default-sort="code" :narrowed="true" :striped="true" :data="data" :mobile-cards="false" :row-class="(row, index) => !row.isValid && 'is-invalid'">
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="code" label="Code" class="nowrap" sortable>
|
||||
<router-link :to="makeSummitLink(props.row.code)">{{ props.row.code }}</router-link>
|
||||
</b-table-column>
|
||||
<b-table-column field="name" label="Name" class="summit-name" sortable>
|
||||
<router-link :to="makeSummitLink(props.row.code)">{{ props.row.name }}</router-link>
|
||||
<font-awesome-icon v-if="props.row.hasPhotos" class="photos-icon" :icon="['far', 'images']" />
|
||||
</b-table-column>
|
||||
<b-table-column field="altitude" :label="$mq.mobile ? 'Alt.' : 'Altitude'" class="nowrap" sortable numeric>
|
||||
<AltitudeLabel :altitude="props.row.altitude" />
|
||||
</b-table-column>
|
||||
<b-table-column field="points" :label="$mq.mobile ? 'Pts.' : 'Points'" class="nowrap" sortable>
|
||||
<SummitPointsLabel :points="props.row.points" :bonus="$mq.mobile ? null : props.row.bonusPoints" class="points" />
|
||||
</b-table-column>
|
||||
<b-table-column field="activationCount" :label="$mq.mobile ? 'Act.' : 'Activations'" class="nowrap" sortable numeric>
|
||||
{{ props.row.activationCount }}
|
||||
<font-awesome-icon v-if="myActivatedSummits" :icon="activationIcon(props.row)" :class="activationIconClass(props.row)" :label="activationIconLabel(props.row)" :title="activationIconLabel(props.row)" />
|
||||
<font-awesome-icon v-if="myActivatedSummitsThisYear" :icon="['far', 'calendar-check']" :class="calendarIconClass(props.row)" :label="calendarIconLabel(props.row)" :title="calendarIconLabel(props.row)" />
|
||||
</b-table-column>
|
||||
</template>
|
||||
|
||||
<template v-if="myActivatedSummits" slot="footer">
|
||||
<ul class="legend">
|
||||
<li><font-awesome-icon :icon="['far', 'chevron-circle-down']" :class="['activation-icon', 'chased']" /> Chased</li>
|
||||
<li><font-awesome-icon :icon="['far', 'chevron-circle-up']" :class="['activation-icon', 'activated']" /> Activated</li>
|
||||
<li><font-awesome-icon :icon="['fas', 'check-circle']" :class="['activation-icon', 'complete']" /> Complete</li>
|
||||
<li v-if="myActivatedSummitsThisYear"><font-awesome-icon :icon="['far', 'calendar-check']" :class="['calendar-icon', 'active']" /> Activated this year</li>
|
||||
</ul>
|
||||
</template>
|
||||
</b-table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from '../mixins/utils.js'
|
||||
import SummitPointsLabel from '../components/SummitPointsLabel.vue'
|
||||
import AltitudeLabel from '../components/AltitudeLabel.vue'
|
||||
|
||||
export default {
|
||||
name: 'SummitList',
|
||||
props: {
|
||||
data: Array,
|
||||
myActivatedSummits: Set,
|
||||
myActivatedSummitsThisYear: Set,
|
||||
myChasedSummits: Set,
|
||||
autoWidth: Boolean
|
||||
},
|
||||
mixins: [utils],
|
||||
components: {
|
||||
SummitPointsLabel, AltitudeLabel
|
||||
},
|
||||
methods: {
|
||||
activationIcon (row) {
|
||||
if (this.isComplete(row.code)) {
|
||||
return ['fas', 'check-circle']
|
||||
} else if (this.isActivated(row.code)) {
|
||||
return ['far', 'chevron-circle-up']
|
||||
} else if (this.isChased(row.code)) {
|
||||
return ['far', 'chevron-circle-down']
|
||||
} else {
|
||||
return ['far', 'check-circle']
|
||||
}
|
||||
},
|
||||
activationIconClass (row) {
|
||||
if (this.isComplete(row.code)) {
|
||||
return ['activation-icon', 'complete']
|
||||
} else if (this.isActivated(row.code)) {
|
||||
return ['activation-icon', 'activated']
|
||||
} else if (this.isChased(row.code)) {
|
||||
return ['activation-icon', 'chased']
|
||||
} else {
|
||||
return ['activation-icon']
|
||||
}
|
||||
},
|
||||
activationIconLabel (row) {
|
||||
if (this.isComplete(row.code)) {
|
||||
return 'completed by me'
|
||||
} else if (this.isActivated(row.code)) {
|
||||
return 'activated by me'
|
||||
} else if (this.isChased(row.code)) {
|
||||
return 'chased by me'
|
||||
} else {
|
||||
return 'not activated/chased by me'
|
||||
}
|
||||
},
|
||||
calendarIconClass (row) {
|
||||
if (this.isActivatedThisYear(row.code)) {
|
||||
return ['calendar-icon', 'active']
|
||||
} else {
|
||||
return ['calendar-icon']
|
||||
}
|
||||
},
|
||||
calendarIconLabel (row) {
|
||||
if (this.isActivatedThisYear(row.code)) {
|
||||
return 'activated this year'
|
||||
} else {
|
||||
return 'not activated this year'
|
||||
}
|
||||
},
|
||||
isActivated (code) {
|
||||
if (!this.myActivatedSummits) {
|
||||
return false
|
||||
}
|
||||
return this.myActivatedSummits.has(code)
|
||||
},
|
||||
isActivatedThisYear (code) {
|
||||
if (!this.myActivatedSummitsThisYear) {
|
||||
return null
|
||||
}
|
||||
return this.myActivatedSummitsThisYear.has(code)
|
||||
},
|
||||
isChased (code) {
|
||||
if (!this.myChasedSummits) {
|
||||
return false
|
||||
}
|
||||
return this.myChasedSummits.has(code)
|
||||
},
|
||||
isComplete (code) {
|
||||
if (!this.myActivatedSummits || !this.myChasedSummits) {
|
||||
return false
|
||||
}
|
||||
return this.myActivatedSummits.has(code) && this.myChasedSummits.has(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summits >>> .is-invalid {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@media (max-width: 414px) {
|
||||
.table .summit-name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 10em;
|
||||
}
|
||||
}
|
||||
@media (max-width: 375px) {
|
||||
.table td, .table th {
|
||||
padding: 0.25em 0.3em;
|
||||
}
|
||||
.table .summit-name {
|
||||
max-width: 8em;
|
||||
}
|
||||
}
|
||||
.activation-icon {
|
||||
color: #e7e7e7;
|
||||
margin-left: 0.1em;
|
||||
}
|
||||
.activation-icon.activated, .activation-icon.complete {
|
||||
color: #406caf;
|
||||
}
|
||||
.activation-icon.chased {
|
||||
color: #2eaa9b;
|
||||
}
|
||||
.calendar-icon {
|
||||
color: #e7e7e7;
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
.calendar-icon.active {
|
||||
color: #444;
|
||||
}
|
||||
.legend {
|
||||
font-weight: normal;
|
||||
list-style-type: none;
|
||||
margin-top: 0.5em;
|
||||
text-align: right;
|
||||
}
|
||||
.legend .calendar-icon, .legend .activation-icon {
|
||||
margin-left: 0;
|
||||
margin-right: 0.1em;
|
||||
}
|
||||
.legend li {
|
||||
display: inline-block;
|
||||
margin-left: 0.75em;
|
||||
}
|
||||
.photos-icon {
|
||||
margin-left: 0.5em;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,142 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-for="group in groups" :key="group.key">
|
||||
<SummitPhotosGroup ref="photosGroup" :photos="group.photos" :title="group.title" :titleLink="group.titleLink" @editPhoto="onEditPhoto" @deletePhoto="onDeletePhoto" @reorderPhotos="onReorderPhotos" />
|
||||
</div>
|
||||
<b-modal :active.sync="isEditorActive" has-modal-card trap-focus aria-role="dialog" aria-modal>
|
||||
<EditPhoto v-if="editingPhoto" :photo="editingPhoto" :summitCode="summit.code" @photoEdited="$emit('photoEdited')" />
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SummitPhotosGroup from './SummitPhotosGroup.vue'
|
||||
import EditPhoto from './EditPhoto.vue'
|
||||
import utils from '../mixins/utils.js'
|
||||
import api from '../mixins/api.js'
|
||||
import moment from 'moment'
|
||||
|
||||
export default {
|
||||
name: 'SummitPhotos',
|
||||
props: {
|
||||
summit: Object
|
||||
},
|
||||
components: {
|
||||
SummitPhotosGroup, EditPhoto
|
||||
},
|
||||
mixins: [utils, api],
|
||||
computed: {
|
||||
groups () {
|
||||
if (!this.summit || !this.summit.photos) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Group photos by author
|
||||
let authorGroups = new Map()
|
||||
this.summit.photos.forEach(photo => {
|
||||
let authorGroup = authorGroups.get(photo.author)
|
||||
if (!authorGroup) {
|
||||
authorGroup = {
|
||||
author: photo.author.toUpperCase(),
|
||||
title: photo.author.toUpperCase(),
|
||||
titleLink: '/activators/' + photo.author.toUpperCase(),
|
||||
photos: []
|
||||
}
|
||||
authorGroups.set(photo.author, authorGroup)
|
||||
}
|
||||
authorGroup.photos.push(photo)
|
||||
})
|
||||
|
||||
// Sort photos within group by sort order, then by photo date, then by upload date
|
||||
authorGroups.forEach((authorGroup, author) => {
|
||||
authorGroup.photos.sort((a, b) => {
|
||||
// No sort order = place at end
|
||||
let sa = a.sortOrder || 1000000
|
||||
let sb = b.sortOrder || 1000000
|
||||
|
||||
if (sa < sb) {
|
||||
return -1
|
||||
} else if (sa > sb) {
|
||||
return 1
|
||||
} else {
|
||||
if (!a.date && !b.date) {
|
||||
return moment(a.uploadDate).diff(b.uploadDate)
|
||||
} else if (a.date && !b.date) {
|
||||
return 1
|
||||
} else if (!a.date && b.date) {
|
||||
return -1
|
||||
} else {
|
||||
let ma = moment(a.date)
|
||||
let mb = moment(b.date)
|
||||
if (ma.isBefore(mb)) {
|
||||
return -1
|
||||
} else if (mb.isBefore(ma)) {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Sort groups by latest uploaded photo
|
||||
return [...authorGroups.values()].sort((a, b) => {
|
||||
let reduceFunc = (prev, current) => {
|
||||
return (moment(prev.uploadDate).isAfter(current.uploadDate)) ? prev : current
|
||||
}
|
||||
let dateA = a.photos.reduce(reduceFunc).uploadDate
|
||||
let dateB = b.photos.reduce(reduceFunc).uploadDate
|
||||
|
||||
if (!dateA && !dateB) {
|
||||
return 0
|
||||
} else if (dateA && !dateB) {
|
||||
return -1
|
||||
} else if (dateB && !dateA) {
|
||||
return 1
|
||||
} else {
|
||||
return (moment(dateB).diff(dateA))
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openPhoto (photo) {
|
||||
this.groups.forEach((group, index) => {
|
||||
if (group.photos.some(curPhoto => curPhoto === photo)) {
|
||||
this.$refs.photosGroup[index].openPhoto(photo)
|
||||
}
|
||||
})
|
||||
},
|
||||
onEditPhoto (photo) {
|
||||
this.editingPhoto = this.summit.photos.find(curPhoto => curPhoto.filename === photo.filename)
|
||||
this.isEditorActive = true
|
||||
},
|
||||
onDeletePhoto (photo) {
|
||||
this.$buefy.dialog.confirm({
|
||||
message: 'Are you sure you want to delete this photo?',
|
||||
confirmText: 'Delete',
|
||||
type: 'is-danger',
|
||||
onConfirm: () => {
|
||||
this.deletePhoto(this.summit.code, photo.filename)
|
||||
.then(() => {
|
||||
this.$emit('photoDeleted')
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
onReorderPhotos (filenames) {
|
||||
this.reorderPhotos(this.summit.code, filenames)
|
||||
.then(() => {
|
||||
this.$emit('photosReordered')
|
||||
})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isEditorActive: false,
|
||||
editingPhoto: null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,170 @@
|
|||
<template>
|
||||
<div class="photo-group">
|
||||
<div class="photo-group-title">
|
||||
<router-link v-if="titleLink" :to="titleLink">{{ title }}</router-link>
|
||||
<span v-else>{{ title }}</span>
|
||||
</div>
|
||||
<PictureSwipe ref="pictureSwipe" class="photos" :items="swipeItems" :options="swipeOptions" @mouseoverPicture="mouseoverPicture" @mouseleavePicture="mouseleavePicture" @editPicture="onEditPicture" @deletePicture="onDeletePicture" @movePicture="onMovePicture" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PictureSwipe from './PictureSwipe.vue'
|
||||
import utils from '../mixins/utils.js'
|
||||
import photos from '../mixins/photos.js'
|
||||
import moment from 'moment'
|
||||
|
||||
export default {
|
||||
name: 'SummitPhotosGroup',
|
||||
props: {
|
||||
photos: Array,
|
||||
title: String,
|
||||
titleLink: String
|
||||
},
|
||||
components: {
|
||||
PictureSwipe
|
||||
},
|
||||
mixins: [utils, photos],
|
||||
computed: {
|
||||
swipeItems () {
|
||||
let largeSize = 1600
|
||||
return this.photos.map(photo => {
|
||||
let largeW = photo.width
|
||||
let largeH = photo.height
|
||||
if (largeW > largeSize) {
|
||||
largeH = Math.round(largeH * largeSize / largeW)
|
||||
largeW = largeSize
|
||||
}
|
||||
if (largeH > largeSize) {
|
||||
largeW = Math.round(largeW * largeSize / largeH)
|
||||
largeH = largeSize
|
||||
}
|
||||
|
||||
return {
|
||||
src: this.photoSrc(photo, 'large'),
|
||||
msrc: this.photoSrc(photo, 'thumb'),
|
||||
osrc: this.photoSrc(photo, 'original'),
|
||||
w: largeW,
|
||||
h: largeH,
|
||||
title: this.makeTitleHtml(photo),
|
||||
thumbTitle: photo.title,
|
||||
editable: (this.$keycloak && this.$keycloak.authenticated && this.$keycloak.tokenParsed.callsign === photo.author),
|
||||
filename: photo.filename
|
||||
}
|
||||
})
|
||||
},
|
||||
swipeOptions () {
|
||||
return {
|
||||
bgOpacity: 0.85,
|
||||
loop: false,
|
||||
history: false,
|
||||
closeOnScroll: false
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
makeTitleHtml (photo) {
|
||||
let html = '<div class="photo-title">' + this.escapeHtml(photo.title).replace('\n', '<br />')
|
||||
let authorLine = ''
|
||||
if (photo.author) {
|
||||
authorLine = this.escapeHtml(photo.author.toUpperCase())
|
||||
}
|
||||
if (photo.date) {
|
||||
authorLine += ' on ' + moment.utc(photo.date).format('DD MMM YYYY HH:mm:ss')
|
||||
}
|
||||
if (photo.camera) {
|
||||
authorLine += ', ' + this.escapeHtml(photo.camera)
|
||||
}
|
||||
if (photo.coordinates) {
|
||||
authorLine += ', Coordinates: <a href="/map/coordinates/' + photo.coordinates.latitude.toFixed(5) + ',' + photo.coordinates.longitude.toFixed(5) + '/16.0?popup=1">' + photo.coordinates.latitude.toFixed(5) + ', ' + photo.coordinates.longitude.toFixed(5) + '</a>'
|
||||
if (photo.positioningError) {
|
||||
if (photo.positioningError >= 1000) {
|
||||
authorLine += ' (± ' + (photo.positioningError / 1000).toFixed(1) + ' km)'
|
||||
} else {
|
||||
authorLine += ' (± ' + photo.positioningError + ' m)'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (authorLine) {
|
||||
html += '<div class="author">' + authorLine + '</div>'
|
||||
}
|
||||
html += '</div>'
|
||||
return html
|
||||
},
|
||||
openPhoto (photo) {
|
||||
let photoIndex = this.photos.findIndex(curPhoto => curPhoto.filename === photo.filename)
|
||||
if (photoIndex !== -1) {
|
||||
this.$refs.pictureSwipe.open(photoIndex, true)
|
||||
}
|
||||
},
|
||||
mouseoverPicture (picture) {
|
||||
this.photos.forEach(photo => {
|
||||
if (photo.filename === picture.filename && !photo.highlight) {
|
||||
this.$set(photo, 'highlight', true)
|
||||
} else if (photo.highlight) {
|
||||
this.$set(photo, 'highlight', false)
|
||||
}
|
||||
})
|
||||
},
|
||||
mouseleavePicture (picture) {
|
||||
this.$set(this.photos.find(photo => photo.filename === picture.filename), 'highlight', false)
|
||||
},
|
||||
onEditPicture (picture) {
|
||||
this.$emit('editPhoto', picture)
|
||||
},
|
||||
onDeletePicture (picture) {
|
||||
this.$emit('deletePhoto', picture)
|
||||
},
|
||||
onMovePicture (newIndex, oldIndex, picture) {
|
||||
// Make new array of filenames in desired order
|
||||
let filenames = this.photos.map(photo => photo.filename)
|
||||
filenames.splice(newIndex, 0, filenames.splice(oldIndex, 1)[0])
|
||||
this.$emit('reorderPhotos', filenames)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.photos >>> figure {
|
||||
margin: 0 0.75rem 0.75rem 0;
|
||||
}
|
||||
.photos >>> figure img {
|
||||
max-height: 128px;
|
||||
max-width: 256px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.photos >>> figure {
|
||||
margin: 0 0.5rem 0.5rem 0;
|
||||
}
|
||||
.photos >>> figure img {
|
||||
max-height: 104px;
|
||||
}
|
||||
}
|
||||
>>> .photo-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
>>> .photo-title .author {
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
.photo-group {
|
||||
background: whitesmoke;
|
||||
padding: 0.25rem 0 0 0.75rem;
|
||||
display: inline-block;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.photo-group-title {
|
||||
color: #777;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.2rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
.photo-group-title a {
|
||||
color: #777;
|
||||
}
|
||||
.photo-group-title a:hover {
|
||||
color: #3273dc;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,82 @@
|
|||
<template>
|
||||
<b-taglist v-if="points !== null && points !== undefined" :class="{ ['points-' + points]: true }" attached>
|
||||
<b-tag>{{ points }}</b-tag>
|
||||
<b-tag class="bonus" v-if="bonus">+{{ bonus }}</b-tag>
|
||||
</b-taglist>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SummitPointsLabel',
|
||||
props: {
|
||||
points: {
|
||||
type: Number
|
||||
},
|
||||
bonus: {
|
||||
type: Number
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tags {
|
||||
display: inline-block;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.tag {
|
||||
min-width: 2.2em;
|
||||
padding: 0.4em;
|
||||
color: #fff;
|
||||
line-height: 1em;
|
||||
height: auto;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.tag {
|
||||
padding: 0.3em 0.4em;
|
||||
}
|
||||
}
|
||||
.points-0 .tag {
|
||||
background-color: rgba(160, 160, 160, 1);
|
||||
}
|
||||
.points-1 .tag {
|
||||
background-color: rgba(77, 122, 32, 1);
|
||||
}
|
||||
.points-2 .tag {
|
||||
background-color: rgba(109, 165, 54, 1);
|
||||
}
|
||||
.points-4 .tag {
|
||||
background-color: rgba(174, 167, 39, 1);
|
||||
}
|
||||
.points-6 .tag {
|
||||
background-color: rgba(239, 168, 24, 1);
|
||||
}
|
||||
.points-8 .tag {
|
||||
background-color: rgba(220, 93, 4, 1);
|
||||
}
|
||||
.points-10 .tag {
|
||||
background-color: rgba(200, 16, 30, 1);
|
||||
}
|
||||
.points-1 .tag.bonus {
|
||||
background-color: rgba(51, 81, 21, 1);
|
||||
}
|
||||
.points-2 .tag.bonus {
|
||||
background-color: rgba(77, 116, 37, 1);
|
||||
}
|
||||
.points-4 .tag.bonus {
|
||||
background-color: rgba(125, 120, 28, 1);
|
||||
}
|
||||
.points-6 .tag.bonus {
|
||||
background-color: rgba(167, 115, 12, 1);
|
||||
}
|
||||
.points-8 .tag.bonus {
|
||||
background-color: rgba(150, 64, 3, 1);
|
||||
}
|
||||
.points-10 .tag.bonus {
|
||||
background-color: rgba(118, 10, 19, 1);
|
||||
}
|
||||
.tag.bonus {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,142 @@
|
|||
<template>
|
||||
<MglPopup v-if="summit" key="summitinfo" :coordinates="[summit.coordinates.longitude, summit.coordinates.latitude]" :showed="true" anchor="bottom" :closeButton="false" @close="$emit('close')">
|
||||
<div :class="{ summitPopup: true, minimize: minimizePopup }">
|
||||
<div v-if="coverPhoto" class="photo">
|
||||
<div style="text-align: center"><a :href="coverPhoto.mediaLink" target="_blank"><img :src="coverPhoto.src" /></a></div>
|
||||
<div v-if="coverPhoto.description" class="description">{{ coverPhoto.description }}</div>
|
||||
<div v-if="coverPhoto.attribution" class="attribution" v-html="coverPhoto.attribution"></div>
|
||||
</div>
|
||||
<h2>{{ summit.name }}<span class="summitCode"><router-link :to="'/summits/' + summit.code">{{ summit.code }}</router-link></span></h2>
|
||||
<table class="summitinfo">
|
||||
<tr><th>Altitude</th><td><AltitudeLabel :altitude="summit.altitude" /></td></tr>
|
||||
<tr class="points"><th>Points</th><td><SummitPointsLabel :points="summit.points" :bonus="summit.bonusPoints" /></td></tr>
|
||||
<tr><th>Activations</th><td>{{ summit.activationCount }}</td></tr>
|
||||
<tr v-if="summit.activationDate"><th>Last activation</th><td>{{ summit.activationDate | formatActivationDate }} (<router-link :to="makeActivatorLink(summit.activationCall)">{{ summit.activationCall }}</router-link>)</td></tr>
|
||||
<tr v-if="lastSpot"><th>Last spot</th><td><span v-html="formatTimeDay(lastSpot.timeStamp)" />: <router-link :to="makeActivatorLink(lastSpot.activatorCallsign)">{{ lastSpot.activatorCallsign }}</router-link>, {{ lastSpot.frequency }} <ModeLabel :mode="lastSpot.mode" /></td></tr>
|
||||
</table>
|
||||
<div class="buttons">
|
||||
<b-button v-if="!minimizePopup" size="is-small" icon-left="window-close" @click="$emit('close')">Close</b-button>
|
||||
<b-button v-if="!minimizePopup" size="is-small" icon-left="window-minimize" @click="minimizePopup = true">Minimize</b-button>
|
||||
<b-button v-if="minimizePopup" size="is-small" icon-left="window-maximize" @click="minimizePopup = false"></b-button>
|
||||
<router-link v-if="!minimizePopup" class="button more is-info is-small" :to="'/summits/' + summit.code"><font-awesome-icon class="fa-icon" :icon="['far', 'expand-arrows']" style="margin-right: 0.5em" /> More</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</MglPopup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { MglPopup } from 'vue-mapbox'
|
||||
import ModeLabel from '../components/ModeLabel.vue'
|
||||
import AltitudeLabel from '../components/AltitudeLabel.vue'
|
||||
import SummitPointsLabel from '../components/SummitPointsLabel.vue'
|
||||
import utils from '../mixins/utils.js'
|
||||
import coverphoto from '../mixins/coverphoto.js'
|
||||
|
||||
export default {
|
||||
name: 'SummitPopup',
|
||||
props: {
|
||||
summit: Object,
|
||||
lastSpot: Object
|
||||
},
|
||||
mixins: [utils, coverphoto],
|
||||
components: {
|
||||
MglPopup, ModeLabel, AltitudeLabel, SummitPointsLabel
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
minimizePopup: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summitPopup {
|
||||
padding: 0.3rem;
|
||||
}
|
||||
.summitPopup h2 {
|
||||
margin: 0.2em 0 0.5em 0;
|
||||
font-size: 16pt;
|
||||
white-space: nowrap;
|
||||
font-weight: normal;
|
||||
}
|
||||
.summitPopup .summitCode {
|
||||
color: #777;
|
||||
font-size: 10pt;
|
||||
padding-left: 0.7em;
|
||||
}
|
||||
.summitPopup table {
|
||||
font-size: 10pt;
|
||||
width: 100%;
|
||||
}
|
||||
.summitPopup .buttons {
|
||||
margin-top: 0.5em;
|
||||
float: right;
|
||||
}
|
||||
.summitPopup.minimize .buttons {
|
||||
margin-top: 0;
|
||||
display: inline;
|
||||
margin-left: 1em;
|
||||
}
|
||||
.summitPopup .points th {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.summitPopup.minimize .photo, .summitPopup.minimize .summitinfo {
|
||||
display: none;
|
||||
}
|
||||
.summitPopup.minimize h2 {
|
||||
font-size: 11pt;
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.summitPopup.minimize .summitCode {
|
||||
display: none;
|
||||
}
|
||||
.photo {
|
||||
width: 300px;
|
||||
padding-bottom: 0.5em;
|
||||
border-bottom: 1px solid #ccc;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.photo img {
|
||||
border: 1px solid #aaa;
|
||||
vertical-align: top;
|
||||
text-align: center;
|
||||
}
|
||||
.photo .description {
|
||||
font-size: 9pt;
|
||||
line-height: 1.4;
|
||||
color: #777;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.photo a {
|
||||
color: #3f5da7;
|
||||
}
|
||||
.photo .attribution {
|
||||
font-size: 8pt;
|
||||
line-height: 1.4;
|
||||
font-style: italic;
|
||||
color: #777;
|
||||
text-align: right;
|
||||
}
|
||||
.summitinfo {
|
||||
margin: 0 !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
.summitinfo th {
|
||||
padding-right: 1em;
|
||||
text-align: left;
|
||||
color: #444;
|
||||
}
|
||||
.summitinfo th, .summitinfo td {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
padding-top: 0.3em !important;
|
||||
padding-bottom: 0.3em !important;
|
||||
white-space: nowrap;
|
||||
text-transform: none;
|
||||
line-height: normal !important;
|
||||
}
|
||||
.tag {
|
||||
padding: 0.2em 0.3em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,152 @@
|
|||
<template>
|
||||
<b-table ref="routesTable" :data="routes" :narrowed="true" :striped="true" detailed @details-open="detailsOpen" @details-close="detailsClose">
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="title" label="Title" :sortable="routes.length > 1">
|
||||
<span><a @click="toggle(props.row)"><strong>{{ props.row.title }}</strong></a> <RouteAttributes class="route-attributes" :route="props.row" :summit="summit" @mapReposition="coordinates => $emit('mapReposition', coordinates)" /></span>
|
||||
</b-table-column>
|
||||
<b-table-column field="difficulty" label="Difficulty" :sortable="routes.length > 1">
|
||||
{{ renderDifficulty(props.row) }}
|
||||
</b-table-column>
|
||||
<b-table-column field="ascent" label="Ascent" numeric :sortable="routes.length > 1">
|
||||
<span><AltitudeLabel v-if="props.row.ascent" :altitude="props.row.ascent" /> <span v-if="props.row.ascentExcludesCounterAscents" class="star"> (*)</span></span>
|
||||
</b-table-column>
|
||||
<b-table-column field="distance" label="Distance" numeric :sortable="routes.length > 1">
|
||||
<DistanceLabel v-if="props.row.distance" :distance="props.row.distance" />
|
||||
</b-table-column>
|
||||
<b-table-column field="duration" label="Duration" numeric :sortable="routes.length > 1">
|
||||
{{ props.row.duration | formatDuration }}
|
||||
</b-table-column>
|
||||
</template>
|
||||
|
||||
<template slot="detail" slot-scope="props">
|
||||
<div v-if="(props.row.parking && props.row.parking.description) || (props.row.publicTransport && props.row.publicTransport.description)" class="add-description-wrapper">
|
||||
<div v-if="props.row.parking && props.row.parking.description" class="add-description">
|
||||
<font-awesome-icon class="fa-icon clickable" :icon="['fas', 'parking']" @click="$emit('mapReposition', props.row.parking.coordinates)" />
|
||||
{{ props.row.parking.description }}
|
||||
</div>
|
||||
<div v-if="props.row.publicTransport && props.row.publicTransport.description" class="add-description">
|
||||
<font-awesome-layers class="fa-icon clickable" @click="$emit('mapReposition', props.row.publicTransport.coordinates)">
|
||||
<font-awesome-icon class="fa-subicon" icon="square" />
|
||||
<font-awesome-icon class="fa-subicon" :icon="['fas', 'bus']" transform="shrink-6" :style="{ color: 'white' }" />
|
||||
</font-awesome-layers>
|
||||
{{ props.row.publicTransport.description }}
|
||||
</div>
|
||||
</div>
|
||||
<article class="routeDescr" v-html="linkifyCoordinates(props.row.description)" />
|
||||
<div class="author">by {{ props.row.author }}</div>
|
||||
<div class="track-download" v-if="props.row.track">
|
||||
<TrackLink :route="props.row" :summit="summit"><font-awesome-icon :icon="['far', 'file-download']" class="fa-icon" /> Download track (.gpx)</TrackLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="anyCounterAscentExcludes" slot="footer">
|
||||
(*) Difference between highest and lowest elevation, excluding counter-ascents
|
||||
</template>
|
||||
</b-table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RouteAttributes from './RouteAttributes.vue'
|
||||
import AltitudeLabel from './AltitudeLabel.vue'
|
||||
import DistanceLabel from './DistanceLabel.vue'
|
||||
import TrackLink from './TrackLink.vue'
|
||||
import utils from '../mixins/utils.js'
|
||||
|
||||
export default {
|
||||
name: 'SummitRoutes',
|
||||
props: {
|
||||
summit: Object,
|
||||
routes: Array
|
||||
},
|
||||
components: {
|
||||
RouteAttributes, AltitudeLabel, DistanceLabel, TrackLink
|
||||
},
|
||||
mixins: [utils],
|
||||
computed: {
|
||||
anyCounterAscentExcludes () {
|
||||
return this.routes.some(el => el.ascentExcludesCounterAscents)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggle (row) {
|
||||
this.$refs.routesTable.toggleDetails(row)
|
||||
},
|
||||
detailsOpen (row) {
|
||||
this.$emit('detailsOpen', row)
|
||||
},
|
||||
detailsClose (row) {
|
||||
this.$emit('detailsClose', row)
|
||||
},
|
||||
renderDifficulty (row) {
|
||||
let fields = ['hikingDifficulty', 'snowshoeDifficulty', 'alpineDifficulty', 'skiDifficulty', 'climbingDifficulty']
|
||||
let difficulties = []
|
||||
fields.forEach(field => {
|
||||
if (row[field]) {
|
||||
difficulties.push(row[field])
|
||||
}
|
||||
})
|
||||
return difficulties.join(' ')
|
||||
},
|
||||
linkifyCoordinates (description) {
|
||||
return description.replace(/(?:^|\s)([-+]?[1-8]?\d\.\d+),\s*([-+]?(?:(?:1[0-7]\d)|(?:[1-9]?\d))\.\d+)\b/g, '<a href="/map/coordinates/$1,$2/16.0?popup=1">$&</a>')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
>>> .routeDescr p, >>> .routeDescr ul {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
>>> .routeDescr ul {
|
||||
list-style-type: disc;
|
||||
margin-left: 1.5em;
|
||||
}
|
||||
>>> .table-wrapper {
|
||||
overflow-x: initial;
|
||||
}
|
||||
.route-attributes {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
.add-description-wrapper {
|
||||
margin-bottom: 1em;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 0.5em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.add-description {
|
||||
margin-bottom: 0.3em;
|
||||
font-style: italic;
|
||||
}
|
||||
.add-description .fa-icon {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
vertical-align: text-bottom;
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
.add-description .fa-subicon {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
}
|
||||
.track-download .fa-icon {
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
.author {
|
||||
float: right;
|
||||
color: #7a7a7a;
|
||||
font-size: 95%;
|
||||
font-style: italic;
|
||||
}
|
||||
>>> .table-footer th {
|
||||
font-weight: normal;
|
||||
font-size: 0.9em;
|
||||
padding-top: 0.5em;
|
||||
color: #7a7a7a;
|
||||
text-align: right;
|
||||
}
|
||||
.star {
|
||||
color: #7a7a7a;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-for="group in groups" :key="group.key" class="video-group-wrapper">
|
||||
<SummitVideosGroup :videos="group.videos" :title="group.title" :titleLink="group.titleLink" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SummitVideosGroup from './SummitVideosGroup.vue'
|
||||
import moment from 'moment'
|
||||
|
||||
export default {
|
||||
name: 'SummitVideos',
|
||||
props: {
|
||||
videos: Array
|
||||
},
|
||||
components: {
|
||||
SummitVideosGroup
|
||||
},
|
||||
computed: {
|
||||
groups () {
|
||||
if (!this.videos) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Group videos by author
|
||||
let authorGroups = new Map()
|
||||
this.videos.forEach(video => {
|
||||
let authorGroup = authorGroups.get(video.author)
|
||||
if (!authorGroup) {
|
||||
authorGroup = {
|
||||
author: video.author.toUpperCase(),
|
||||
title: video.author.toUpperCase(),
|
||||
titleLink: '/activators/' + video.author.toUpperCase(),
|
||||
videos: []
|
||||
}
|
||||
authorGroups.set(video.author, authorGroup)
|
||||
}
|
||||
authorGroup.videos.push(video)
|
||||
})
|
||||
|
||||
// Sort groups by latest uploaded video
|
||||
return [...authorGroups.values()].sort((a, b) => {
|
||||
let reduceFunc = (prev, current) => {
|
||||
return (moment(prev.date).isAfter(current.date)) ? prev : current
|
||||
}
|
||||
let dateA = a.videos.reduce(reduceFunc).date
|
||||
let dateB = b.videos.reduce(reduceFunc).date
|
||||
|
||||
if (!dateA && !dateB) {
|
||||
return 0
|
||||
} else if (dateA && !dateB) {
|
||||
return -1
|
||||
} else if (dateB && !dateA) {
|
||||
return 1
|
||||
} else {
|
||||
return (moment(dateB).diff(dateA))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-group-wrapper:not(:last-child) {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,66 @@
|
|||
<template>
|
||||
<div class="video-group">
|
||||
<div class="video-group-title">
|
||||
<router-link v-if="titleLink" :to="titleLink">{{ title }}</router-link>
|
||||
<span v-else>{{ title }}</span>
|
||||
</div>
|
||||
<LazyYoutubeVideo v-for="video in videos" :key="video.src" :src="video.src" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LazyYoutubeVideo from 'vue-lazy-youtube-video'
|
||||
import 'vue-lazy-youtube-video/dist/style.css'
|
||||
|
||||
export default {
|
||||
name: 'SummitVideosGroup',
|
||||
props: {
|
||||
videos: Array,
|
||||
title: String,
|
||||
titleLink: String
|
||||
},
|
||||
components: {
|
||||
LazyYoutubeVideo
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.y-video {
|
||||
margin: 0 0.75rem 0.75rem 0;
|
||||
width: 30rem;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.y-video {
|
||||
margin: 0 0.5rem 0.5rem 0;
|
||||
width: 70vw;
|
||||
}
|
||||
}
|
||||
>>> .video-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
>>> .video-title .author {
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
.video-group {
|
||||
background: whitesmoke;
|
||||
padding: 0.25rem 0 0 0.75rem;
|
||||
display: inline-block;
|
||||
}
|
||||
.video-group-title {
|
||||
color: #777;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.2rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
.video-group-title a {
|
||||
color: #777;
|
||||
}
|
||||
.video-group-title a:hover {
|
||||
color: #3273dc;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,86 @@
|
|||
<template>
|
||||
<a class="track-link" :href="href" :download="filename"><slot></slot></a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import tracks from '../mixins/tracks.js'
|
||||
|
||||
export default {
|
||||
name: 'TrackLink',
|
||||
mixins: [tracks],
|
||||
props: {
|
||||
route: Object,
|
||||
summit: Object
|
||||
},
|
||||
watch: {
|
||||
route: {
|
||||
immediate: true,
|
||||
handler () {
|
||||
if (this.route.track.points) {
|
||||
let trkpts = this.route.track.points.map(point => {
|
||||
if (point.altitude !== undefined) {
|
||||
return `<trkpt lat="${point.latitude}" lon="${point.longitude}"><ele>${Math.round(point.altitude)}</ele></trkpt>`
|
||||
} else {
|
||||
return `<trkpt lat="${point.latitude}" lon="${point.longitude}"></trkpt>`
|
||||
}
|
||||
})
|
||||
|
||||
let gpx = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<gpx version="1.1"
|
||||
creator="SOTLAS"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://www.topografix.com/GPX/1/1"
|
||||
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
|
||||
<metadata>
|
||||
<name>Track for SOTA Summit ${this.summit.code} - ${this.summit.name}</name>
|
||||
<desc>Imported from SMP</desc>
|
||||
<author>
|
||||
<name>${this.route.author}</name>
|
||||
</author>
|
||||
<link href="https://sotl.as/summits/${this.summit.code}">
|
||||
<text>SOTLAS</text>
|
||||
</link>
|
||||
</metadata>
|
||||
<trk>
|
||||
<name>${this.route.title}</name>
|
||||
<src>${this.route.author}</src>
|
||||
<trkseg>
|
||||
${trkpts.join('\n')}
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>`
|
||||
|
||||
let blob = new Blob([gpx], { type: 'application/gpx+xml' })
|
||||
if (this.objectUrl) {
|
||||
URL.revokeObjectURL(this.objectUrl)
|
||||
}
|
||||
this.objectUrl = URL.createObjectURL(blob)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filename () {
|
||||
return this.summit.code.replace('/', '_') + '_' + this.route.id.substr(-8) + '.gpx'
|
||||
},
|
||||
href () {
|
||||
if (this.route.track.filename) {
|
||||
return this.trackUrl(this.route.track)
|
||||
} else {
|
||||
return this.objectUrl
|
||||
}
|
||||
}
|
||||
},
|
||||
destroyed () {
|
||||
if (this.objectUrl) {
|
||||
URL.revokeObjectURL(this.objectUrl)
|
||||
this.objectUrl = null
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
objectUrl: null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,5 @@
|
|||
import Vue from 'vue'
|
||||
|
||||
const EventBus = new Vue()
|
||||
|
||||
export default EventBus
|
|
@ -0,0 +1,64 @@
|
|||
const KEY_DECOMPRESSION_MAP = {
|
||||
a: 'altitude',
|
||||
ac: 'activatorCallsign',
|
||||
ao: 'activationCount',
|
||||
c: 'comments',
|
||||
d: 'code',
|
||||
e: 'speed',
|
||||
f: 'frequency',
|
||||
hc: 'homeCallsign',
|
||||
i: 'isActivator',
|
||||
ic: 'isoCode',
|
||||
l: 'callsign',
|
||||
m: 'mode',
|
||||
n: 'name',
|
||||
o: 'continent',
|
||||
p: 'points',
|
||||
s: 'summit',
|
||||
t: 'spotter',
|
||||
ts: 'timeStamp'
|
||||
}
|
||||
|
||||
let KEY_COMPRESSION_MAP = null
|
||||
|
||||
function compressKeys (obj) {
|
||||
// Lazy init
|
||||
if (KEY_COMPRESSION_MAP === null) {
|
||||
KEY_COMPRESSION_MAP = {}
|
||||
Object.keys(KEY_DECOMPRESSION_MAP).forEach(key => {
|
||||
KEY_COMPRESSION_MAP[KEY_DECOMPRESSION_MAP[key]] = key
|
||||
})
|
||||
}
|
||||
|
||||
return mapKeys(obj, KEY_COMPRESSION_MAP)
|
||||
}
|
||||
|
||||
function decompressKeys (obj) {
|
||||
return mapKeys(obj, KEY_DECOMPRESSION_MAP)
|
||||
}
|
||||
|
||||
function mapKeys (obj, map) {
|
||||
if (obj === null) {
|
||||
return null
|
||||
} else if (Array.isArray(obj)) {
|
||||
return obj.map(el => {
|
||||
return mapKeys(el, map)
|
||||
})
|
||||
} else if (typeof obj === 'object' && !(obj instanceof Date)) {
|
||||
let ret = {}
|
||||
Object.keys(obj).forEach(key => {
|
||||
let val = mapKeys(obj[key], map)
|
||||
|
||||
if (map[key]) {
|
||||
ret[map[key]] = val
|
||||
} else {
|
||||
ret[key] = val
|
||||
}
|
||||
})
|
||||
return ret
|
||||
} else {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
export { compressKeys, decompressKeys }
|
|
@ -0,0 +1,110 @@
|
|||
import Vue from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import Buefy from 'buefy'
|
||||
import VueSVGIcon from 'vue-svgicon'
|
||||
import vueDebounce from 'vue-debounce'
|
||||
import VueClipboard from 'vue-clipboard2'
|
||||
import MatchMedia from 'vue-match-media/src'
|
||||
import VueKeyCloak from '@dsb-norge/vue-keycloak-js'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { faCheck, faCheckCircle, faInfoCircle, faExclamationTriangle, faExclamationCircle, faArrowUp, faPlus, faCheckDouble,
|
||||
faAngleRight, faAngleLeft, faAngleDown, faEye, faEyeSlash, faCaretUp, faUpload, faLink, faHistory, faThList, faImages,
|
||||
faQuoteRight, faSearch, faMountains, faUser, faClock, faChevronCircleUp, faChevronCircleDown, faChartBar, faFileDownload,
|
||||
faExchange, faGlobe, faCalendarDay, faTrashAlt, faEdit, faClone, faCheckCircle as farCheckCircle, faArrowsH, faArrowsAlt,
|
||||
faSnowflake, faWindowMinimize, faWindowMaximize, faWindowClose, faExpandArrows, faLocation, faCalendarCheck, faComment, faSpinner } from '@fortawesome/pro-regular-svg-icons'
|
||||
import { faMap, faCheckCircle as fasCheckCircle, faChevronCircleDown as fasChevronCircleDown, faChevronCircleUp as fasChevronCircleUp,
|
||||
faParking, faSquare, faBus, faHiking, faCircle, faCamera, faVolume, faVolumeMute, faCog, faCaretDown as fasCaretDown, faLocationArrow as fasLocationArrow } from '@fortawesome/pro-solid-svg-icons'
|
||||
import { faWikipediaW, faGoogle } from '@fortawesome/free-brands-svg-icons'
|
||||
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
|
||||
import '@/assets/global.css'
|
||||
import store from './store'
|
||||
import axios from 'axios'
|
||||
import { SnackbarProgrammatic as Snackbar } from 'buefy/dist/components/snackbar'
|
||||
|
||||
library.add(faCheck, faCheckCircle, faInfoCircle, faExclamationTriangle, faExclamationCircle, faArrowUp, faPlus, faCheckDouble,
|
||||
faAngleRight, faAngleLeft, faAngleDown, faEye, faEyeSlash, faCaretUp, faUpload, faLink, faHistory, faThList, faImages,
|
||||
faQuoteRight, faSearch, faMountains, faUser, faClock, faChevronCircleUp, faChevronCircleDown, faMap, faChartBar, faFileDownload,
|
||||
faExchange, faGlobe, faCalendarDay, faTrashAlt, faEdit, faClone, farCheckCircle, faArrowsH, faArrowsAlt,
|
||||
faSnowflake, faWindowMinimize, faWindowMaximize, faWindowClose, faExpandArrows, faLocation, faCalendarCheck, faComment, faSpinner)
|
||||
library.add(faMap, fasCheckCircle, fasChevronCircleDown, fasChevronCircleUp, faParking, faSquare, faBus, faHiking, faCircle, faCamera, faVolume, faVolumeMute, faCog, fasCaretDown, fasLocationArrow)
|
||||
library.add(faWikipediaW, faGoogle)
|
||||
Vue.component('font-awesome-icon', FontAwesomeIcon)
|
||||
Vue.component('font-awesome-layers', FontAwesomeLayers)
|
||||
Vue.use(VueSVGIcon)
|
||||
Vue.use(vueDebounce)
|
||||
Vue.use(VueClipboard)
|
||||
Vue.use(Buefy, {
|
||||
defaultIconComponent: 'font-awesome-icon',
|
||||
defaultIconPack: 'far'
|
||||
})
|
||||
Vue.use(MatchMedia)
|
||||
|
||||
if (window.performance && performance.navigation.type === 1) {
|
||||
// Store last reload timestamp so user reloads can be detected despite SSO redirect
|
||||
sessionStorage.setItem('lastReload', new Date().getTime())
|
||||
}
|
||||
|
||||
if (sessionStorage.getItem('wantSso') || localStorage.getItem('wantSso')) {
|
||||
Vue.use(VueKeyCloak, {
|
||||
config: {
|
||||
realm: 'SOTA',
|
||||
url: 'https://sso.sota.org.uk/auth',
|
||||
clientId: 'sotlas'
|
||||
},
|
||||
init: {
|
||||
onLoad: 'check-sso',
|
||||
checkLoginIframe: false
|
||||
},
|
||||
onReady: keycloak => {
|
||||
if (sessionStorage.getItem('wantSsoLogin')) {
|
||||
sessionStorage.removeItem('wantSsoLogin')
|
||||
keycloak.login()
|
||||
} else {
|
||||
startVue()
|
||||
}
|
||||
},
|
||||
onInitError: error => {
|
||||
console.error('Keycloak error: ' + error)
|
||||
startVue()
|
||||
},
|
||||
autoUpdateToken: false
|
||||
})
|
||||
} else {
|
||||
startVue()
|
||||
}
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
// Axios error handling
|
||||
let lastError = null
|
||||
axios.interceptors.response.use(response => {
|
||||
return response
|
||||
}, error => {
|
||||
if ((!lastError || new Date().getTime() - lastError > 9000) && (!error.response || error.response.status !== 404)) {
|
||||
Snackbar.open({
|
||||
duration: 9000,
|
||||
message: 'Network or server error while loading data, try again later',
|
||||
type: 'is-danger',
|
||||
position: 'is-bottom-left',
|
||||
queue: false
|
||||
})
|
||||
|
||||
lastError = new Date().getTime()
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
})
|
||||
|
||||
function startVue () {
|
||||
new Vue({
|
||||
store,
|
||||
router,
|
||||
render: h => h(App),
|
||||
mq: {
|
||||
mobile: '(max-width: 768px)',
|
||||
desktop: '(min-width: 1024px)',
|
||||
fullhd: '(min-width: 1408px)'
|
||||
}
|
||||
}).$mount('#app')
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import axios from 'axios'
|
||||
import ssoauth from './ssoauth.js'
|
||||
|
||||
export default {
|
||||
mixins: [ssoauth],
|
||||
methods: {
|
||||
loadActivations (callsign) {
|
||||
return axios.get('https://api.sotl.as/activations/' + callsign)
|
||||
.then(response => {
|
||||
return response.data
|
||||
})
|
||||
},
|
||||
uploadPhoto (summitCode, file, progress, cancelToken) {
|
||||
let formData = new FormData()
|
||||
formData.append('photo', file)
|
||||
return this.axiosAuth.post('https://api.sotl.as/photos/summits/' + summitCode + '/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: progress,
|
||||
cancelToken
|
||||
})
|
||||
},
|
||||
deletePhoto (summitCode, filename) {
|
||||
return this.axiosAuth.delete('https://api.sotl.as/photos/summits/' + summitCode + '/' + filename)
|
||||
},
|
||||
editPhoto (summitCode, filename, data) {
|
||||
return this.axiosAuth.post('https://api.sotl.as/photos/summits/' + summitCode + '/' + filename, data)
|
||||
},
|
||||
reorderPhotos (summitCode, filenames) {
|
||||
return this.axiosAuth.post('https://api.sotl.as/photos/summits/' + summitCode + '/reorder', { filenames })
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import photos from '../mixins/photos.js'
|
||||
import Wikipedia from '../wikipedia.js'
|
||||
|
||||
export default {
|
||||
mixins: [photos],
|
||||
computed: {
|
||||
coverPhoto () {
|
||||
if (this.summit.photos) {
|
||||
let coverPhoto = this.summit.photos.find(photo => photo.isCover)
|
||||
if (coverPhoto) {
|
||||
return {
|
||||
src: this.photoSrc(coverPhoto, 'thumb'),
|
||||
mediaLink: this.photoSrc(coverPhoto, 'large'),
|
||||
attribution: coverPhoto.author
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.wikipediaPhoto) {
|
||||
return this.wikipediaPhoto
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
summit: {
|
||||
handler (newSummit, oldSummit) {
|
||||
if (!newSummit.code) {
|
||||
return
|
||||
}
|
||||
if (newSummit && oldSummit && newSummit.code === oldSummit.code && this.wikipediaPhoto !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.wikipediaPhoto = null
|
||||
|
||||
if (!this.alwaysLoadWikipedia && this.summit.photos && this.summit.photos.some(photo => photo.isCover)) {
|
||||
// We have our own photo; no need to load
|
||||
return
|
||||
}
|
||||
|
||||
let loadingSummit = this.summit
|
||||
Wikipedia.loadSummitPhoto(this.summit, 320)
|
||||
.then(photo => {
|
||||
if (!photo) {
|
||||
return
|
||||
}
|
||||
let preloadImg = new Image()
|
||||
preloadImg.onload = () => {
|
||||
if (this.summit && loadingSummit.code === this.summit.code) {
|
||||
this.wikipediaPhoto = photo
|
||||
}
|
||||
}
|
||||
preloadImg.src = photo.src
|
||||
})
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
wikipediaPhoto: null
|
||||
}
|
||||
}
|
||||
}
|