Add Navigation Arrow and simplify static page
This commit is contained in:
parent
278813fba6
commit
4dfa7da08b
@ -1,6 +1,6 @@
|
|||||||
:root {
|
:root {
|
||||||
--page-background: #eef4e9;
|
--page-background: #eef4e9;
|
||||||
--surface: #ffffff;
|
--surface: #fff;
|
||||||
--primary: #315d3a;
|
--primary: #315d3a;
|
||||||
--primary-dark: #24462b;
|
--primary-dark: #24462b;
|
||||||
--text: #1f3022;
|
--text: #1f3022;
|
||||||
@ -8,41 +8,57 @@
|
|||||||
--notice: #fff8d6;
|
--notice: #fff8d6;
|
||||||
--notice-border: #d4a72c;
|
--notice-border: #d4a72c;
|
||||||
--focus: #1e6fba;
|
--focus: #1e6fba;
|
||||||
|
--footer-height: 5.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html,
|
||||||
|
body {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
background: var(--page-background);
|
background: var(--page-background);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
button, audio {
|
button,
|
||||||
|
input,
|
||||||
|
audio {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
[data-page-target] {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
[hidden] {
|
[hidden] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app-shell {
|
#app-shell,
|
||||||
min-height: 100vh;
|
.app-page {
|
||||||
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-page {
|
.app-page {
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--page-background);
|
background: var(--page-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header {
|
#app-shell.has-navigation .app-page {
|
||||||
|
padding-bottom: calc(var(--footer-height) + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page > header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 3.5rem;
|
min-height: 3.5rem;
|
||||||
padding: env(safe-area-inset-top) max(1rem, env(safe-area-inset-right)) 0 max(1rem, env(safe-area-inset-left));
|
padding: env(safe-area-inset-top) max(1rem, env(safe-area-inset-right)) 0 max(1rem, env(safe-area-inset-left));
|
||||||
@ -51,53 +67,43 @@ button, audio {
|
|||||||
box-shadow: 0 .15rem .5rem rgba(0, 0, 0, .2);
|
box-shadow: 0 .15rem .5rem rgba(0, 0, 0, .2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header h1 {
|
.app-page > header:has(.back-button) {
|
||||||
margin: 0;
|
grid-template-columns: 5.5rem minmax(0, 1fr) 5.5rem;
|
||||||
padding: .85rem 0;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header-with-back {
|
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header-with-back::after {
|
.app-page > header:has(.back-button)::after {
|
||||||
content: "";
|
content: "";
|
||||||
width: 5.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header .back-button.ui-button {
|
.app-page > header h1 {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: .85rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page > header .back-button.ui-button {
|
||||||
width: 5.5rem;
|
width: 5.5rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-color: rgba(255, 255, 255, .45);
|
border-color: rgba(255, 255, 255, .45);
|
||||||
background: rgba(255, 255, 255, .12);
|
background: rgba(255, 255, 255, .12);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header .back-button.ui-button:hover,
|
.content {
|
||||||
.app-header .back-button.ui-button:focus {
|
|
||||||
border-color: #fff;
|
|
||||||
background: rgba(255, 255, 255, .22);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content {
|
|
||||||
width: min(48rem, 100%);
|
width: min(48rem, 100%);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
|
padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-footer {
|
|
||||||
padding: .85rem max(1rem, env(safe-area-inset-right)) calc(.85rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
|
|
||||||
background: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notice {
|
.notice {
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
padding: .75rem;
|
padding: .75rem;
|
||||||
@ -106,25 +112,17 @@ button, audio {
|
|||||||
background: var(--notice);
|
background: var(--notice);
|
||||||
}
|
}
|
||||||
|
|
||||||
.route-section + .route-section {
|
#routes-page .content > section + section {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
padding-top: 1.25rem;
|
padding-top: 1.25rem;
|
||||||
border-top: 1px solid #c8d3ca;
|
border-top: 1px solid #c8d3ca;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-heading-row {
|
#routes-page h2 {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(14rem, 22rem);
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: end;
|
|
||||||
margin-bottom: .75rem;
|
margin-bottom: .75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-heading-row h2 {
|
#routes-page label {
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-search-controls label {
|
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: .35rem;
|
margin-bottom: .35rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@ -132,7 +130,7 @@ button, audio {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.route-search-row {
|
.search-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: .35rem;
|
gap: .35rem;
|
||||||
@ -140,13 +138,14 @@ button, audio {
|
|||||||
|
|
||||||
#route-search {
|
#route-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 2.5rem;
|
min-height: 3rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: .5rem .65rem;
|
padding: .65rem .75rem;
|
||||||
border: 1px solid #aebcaf;
|
border: 1px solid #aebcaf;
|
||||||
border-radius: .3rem;
|
border-radius: .3rem;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#route-search:focus {
|
#route-search:focus {
|
||||||
@ -155,22 +154,23 @@ button, audio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#clear-route-search.ui-button {
|
#clear-route-search.ui-button {
|
||||||
|
min-width: 3rem;
|
||||||
|
min-height: 3rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.route-list-summary {
|
#all-routes-summary {
|
||||||
margin: 0 0 .75rem;
|
margin: .75rem 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: .9rem;
|
font-size: .9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-autocomplete {
|
.ui-autocomplete {
|
||||||
max-height: 16rem;
|
max-height: min(16rem, 45dvh);
|
||||||
overflow-y: auto;
|
overflow: auto;
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-list {
|
.route-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -178,9 +178,10 @@ button, audio {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-link {
|
.route-list button {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 3.5rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
gap: .25rem;
|
gap: .25rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@ -193,38 +194,33 @@ button, audio {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-link:hover {
|
.route-list button > strong {
|
||||||
border-color: var(--primary);
|
font-size: 1.05rem;
|
||||||
box-shadow: 0 .2rem .55rem rgba(0, 0, 0, .13);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-link:focus-visible {
|
.route-list button > span,
|
||||||
|
.route-list button > small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-list button > small {
|
||||||
|
margin-top: .25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-list button:focus-visible,
|
||||||
|
.ui-button:focus-visible {
|
||||||
outline: .2rem solid var(--focus);
|
outline: .2rem solid var(--focus);
|
||||||
outline-offset: .15rem;
|
outline-offset: .15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-link:disabled {
|
.route-list button:disabled {
|
||||||
cursor: progress;
|
cursor: progress;
|
||||||
opacity: .65;
|
opacity: .65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.route-list > li > p {
|
||||||
font-size: 1.05rem;
|
margin: 0;
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-description,
|
|
||||||
.route-distance {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-distance {
|
|
||||||
margin-top: .25rem;
|
|
||||||
font-size: .9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-list {
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: .5rem;
|
border-radius: .5rem;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@ -257,43 +253,39 @@ button, audio {
|
|||||||
|
|
||||||
#start-tracking.ui-button {
|
#start-tracking.ui-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 3rem;
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
border-color: var(--primary-dark);
|
border-color: var(--primary-dark);
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
#start-tracking.ui-button:hover,
|
#slideshow {
|
||||||
#start-tracking.ui-button:focus {
|
|
||||||
background: var(--primary-dark);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slideshow {
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: .75rem;
|
border-radius: .75rem;
|
||||||
background: #172019;
|
background: #172019;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
touch-action: pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slideshow figure {
|
#slideshow figure {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slideshow img {
|
#slideshow img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: min(55vh, 28rem);
|
height: min(55dvh, 28rem);
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: #111;
|
background: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slideshow figcaption {
|
#slideshow figcaption {
|
||||||
min-height: 1.4rem;
|
min-height: 1.4rem;
|
||||||
padding: .5rem .75rem;
|
padding: .5rem .75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-controls {
|
#slide-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(4rem, 1fr) auto;
|
grid-template-columns: auto minmax(4rem, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -303,14 +295,19 @@ button, audio {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-controls.ui-controlgroup-horizontal .ui-controlgroup-item {
|
#slide-controls.ui-controlgroup-horizontal .ui-controlgroup-item {
|
||||||
float: none;
|
float: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#slide-controls .ui-button {
|
||||||
|
min-width: 3rem;
|
||||||
|
min-height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
#slide-position {
|
#slide-position {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 2.2rem;
|
min-height: 2.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#poi-audio {
|
#poi-audio {
|
||||||
@ -318,20 +315,94 @@ button, audio {
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-button:focus-visible {
|
#navigation-footer {
|
||||||
outline: .2rem solid var(--focus);
|
position: fixed;
|
||||||
outline-offset: .12rem;
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3.75rem minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--footer-height);
|
||||||
|
box-sizing: border-box;
|
||||||
|
gap: .85rem;
|
||||||
|
padding: .6rem max(1rem, env(safe-area-inset-right)) calc(.6rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, .35);
|
||||||
|
background: rgba(36, 70, 43, .96);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 -.25rem .8rem rgba(0, 0, 0, .2);
|
||||||
|
backdrop-filter: blur(.4rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
#navigation-arrow {
|
||||||
.section-heading-row {
|
width: 3.5rem;
|
||||||
grid-template-columns: 1fr;
|
height: 3.5rem;
|
||||||
align-items: stretch;
|
fill: currentColor;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
transition: transform 180ms linear;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigation-footer p {
|
||||||
|
display: grid;
|
||||||
|
gap: .15rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigation-mode {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: .78rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigation-distance {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.route-list button:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 .2rem .55rem rgba(0, 0, 0, .13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page > header .back-button.ui-button:hover,
|
||||||
|
.app-page > header .back-button.ui-button:focus,
|
||||||
|
#start-tracking.ui-button:hover,
|
||||||
|
#start-tracking.ui-button:focus {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 32rem) {
|
||||||
|
.search-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-facts {
|
||||||
|
gap: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-facts div {
|
||||||
|
padding: .6rem .25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 22rem) {
|
||||||
.route-facts {
|
.route-facts {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: .01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
66
public/css/bootstrap.css
vendored
66
public/css/bootstrap.css
vendored
@ -1,9 +1,57 @@
|
|||||||
html, body { min-height: 100%; margin: 0; font-family: system-ui, sans-serif; background: #eef4e9; color: #1f3022; }
|
html,
|
||||||
#bootstrap-screen { position: fixed; inset: 0; display: grid; place-items: center; padding: 1rem; z-index: 9999; }
|
body {
|
||||||
.bootstrap-card { width: min(30rem, 100%); background: white; border-radius: 1rem; padding: 1.5rem; box-shadow: 0 .5rem 2rem rgba(0,0,0,.15); text-align: center; }
|
min-height: 100%;
|
||||||
.mascot { font-size: 4rem; }
|
margin: 0;
|
||||||
progress { width: 100%; height: 1.2rem; }
|
background: #eef4e9;
|
||||||
#bootstrap-steps { text-align: left; line-height: 1.8; }
|
color: #1f3022;
|
||||||
#bootstrap-steps li.done::marker { content: '✓ '; }
|
font-family: system-ui, sans-serif;
|
||||||
#bootstrap-steps li.failed { color: #8b1e1e; }
|
}
|
||||||
#bootstrap-error { background: #fee; color: #701010; padding: .75rem; border-radius: .5rem; }
|
|
||||||
|
#bootstrap-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-screen > section {
|
||||||
|
width: min(30rem, 100%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 .5rem 2rem rgba(0, 0, 0, .15);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-screen > section > span {
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-steps {
|
||||||
|
line-height: 1.8;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-steps li.done::marker {
|
||||||
|
content: "✓ ";
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-steps li.failed {
|
||||||
|
color: #8b1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-error {
|
||||||
|
padding: .75rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
background: #fee;
|
||||||
|
color: #701010;
|
||||||
|
}
|
||||||
|
|||||||
@ -4,16 +4,18 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#315d3a">
|
<meta name="theme-color" content="#315d3a">
|
||||||
<title>Wegwichtel – Atlas</title>
|
<title>Wegwichtel</title>
|
||||||
<link rel="stylesheet" href="css/bootstrap.css">
|
<link rel="stylesheet" href="css/bootstrap.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="bootstrap-screen" role="status" aria-live="polite">
|
<div id="bootstrap-screen" role="status" aria-live="polite">
|
||||||
<div class="bootstrap-card">
|
<section aria-label="Anwendung wird initialisiert">
|
||||||
<div class="mascot" aria-hidden="true">🧭</div>
|
<span aria-hidden="true">🧭</span>
|
||||||
<h1>Wegwichtel</h1>
|
<h1>Wegwichtel</h1>
|
||||||
<p>Atlas bereitet Routen und Medien vor.</p>
|
<p>Der Wegwichtel bereitet Routen und Medien vor.</p>
|
||||||
|
|
||||||
<progress id="bootstrap-progress" max="6" value="0"></progress>
|
<progress id="bootstrap-progress" max="6" value="0"></progress>
|
||||||
|
|
||||||
<ol id="bootstrap-steps">
|
<ol id="bootstrap-steps">
|
||||||
<li data-step="jquery">jQuery laden</li>
|
<li data-step="jquery">jQuery laden</li>
|
||||||
<li data-step="jquery-ui">jQuery UI laden</li>
|
<li data-step="jquery-ui">jQuery UI laden</li>
|
||||||
@ -22,80 +24,103 @@
|
|||||||
<li data-step="routes">Routen laden</li>
|
<li data-step="routes">Routen laden</li>
|
||||||
<li data-step="location">Standort ermitteln</li>
|
<li data-step="location">Standort ermitteln</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<p id="bootstrap-error" hidden></p>
|
<p id="bootstrap-error" hidden></p>
|
||||||
<button id="bootstrap-retry" type="button" hidden>Erneut versuchen</button>
|
<button id="bootstrap-retry" type="button" hidden>Erneut versuchen</button>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main id="app-shell" hidden>
|
<main id="app-shell" hidden>
|
||||||
<section class="app-page" id="routes-page" aria-labelledby="routes-heading">
|
<section id="routes-page" class="app-page" aria-label="Routenauswahl">
|
||||||
<header class="app-header">
|
<header>
|
||||||
<h1 id="routes-heading">Wegwichtel <small>Atlas</small></h1>
|
<h1>Wegwichtel</h1>
|
||||||
</header>
|
</header>
|
||||||
<div class="app-content">
|
|
||||||
<div id="location-hint" class="notice">Standort wird ermittelt.</div>
|
|
||||||
|
|
||||||
<section class="route-section" aria-labelledby="nearby-routes-heading">
|
<div class="content">
|
||||||
<h2 id="nearby-routes-heading">Routen in deiner Nähe</h2>
|
<p id="location-hint" class="notice">Standort wird ermittelt.</p>
|
||||||
<ul id="nearby-route-list" class="card-list" aria-live="polite"></ul>
|
|
||||||
|
<section aria-label="Routen in der Nähe">
|
||||||
|
<h2>Routen in deiner Nähe</h2>
|
||||||
|
<ul id="nearby-route-list" class="route-list" aria-live="polite"></ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="route-section" aria-labelledby="all-routes-heading">
|
<section aria-label="Alle Routen">
|
||||||
<div class="section-heading-row">
|
<h2>Alle Routen</h2>
|
||||||
<h2 id="all-routes-heading">Alle Routen</h2>
|
<label for="route-search">Routen durchsuchen</label>
|
||||||
<div class="route-search-controls">
|
|
||||||
<label for="route-search">Routen durchsuchen</label>
|
<div class="search-row">
|
||||||
<div class="route-search-row">
|
<input
|
||||||
<input id="route-search" type="search" autocomplete="off"
|
id="route-search"
|
||||||
placeholder="Name, Schule oder Beschreibung">
|
type="search"
|
||||||
<button id="clear-route-search" type="button">Suche leeren</button>
|
autocomplete="off"
|
||||||
</div>
|
inputmode="search"
|
||||||
</div>
|
placeholder="Name, Schule oder Beschreibung">
|
||||||
|
<button id="clear-route-search" type="button">Suche leeren</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="all-routes-summary" class="route-list-summary" aria-live="polite"></p>
|
|
||||||
<ul id="all-route-list" class="card-list" aria-live="polite"></ul>
|
<p id="all-routes-summary" aria-live="polite"></p>
|
||||||
|
<ul id="all-route-list" class="route-list" aria-live="polite"></ul>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<footer class="app-footer">GPS-Lernwege</footer>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="app-page" id="route-page" aria-labelledby="route-title" hidden>
|
<section id="route-page" class="app-page" aria-label="Ausgewählte Route" hidden>
|
||||||
<header class="app-header app-header-with-back">
|
<header>
|
||||||
<button class="back-button" type="button" data-page-target="#routes-page">Zurück</button>
|
<button class="back-button" type="button" data-page-target="#routes-page">Zurück</button>
|
||||||
<h1 id="route-title">Route</h1>
|
<h1 id="route-title">Route</h1>
|
||||||
</header>
|
</header>
|
||||||
<div class="app-content">
|
|
||||||
|
<div class="content">
|
||||||
<p id="route-description"></p>
|
<p id="route-description"></p>
|
||||||
|
|
||||||
<dl class="route-facts">
|
<dl class="route-facts">
|
||||||
<div><dt>Länge</dt><dd id="route-distance">–</dd></div>
|
<div><dt>Länge</dt><dd id="route-distance">–</dd></div>
|
||||||
<div><dt>Höhenmeter</dt><dd id="route-elevation">–</dd></div>
|
<div><dt>Höhenmeter</dt><dd id="route-elevation">–</dd></div>
|
||||||
<div><dt>POIs</dt><dd id="route-poi-count">–</dd></div>
|
<div><dt>POIs</dt><dd id="route-poi-count">–</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<button id="start-tracking" type="button">GPS-Begleitung starten</button>
|
<button id="start-tracking" type="button">GPS-Begleitung starten</button>
|
||||||
<div id="tracking-status" class="notice" hidden></div>
|
<p id="tracking-status" class="notice" hidden></p>
|
||||||
|
|
||||||
<h2>Stationen</h2>
|
<h2>Stationen</h2>
|
||||||
<ul id="poi-list" class="card-list"></ul>
|
<ul id="poi-list" class="route-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="app-page" id="poi-page" aria-labelledby="poi-title" hidden>
|
<section id="poi-page" class="app-page" aria-label="Station" hidden>
|
||||||
<header class="app-header app-header-with-back">
|
<header>
|
||||||
<button class="back-button" type="button" data-page-target="#route-page">Zurück</button>
|
<button class="back-button" type="button" data-page-target="#route-page">Zurück</button>
|
||||||
<h1 id="poi-title">Station</h1>
|
<h1 id="poi-title">Station</h1>
|
||||||
</header>
|
</header>
|
||||||
<div class="app-content">
|
|
||||||
<div id="slideshow" class="slideshow" aria-live="polite">
|
<div class="content">
|
||||||
<figure><img id="slide-image" alt=""><figcaption id="slide-caption"></figcaption></figure>
|
<div id="slideshow" aria-live="polite">
|
||||||
<div id="slide-controls" class="slide-controls">
|
<figure>
|
||||||
|
<img id="slide-image" alt="">
|
||||||
|
<figcaption id="slide-caption"></figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<div id="slide-controls">
|
||||||
<button id="slide-prev" type="button">Vorheriges Bild</button>
|
<button id="slide-prev" type="button">Vorheriges Bild</button>
|
||||||
<span id="slide-position" aria-live="polite">0 / 0</span>
|
<span id="slide-position" aria-live="polite">0 / 0</span>
|
||||||
<button id="slide-next" type="button">Nächstes Bild</button>
|
<button id="slide-next" type="button">Nächstes Bild</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="poi-description"></p>
|
<p id="poi-description"></p>
|
||||||
<audio id="poi-audio" controls preload="metadata"></audio>
|
<audio id="poi-audio" controls preload="metadata"></audio>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<footer id="navigation-footer" hidden aria-live="polite">
|
||||||
|
<svg id="navigation-arrow" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M32 4 53 54 32 44 11 54Z"/>
|
||||||
|
</svg>
|
||||||
|
<p>
|
||||||
|
<span id="navigation-mode">Richtung zum nächsten Routenpunkt</span>
|
||||||
|
<strong id="navigation-distance">–</strong>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="js/bootstrap-loader.js"></script>
|
<script src="js/bootstrap-loader.js"></script>
|
||||||
|
|||||||
221
public/js/app.js
221
public/js/app.js
@ -7,13 +7,31 @@
|
|||||||
position: null,
|
position: null,
|
||||||
triggered: new Set(),
|
triggered: new Set(),
|
||||||
tracking: false,
|
tracking: false,
|
||||||
activePage: null
|
activePage: null,
|
||||||
|
routeProgressIndex: null,
|
||||||
|
deviceHeading: null
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return $('<div>').text(value ?? '').html();
|
return $('<div>').text(value ?? '').html();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activate(selector, handler) {
|
||||||
|
$(document)
|
||||||
|
.on('pointerup', selector, function (event) {
|
||||||
|
const pointer = event.originalEvent;
|
||||||
|
if (pointer && pointer.isPrimary === false) return;
|
||||||
|
if (pointer?.pointerType === 'mouse' && pointer.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
handler.call(this, event);
|
||||||
|
})
|
||||||
|
.on('keydown', selector, function (event) {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
event.preventDefault();
|
||||||
|
handler.call(this, event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function navigate(pageSelector, options = {}) {
|
function navigate(pageSelector, options = {}) {
|
||||||
const target = $(pageSelector);
|
const target = $(pageSelector);
|
||||||
if (!target.length) throw new Error(`Unbekannte Ansicht: ${pageSelector}`);
|
if (!target.length) throw new Error(`Unbekannte Ansicht: ${pageSelector}`);
|
||||||
@ -49,19 +67,20 @@
|
|||||||
function renderRouteItems(listSelector, routes, emptyText) {
|
function renderRouteItems(listSelector, routes, emptyText) {
|
||||||
const list = $(listSelector).empty();
|
const list = $(listSelector).empty();
|
||||||
if (!routes.length) {
|
if (!routes.length) {
|
||||||
list.append(`<li class="empty-list">${escapeHtml(emptyText)}</li>`);
|
list.append(`<li><p>${escapeHtml(emptyText)}</p></li>`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
routes.forEach(route => {
|
routes.forEach(route => {
|
||||||
const proximity = route.proximityM == null
|
const proximity = route.proximityM == null
|
||||||
? ''
|
? ''
|
||||||
: `<span class="route-distance">${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</span>`;
|
: `<small>${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</small>`;
|
||||||
|
|
||||||
list.append(`
|
list.append(`
|
||||||
<li>
|
<li>
|
||||||
<button class="card-link" type="button" data-route-id="${route.id}">
|
<button type="button" data-route-id="${route.id}">
|
||||||
<span class="card-title">${escapeHtml(route.name)}</span>
|
<strong>${escapeHtml(route.name)}</strong>
|
||||||
<span class="card-description">${escapeHtml(route.schoolName || route.description || '')}</span>
|
<span>${escapeHtml(route.schoolName || route.description || '')}</span>
|
||||||
${proximity}
|
${proximity}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@ -88,8 +107,11 @@
|
|||||||
: 'Für Empfehlungen in der Nähe ist ein Standort erforderlich. Alle Routen bleiben unten auswählbar.';
|
: 'Für Empfehlungen in der Nähe ist ein Standort erforderlich. Alle Routen bleiben unten auswählbar.';
|
||||||
|
|
||||||
renderRouteItems('#nearby-route-list', nearbyRoutes, nearbyEmptyText);
|
renderRouteItems('#nearby-route-list', nearbyRoutes, nearbyEmptyText);
|
||||||
renderRouteItems('#all-route-list', allRoutes,
|
renderRouteItems(
|
||||||
searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine Routen vorhanden.');
|
'#all-route-list',
|
||||||
|
allRoutes,
|
||||||
|
searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine Routen vorhanden.'
|
||||||
|
);
|
||||||
|
|
||||||
$('#all-routes-summary').text(
|
$('#all-routes-summary').text(
|
||||||
searchTerm
|
searchTerm
|
||||||
@ -117,9 +139,43 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideNavigation() {
|
||||||
|
$('#navigation-footer').prop('hidden', true);
|
||||||
|
$('#app-shell').removeClass('has-navigation');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNavigation(message = 'Position wird ermittelt', distance = '–') {
|
||||||
|
$('#navigation-mode').text(message);
|
||||||
|
$('#navigation-distance').text(distance);
|
||||||
|
$('#navigation-footer').prop('hidden', false);
|
||||||
|
$('#app-shell').addClass('has-navigation');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTrackingButton() {
|
||||||
|
const button = $('#start-tracking');
|
||||||
|
button.button('option', {
|
||||||
|
label: state.tracking ? 'GPS-Begleitung stoppen' : 'GPS-Begleitung starten',
|
||||||
|
icon: state.tracking ? 'ui-icon-stop' : 'ui-icon-pin-s'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTracking() {
|
||||||
|
state.tracking = false;
|
||||||
|
state.routeProgressIndex = null;
|
||||||
|
state.deviceHeading = null;
|
||||||
|
ns.Geo.stop();
|
||||||
|
ns.Orientation.stop();
|
||||||
|
$('#tracking-status').prop('hidden', true).text('');
|
||||||
|
hideNavigation();
|
||||||
|
updateTrackingButton();
|
||||||
|
}
|
||||||
|
|
||||||
function renderRoute(route) {
|
function renderRoute(route) {
|
||||||
|
if (state.tracking) stopTracking();
|
||||||
|
|
||||||
state.route = route;
|
state.route = route;
|
||||||
state.triggered.clear();
|
state.triggered.clear();
|
||||||
|
state.routeProgressIndex = null;
|
||||||
$('#route-title').text(route.name);
|
$('#route-title').text(route.name);
|
||||||
$('#route-description').text(route.description || '');
|
$('#route-description').text(route.description || '');
|
||||||
$('#route-distance').text(ns.Distance.format(route.distanceM));
|
$('#route-distance').text(ns.Distance.format(route.distanceM));
|
||||||
@ -128,16 +184,16 @@
|
|||||||
|
|
||||||
const list = $('#poi-list').empty();
|
const list = $('#poi-list').empty();
|
||||||
if (!route.pois.length) {
|
if (!route.pois.length) {
|
||||||
list.append('<li class="empty-list">Diese Route enthält noch keine Stationen.</li>');
|
list.append('<li><p>Diese Route enthält noch keine Stationen.</p></li>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
route.pois.forEach(poi => {
|
route.pois.forEach(poi => {
|
||||||
list.append(`
|
list.append(`
|
||||||
<li>
|
<li>
|
||||||
<button class="card-link" type="button" data-poi-id="${poi.id}">
|
<button type="button" data-poi-id="${poi.id}">
|
||||||
<span class="card-title">${escapeHtml(poi.title)}</span>
|
<strong>${escapeHtml(poi.title)}</strong>
|
||||||
<span class="card-description">${escapeHtml(poi.description || '')}</span>
|
<span>${escapeHtml(poi.description || '')}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
`);
|
`);
|
||||||
@ -153,10 +209,99 @@
|
|||||||
if (automatic && poi.audioUrl) ns.AudioPlayer.play().catch(() => {});
|
if (automatic && poi.audioUrl) ns.AudioPlayer.play().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nearestRoutePointIndex(position) {
|
||||||
|
const points = state.route?.points || [];
|
||||||
|
if (!points.length) return null;
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
let end = points.length;
|
||||||
|
if (state.routeProgressIndex != null) {
|
||||||
|
start = Math.max(0, state.routeProgressIndex - 3);
|
||||||
|
end = Math.min(points.length, state.routeProgressIndex + 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nearestIndex = start;
|
||||||
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
for (let index = start; index < end; index += 1) {
|
||||||
|
const distance = ns.Distance.meters(position, points[index]);
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearestDistance = distance;
|
||||||
|
nearestIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nearestIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNavigation(position) {
|
||||||
|
const points = state.route?.points || [];
|
||||||
|
if (!state.tracking || !points.length) return;
|
||||||
|
|
||||||
|
const nearestIndex = nearestRoutePointIndex(position);
|
||||||
|
if (nearestIndex == null) return;
|
||||||
|
|
||||||
|
state.routeProgressIndex = state.routeProgressIndex == null
|
||||||
|
? nearestIndex
|
||||||
|
: Math.max(state.routeProgressIndex, nearestIndex);
|
||||||
|
|
||||||
|
const lastIndex = points.length - 1;
|
||||||
|
let targetIndex = Math.min(lastIndex, state.routeProgressIndex + 1);
|
||||||
|
let target = points[targetIndex];
|
||||||
|
let distance = ns.Distance.meters(position, target);
|
||||||
|
const reachedRadius = Math.max(4, Math.min(12, Math.round(position.accuracy || 4)));
|
||||||
|
|
||||||
|
while (targetIndex < lastIndex && distance <= reachedRadius) {
|
||||||
|
state.routeProgressIndex = targetIndex;
|
||||||
|
targetIndex += 1;
|
||||||
|
target = points[targetIndex];
|
||||||
|
distance = ns.Distance.meters(position, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetIndex === lastIndex && distance <= reachedRadius) {
|
||||||
|
state.routeProgressIndex = lastIndex;
|
||||||
|
$('#navigation-arrow').css('transform', 'rotate(0deg)');
|
||||||
|
showNavigation('Ziel der Route erreicht', '0 m');
|
||||||
|
$('#navigation-footer').attr('aria-label', 'Ziel der Route erreicht');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bearing = ns.Distance.bearing(position, target);
|
||||||
|
const movementHeading = Number.isFinite(position.heading) && Number(position.speed) > 0.5
|
||||||
|
? position.heading
|
||||||
|
: null;
|
||||||
|
const heading = state.deviceHeading ?? movementHeading;
|
||||||
|
const rotation = heading == null
|
||||||
|
? bearing
|
||||||
|
: ns.Distance.normalizeDegrees(bearing - heading);
|
||||||
|
|
||||||
|
$('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`);
|
||||||
|
showNavigation(
|
||||||
|
heading == null ? 'Norden oben · nächster Routenpunkt' : 'Nächster Routenpunkt',
|
||||||
|
`${Math.round(distance)} m`
|
||||||
|
);
|
||||||
|
$('#navigation-footer').attr(
|
||||||
|
'aria-label',
|
||||||
|
`Nächster Routenpunkt in ${Math.round(distance)} Metern`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeading(heading) {
|
||||||
|
if (state.deviceHeading == null) {
|
||||||
|
state.deviceHeading = heading;
|
||||||
|
} else {
|
||||||
|
const delta = ((heading - state.deviceHeading + 540) % 360) - 180;
|
||||||
|
state.deviceHeading = ns.Distance.normalizeDegrees(state.deviceHeading + delta * 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.position) updateNavigation(state.position);
|
||||||
|
}
|
||||||
|
|
||||||
function evaluatePosition(position) {
|
function evaluatePosition(position) {
|
||||||
state.position = position;
|
state.position = position;
|
||||||
if (!state.route || !state.tracking) return;
|
if (!state.route || !state.tracking) return;
|
||||||
|
|
||||||
|
updateNavigation(position);
|
||||||
|
|
||||||
let nearest = null;
|
let nearest = null;
|
||||||
state.route.pois.forEach(poi => {
|
state.route.pois.forEach(poi => {
|
||||||
if (state.triggered.has(poi.id)) return;
|
if (state.triggered.has(poi.id)) return;
|
||||||
@ -165,8 +310,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('#tracking-status').text(
|
$('#tracking-status').text(
|
||||||
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m` +
|
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m`
|
||||||
(nearest ? ` · nächste Station ${Math.round(nearest.distance)} m` : '')
|
+ (nearest ? ` · nächste Station ${Math.round(nearest.distance)} m` : '')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
|
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
|
||||||
@ -176,22 +321,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTrackingButton() {
|
async function startTracking() {
|
||||||
const button = $('#start-tracking');
|
state.tracking = true;
|
||||||
button.button('option', {
|
state.routeProgressIndex = null;
|
||||||
label: state.tracking ? 'GPS-Begleitung stoppen' : 'GPS-Begleitung starten',
|
state.deviceHeading = null;
|
||||||
icon: state.tracking ? 'ui-icon-stop' : 'ui-icon-pin-s'
|
updateTrackingButton();
|
||||||
});
|
$('#tracking-status').prop('hidden', false).text('GPS wird gestartet.');
|
||||||
|
showNavigation();
|
||||||
|
|
||||||
|
ns.Geo.start(
|
||||||
|
evaluatePosition,
|
||||||
|
error => $('#tracking-status').text(`GPS-Fehler: ${error.message}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const orientationAvailable = await ns.Orientation.start(updateHeading);
|
||||||
|
if (!orientationAvailable && state.tracking) {
|
||||||
|
$('#navigation-mode').text('Norden oben · nächster Routenpunkt');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
$(document).on('input', '#route-search', renderRoutes);
|
$(document).on('input', '#route-search', renderRoutes);
|
||||||
|
|
||||||
$(document).on('click', '#clear-route-search', function () {
|
activate('#clear-route-search', function () {
|
||||||
$('#route-search').val('').trigger('input').trigger('focus');
|
$('#route-search').val('').trigger('input').trigger('focus');
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document).on('click', '[data-route-id]', async function () {
|
activate('[data-route-id]', async function () {
|
||||||
const button = $(this);
|
const button = $(this);
|
||||||
button.prop('disabled', true);
|
button.prop('disabled', true);
|
||||||
try {
|
try {
|
||||||
@ -205,26 +361,20 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document).on('click', '[data-poi-id]', function () {
|
activate('[data-poi-id]', function () {
|
||||||
const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id')));
|
const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id')));
|
||||||
if (poi) showPoi(poi, false);
|
if (poi) showPoi(poi, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document).on('click', '[data-page-target]', function () {
|
activate('[data-page-target]', function () {
|
||||||
const target = $(this).data('page-target');
|
const target = $(this).data('page-target');
|
||||||
if (history.state?.previous === target) history.back();
|
if (history.state?.previous === target) history.back();
|
||||||
else navigate(target, { replace: true });
|
else navigate(target, { replace: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document).on('click', '#start-tracking', function () {
|
activate('#start-tracking', async function () {
|
||||||
state.tracking = !state.tracking;
|
if (state.tracking) stopTracking();
|
||||||
updateTrackingButton();
|
else await startTracking();
|
||||||
$('#tracking-status').toggle(state.tracking);
|
|
||||||
if (state.tracking) {
|
|
||||||
ns.Geo.start(evaluatePosition, error => $('#tracking-status').text(`GPS-Fehler: ${error.message}`));
|
|
||||||
} else {
|
|
||||||
ns.Geo.stop();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('popstate', event => {
|
window.addEventListener('popstate', event => {
|
||||||
@ -249,8 +399,8 @@
|
|||||||
const position = await ns.Geo.current();
|
const position = await ns.Geo.current();
|
||||||
state.position = position;
|
state.position = position;
|
||||||
$('#location-hint').text(
|
$('#location-hint').text(
|
||||||
`Standort ermittelt (Genauigkeit ${Math.round(position.accuracy)} m). ` +
|
`Standort ermittelt (Genauigkeit ${Math.round(position.accuracy)} m). `
|
||||||
`Nahe Routen werden bis ${ns.Config.routeRadiusKm} km empfohlen.`
|
+ `Nahe Routen werden bis ${ns.Config.routeRadiusKm} km empfohlen.`
|
||||||
);
|
);
|
||||||
state.routes = state.routes.map(route => ({
|
state.routes = state.routes.map(route => ({
|
||||||
...route,
|
...route,
|
||||||
@ -266,6 +416,7 @@
|
|||||||
initializeRouteSearch();
|
initializeRouteSearch();
|
||||||
renderRoutes();
|
renderRoutes();
|
||||||
updateTrackingButton();
|
updateTrackingButton();
|
||||||
|
hideNavigation();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}(window.Wegwichtel, window.jQuery));
|
}(window.Wegwichtel, window.jQuery));
|
||||||
|
|||||||
12
public/js/bootstrap-loader.js
vendored
12
public/js/bootstrap-loader.js
vendored
@ -71,7 +71,7 @@
|
|||||||
initializeJqueryUi();
|
initializeJqueryUi();
|
||||||
mark('jquery-ui', 'done');
|
mark('jquery-ui', 'done');
|
||||||
|
|
||||||
for (const module of ['config', 'api-client', 'distance', 'geolocation', 'slideshow', 'audio-player', 'app']) {
|
for (const module of ['config', 'api-client', 'distance', 'geolocation', 'orientation', 'slideshow', 'audio-player', 'app']) {
|
||||||
await loadScript(`js/${module}.js`, () => Boolean(window.Wegwichtel));
|
await loadScript(`js/${module}.js`, () => Boolean(window.Wegwichtel));
|
||||||
}
|
}
|
||||||
mark('modules', 'done');
|
mark('modules', 'done');
|
||||||
@ -90,6 +90,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
retry.addEventListener('click', () => location.reload());
|
function retryBoot(event) {
|
||||||
|
if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
if (event.type === 'pointerup' && event.pointerType === 'mouse' && event.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
retry.addEventListener('pointerup', retryBoot);
|
||||||
|
retry.addEventListener('keydown', retryBoot);
|
||||||
boot();
|
boot();
|
||||||
}());
|
}());
|
||||||
|
|||||||
@ -1,12 +1,33 @@
|
|||||||
(function (ns) {
|
(function (ns) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const radians = value => value * Math.PI / 180;
|
const radians = value => value * Math.PI / 180;
|
||||||
|
const degrees = value => value * 180 / Math.PI;
|
||||||
|
const normalizeDegrees = value => (value % 360 + 360) % 360;
|
||||||
|
|
||||||
ns.Distance = {
|
ns.Distance = {
|
||||||
meters(a, b) {
|
meters(a, b) {
|
||||||
const dLat = radians(b.lat - a.lat); const dLon = radians(b.lon - a.lon);
|
const dLat = radians(b.lat - a.lat);
|
||||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(a.lat)) * Math.cos(radians(b.lat)) * Math.sin(dLon / 2) ** 2;
|
const dLon = radians(b.lon - a.lon);
|
||||||
|
const h = Math.sin(dLat / 2) ** 2
|
||||||
|
+ Math.cos(radians(a.lat)) * Math.cos(radians(b.lat)) * Math.sin(dLon / 2) ** 2;
|
||||||
return 2 * 6371000 * Math.asin(Math.sqrt(h));
|
return 2 * 6371000 * Math.asin(Math.sqrt(h));
|
||||||
},
|
},
|
||||||
format(meters) { return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`; }
|
|
||||||
|
bearing(a, b) {
|
||||||
|
const lat1 = radians(a.lat);
|
||||||
|
const lat2 = radians(b.lat);
|
||||||
|
const dLon = radians(b.lon - a.lon);
|
||||||
|
const y = Math.sin(dLon) * Math.cos(lat2);
|
||||||
|
const x = Math.cos(lat1) * Math.sin(lat2)
|
||||||
|
- Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon);
|
||||||
|
return normalizeDegrees(degrees(Math.atan2(y, x)));
|
||||||
|
},
|
||||||
|
|
||||||
|
normalizeDegrees,
|
||||||
|
|
||||||
|
format(meters) {
|
||||||
|
return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}(window.Wegwichtel));
|
}(window.Wegwichtel));
|
||||||
|
|||||||
@ -1,18 +1,61 @@
|
|||||||
(function (ns) {
|
(function (ns) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
let watchId = null;
|
let watchId = null;
|
||||||
function normalize(position) { return { lat: position.coords.latitude, lon: position.coords.longitude, accuracy: position.coords.accuracy, timestamp: position.timestamp }; }
|
|
||||||
|
function normalize(position) {
|
||||||
|
return {
|
||||||
|
lat: position.coords.latitude,
|
||||||
|
lon: position.coords.longitude,
|
||||||
|
accuracy: position.coords.accuracy,
|
||||||
|
altitude: position.coords.altitude,
|
||||||
|
heading: position.coords.heading,
|
||||||
|
speed: position.coords.speed,
|
||||||
|
timestamp: position.timestamp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
ns.Geo = {
|
ns.Geo = {
|
||||||
current() {
|
current() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!navigator.geolocation) return reject(new Error('Dieses Gerät unterstützt keine Geolokalisierung.'));
|
if (!navigator.geolocation) {
|
||||||
navigator.geolocation.getCurrentPosition(p => resolve(normalize(p)), reject, { enableHighAccuracy: true, timeout: ns.Config.geolocationTimeoutMs, maximumAge: 30000 });
|
reject(new Error('Dieses Gerät unterstützt keine Geolokalisierung.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
position => resolve(normalize(position)),
|
||||||
|
reject,
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: ns.Config.geolocationTimeoutMs,
|
||||||
|
maximumAge: 30000
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
start(callback, onError) {
|
start(callback, onError) {
|
||||||
this.stop();
|
this.stop();
|
||||||
watchId = navigator.geolocation.watchPosition(p => callback(normalize(p)), onError, { enableHighAccuracy: true, timeout: 20000, maximumAge: 5000 });
|
if (!navigator.geolocation) {
|
||||||
|
onError?.(new Error('Dieses Gerät unterstützt keine Geolokalisierung.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
watchId = navigator.geolocation.watchPosition(
|
||||||
|
position => callback(normalize(position)),
|
||||||
|
onError,
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 20000,
|
||||||
|
maximumAge: 5000
|
||||||
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
stop() { if (watchId != null) navigator.geolocation.clearWatch(watchId); watchId = null; }
|
|
||||||
|
stop() {
|
||||||
|
if (watchId != null) navigator.geolocation.clearWatch(watchId);
|
||||||
|
watchId = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}(window.Wegwichtel));
|
}(window.Wegwichtel));
|
||||||
|
|||||||
64
public/js/orientation.js
Normal file
64
public/js/orientation.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
(function (ns) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let listener = null;
|
||||||
|
const eventNames = ['deviceorientationabsolute', 'deviceorientation'];
|
||||||
|
let callback = null;
|
||||||
|
|
||||||
|
function normalizeDegrees(value) {
|
||||||
|
return (value % 360 + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHeading(event) {
|
||||||
|
if (Number.isFinite(event.webkitCompassHeading)) {
|
||||||
|
return normalizeDegrees(event.webkitCompassHeading);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((event.absolute || event.type === 'deviceorientationabsolute') && Number.isFinite(event.alpha)) {
|
||||||
|
return normalizeDegrees(360 - event.alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPermission() {
|
||||||
|
const orientation = window.DeviceOrientationEvent;
|
||||||
|
if (!orientation) return false;
|
||||||
|
|
||||||
|
if (typeof orientation.requestPermission === 'function') {
|
||||||
|
return (await orientation.requestPermission()) === 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ns.Orientation = {
|
||||||
|
async start(onHeading) {
|
||||||
|
this.stop();
|
||||||
|
callback = onHeading;
|
||||||
|
|
||||||
|
let permitted = false;
|
||||||
|
try {
|
||||||
|
permitted = await requestPermission();
|
||||||
|
} catch {
|
||||||
|
permitted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permitted) return false;
|
||||||
|
|
||||||
|
listener = event => {
|
||||||
|
const heading = readHeading(event);
|
||||||
|
if (heading != null) callback?.(heading);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventNames.forEach(name => window.addEventListener(name, listener, true));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (listener) eventNames.forEach(name => window.removeEventListener(name, listener, true));
|
||||||
|
listener = null;
|
||||||
|
callback = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}(window.Wegwichtel));
|
||||||
@ -3,6 +3,23 @@
|
|||||||
|
|
||||||
let images = [];
|
let images = [];
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
let swipe = null;
|
||||||
|
|
||||||
|
function activate(selector, handler) {
|
||||||
|
$(document)
|
||||||
|
.on('pointerup', selector, function (event) {
|
||||||
|
const pointer = event.originalEvent;
|
||||||
|
if (pointer && pointer.isPrimary === false) return;
|
||||||
|
if (pointer?.pointerType === 'mouse' && pointer.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
handler.call(this, event);
|
||||||
|
})
|
||||||
|
.on('keydown', selector, function (event) {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
event.preventDefault();
|
||||||
|
handler.call(this, event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function refreshButton(selector, disabled) {
|
function refreshButton(selector, disabled) {
|
||||||
const button = $(selector).prop('disabled', disabled);
|
const button = $(selector).prop('disabled', disabled);
|
||||||
@ -30,17 +47,42 @@
|
|||||||
index = 0;
|
index = 0;
|
||||||
render();
|
render();
|
||||||
},
|
},
|
||||||
|
|
||||||
next() {
|
next() {
|
||||||
if (images.length) index = (index + 1) % images.length;
|
if (images.length) index = (index + 1) % images.length;
|
||||||
render();
|
render();
|
||||||
},
|
},
|
||||||
|
|
||||||
previous() {
|
previous() {
|
||||||
if (images.length) index = (index - 1 + images.length) % images.length;
|
if (images.length) index = (index - 1 + images.length) % images.length;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
activate('#slide-next', () => ns.Slideshow.next());
|
||||||
|
activate('#slide-prev', () => ns.Slideshow.previous());
|
||||||
|
|
||||||
$(document)
|
$(document)
|
||||||
.on('click', '#slide-next', () => ns.Slideshow.next())
|
.on('pointerdown', '#slideshow', function (event) {
|
||||||
.on('click', '#slide-prev', () => ns.Slideshow.previous());
|
if ($(event.target).closest('button').length) return;
|
||||||
|
const pointer = event.originalEvent;
|
||||||
|
if (!pointer?.isPrimary) return;
|
||||||
|
swipe = { id: pointer.pointerId, x: pointer.clientX, y: pointer.clientY };
|
||||||
|
})
|
||||||
|
.on('pointerup pointercancel', '#slideshow', function (event) {
|
||||||
|
const pointer = event.originalEvent;
|
||||||
|
if (!swipe || pointer?.pointerId !== swipe.id) return;
|
||||||
|
if (event.type === 'pointercancel') {
|
||||||
|
swipe = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = pointer.clientX - swipe.x;
|
||||||
|
const deltaY = pointer.clientY - swipe.y;
|
||||||
|
swipe = null;
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) < 45 || Math.abs(deltaX) <= Math.abs(deltaY)) return;
|
||||||
|
if (deltaX < 0) ns.Slideshow.next();
|
||||||
|
else ns.Slideshow.previous();
|
||||||
|
});
|
||||||
}(window.Wegwichtel, window.jQuery));
|
}(window.Wegwichtel, window.jQuery));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user