feat(web): add ocean telemetry dashboard

This commit is contained in:
Alex 2026-08-24 23:17:43 +02:00
parent e1f728ff9b
commit 3355f8b7ef
7 changed files with 1140 additions and 0 deletions

242
apps/web/app.js Normal file
View file

@ -0,0 +1,242 @@
(() => {
"use strict";
const CACHE_KEY = "nereus.telemetry.v1";
const state = {
buoys: [],
readings: [],
markers: [],
responses: [],
totalErrors: 0,
connectivity: "offline",
version: null,
lastDataAt: null,
healthPolling: false,
dataPolling: false,
probes: {
liveness: {ok: null, changedAt: null},
readiness: {ok: null, changedAt: null}
}
};
const elements = Object.fromEntries([
"clock", "version-value", "version-note", "liveness-dot", "liveness-time",
"readiness-dot", "readiness-time", "error-total", "error-rate", "error-state",
"buoy-count", "data-age", "map-state", "telemetry-state", "latest-temp",
"latest-wave", "latest-salinity", "readings-chart"
].map((id) => [id, document.getElementById(id)]));
const map = L.map("map", {zoomControl: false, attributionControl: true, scrollWheelZoom: false}).setView([39.3, -4.2], 5);
map.attributionControl.addAttribution("Natural Earth");
if (window.NEREUS_LAND) {
L.geoJSON(window.NEREUS_LAND, {
interactive: false,
style: {className: "landmass", fillColor: "#183640", fillOpacity: .72, color: "#41636a", weight: .7}
}).addTo(map);
}
function restoreCache() {
try {
const cached = JSON.parse(localStorage.getItem(CACHE_KEY));
if (!cached || !Array.isArray(cached.buoys) || !Array.isArray(cached.readings)) return;
state.buoys = cached.buoys;
state.readings = cached.readings;
state.lastDataAt = cached.savedAt || null;
renderTelemetry(true);
} catch (_) {
// A damaged cache is equivalent to no cache and should stay invisible.
}
}
function saveCache() {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({buoys: state.buoys, readings: state.readings, savedAt: state.lastDataAt}));
} catch (_) {
// Private browsing and storage quotas must not affect the dashboard.
}
}
async function apiFetch(path) {
const startedAt = Date.now();
try {
const response = await fetch(path, {headers: {Accept: "application/json"}, cache: "no-store"});
state.responses.push({at: startedAt, error: !response.ok});
if (!response.ok) {
state.totalErrors += 1;
throw Object.assign(new Error("API response was not successful"), {httpFailure: true});
}
return response.json();
} catch (error) {
if (!error.httpFailure) state.connectivity = "offline";
throw error;
} finally {
renderErrors();
}
}
async function probe(path, includeVersion = false) {
try {
const response = await fetch(path, {cache: "no-store"});
return {ok: response.status === 200, version: includeVersion ? response.headers.get("X-Nereus-Version") : null};
} catch (_) {
return {ok: false, version: null};
}
}
function updateProbe(name, ok) {
const probeState = state.probes[name];
if (probeState.ok !== ok) {
probeState.ok = ok;
probeState.changedAt = new Date();
}
elements[`${name}-dot`].className = `indicator ${ok ? "ok" : "bad"}`;
elements[`${name}-time`].textContent = probeState.changedAt ? `Changed ${formatTime(probeState.changedAt)}` : "No transition yet";
}
async function pollHealth() {
if (state.healthPolling) return;
state.healthPolling = true;
try {
const [live, ready] = await Promise.all([probe("/healthz", true), probe("/readyz")]);
if (live.version) state.version = live.version;
updateProbe("liveness", live.ok);
updateProbe("readiness", ready.ok);
if (!live.ok) state.connectivity = "offline";
renderConnection();
} finally {
state.healthPolling = false;
}
}
async function pollData() {
if (state.dataPolling) return;
state.dataPolling = true;
try {
const [buoys, readings] = await Promise.all([
apiFetch("/api/v1/buoys"),
apiFetch("/api/v1/readings?limit=500")
]);
state.buoys = Array.isArray(buoys) ? buoys : [];
state.readings = Array.isArray(readings) ? readings : [];
state.lastDataAt = new Date().toISOString();
state.connectivity = "online";
saveCache();
renderTelemetry(false);
} catch (error) {
if (error.httpFailure) state.connectivity = "degraded";
renderTelemetry(true);
} finally {
state.dataPolling = false;
renderConnection();
}
}
function renderConnection() {
const online = state.connectivity === "online";
const reachable = state.connectivity !== "offline";
elements["telemetry-state"].className = `connection-pill ${state.connectivity}`;
elements["telemetry-state"].textContent = state.connectivity === "online" ? "Live" : state.connectivity === "degraded" ? "API errors" : "Offline";
elements["version-value"].textContent = reachable ? state.version || "unavailable" : "offline";
elements["version-note"].textContent = reachable ? (state.version ? "Reported by API" : "Version unavailable") : "Cluster unavailable";
document.querySelector(".map-card").classList.toggle("stale", !online);
document.querySelector(".telemetry-card").classList.toggle("stale", !online);
renderErrors();
}
function renderErrors() {
const cutoff = Date.now() - 60000;
state.responses = state.responses.filter((event) => event.at >= cutoff);
const failed = state.responses.filter((event) => event.error).length;
const rate = state.responses.length ? failed / state.responses.length : 0;
elements["error-total"].textContent = state.totalErrors.toLocaleString();
elements["error-rate"].textContent = `${(rate * 100).toFixed(1)}%`;
elements["error-state"].textContent = state.connectivity === "offline"
? "API unreachable"
: failed > 0 ? "API responding with errors" : "API responses healthy";
}
function renderTelemetry(stale) {
renderMap(stale);
drawChart();
const latest = [...state.readings].sort((a, b) => new Date(b.recorded_at) - new Date(a.recorded_at))[0];
elements["latest-temp"].textContent = numberOrDash(latest?.water_temp, 1);
elements["latest-wave"].textContent = numberOrDash(latest?.wave_height, 1);
elements["latest-salinity"].textContent = numberOrDash(latest?.salinity, 1);
elements["buoy-count"].textContent = `${state.buoys.length} ${state.buoys.length === 1 ? "buoy" : "buoys"}`;
elements["data-age"].textContent = state.lastDataAt ? `Updated ${formatTime(new Date(state.lastDataAt))}` : "No telemetry";
elements["map-state"].textContent = state.buoys.length ? (stale ? "Showing last known positions" : "Live positions") : "Waiting for buoy positions";
}
function renderMap() {
state.markers.forEach((marker) => marker.remove());
const latest = new Map();
state.readings.forEach((reading) => {
const previous = latest.get(reading.buoy_id);
if (!previous || new Date(reading.recorded_at) > new Date(previous.recorded_at)) latest.set(reading.buoy_id, reading);
});
state.markers = state.buoys.filter((buoy) => Number.isFinite(buoy.latitude) && Number.isFinite(buoy.longitude)).map((buoy) => {
const reading = latest.get(buoy.id);
const details = reading ? `${numberOrDash(reading.water_temp, 1)} °C · ${numberOrDash(reading.wave_height, 1)} m · ${numberOrDash(reading.salinity, 1)} PSU<br><small>${formatTime(new Date(reading.recorded_at))}</small>` : "Awaiting first sample";
const icon = L.divIcon({className: "", html: '<div class="buoy-marker"></div>', iconSize: [13, 13], iconAnchor: [6, 6]});
return L.marker([buoy.latitude, buoy.longitude], {icon}).bindTooltip(`<strong>${escapeText(buoy.name)}</strong><br>${buoy.latitude.toFixed(2)}, ${buoy.longitude.toFixed(2)}<br>${details}`, {direction: "top"}).addTo(map);
});
if (state.markers.length) {
const bounds = L.featureGroup(state.markers).getBounds();
if (bounds.isValid()) map.fitBounds(bounds.pad(.22), {maxZoom: 7, animate: false});
}
}
function drawChart() {
const canvas = elements["readings-chart"];
const rect = canvas.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(rect.width * ratio));
canvas.height = Math.max(1, Math.floor(rect.height * ratio));
const context = canvas.getContext("2d");
context.scale(ratio, ratio);
const width = rect.width, height = rect.height, pad = {top: 13, right: 13, bottom: 23, left: 34};
const values = [...state.readings].sort((a, b) => new Date(a.recorded_at) - new Date(b.recorded_at)).slice(-45);
context.clearRect(0, 0, width, height);
context.strokeStyle = "rgba(121, 173, 180, .12)";
context.lineWidth = 1;
for (let i = 0; i < 5; i += 1) {
const y = pad.top + ((height - pad.top - pad.bottom) * i / 4);
context.beginPath(); context.moveTo(pad.left, y); context.lineTo(width - pad.right, y); context.stroke();
}
if (values.length < 2) {
context.fillStyle = "#789aa1"; context.font = "11px system-ui"; context.textAlign = "center";
context.fillText("Telemetry will appear when the cluster is online", width / 2, height / 2);
return;
}
const plot = (field, color, min, max) => {
const spread = max - min;
context.beginPath(); context.strokeStyle = color; context.lineWidth = 2; context.lineJoin = "round";
values.forEach((item, index) => {
const x = pad.left + ((width - pad.left - pad.right) * index / (values.length - 1));
const y = pad.top + (height - pad.top - pad.bottom) * (1 - (Number(item[field]) - min) / spread);
if (index === 0) context.moveTo(x, y); else context.lineTo(x, y);
});
context.stroke();
};
plot("water_temp", "#55e4d7", 10, 30);
plot("wave_height", "#55a9ff", 0, 6);
context.fillStyle = "#668d95"; context.font = "9px system-ui"; context.textAlign = "left";
context.fillText(formatTime(new Date(values[0].recorded_at)), pad.left, height - 5);
context.textAlign = "right"; context.fillText(formatTime(new Date(values.at(-1).recorded_at)), width - pad.right, height - 5);
}
function formatTime(date) { return new Intl.DateTimeFormat(undefined, {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(date); }
function numberOrDash(value, digits) { return Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : "--"; }
function escapeText(value) { const node = document.createElement("span"); node.textContent = String(value || "Unnamed buoy"); return node.innerHTML; }
restoreCache();
renderConnection();
pollHealth();
pollData();
setInterval(pollHealth, 2000);
setInterval(pollData, 5000);
setInterval(renderErrors, 1000);
setInterval(() => { elements.clock.textContent = formatTime(new Date()); }, 1000);
elements.clock.textContent = formatTime(new Date());
window.addEventListener("resize", () => { map.invalidateSize(false); drawChart(); });
})();

