initial commit

This commit is contained in:
Florian Zumpe
2026-06-16 10:31:41 +02:00
parent e607876d37
commit eb559b16fb
282 changed files with 3118 additions and 41422 deletions
+266
View File
@@ -0,0 +1,266 @@
:root {
--page-background: #eef4e9;
--surface: #ffffff;
--primary: #315d3a;
--primary-dark: #24462b;
--text: #1f3022;
--muted: #526258;
--notice: #fff8d6;
--notice-border: #d4a72c;
--focus: #1e6fba;
}
html, body {
min-height: 100%;
background: var(--page-background);
color: var(--text);
}
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, audio {
font: inherit;
}
[hidden] {
display: none !important;
}
#app-shell {
min-height: 100vh;
}
.app-page {
min-height: 100vh;
background: var(--page-background);
}
.app-header {
position: sticky;
top: 0;
z-index: 20;
display: grid;
align-items: center;
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));
background: var(--primary);
color: #fff;
box-shadow: 0 .15rem .5rem rgba(0, 0, 0, .2);
}
.app-header h1 {
margin: 0;
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;
}
.app-header-with-back::after {
content: "";
width: 5.5rem;
}
.app-header .back-button.ui-button {
width: 5.5rem;
margin: 0;
border-color: rgba(255, 255, 255, .45);
background: rgba(255, 255, 255, .12);
color: #fff;
}
.app-header .back-button.ui-button:hover,
.app-header .back-button.ui-button:focus {
border-color: #fff;
background: rgba(255, 255, 255, .22);
color: #fff;
}
.app-content {
width: min(48rem, 100%);
box-sizing: border-box;
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));
}
.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 {
margin: 0 0 1rem;
padding: .75rem;
border-left: .35rem solid var(--notice-border);
border-radius: .25rem;
background: var(--notice);
}
.card-list {
display: grid;
gap: .75rem;
margin: 0;
padding: 0;
list-style: none;
}
.card-link {
display: grid;
width: 100%;
box-sizing: border-box;
gap: .25rem;
padding: 1rem;
border: 1px solid #c8d3ca;
border-radius: .65rem;
background: var(--surface);
color: var(--text);
box-shadow: 0 .12rem .35rem rgba(0, 0, 0, .08);
text-align: left;
cursor: pointer;
}
.card-link:hover {
border-color: var(--primary);
box-shadow: 0 .2rem .55rem rgba(0, 0, 0, .13);
}
.card-link:focus-visible {
outline: .2rem solid var(--focus);
outline-offset: .15rem;
}
.card-link:disabled {
cursor: progress;
opacity: .65;
}
.card-title {
font-size: 1.05rem;
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;
border-radius: .5rem;
background: var(--surface);
color: var(--muted);
}
.route-facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .5rem;
margin: 1rem 0;
}
.route-facts div {
padding: .75rem;
border-radius: .5rem;
background: var(--surface);
text-align: center;
}
.route-facts dt {
color: var(--muted);
font-size: .8rem;
}
.route-facts dd {
margin: .25rem 0 0;
font-weight: 700;
}
#start-tracking.ui-button {
width: 100%;
margin: 0 0 1rem;
border-color: var(--primary-dark);
background: var(--primary);
color: #fff;
}
#start-tracking.ui-button:hover,
#start-tracking.ui-button:focus {
background: var(--primary-dark);
color: #fff;
}
.slideshow {
overflow: hidden;
border-radius: .75rem;
background: #172019;
color: #fff;
}
.slideshow figure {
margin: 0;
}
.slideshow img {
display: block;
width: 100%;
height: min(55vh, 28rem);
object-fit: contain;
background: #111;
}
.slideshow figcaption {
min-height: 1.4rem;
padding: .5rem .75rem;
}
.slide-controls {
display: grid;
grid-template-columns: auto minmax(4rem, 1fr) auto;
align-items: center;
width: 100%;
box-sizing: border-box;
padding: .5rem;
text-align: center;
}
.slide-controls.ui-controlgroup-horizontal .ui-controlgroup-item {
float: none;
}
#slide-position {
display: grid;
align-items: center;
min-height: 2.2rem;
}
#poi-audio {
width: 100%;
margin-top: 1rem;
}
.ui-button:focus-visible {
outline: .2rem solid var(--focus);
outline-offset: .12rem;
}
@media (max-width: 420px) {
.route-facts {
grid-template-columns: 1fr;
}
}
+9
View File
@@ -0,0 +1,9 @@
html, body { min-height: 100%; margin: 0; font-family: system-ui, sans-serif; background: #eef4e9; color: #1f3022; }
#bootstrap-screen { position: fixed; inset: 0; display: grid; place-items: center; padding: 1rem; z-index: 9999; }
.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; }
.mascot { font-size: 4rem; }
progress { width: 100%; height: 1.2rem; }
#bootstrap-steps { text-align: left; line-height: 1.8; }
#bootstrap-steps li.done::marker { content: '✓ '; }
#bootstrap-steps li.failed { color: #8b1e1e; }
#bootstrap-error { background: #fee; color: #701010; padding: .75rem; border-radius: .5rem; }
+84
View File
@@ -0,0 +1,84 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#315d3a">
<title>Wegwichtel – Atlas</title>
<link rel="stylesheet" href="css/bootstrap.css">
</head>
<body>
<div id="bootstrap-screen" role="status" aria-live="polite">
<div class="bootstrap-card">
<div class="mascot" aria-hidden="true">🧭</div>
<h1>Wegwichtel</h1>
<p>Atlas bereitet Routen und Medien vor.</p>
<progress id="bootstrap-progress" max="6" value="0"></progress>
<ol id="bootstrap-steps">
<li data-step="jquery">jQuery laden</li>
<li data-step="jquery-ui">jQuery UI laden</li>
<li data-step="modules">Anwendungsmodule laden</li>
<li data-step="server">Server prüfen</li>
<li data-step="routes">Routen laden</li>
<li data-step="location">Standort ermitteln</li>
</ol>
<p id="bootstrap-error" hidden></p>
<button id="bootstrap-retry" type="button" hidden>Erneut versuchen</button>
</div>
</div>
<main id="app-shell" hidden>
<section class="app-page" id="routes-page" aria-labelledby="routes-heading">
<header class="app-header">
<h1 id="routes-heading">Wegwichtel <small>Atlas</small></h1>
</header>
<div class="app-content">
<div id="location-hint" class="notice">Standort wird ermittelt.</div>
<h2>Routen in deiner Nähe</h2>
<ul id="route-list" class="card-list" aria-live="polite"></ul>
</div>
<footer class="app-footer">GPS-Lernwege</footer>
</section>
<section class="app-page" id="route-page" aria-labelledby="route-title" hidden>
<header class="app-header app-header-with-back">
<button class="back-button" type="button" data-page-target="#routes-page">Zurück</button>
<h1 id="route-title">Route</h1>
</header>
<div class="app-content">
<p id="route-description"></p>
<dl class="route-facts">
<div><dt>Länge</dt><dd id="route-distance">–</dd></div>
<div><dt>Höhenmeter</dt><dd id="route-elevation">–</dd></div>
<div><dt>POIs</dt><dd id="route-poi-count">–</dd></div>
</dl>
<button id="start-tracking" type="button">GPS-Begleitung starten</button>
<div id="tracking-status" class="notice" hidden></div>
<h2>Stationen</h2>
<ul id="poi-list" class="card-list"></ul>
</div>
</section>
<section class="app-page" id="poi-page" aria-labelledby="poi-title" hidden>
<header class="app-header app-header-with-back">
<button class="back-button" type="button" data-page-target="#route-page">Zurück</button>
<h1 id="poi-title">Station</h1>
</header>
<div class="app-content">
<div id="slideshow" class="slideshow" aria-live="polite">
<figure><img id="slide-image" alt=""><figcaption id="slide-caption"></figcaption></figure>
<div id="slide-controls" class="slide-controls">
<button id="slide-prev" type="button">Vorheriges Bild</button>
<span id="slide-position" aria-live="polite">0 / 0</span>
<button id="slide-next" type="button">Nächstes Bild</button>
</div>
</div>
<p id="poi-description"></p>
<audio id="poi-audio" controls preload="metadata"></audio>
</div>
</section>
</main>
<script src="js/bootstrap-loader.js"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
(function (ns, $) {
'use strict';
function request(path) {
return $.ajax({ url: ns.Config.apiBase + path, method: 'GET', dataType: 'json', cache: false });
}
ns.Api = {
health: () => request('/health'),
routes: position => request('/routes' + (position ? `?lat=${encodeURIComponent(position.lat)}&lon=${encodeURIComponent(position.lon)}&radiusKm=${ns.Config.routeRadiusKm}` : '')),
route: id => request(`/routes/${encodeURIComponent(id)}`)
};
}(window.Wegwichtel, window.jQuery));
+198
View File
@@ -0,0 +1,198 @@
(function (ns, $) {
'use strict';
const state = {
routes: [],
route: null,
position: null,
triggered: new Set(),
tracking: false,
activePage: null
};
function escapeHtml(value) {
return $('<div>').text(value ?? '').html();
}
function navigate(pageSelector, options = {}) {
const target = $(pageSelector);
if (!target.length) throw new Error(`Unbekannte Ansicht: ${pageSelector}`);
const previous = state.activePage;
$('.app-page').attr('hidden', true).removeClass('is-active');
target.removeAttr('hidden').addClass('is-active');
state.activePage = pageSelector;
window.scrollTo({ top: 0, behavior: options.instant ? 'auto' : 'smooth' });
if (options.updateHistory === false) return;
const historyState = { page: pageSelector, previous };
if (options.replace) history.replaceState(historyState, '', pageSelector);
else if (previous !== pageSelector) history.pushState(historyState, '', pageSelector);
}
function renderRoutes() {
const list = $('#route-list').empty();
if (!state.routes.length) {
list.append('<li class="empty-list">Keine Route in der Nähe gefunden.</li>');
return;
}
state.routes.forEach(route => {
const proximity = route.proximityM == null
? ''
: `<span class="route-distance">${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</span>`;
list.append(`
<li>
<button class="card-link" type="button" data-route-id="${route.id}">
<span class="card-title">${escapeHtml(route.name)}</span>
<span class="card-description">${escapeHtml(route.schoolName || route.description || '')}</span>
${proximity}
</button>
</li>
`);
});
}
function renderRoute(route) {
state.route = route;
state.triggered.clear();
$('#route-title').text(route.name);
$('#route-description').text(route.description || '');
$('#route-distance').text(ns.Distance.format(route.distanceM));
$('#route-elevation').text(`${Math.round(route.elevationGainM)} m`);
$('#route-poi-count').text(route.pois.length);
const list = $('#poi-list').empty();
if (!route.pois.length) {
list.append('<li class="empty-list">Diese Route enthält noch keine Stationen.</li>');
return;
}
route.pois.forEach(poi => {
list.append(`
<li>
<button class="card-link" type="button" data-poi-id="${poi.id}">
<span class="card-title">${escapeHtml(poi.title)}</span>
<span class="card-description">${escapeHtml(poi.description || '')}</span>
</button>
</li>
`);
});
}
function showPoi(poi, automatic) {
$('#poi-title').text(poi.title);
$('#poi-description').text(poi.description || '');
ns.Slideshow.show(poi.images);
ns.AudioPlayer.load(poi.audioUrl);
navigate('#poi-page');
if (automatic && poi.audioUrl) ns.AudioPlayer.play().catch(() => {});
}
function evaluatePosition(position) {
state.position = position;
if (!state.route || !state.tracking) return;
let nearest = null;
state.route.pois.forEach(poi => {
if (state.triggered.has(poi.id)) return;
const distance = ns.Distance.meters(position, poi);
if (!nearest || distance < nearest.distance) nearest = { poi, distance };
});
$('#tracking-status').text(
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m` +
(nearest ? ` · nächste Station ${Math.round(nearest.distance)} m` : '')
);
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
state.triggered.add(nearest.poi.id);
if (navigator.vibrate) navigator.vibrate([120, 80, 120]);
showPoi(nearest.poi, true);
}
}
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 bindEvents() {
$(document).on('click', '[data-route-id]', async function () {
const button = $(this);
button.prop('disabled', true);
try {
const route = await ns.Api.route(button.data('route-id'));
renderRoute(route);
navigate('#route-page');
} catch (error) {
alert(error.responseJSON?.message || error.statusText || error.message);
} finally {
button.prop('disabled', false);
}
});
$(document).on('click', '[data-poi-id]', function () {
const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id')));
if (poi) showPoi(poi, false);
});
$(document).on('click', '[data-page-target]', function () {
const target = $(this).data('page-target');
if (history.state?.previous === target) history.back();
else navigate(target, { replace: true });
});
$(document).on('click', '#start-tracking', function () {
state.tracking = !state.tracking;
updateTrackingButton();
$('#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 => {
const page = event.state?.page || '#routes-page';
navigate(page, { updateHistory: false, instant: true });
});
}
ns.App = {
navigate,
async initialize(context) {
bindEvents();
await ns.Api.health();
context.markStep('server', 'done');
const response = await ns.Api.routes(null);
state.routes = response.routes;
context.markStep('routes', 'done');
try {
const position = await ns.Geo.current();
state.position = position;
$('#location-hint').text(`Standort ermittelt (Genauigkeit ${Math.round(position.accuracy)} m).`);
state.routes = state.routes
.map(route => ({
...route,
proximityM: route.start ? ns.Distance.meters(position, route.start) : null
}))
.filter(route => route.proximityM == null || route.proximityM <= ns.Config.routeRadiusKm * 1000)
.sort((a, b) => (a.proximityM ?? Number.MAX_VALUE) - (b.proximityM ?? Number.MAX_VALUE));
} catch (error) {
$('#location-hint').text(`Standort nicht verfügbar: ${error.message}. Es werden alle Routen angezeigt.`);
}
context.markStep('location', 'done');
renderRoutes();
updateTrackingButton();
}
};
}(window.Wegwichtel, window.jQuery));
+11
View File
@@ -0,0 +1,11 @@
(function (ns, $) {
'use strict';
ns.AudioPlayer = {
load(url) {
const audio = $('#poi-audio');
audio[0].pause(); audio.attr('src', url || '').toggle(Boolean(url));
if (url) audio[0].load();
},
play() { const audio = $('#poi-audio')[0]; if (audio.src) return audio.play(); }
};
}(window.Wegwichtel, window.jQuery));
+94
View File
@@ -0,0 +1,94 @@
(function () {
'use strict';
const versions = Object.freeze({ jquery: '4.0.0', jqueryUi: '1.14.2' });
const steps = ['jquery', 'jquery-ui', 'modules', 'server', 'routes', 'location'];
const progress = document.getElementById('bootstrap-progress');
const errorBox = document.getElementById('bootstrap-error');
const retry = document.getElementById('bootstrap-retry');
function mark(step, state) {
const item = document.querySelector(`[data-step="${step}"]`);
if (!item) return;
item.classList.remove('done', 'failed');
item.classList.add(state);
progress.value = document.querySelectorAll('#bootstrap-steps .done').length;
}
function loadScript(src, test) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.async = false;
script.onload = () => {
if (test && !test()) reject(new Error(`Selbsttest fehlgeschlagen: ${src}`));
else resolve();
};
script.onerror = () => reject(new Error(`Datei konnte nicht geladen werden: ${src}`));
document.head.appendChild(script);
});
}
function loadStyle(href) {
return new Promise((resolve, reject) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.onload = resolve;
link.onerror = () => reject(new Error(`Stylesheet konnte nicht geladen werden: ${href}`));
document.head.appendChild(link);
});
}
function initializeJqueryUi() {
const $ = window.jQuery;
$('#bootstrap-retry').button({ icon: 'ui-icon-refresh' });
$('.back-button').button({ icon: 'ui-icon-caret-1-w' });
$('#start-tracking').button({ icon: 'ui-icon-pin-s' });
$('#slide-prev').button({ icon: 'ui-icon-caret-1-w', showLabel: false });
$('#slide-next').button({ icon: 'ui-icon-caret-1-e', showLabel: false });
$('#slide-controls').controlgroup();
}
async function boot() {
errorBox.hidden = true;
retry.hidden = true;
try {
await loadScript(
`vendor/jquery/jquery-${versions.jquery}.min.js`,
() => window.jQuery?.fn?.jquery === versions.jquery
);
mark('jquery', 'done');
await loadStyle(`vendor/jquery-ui/jquery-ui-${versions.jqueryUi}.min.css`);
await loadStyle('css/app.css');
await loadScript(
`vendor/jquery-ui/jquery-ui-${versions.jqueryUi}.min.js`,
() => window.jQuery?.ui?.version === versions.jqueryUi
);
initializeJqueryUi();
mark('jquery-ui', 'done');
for (const module of ['config', 'api-client', 'distance', 'geolocation', 'slideshow', 'audio-player', 'app']) {
await loadScript(`js/${module}.js`, () => Boolean(window.Wegwichtel));
}
mark('modules', 'done');
await window.Wegwichtel.App.initialize({ markStep: mark });
document.getElementById('bootstrap-screen').hidden = true;
document.getElementById('app-shell').hidden = false;
window.Wegwichtel.App.navigate('#routes-page', { replace: true });
} catch (error) {
console.error(error);
const current = steps.find(step => !document.querySelector(`[data-step="${step}"]`)?.classList.contains('done'));
if (current) mark(current, 'failed');
errorBox.textContent = error.message;
errorBox.hidden = false;
retry.hidden = false;
}
}
retry.addEventListener('click', () => location.reload());
boot();
}());
+2
View File
@@ -0,0 +1,2 @@
window.Wegwichtel = window.Wegwichtel || {};
window.Wegwichtel.Config = Object.freeze({ apiBase: '/api', routeRadiusKm: 25, geolocationTimeoutMs: 12000 });
+12
View File
@@ -0,0 +1,12 @@
(function (ns) {
'use strict';
const radians = value => value * Math.PI / 180;
ns.Distance = {
meters(a, b) {
const dLat = radians(b.lat - a.lat); 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));
},
format(meters) { return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`; }
};
}(window.Wegwichtel));
+18
View File
@@ -0,0 +1,18 @@
(function (ns) {
'use strict';
let watchId = null;
function normalize(position) { return { lat: position.coords.latitude, lon: position.coords.longitude, accuracy: position.coords.accuracy, timestamp: position.timestamp }; }
ns.Geo = {
current() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) return reject(new Error('Dieses Gerät unterstützt keine Geolokalisierung.'));
navigator.geolocation.getCurrentPosition(p => resolve(normalize(p)), reject, { enableHighAccuracy: true, timeout: ns.Config.geolocationTimeoutMs, maximumAge: 30000 });
});
},
start(callback, onError) {
this.stop();
watchId = navigator.geolocation.watchPosition(p => callback(normalize(p)), onError, { enableHighAccuracy: true, timeout: 20000, maximumAge: 5000 });
},
stop() { if (watchId != null) navigator.geolocation.clearWatch(watchId); watchId = null; }
};
}(window.Wegwichtel));
+46
View File
@@ -0,0 +1,46 @@
(function (ns, $) {
'use strict';
let images = [];
let index = 0;
function refreshButton(selector, disabled) {
const button = $(selector).prop('disabled', disabled);
if (button.hasClass('ui-button')) button.button('refresh');
}
function render() {
const hasImages = images.length > 0;
$('#slideshow').toggle(hasImages);
if (!hasImages) return;
const image = images[index];
$('#slide-image')
.attr('src', image.url)
.attr('alt', image.caption || 'Bild zur Station');
$('#slide-caption').text(image.caption || '');
$('#slide-position').text(`${index + 1} / ${images.length}`);
refreshButton('#slide-prev', images.length < 2);
refreshButton('#slide-next', images.length < 2);
}
ns.Slideshow = {
show(items) {
images = items || [];
index = 0;
render();
},
next() {
if (images.length) index = (index + 1) % images.length;
render();
},
previous() {
if (images.length) index = (index - 1 + images.length) % images.length;
render();
}
};
$(document)
.on('click', '#slide-next', () => ns.Slideshow.next())
.on('click', '#slide-prev', () => ns.Slideshow.previous());
}(window.Wegwichtel, window.jQuery));
+14
View File
@@ -0,0 +1,14 @@
# Lokale Client-Abhängigkeiten
Die Browseranwendung verwendet ausschließlich die hier abgelegten Dateien:
- `jquery/jquery-4.0.0.min.js`
- `jquery/LICENSE.txt`
- `jquery-ui/jquery-ui-1.14.2.min.js`
- `jquery-ui/jquery-ui-1.14.2.min.css`
- `jquery-ui/images/*.png`
- `jquery-ui/LICENSE.txt`
Die Dateien stammen aus den offiziellen npm-Paketen `jquery@4.0.0` und `jquery-ui@1.14.2`. `npm install` führt `scripts/vendor-sync.js` aus und kopiert die Distributionsdateien, das Base-Theme, dessen Bildressourcen und die Lizenztexte erneut aus `node_modules` in dieses Verzeichnis.
Die Dateien sind bereits im Projektarchiv enthalten. Der Browser benötigt deshalb weder ein CDN noch eine Netzwerkverbindung zum Laden der UI-Bibliotheken.
+43
View File
@@ -0,0 +1,43 @@
Copyright OpenJS Foundation and other contributors, https://openjsf.org/
This software consists of voluntary contributions made by many
individuals. For exact contribution history, see the revision history
available at https://github.com/jquery/jquery-ui
The following license applies to all parts of this software except as
documented below:
====
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
====
Copyright and related rights for sample code are waived via CC0. Sample
code is defined as all source code contained within the demos directory.
CC0: http://creativecommons.org/publicdomain/zero/1.0/
====
All files located in the node_modules and external directories are
externally maintained libraries used by this software which have their
own licenses; we recommend you read them, as their terms may differ from
the terms above.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20
View File
@@ -0,0 +1,20 @@
Copyright OpenJS Foundation and other contributors, https://openjsf.org/
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
File diff suppressed because one or more lines are too long