feat: v0.2.0 - Config YAML, theme toggle, hop map, channel names
- Central config.yaml with live-reload file watcher - Configurable command prefix (default: /) - Light/dark theme toggle with localStorage persistence - Map: hop-based node coloring with legend, hover tooltips - Dashboard: node long names, hop column, channel names in messages - Load existing messages on WebSocket connect - Weather fallback to Dresden center - Remove version.py and pre-commit hook Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a6c09b19eb
commit
9e880a1f36
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -1,5 +1,26 @@
|
|||
# Changelog
|
||||
|
||||
## [0.2.0] - 2026-02-15
|
||||
### Added
|
||||
- Zentrale config.yaml mit Live-Reload (File-Watcher)
|
||||
- Konfigurierbarer Command-Prefix (Standard: /)
|
||||
- Kanalnamen in der Nachrichtenliste
|
||||
- Hops-Spalte in der Nodes-Tabelle
|
||||
- Karte: Farbcodierung der Nodes nach Hop-Anzahl mit Legende
|
||||
- Karte: Tooltip mit Node-Infos beim Hover
|
||||
- Hell/Dunkel Theme-Umschalter im Dashboard
|
||||
- Node-Namen (LongName/ShortName) werden korrekt angezeigt
|
||||
- Nachrichten werden beim Connect aus der DB geladen
|
||||
- Wetter-Fallback auf Dresden Zentrum bei fehlender Position
|
||||
|
||||
### Changed
|
||||
- Konfiguration von Environment-Variablen auf config.yaml umgestellt
|
||||
- Version wird in config.yaml statt version.py verwaltet
|
||||
|
||||
### Removed
|
||||
- Git pre-commit Hook (manuelle Versionierung)
|
||||
- version.py (ersetzt durch config.yaml)
|
||||
|
||||
## [0.1.2] - 2026-02-15
|
||||
### Changed
|
||||
- Auto-commit update
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
version: "0.1.2"
|
||||
version: "0.2.0"
|
||||
|
||||
bot:
|
||||
name: "MeshDD-Bot"
|
||||
command_prefix: "/"
|
||||
|
||||
meshtastic:
|
||||
host: "192.168.11.11"
|
||||
|
|
|
|||
2
main.py
2
main.py
|
|
@ -31,7 +31,7 @@ async def main():
|
|||
bot.ws_manager = ws_manager
|
||||
|
||||
# Webserver
|
||||
webserver = WebServer(db, ws_manager)
|
||||
webserver = WebServer(db, ws_manager, bot)
|
||||
runner = await webserver.start(config.get("web.host", "0.0.0.0"), config.get("web.port", 8080))
|
||||
|
||||
# Connect Meshtastic in a thread (blocking call)
|
||||
|
|
|
|||
|
|
@ -35,8 +35,20 @@ class MeshBot:
|
|||
if self.interface:
|
||||
self.interface.close()
|
||||
|
||||
def get_channels(self) -> dict:
|
||||
channels = {}
|
||||
if self.interface and self.interface.localNode:
|
||||
for ch in self.interface.localNode.channels:
|
||||
if ch.role:
|
||||
name = ch.settings.name if ch.settings.name else ("Primary" if ch.index == 0 else f"Ch {ch.index}")
|
||||
channels[ch.index] = name
|
||||
return channels
|
||||
|
||||
def _on_connection(self, interface, topic=pub.AUTO_TOPIC):
|
||||
logger.info("Meshtastic connection established")
|
||||
if hasattr(interface, 'nodes') and interface.nodes:
|
||||
for node in interface.nodes.values():
|
||||
self.loop.call_soon_threadsafe(asyncio.ensure_future, self._handle_node_update(node))
|
||||
|
||||
def _on_node_updated(self, node, topic=pub.AUTO_TOPIC):
|
||||
self.loop.call_soon_threadsafe(asyncio.ensure_future, self._handle_node_update(node))
|
||||
|
|
@ -46,7 +58,11 @@ class MeshBot:
|
|||
|
||||
async def _handle_node_update(self, node):
|
||||
try:
|
||||
node_id = node.get("user", {}).get("id") or node.get("num")
|
||||
logger.debug("Node update raw: %s", node)
|
||||
node_id = node.get("user", {}).get("id")
|
||||
node_num = node.get("num")
|
||||
if not node_id and node_num:
|
||||
node_id = f"!{node_num:08x}"
|
||||
if not node_id:
|
||||
return
|
||||
node_id = str(node_id)
|
||||
|
|
@ -57,7 +73,7 @@ class MeshBot:
|
|||
snr = node.get("snr")
|
||||
|
||||
data = {
|
||||
"node_num": node.get("num"),
|
||||
"node_num": node_num,
|
||||
"long_name": user.get("longName"),
|
||||
"short_name": user.get("shortName"),
|
||||
"hw_model": user.get("hwModel"),
|
||||
|
|
@ -91,6 +107,18 @@ class MeshBot:
|
|||
if from_id:
|
||||
await self.db.upsert_node(str(from_id), **{k: v for k, v in node_data.items() if v is not None})
|
||||
|
||||
# Handle nodeinfo
|
||||
if portnum == "NODEINFO_APP":
|
||||
user = packet.get("decoded", {}).get("user", {})
|
||||
if user and from_id:
|
||||
await self.db.upsert_node(str(from_id),
|
||||
long_name=user.get("longName"),
|
||||
short_name=user.get("shortName"),
|
||||
hw_model=user.get("hwModel"))
|
||||
node = await self.db.get_node(str(from_id))
|
||||
if node and self.ws_manager:
|
||||
await self.ws_manager.broadcast("node_update", node)
|
||||
|
||||
# Handle position updates
|
||||
if portnum == "POSITION_APP":
|
||||
pos = packet.get("decoded", {}).get("position", {})
|
||||
|
|
@ -125,43 +153,45 @@ class MeshBot:
|
|||
await self.ws_manager.broadcast("stats_update", stats)
|
||||
|
||||
# Process commands
|
||||
if text.startswith("!"):
|
||||
prefix = config.get("bot.command_prefix", "!")
|
||||
if text.startswith(prefix):
|
||||
await self._handle_command(text.strip(), channel, str(from_id))
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error handling packet")
|
||||
|
||||
async def _handle_command(self, text: str, channel: int, from_id: str):
|
||||
prefix = config.get("bot.command_prefix", "!")
|
||||
cmd = text.split()[0].lower()
|
||||
response = None
|
||||
|
||||
if cmd == "!ping":
|
||||
if cmd == f"{prefix}ping":
|
||||
response = "🏓 Pong!"
|
||||
|
||||
elif cmd == "!nodes":
|
||||
elif cmd == f"{prefix}nodes":
|
||||
nodes = await self.db.get_all_nodes()
|
||||
response = f"📡 Bekannte Nodes: {len(nodes)}"
|
||||
|
||||
elif cmd == "!info":
|
||||
elif cmd == f"{prefix}info":
|
||||
uptime = self._format_uptime()
|
||||
response = f"ℹ️ {config.get('bot.name', 'MeshDD-Bot')} v{config.get('version', '0.0.0')}\nUptime: {uptime}"
|
||||
|
||||
elif cmd == "!help":
|
||||
elif cmd == f"{prefix}help":
|
||||
response = (
|
||||
"📋 Kommandos:\n"
|
||||
"!ping - Pong\n"
|
||||
"!nodes - Anzahl Nodes\n"
|
||||
"!info - Bot-Info\n"
|
||||
"!stats - Statistiken\n"
|
||||
"!uptime - Laufzeit\n"
|
||||
"!weather - Wetter\n"
|
||||
"!help - Diese Hilfe"
|
||||
f"📋 Kommandos:\n"
|
||||
f"{prefix}ping - Pong\n"
|
||||
f"{prefix}nodes - Anzahl Nodes\n"
|
||||
f"{prefix}info - Bot-Info\n"
|
||||
f"{prefix}stats - Statistiken\n"
|
||||
f"{prefix}uptime - Laufzeit\n"
|
||||
f"{prefix}weather - Wetter\n"
|
||||
f"{prefix}help - Diese Hilfe"
|
||||
)
|
||||
|
||||
elif cmd == "!weather":
|
||||
elif cmd == f"{prefix}weather":
|
||||
response = await self._get_weather(from_id)
|
||||
|
||||
elif cmd == "!stats":
|
||||
elif cmd == f"{prefix}stats":
|
||||
stats = await self.db.get_stats()
|
||||
response = (
|
||||
f"📊 Statistiken:\n"
|
||||
|
|
@ -171,7 +201,7 @@ class MeshBot:
|
|||
f"Textnachrichten: {stats['text_messages']}"
|
||||
)
|
||||
|
||||
elif cmd == "!uptime":
|
||||
elif cmd == f"{prefix}uptime":
|
||||
response = f"⏱️ Uptime: {self._format_uptime()}"
|
||||
|
||||
if response:
|
||||
|
|
@ -201,10 +231,12 @@ class MeshBot:
|
|||
|
||||
async def _get_weather(self, from_id: str) -> str:
|
||||
node = await self.db.get_node(from_id)
|
||||
if not node or not node.get("lat") or not node.get("lon"):
|
||||
return "❌ Keine Position für diesen Node bekannt."
|
||||
|
||||
lat, lon = node["lat"], node["lon"]
|
||||
fallback = False
|
||||
if node and node.get("lat") and node.get("lon"):
|
||||
lat, lon = node["lat"], node["lon"]
|
||||
else:
|
||||
lat, lon = 51.0504, 13.7373 # Dresden Zentrum
|
||||
fallback = True
|
||||
url = (
|
||||
f"https://api.open-meteo.com/v1/forecast?"
|
||||
f"latitude={lat}&longitude={lon}"
|
||||
|
|
@ -221,8 +253,9 @@ class MeshBot:
|
|||
code = current.get("weather_code", 0)
|
||||
weather_icon = self._weather_code_to_icon(code)
|
||||
|
||||
location = " (Dresden)" if fallback else ""
|
||||
return (
|
||||
f"{weather_icon} Wetter:\n"
|
||||
f"{weather_icon} Wetter{location}:\n"
|
||||
f"Temp: {temp}°C\n"
|
||||
f"Feuchte: {humidity}%\n"
|
||||
f"Wind: {wind} km/h"
|
||||
|
|
|
|||
|
|
@ -28,9 +28,10 @@ class WebSocketManager:
|
|||
|
||||
|
||||
class WebServer:
|
||||
def __init__(self, db: Database, ws_manager: WebSocketManager):
|
||||
def __init__(self, db: Database, ws_manager: WebSocketManager, bot=None):
|
||||
self.db = db
|
||||
self.ws_manager = ws_manager
|
||||
self.bot = bot
|
||||
self.app = web.Application()
|
||||
self._setup_routes()
|
||||
|
||||
|
|
@ -57,6 +58,13 @@ class WebServer:
|
|||
stats = await self.db.get_stats()
|
||||
await ws.send_str(json.dumps({"type": "stats_update", "data": stats}))
|
||||
|
||||
messages = await self.db.get_recent_messages(50)
|
||||
await ws.send_str(json.dumps({"type": "initial_messages", "data": messages}))
|
||||
|
||||
if self.bot:
|
||||
channels = self.bot.get_channels()
|
||||
await ws.send_str(json.dumps({"type": "channels", "data": channels}))
|
||||
|
||||
async for msg in ws:
|
||||
pass # We only send, not receive
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@
|
|||
<a href="/map" target="_blank" class="btn btn-outline-info btn-sm">
|
||||
<i class="bi bi-map me-1"></i>Karte
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary btn-sm" id="themeToggle" title="Theme wechseln">
|
||||
<i class="bi bi-sun-fill" id="themeIcon"></i>
|
||||
</button>
|
||||
<span class="badge d-flex align-items-center gap-2" id="statusBadge">
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
<span id="statusText">Verbinde...</span>
|
||||
|
|
@ -79,10 +82,10 @@
|
|||
<thead class="table-dark sticky-top">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>ID</th>
|
||||
<th>Hardware</th>
|
||||
<th class="text-center">SNR</th>
|
||||
<th class="text-center">Batterie</th>
|
||||
<th class="text-center">Hops</th>
|
||||
<th class="text-end">Zuletzt gesehen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const statusText = document.getElementById('statusText');
|
|||
const nodeCountBadge = document.getElementById('nodeCountBadge');
|
||||
|
||||
let nodes = {};
|
||||
let channels = {};
|
||||
let ws;
|
||||
|
||||
function connectWebSocket() {
|
||||
|
|
@ -37,6 +38,12 @@ function connectWebSocket() {
|
|||
nodes[msg.data.node_id] = msg.data;
|
||||
renderNodes();
|
||||
break;
|
||||
case 'initial_messages':
|
||||
msg.data.reverse().forEach(m => addMessage(m));
|
||||
break;
|
||||
case 'channels':
|
||||
channels = msg.data;
|
||||
break;
|
||||
case 'new_message':
|
||||
addMessage(msg.data);
|
||||
break;
|
||||
|
|
@ -51,19 +58,26 @@ function renderNodes() {
|
|||
const sorted = Object.values(nodes).sort((a, b) => (b.last_seen || 0) - (a.last_seen || 0));
|
||||
nodeCountBadge.textContent = sorted.length;
|
||||
nodesTable.innerHTML = sorted.map(node => {
|
||||
const name = node.long_name || node.short_name || node.node_id;
|
||||
const shortId = node.node_id ? node.node_id.slice(-4) : '?';
|
||||
let name = node.node_id;
|
||||
if (node.long_name && node.short_name) {
|
||||
name = `${node.long_name} (${node.short_name})`;
|
||||
} else if (node.long_name) {
|
||||
name = node.long_name;
|
||||
} else if (node.short_name) {
|
||||
name = node.short_name;
|
||||
}
|
||||
const hw = node.hw_model || '-';
|
||||
const snr = node.snr != null ? `${node.snr.toFixed(1)} dB` : '-';
|
||||
const battery = renderBattery(node.battery);
|
||||
const hops = node.hop_count != null ? node.hop_count : '-';
|
||||
const lastSeen = node.last_seen ? timeAgo(node.last_seen) : '-';
|
||||
const onlineClass = isOnline(node.last_seen) ? 'text-success' : '';
|
||||
return `<tr>
|
||||
<td class="${onlineClass}">${escapeHtml(name)}</td>
|
||||
<td><code>${escapeHtml(shortId)}</code></td>
|
||||
<td class="text-body-secondary">${escapeHtml(hw)}</td>
|
||||
<td class="text-center">${snr}</td>
|
||||
<td class="text-center">${battery}</td>
|
||||
<td class="text-center">${hops}</td>
|
||||
<td class="text-end text-body-secondary">${lastSeen}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
|
@ -84,11 +98,14 @@ function addMessage(msg) {
|
|||
const item = document.createElement('div');
|
||||
item.className = 'list-group-item list-group-item-action py-2 px-3';
|
||||
const time = msg.timestamp ? new Date(msg.timestamp * 1000).toLocaleTimeString('de-DE') : '';
|
||||
const from = msg.from_node ? msg.from_node.slice(-4) : '?';
|
||||
const fromNode = nodes[msg.from_node];
|
||||
const from = (fromNode && fromNode.long_name) ? fromNode.long_name : (msg.from_node || '?');
|
||||
const chIdx = msg.channel != null ? msg.channel : '?';
|
||||
const chName = channels[chIdx] || `Ch ${chIdx}`;
|
||||
item.innerHTML = `
|
||||
<div class="d-flex justify-content-between">
|
||||
<small class="text-body-secondary"><i class="bi bi-person-fill me-1"></i>${escapeHtml(from)}</small>
|
||||
<small class="text-body-secondary">${time}</small>
|
||||
<small class="text-body-secondary"><span class="badge bg-secondary me-2">${escapeHtml(chName)}</span>${time}</small>
|
||||
</div>
|
||||
<div class="mt-1">${escapeHtml(msg.payload || '')}</div>`;
|
||||
messagesList.prepend(item);
|
||||
|
|
@ -123,4 +140,22 @@ function escapeHtml(str) {
|
|||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Theme toggle
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const themeIcon = document.getElementById('themeIcon');
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
themeIcon.className = theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill';
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
|
||||
// Load saved theme
|
||||
applyTheme(localStorage.getItem('theme') || 'dark');
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const current = document.documentElement.getAttribute('data-bs-theme');
|
||||
applyTheme(current === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
|
||||
connectWebSocket();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const map = L.map('map').setView([51.1657, 10.4515], 6); // Germany center
|
||||
const map = L.map('map').setView([51.1657, 10.4515], 6);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
|
|
@ -6,11 +6,28 @@ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|||
}).addTo(map);
|
||||
|
||||
const markers = {};
|
||||
const nodeData = {};
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const nodeCount = document.getElementById('nodeCount');
|
||||
let ws;
|
||||
|
||||
const hopColors = {
|
||||
0: '#2196F3', // Direkt - Blau
|
||||
1: '#4CAF50', // 1 Hop - Grün
|
||||
2: '#FF9800', // 2 Hops - Orange
|
||||
3: '#F44336', // 3 Hops - Rot
|
||||
4: '#9C27B0', // 4 Hops - Lila
|
||||
5: '#795548', // 5 Hops - Braun
|
||||
};
|
||||
const hopColorDefault = '#9E9E9E'; // Unbekannt - Grau
|
||||
|
||||
function getHopColor(hopCount) {
|
||||
if (hopCount == null) return hopColorDefault;
|
||||
if (hopCount >= 6) return hopColorDefault;
|
||||
return hopColors[hopCount] || hopColorDefault;
|
||||
}
|
||||
|
||||
function createIcon(color) {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
|
|
@ -19,31 +36,26 @@ function createIcon(color) {
|
|||
</svg>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
popupAnchor: [0, -12]
|
||||
popupAnchor: [0, -14],
|
||||
tooltipAnchor: [14, 0]
|
||||
});
|
||||
}
|
||||
|
||||
function getNodeColor(node) {
|
||||
const age = Date.now() / 1000 - (node.last_seen || 0);
|
||||
if (age < 900) return '#00e676'; // < 15 min: green
|
||||
if (age < 3600) return '#ff9100'; // < 1h: orange
|
||||
return '#ff5252'; // > 1h: red
|
||||
}
|
||||
|
||||
function nodePopup(node) {
|
||||
function nodeTooltip(node) {
|
||||
const name = node.long_name || node.short_name || node.node_id;
|
||||
const lastSeen = node.last_seen ? new Date(node.last_seen * 1000).toLocaleString('de-DE') : '-';
|
||||
const hops = node.hop_count != null ? node.hop_count : '?';
|
||||
const bat = node.battery != null ? `${node.battery}%` : '-';
|
||||
const snr = node.snr != null ? `${node.snr.toFixed(1)} dB` : '-';
|
||||
const alt = node.alt != null ? `${Math.round(node.alt)} m` : '-';
|
||||
const hw = node.hw_model || '-';
|
||||
const alt = node.alt != null ? `${Math.round(node.alt)} m` : '-';
|
||||
const lastSeen = node.last_seen ? new Date(node.last_seen * 1000).toLocaleString('de-DE') : '-';
|
||||
|
||||
return `<div class="node-popup">
|
||||
return `<div class="node-tooltip">
|
||||
<strong>${escapeHtml(name)}</strong><br>
|
||||
<span class="label">ID:</span> ${escapeHtml(node.node_id)}<br>
|
||||
<span class="label">Hardware:</span> ${escapeHtml(hw)}<br>
|
||||
<span class="label">Batterie:</span> ${bat}<br>
|
||||
<span class="label">Hops:</span> ${hops}<br>
|
||||
<span class="label">SNR:</span> ${snr}<br>
|
||||
<span class="label">Batterie:</span> ${bat}<br>
|
||||
<span class="label">Höhe:</span> ${alt}<br>
|
||||
<span class="label">Zuletzt:</span> ${lastSeen}
|
||||
</div>`;
|
||||
|
|
@ -53,16 +65,17 @@ function updateMarker(node) {
|
|||
if (!node.lat || !node.lon) return;
|
||||
|
||||
const id = node.node_id;
|
||||
const icon = createIcon(getNodeColor(node));
|
||||
nodeData[id] = node;
|
||||
const icon = createIcon(getHopColor(node.hop_count));
|
||||
|
||||
if (markers[id]) {
|
||||
markers[id].setLatLng([node.lat, node.lon]);
|
||||
markers[id].setIcon(icon);
|
||||
markers[id].setPopupContent(nodePopup(node));
|
||||
markers[id].setTooltipContent(nodeTooltip(node));
|
||||
} else {
|
||||
markers[id] = L.marker([node.lat, node.lon], { icon })
|
||||
.addTo(map)
|
||||
.bindPopup(nodePopup(node));
|
||||
.bindTooltip(nodeTooltip(node), { direction: 'right', className: 'node-tooltip-wrap' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,11 +126,26 @@ function escapeHtml(str) {
|
|||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Refresh marker colors every 60s
|
||||
setInterval(() => {
|
||||
Object.values(markers).forEach(marker => {
|
||||
// Markers will be refreshed on next update
|
||||
// Legend
|
||||
const legend = L.control({ position: 'bottomright' });
|
||||
legend.onAdd = function () {
|
||||
const div = L.DomUtil.create('div', 'legend');
|
||||
div.innerHTML = '<strong>Hops</strong>';
|
||||
const entries = [
|
||||
[0, 'Direkt'],
|
||||
[1, '1 Hop'],
|
||||
[2, '2 Hops'],
|
||||
[3, '3 Hops'],
|
||||
[4, '4 Hops'],
|
||||
[5, '5+ Hops'],
|
||||
[null, 'Unbekannt'],
|
||||
];
|
||||
entries.forEach(([hop, label]) => {
|
||||
const color = hop != null ? (hopColors[hop] || hopColorDefault) : hopColorDefault;
|
||||
div.innerHTML += `<div class="legend-item"><span class="legend-dot" style="background:${color}"></span>${label}</div>`;
|
||||
});
|
||||
}, 60000);
|
||||
return div;
|
||||
};
|
||||
legend.addTo(map);
|
||||
|
||||
connectWebSocket();
|
||||
|
|
|
|||
|
|
@ -9,9 +9,28 @@
|
|||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; }
|
||||
#map { width: 100vw; height: 100vh; }
|
||||
.node-popup { font-size: 13px; line-height: 1.6; }
|
||||
.node-popup strong { color: #0f3460; }
|
||||
.node-popup .label { color: #666; }
|
||||
.node-tooltip { font-size: 13px; line-height: 1.6; }
|
||||
.node-tooltip strong { color: #0f3460; }
|
||||
.node-tooltip .label { color: #666; }
|
||||
.node-tooltip-wrap { padding: 0; }
|
||||
.node-tooltip-wrap .leaflet-tooltip-content { margin: 0; }
|
||||
.legend {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.legend strong { display: block; margin-bottom: 4px; color: #333; }
|
||||
.legend-item { display: flex; align-items: center; gap: 8px; }
|
||||
.legend-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0,0,0,0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-bar {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue