Files
MarekWoandClaude Opus 5 e0c4e48d88 feat(analyzer): maximize the map to the whole screen
On a phone the map view gives the map a fixed ~45vh strip under the
message list, which is too small to read a multi-hop route on. A control
in the map's top-right corner now expands #paMap over the frame and asks
the parent to hide the modal header, so the map really does get the whole
screen; the same button becomes a labelled Close, and Escape works too.

CSS overlay rather than the Fullscreen API: the panel runs in an iframe
and inside the Android WebView, where requestFullscreen on a plain
element is unreliable. The position rule needs !important — Leaflet
writes position:relative on the container as an inline style.

Verified live at 412x915 and 1440x900, both themes, in the modal, as the
standalone page and through a chat deep link: the map fills the frame,
the drawn route and the panned viewport survive both transitions, and no
console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 12:52:34 +02:00

769 lines
27 KiB
HTML

<!DOCTYPE html>
<html lang="{{ lang }}" data-theme="light" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{ t('pa.title') }} - mc-webui</title>
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
(function() {
var theme = localStorage.getItem('mc-webui-theme') || 'light';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
{% include "_head_i18n.html" %}
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Bootstrap 5 CSS (local) -->
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap Icons (local) -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
<!-- Leaflet CSS (for the map view, stage 5) -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- Theme CSS (light/dark mode) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<style>
/* Standalone page: allow normal scrolling (style.css sets overflow hidden) */
html, body {
overflow: auto !important;
height: 100%;
}
body {
display: flex;
flex-direction: column;
background-color: var(--bg-body);
color: var(--text-primary);
}
.pa-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
/* Collapsible filter group. On >=768px it renders as if its children
sat directly in the toolbar (display: contents), so the desktop
layout is unchanged and the collapse state is ignored. Below 768px
it is a Bootstrap collapse spanning the full toolbar width,
toggled by the Filters button. Keep these rules on the class (not
the #id) so Bootstrap's .collapse:not(.show) still wins. */
.pa-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
@media (min-width: 768px) {
.pa-filters {
display: contents !important;
}
}
@media (max-width: 767.98px) {
.pa-filters {
width: 100%;
order: 10; /* keep the counter on the first toolbar line */
}
.pa-filters input.form-control {
flex: 1 1 42%;
width: auto !important;
min-width: 0;
}
}
.pa-content {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0.75rem 1rem;
}
#paMapWrap {
flex: 1 1 0;
min-height: 0;
display: flex;
gap: 0.75rem;
}
#paMapSidebar {
width: 340px;
min-width: 280px;
overflow-y: auto;
flex-shrink: 0;
}
#paMap {
flex: 1 1 0;
min-height: 300px;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
}
@media (max-width: 768px) {
#paMapWrap {
flex-direction: column;
}
/* Message/path list gets the remaining space, the map keeps a
fixed ~45% of the viewport height */
#paMapSidebar {
width: auto;
min-width: 0;
max-height: none;
flex: 1 1 0;
min-height: 0;
}
#paMap {
flex: 0 0 45vh;
min-height: 0;
}
}
/* Maximized map. A CSS overlay rather than the Fullscreen API: the
panel runs inside an iframe (and inside the Android WebView),
where requestFullscreen on a plain element is unreliable. The
parent hides the modal header on the same toggle, so the map
really does get the whole screen. Specificity beats the
media-query rules above regardless of order. */
body.pa-map-full {
overflow: hidden !important;
}
body.pa-map-full #paMap {
/* !important: Leaflet writes position:relative on the container
as an inline style, which no stylesheet rule can outrank */
position: fixed !important;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1080;
flex: none;
min-height: 0;
border: none;
border-radius: 0;
}
/* Maximize / close control, top-right above the overlay toggles */
.pa-map-fs-btn {
display: flex;
align-items: center;
gap: 0.3rem;
background-color: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 0.35rem;
padding: 0.4rem 0.55rem;
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.3);
}
.pa-map-msg {
border: 1px solid var(--border-color);
border-radius: 0.4rem;
padding: 0.4rem 0.6rem;
margin-bottom: 0.4rem;
cursor: pointer;
font-size: 0.82rem;
}
.pa-map-msg:hover {
background-color: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
.pa-map-msg.pa-selected {
border-color: #6f42c1;
}
.pa-map-echo {
border-top: 1px dashed var(--border-color);
margin-top: 0.35rem;
padding: 0.25rem 0.15rem 0;
cursor: pointer;
font-size: 0.78rem;
}
.pa-map-echo:hover {
background-color: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
.pa-map-echo.pa-selected {
color: #6f42c1;
font-weight: 600;
}
/* Ties an echo row to the colour of its line on the map */
.pa-echo-swatch {
display: inline-block;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
margin-right: 0.3rem;
vertical-align: baseline;
}
.pa-legend {
margin-top: 0.4rem;
padding: 0.4rem 0.5rem;
background-color: var(--bg-surface, rgba(0, 0, 0, 0.03));
border-radius: 0.4rem;
font-size: 0.78rem;
}
.pa-legend-hop {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.3rem;
padding: 0.15rem 0;
}
.pa-pick-chip {
border: 1px solid #d39e00;
color: #b58900;
border-radius: 0.25rem;
padding: 0 0.3rem;
cursor: pointer;
font-size: 0.72rem;
}
.pa-pick-chip:hover,
.pa-pick-chip.pa-picked {
background-color: #ffc107;
color: #212529;
}
.pa-unpick {
cursor: pointer;
color: var(--text-secondary, #6c757d);
}
.pa-unpick:hover {
color: #dc3545;
}
.pa-reset-picks {
font-size: 0.72rem;
}
/* Numbered hop markers + name labels on the drawn path */
.pa-hop-badge {
width: 22px;
height: 22px;
border-radius: 50%;
background-color: #dc3545;
color: #fff;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 700;
}
.pa-hop-label {
font-size: 0.7rem;
padding: 0 4px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(0, 0, 0, 0.2);
box-shadow: none;
}
/* Map overlay toggles (Leaflet control, top-right corner) - these
only apply to the Map view, hence not in the shared filter bar */
.pa-map-toggles {
background-color: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 0.35rem;
padding: 0.35rem 0.55rem;
font-size: 0.78rem;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.3);
}
.pa-map-toggles label {
display: flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
white-space: nowrap;
margin: 0;
}
.pa-map-toggles label + label {
margin-top: 0.25rem;
}
.pa-map-toggles input {
cursor: pointer;
}
/* Packet hash inside the expanded detail row - narrow screens only,
where the Hash column is hidden */
.pa-detail-hash {
display: none;
font-size: 0.75rem;
padding-bottom: 0.2rem;
}
.pa-time-short {
display: none;
}
.pa-table-wrap {
overflow-x: auto;
}
.pa-table {
font-size: 0.85rem;
}
.pa-table th {
white-space: nowrap;
}
.pa-table td {
vertical-align: middle;
}
.pa-hash {
font-family: var(--bs-font-monospace, monospace);
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.pa-hash:hover {
text-decoration: underline;
}
.pa-content-preview {
max-width: 22rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pa-time {
white-space: nowrap;
}
.pa-caret {
display: inline-block;
transition: transform 0.15s;
font-size: 0.7rem;
width: 1rem;
}
tr.pa-open .pa-caret {
transform: rotate(90deg);
}
.pa-msg-row {
cursor: pointer;
}
.pa-msg-row.pa-no-echoes {
cursor: default;
}
.pa-msg-row.pa-no-echoes .pa-caret {
visibility: hidden;
}
.pa-echo-cell {
background-color: var(--bg-surface, rgba(0, 0, 0, 0.03));
padding: 0.35rem 0.75rem 0.35rem 2rem;
}
.pa-echo-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.2rem 0;
cursor: pointer;
border-radius: 0.25rem;
}
.pa-echo-line:hover {
background-color: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
.pa-echo-meta {
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
white-space: nowrap;
}
.pa-chip {
font-family: var(--bs-font-monospace, monospace);
font-size: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
padding: 0.05rem 0.35rem;
cursor: pointer;
background-color: var(--card-bg, transparent);
}
.pa-chip:hover {
background-color: #6f42c1;
border-color: #6f42c1;
color: #fff;
}
.pa-chip-arrow {
font-size: 0.7rem;
color: var(--text-secondary, #6c757d);
}
.pa-echo-mapbtn {
cursor: pointer;
padding: 0 0.3rem;
color: var(--text-secondary, #6c757d);
}
.pa-echo-mapbtn:hover {
color: #6f42c1;
}
.pa-map-msg-chan {
font-size: 0.72rem;
color: var(--text-secondary, #6c757d);
white-space: nowrap;
}
.pa-map-msg-preview {
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
margin-top: 0.1rem;
}
.pa-direct {
font-size: 0.75rem;
font-style: italic;
color: var(--text-secondary, #6c757d);
}
.pa-sortable {
cursor: pointer;
user-select: none;
}
.pa-sortable:hover {
text-decoration: underline;
}
.pa-stats-row {
cursor: pointer;
}
.pa-ambiguous {
color: var(--text-secondary, #6c757d);
font-style: italic;
}
.empty-state {
text-align: center;
color: var(--text-secondary, #6c757d);
padding: 3rem 1rem;
}
.empty-state i {
font-size: 3rem;
display: block;
margin-bottom: 0.75rem;
}
/* Narrow screens: drop secondary columns so rows fit the viewport
and echo paths can wrap (keeps the jump-to-map button reachable).
Hash and HB move into the expanded detail row. Must stay LAST in
this stylesheet so it overrides the base rules above. */
@media (max-width: 576px) {
.pa-col-hash,
.pa-col-hb,
.pa-col-msgs,
.pa-col-lasthop {
display: none;
}
.pa-content-preview {
max-width: 9rem;
}
.pa-time-full {
display: none;
}
.pa-time-short {
display: inline;
}
.pa-detail-hash {
display: block;
}
.pa-echo-cell {
padding-left: 0.75rem;
}
/* Long contact names must not push the SNR column off-screen */
#paStatsBody td:nth-child(2) {
max-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pa-col-echoes {
display: none;
}
.pa-table td,
.pa-table th {
padding: 0.3rem 0.2rem;
}
/* Truncate long channel names */
#paTableBody td:nth-child(3) {
max-width: 3.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Let long headers (Avg SNR...) wrap instead of widening columns */
.pa-table th {
white-space: normal;
}
/* Truncate long sender names */
#paTableBody td:nth-child(4) {
max-width: 5.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pa-content-preview {
max-width: 5.2rem;
}
/* Date and time stack into two lines */
.pa-time {
white-space: normal;
}
.pa-time-short {
display: inline-block;
width: 3.2rem;
}
}
</style>
</head>
<body>
<!-- Toolbar -->
<div class="pa-toolbar">
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('pa.view_aria') }}">
<button id="paViewMessagesBtn" class="btn btn-primary" title="{{ t('pa.view.messages_title') }}">
<i class="bi bi-list-ul"></i><span class="d-none d-sm-inline"> {{ t('pa.view.messages') }}</span>
</button>
<button id="paViewStatsBtn" class="btn btn-outline-primary" title="{{ t('pa.view.repeaters_title') }}">
<i class="bi bi-bar-chart"></i><span class="d-none d-sm-inline"> {{ t('pa.view.repeaters') }}</span>
</button>
<button id="paViewRoutesBtn" class="btn btn-outline-primary" title="{{ t('pa.view.routes_title') }}">
<i class="bi bi-signpost-2"></i><span class="d-none d-sm-inline"> {{ t('pa.view.routes') }}</span>
</button>
<button id="paViewMapBtn" class="btn btn-outline-primary" title="{{ t('pa.view.map_title') }}">
<i class="bi bi-map"></i><span class="d-none d-sm-inline"> {{ t('pa.view.map') }}</span>
</button>
</div>
<button id="paFiltersToggleBtn" class="btn btn-sm btn-outline-secondary d-md-none" type="button"
data-bs-toggle="collapse" data-bs-target="#paFiltersPanel"
aria-expanded="false" aria-controls="paFiltersPanel" title="{{ t('pa.filters_toggle_title') }}">
<i class="bi bi-funnel"></i> {{ t('pa.filters') }}
<span id="paFiltersBadge" class="badge text-bg-primary d-none"></span>
</button>
<div id="paFiltersPanel" class="pa-filters collapse">
<div class="vr d-none d-sm-block"></div>
<label for="paDaysSelect" class="form-label mb-0 small text-muted">{{ t('pa.time_range') }}</label>
<select id="paDaysSelect" class="form-select form-select-sm" style="width: auto;">
<option value="1">{{ tn('pa.last_days', 1) }}</option>
<option value="3" selected>{{ tn('pa.last_days', 3) }}</option>
<option value="5">{{ tn('pa.last_days', 5) }}</option>
<option value="7">{{ tn('pa.last_days', 7) }}</option>
</select>
<button id="paRefreshBtn" class="btn btn-sm btn-outline-secondary" title="{{ t('pa.reload_title') }}">
<i class="bi bi-arrow-clockwise"></i>
</button>
<div class="vr d-none d-sm-block"></div>
<select id="paHopsFilter" class="form-select form-select-sm" style="width: auto;" title="{{ t('pa.hops_filter_title') }}">
<option value="any" selected>{{ t('pa.any_hops') }}</option>
<option value="0">{{ tn('pa.n_hops', 0) }}</option>
<option value="1">{{ tn('pa.n_hops', 1) }}</option>
<option value="2">{{ tn('pa.n_hops', 2) }}</option>
<option value="3">{{ tn('pa.n_hops', 3) }}</option>
<option value="4+">{{ t('pa.hops_4plus') }}</option>
</select>
<select id="paHashSizeFilter" class="form-select form-select-sm" style="width: auto;"
title="{{ t('pa.hb_filter_title') }}">
<option value="any" selected>{{ t('pa.any_hb') }}</option>
<option value="1">{{ tn('pa.n_byte', 1) }}</option>
<option value="2">{{ tn('pa.n_byte', 2) }}</option>
<option value="3">{{ tn('pa.n_byte', 3) }}</option>
<option value="2,3">{{ t('pa.byte_2_3') }}</option>
</select>
<input id="paTokenFilter" type="text" class="form-control form-control-sm" style="width: 10.5rem;"
placeholder="{{ t('pa.token_ph') }}"
title="{{ t('pa.token_title') }}">
<input id="paSenderFilter" type="text" class="form-control form-control-sm" style="width: 9.5rem;"
placeholder="{{ t('pa.sender_ph') }}" title="{{ t('pa.sender_title') }}">
<input id="paContentFilter" type="text" class="form-control form-control-sm" style="width: 11rem;"
placeholder="{{ t('pa.content_ph') }}" title="{{ t('pa.content_title') }}">
<button id="paClearFiltersBtn" class="btn btn-sm btn-outline-secondary d-none" title="{{ t('pa.clear_filters_title') }}">
<i class="bi bi-x-lg"></i> {{ t('pa.clear') }}
</button>
</div>
<span id="paCounter" class="small text-muted ms-auto"></span>
</div>
<!-- Content -->
<div class="pa-content">
<div id="paLoading" class="empty-state">
<div class="spinner-border text-secondary" role="status"></div>
<div class="mt-2">{{ t('pa.loading') }}</div>
</div>
<div id="paEmpty" class="empty-state d-none">
<i class="bi bi-signpost-split"></i>
<span id="paEmptyText">{{ t('pa.empty_range') }}</span>
</div>
<div id="paTableWrap" class="pa-table-wrap d-none">
<table class="table table-hover pa-table mb-0">
<thead>
<tr>
<th style="width: 1.5rem;"></th>
<th>{{ t('pa.col.time') }}</th>
<th>{{ t('pa.col.channel') }}</th>
<th>{{ t('pa.col.sender') }}</th>
<th>{{ t('pa.col.message') }}</th>
<th class="pa-col-hash">{{ t('pa.col.hash') }}</th>
<th class="text-end">{{ t('pa.col.hops') }}</th>
<th class="text-end pa-col-hb" title="{{ t('pa.col.hb_title') }}">{{ t('pa.col.hb') }}</th>
<th class="text-end pa-col-echoes">{{ t('pa.col.echoes') }}</th>
</tr>
</thead>
<tbody id="paTableBody"></tbody>
</table>
</div>
<div id="paStatsWrap" class="pa-table-wrap d-none">
<table class="table table-hover pa-table mb-0">
<thead>
<tr>
<th>{{ t('pa.col.repeater') }}</th>
<th>{{ t('pa.col.contact') }}</th>
<th class="text-end pa-sortable" data-sort="relayed"
title="{{ t('pa.col.relayed_title') }}">{{ t('pa.col.relayed') }}</th>
<th class="text-end pa-sortable pa-col-msgs" data-sort="messages"
title="{{ t('pa.col.msgs_hash_title') }}">{{ t('pa.col.message_plural') }}</th>
<th class="text-end pa-sortable pa-col-lasthop" data-sort="lastHop"
title="{{ t('pa.col.lasthop_title') }}">{{ t('pa.col.lasthop') }}</th>
<th class="text-end pa-sortable" data-sort="avgSnr"
title="{{ t('pa.col.avgsnr_title') }}">{{ t('pa.col.avgsnr') }}</th>
</tr>
</thead>
<tbody id="paStatsBody"></tbody>
</table>
</div>
<div id="paRoutesWrap" class="d-none">
<div class="d-flex align-items-center gap-2 mb-2">
<label for="paSegLenSelect" class="form-label small text-muted mb-0">{{ t('pa.segment_length') }}</label>
<select id="paSegLenSelect" class="form-select form-select-sm" style="width: auto;"
title="{{ t('pa.seglen_title') }}">
<option value="2" selected>{{ tn('pa.n_hops', 2) }}</option>
<option value="3">{{ tn('pa.n_hops', 3) }}</option>
<option value="4">{{ tn('pa.n_hops', 4) }}</option>
</select>
</div>
<div class="pa-table-wrap">
<table class="table table-hover pa-table mb-0">
<thead>
<tr>
<th>{{ t('pa.col.route') }}</th>
<th class="text-end pa-sortable" data-sort="echoes"
title="{{ t('pa.col.echoes_seg_title') }}">{{ t('pa.col.echoes') }}</th>
<th class="text-end pa-sortable pa-col-msgs" data-sort="messages"
title="{{ t('pa.col.msgs_seg_title') }}">{{ t('pa.col.message_plural') }}</th>
<th class="text-end pa-sortable" data-sort="pathEnd"
title="{{ t('pa.col.pathend_title') }}">{{ t('pa.col.pathend') }}</th>
</tr>
</thead>
<tbody id="paRoutesBody"></tbody>
</table>
</div>
</div>
<div id="paMapWrap" class="d-none">
<div id="paMapSidebar">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="small text-muted">{{ t('pa.map.hint') }}</span>
<button id="paMapClearBtn" class="btn btn-sm btn-outline-secondary d-none" title="{{ t('pa.map.clear_title') }}">
<i class="bi bi-eraser"></i>
</button>
</div>
<div id="paMapMsgList"></div>
</div>
<div id="paMap"></div>
</div>
</div>
<!-- Toast container for notifications (position applied by JS from ui_settings) -->
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">{{ t('pa.title') }}</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body"></div>
</div>
</div>
<!-- Bootstrap JS Bundle (local) -->
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Leaflet JS (for the map view, stage 5) -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Coordinate validation shared by every map (must load after Leaflet) -->
<script src="{{ url_for('static', filename='js/geo-utils.js') }}"></script>
<!-- Path Analyzer JS -->
<script src="{{ url_for('static', filename='js/path-analyzer.js') }}"></script>
</body>
</html>