File diff suppressed because one or more lines are too long

101
apps/web/index.html Normal file
View file

@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Nereus Ocean Telemetry</title>
<link rel="stylesheet" href="vendor/leaflet.css">
<link rel="stylesheet" href="styles.css?v=20260824-2">
</head>
<body>
<main class="dashboard">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 48 48"><path d="M8 29c7-8 14-8 21 0 5 5 9 5 13 1M10 20c5-6 10-6 15 0 4 4 8 4 12 0"/></svg>
</span>
<div>
<p class="eyebrow">Ocean intelligence</p>
<h1>Nereus</h1>
</div>
</div>
<a class="dev-link" href="https://fiwlabs.dev" target="_blank" rel="noopener noreferrer"><span>fiwlabs.dev</span><b aria-hidden="true"></b></a>
<div class="clock-block">
<span>Local time</span>
<strong id="clock">--:--:--</strong>
</div>
</header>
<section class="status-grid" aria-label="System status">
<article class="glass status-card version-card">
<div class="status-icon">V</div>
<div>
<p class="card-label">API version</p>
<strong id="version-value">offline</strong>
<span id="version-note">Cluster unavailable</span>
</div>
</article>
<article class="glass status-card health-card">
<div>
<p class="card-label">Cluster health</p>
<strong>Service probes</strong>
</div>
<div class="probes">
<div class="probe">
<span id="liveness-dot" class="indicator unknown"></span>
<div><b>Liveness</b><time id="liveness-time">No transition yet</time></div>
</div>
<div class="probe">
<span id="readiness-dot" class="indicator unknown"></span>
<div><b>Readiness</b><time id="readiness-time">No transition yet</time></div>
</div>
</div>
</article>
<article class="glass status-card error-card">
<div>
<p class="card-label">Client-side API errors</p>
<strong id="error-total">0</strong>
<span>non-2xx responses</span>
</div>
<div class="error-rate">
<span id="error-rate">0.0%</span>
<small>rolling 60s</small>
<em id="error-state">Waiting for API</em>
</div>
</article>
</section>
<section class="workspace">
<article class="glass map-card">
<div class="panel-heading">
<div><p class="card-label">Active network</p><h2>Buoy positions</h2></div>
<div class="panel-meta"><span id="buoy-count">0 buoys</span><span id="data-age">No telemetry</span></div>
</div>
<div id="map" aria-label="Map of buoy positions"></div>
<div id="map-state" class="quiet-state">Waiting for buoy positions</div>
</article>
<article class="glass telemetry-card">
<div class="panel-heading">
<div><p class="card-label">Latest samples</p><h2>Water conditions</h2></div>
<span id="telemetry-state" class="connection-pill offline">Offline</span>
</div>
<div class="legend" aria-hidden="true"><span class="temperature">Water temperature</span><span class="waves">Wave height</span></div>
<div class="chart-wrap"><canvas id="readings-chart" aria-label="Recent water temperature and wave height chart"></canvas></div>
<div class="telemetry-summary">
<div><span>Temperature</span><strong id="latest-temp">--</strong><small>°C</small></div>
<div><span>Wave height</span><strong id="latest-wave">--</strong><small>m</small></div>
<div><span>Salinity</span><strong id="latest-salinity">--</strong><small>PSU</small></div>
</div>
</article>
</section>
</main>
<script src="vendor/leaflet.js"></script>
<script src="assets/ne_110m_land.js"></script>
<script src="app.js?v=20260824-3"></script>
</body>
</html>

103
apps/web/styles.css Normal file
View file

@ -0,0 +1,103 @@
:root {
color-scheme: dark;
font-family: Arial, Helvetica, sans-serif;
background: #07151d;
color: #d8e3e3;
--ink: #07151d;
--panel: #0b202a;
--panel-deep: #081a23;
--rule: #27434b;
--rule-strong: #3c6269;
--muted: #7f999d;
--signal: #52c8bc;
--blue: #5aa9d6;
--warn: #d6a85a;
--bad: #db7770;
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body {
min-width: 1280px;
background-color: var(--ink);
background-image:
linear-gradient(rgba(85, 126, 134, .045) 1px, transparent 1px),
linear-gradient(90deg, rgba(85, 126, 134, .045) 1px, transparent 1px);
background-size: 32px 32px;
}
.ocean-background { display: none; }
.dashboard { height: 100vh; padding: 0 26px 24px; display: grid; grid-template-rows: 74px 102px minmax(0, 1fr); gap: 12px; }
.topbar { position: relative; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--rule-strong); }
.dev-link { position: absolute; top: 50%; left: 50%; display: flex; align-items: center; gap: 9px; padding: 9px 13px 8px 15px; transform: translate(-50%, -50%); border: 1px solid var(--rule-strong); color: #9cb3b6; background: #0a1c25; box-shadow: inset 2px 0 0 var(--signal); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .16em; text-decoration: none; text-transform: uppercase; transition: color .2s ease, border-color .2s ease, background-color .2s ease; }
.dev-link b { color: var(--signal); font-size: 13px; font-weight: 400; line-height: .7; }
.dev-link:hover, .dev-link:focus-visible { border-color: var(--signal); color: #e0f5f2; background: #102b34; outline: none; }
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark { width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--rule-strong); }
.brand-mark svg { width: 24px; fill: none; stroke: var(--signal); stroke-width: 1.7; stroke-linecap: square; }
.eyebrow, .card-label { margin: 0 0 4px; color: var(--muted); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .15em; text-transform: uppercase; }
h1 { margin: 0; font-size: 22px; line-height: 1; letter-spacing: .08em; text-transform: uppercase; }
.clock-block { text-align: right; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.clock-block span { display: block; color: var(--muted); font-size: 8px; letter-spacing: .14em; text-transform: uppercase; }
.clock-block strong { font-size: 16px; font-weight: 500; font-variant-numeric: tabular-nums; }
.glass { border: 1px solid var(--rule); background: var(--panel); box-shadow: none; backdrop-filter: none; }
.status-grid { display: grid; grid-template-columns: .72fr 1.48fr 1.1fr; gap: 12px; }
.status-card { position: relative; min-width: 0; padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; border-radius: 0; }
.status-card::before { content: ""; position: absolute; top: -1px; left: -1px; width: 54px; height: 2px; background: var(--signal); }
.status-card strong { display: block; font: 600 16px ui-monospace, SFMono-Regular, Consolas, monospace; }
.status-card > div > span:not(.indicator) { color: var(--muted); font-size: 10px; }
.status-icon { display: none; }
.version-card { justify-content: flex-start; border-left-color: var(--rule-strong); }
.version-card strong { color: var(--bad); text-transform: uppercase; }
.probes { display: flex; align-items: center; gap: 34px; }
.probe { display: flex; align-items: center; gap: 10px; }
.probe b { display: block; font: 600 11px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.probe time { display: block; width: 132px; margin-top: 4px; color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; }
.indicator { width: 8px; height: 8px; border-radius: 50%; background: #64747a; transition: background-color .2s ease; }
.indicator.ok { background: var(--signal); box-shadow: none; }
.indicator.bad { background: var(--bad); box-shadow: none; }
.error-card strong { font-size: 27px; font-weight: 500; font-variant-numeric: tabular-nums; }
.error-rate { min-width: 144px; padding-left: 18px; border-left: 1px solid var(--rule); text-align: left; }
.error-rate > span { display: block; color: var(--signal); font: 500 20px ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
.error-rate small { color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.error-rate em { display: block; margin-top: 7px; color: #9cb0b2; font-size: 9px; font-style: normal; }
.workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(400px, .85fr); gap: 12px; }
.map-card, .telemetry-card { position: relative; min-height: 0; overflow: hidden; border-radius: 0; }
.panel-heading { height: 66px; padding: 14px 16px 11px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--rule); background: var(--panel-deep); }
.panel-heading h2 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }
.panel-meta { display: flex; gap: 18px; }
.panel-meta span, .connection-pill { padding: 0; border: 0; border-radius: 0; color: var(--muted); background: transparent; font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.panel-meta span + span { padding-left: 18px; border-left: 1px solid var(--rule); }
#map { height: calc(100% - 66px); background: #091b24; transition: opacity .2s ease; }
#map::before { content: ""; position: absolute; inset: 0; z-index: 200; pointer-events: none; background-image: linear-gradient(rgba(96,137,143,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(96,137,143,.1) 1px, transparent 1px); background-size: 64px 64px; }
.map-card.stale #map { filter: saturate(.3) brightness(.72); }
.quiet-state { position: absolute; left: 16px; bottom: 14px; z-index: 500; padding: 6px 9px; border: 1px solid var(--rule-strong); color: #9db0b2; background: rgba(7, 21, 29, .92); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .08em; text-transform: uppercase; pointer-events: none; }
.quiet-state.hidden { opacity: 0; }
.leaflet-container { font-family: Arial, Helvetica, sans-serif; }
.leaflet-control-attribution { color: #6b8589 !important; background: rgba(7,21,29,.84) !important; font-size: 8px !important; }
.leaflet-control-attribution a { color: #83a5a8 !important; }
.landmass { fill: #183640; fill-opacity: .72; stroke: #41636a; stroke-width: .7; }
.buoy-marker { width: 9px; height: 9px; border: 1px solid #d9fffa; border-radius: 50%; background: var(--signal); animation: pulse 2s steps(2, end) infinite; }
@keyframes pulse { 50% { outline: 4px solid rgba(82, 200, 188, .22); } }
.telemetry-card { display: grid; grid-template-rows: 66px 28px minmax(0, 1fr) 72px; }
.connection-pill::before { content: "●"; margin-right: 6px; }
.connection-pill.online { color: var(--signal); }
.connection-pill.degraded { color: var(--warn); }
.connection-pill.offline { color: var(--bad); }
.legend { display: flex; align-items: center; gap: 24px; padding: 0 16px; border-bottom: 1px solid rgba(39,67,75,.65); color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.legend span::before { content: ""; display: inline-block; width: 14px; height: 2px; margin: 0 7px 2px 0; background: var(--signal); }
.legend .waves::before { background: var(--blue); }
.chart-wrap { min-height: 0; padding: 8px 10px 0; transition: opacity .2s ease; }
.telemetry-card.stale .chart-wrap { opacity: .35; }
#readings-chart { width: 100%; height: 100%; }
.telemetry-summary { margin: 0; display: grid; grid-template-columns: repeat(3, 1fr); overflow: hidden; border-top: 1px solid var(--rule); background: var(--panel-deep); }
.telemetry-summary div { padding: 11px 13px; border-right: 1px solid var(--rule); }
.telemetry-summary div:last-child { border: 0; }
.telemetry-summary span { display: block; color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.telemetry-summary strong { display: inline-block; margin-top: 5px; font: 500 17px ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
.telemetry-summary small { margin-left: 3px; color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }

26
apps/web/vendor/LEAFLET-LICENSE vendored Normal file
View file

@ -0,0 +1,26 @@
BSD 2-Clause License
Copyright (c) 2010-2023, Volodymyr Agafonkin
Copyright (c) 2010-2011, CloudMade
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

661
apps/web/vendor/leaflet.css vendored Normal file
View file

@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

6
apps/web/vendor/leaflet.js vendored Normal file

File diff suppressed because one or more lines are too long