mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-04 04:22:47 +02:00
web: add proper short names for meshcore companions (#693)
* web: add proper short names for meshcore companions * web: address review comments
This commit is contained in:
@@ -66,6 +66,51 @@ module PotatoMesh
|
||||
trimmed.start_with?("!") ? trimmed : "!#{trimmed}"
|
||||
end
|
||||
|
||||
# Broad emoji regex covering the most common Unicode emoji blocks:
|
||||
# Supplementary Multilingual Plane emoji (U+1F000–U+1FFFF), Miscellaneous
|
||||
# Symbols and Dingbats (U+2600–U+27BF), and Miscellaneous Symbols and
|
||||
# Arrows (U+2B00–U+2BFF).
|
||||
#
|
||||
# @type [Regexp]
|
||||
MESHCORE_COMPANION_EMOJI_PATTERN = /[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}]/u
|
||||
|
||||
# Derive a display short name for a MeshCore COMPANION node from its long
|
||||
# name. The ingestor stores a raw 2-byte short name; this method produces a
|
||||
# richer, human-readable variant for the API layer without touching the DB.
|
||||
#
|
||||
# Algorithm (applied in priority order):
|
||||
# 1. If the long name contains an emoji character (see
|
||||
# +MESHCORE_COMPANION_EMOJI_PATTERN+), use the first emoji embedded in a
|
||||
# 4-character display slot: ``" E "`` (two leading spaces, emoji, space).
|
||||
# 2. If the long name contains two or more whitespace-separated words, use
|
||||
# the capitalised first letters of the first two words: ``" XY "``.
|
||||
# 3. If the long name is a single word, use its capitalised first letter:
|
||||
# ``" A "``.
|
||||
# 4. Return +nil+ when no short name can be derived (blank input, or a
|
||||
# word without extractable characters).
|
||||
#
|
||||
# @param long_name [String, nil] long name stored on the node.
|
||||
# @return [String, nil] derived display short name or +nil+.
|
||||
def meshcore_companion_display_short_name(long_name)
|
||||
name = string_or_nil(long_name)
|
||||
return nil unless name
|
||||
|
||||
emoji = name.scan(MESHCORE_COMPANION_EMOJI_PATTERN).first
|
||||
return " #{emoji} " if emoji
|
||||
|
||||
words = name.strip.split(/\s+/).reject(&:empty?)
|
||||
return nil if words.empty?
|
||||
|
||||
if words.length >= 2
|
||||
first = words[0][0]&.upcase
|
||||
second = words[1][0]&.upcase
|
||||
return " #{first}#{second} " if first && second
|
||||
end
|
||||
|
||||
letter = words[0][0]&.upcase
|
||||
letter ? " #{letter} " : nil
|
||||
end
|
||||
|
||||
# Recursively coerce hash keys to strings and normalise nested arrays.
|
||||
#
|
||||
# @param value [Object] JSON compatible value.
|
||||
|
||||
@@ -157,6 +157,10 @@ module PotatoMesh
|
||||
end
|
||||
rows.each do |r|
|
||||
r["role"] ||= "CLIENT"
|
||||
if r["role"] == "COMPANION"
|
||||
derived = meshcore_companion_display_short_name(r["long_name"])
|
||||
r["short_name"] = derived if derived
|
||||
end
|
||||
lh = r["last_heard"]&.to_i
|
||||
pt = r["position_time"]&.to_i
|
||||
lh = now if lh && lh > now
|
||||
|
||||
381
web/public/assets/js/app/__tests__/map-center-reset.test.js
Normal file
381
web/public/assets/js/app/__tests__/map-center-reset.test.js
Normal file
@@ -0,0 +1,381 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createMapCenterResetHandler, DEFAULT_CENTER_RESET_ZOOM } from '../map-center-reset.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a minimal mock map object whose setView calls are recorded.
|
||||
*
|
||||
* @returns {{ setView: Function, calls: Array }} Mock map.
|
||||
*/
|
||||
function mockMap() {
|
||||
const obj = {
|
||||
calls: [],
|
||||
setView(target, zoom, options) {
|
||||
obj.calls.push({ type: 'setView', target, zoom, options });
|
||||
}
|
||||
};
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal autoFitController stub.
|
||||
*
|
||||
* @param {{ bounds?: object | null }} [opts]
|
||||
* @returns {object}
|
||||
*/
|
||||
function mockController(opts = {}) {
|
||||
const lastFit = opts.bounds !== undefined ? opts.bounds : null;
|
||||
const runs = [];
|
||||
return {
|
||||
runCallCount: 0,
|
||||
runs,
|
||||
getLastFit() { return lastFit; },
|
||||
runAutoFitOperation(fn) {
|
||||
this.runCallCount += 1;
|
||||
runs.push(fn);
|
||||
fn();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const CENTER = { lat: 38.76, lon: -27.09 };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructor validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('createMapCenterResetHandler throws when getMap is not a function', () => {
|
||||
assert.throws(() => {
|
||||
createMapCenterResetHandler({
|
||||
getMap: null,
|
||||
autoFitController: mockController(),
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
}, /getMap/);
|
||||
});
|
||||
|
||||
test('createMapCenterResetHandler throws when autoFitController is missing getLastFit', () => {
|
||||
assert.throws(() => {
|
||||
createMapCenterResetHandler({
|
||||
getMap: () => mockMap(),
|
||||
autoFitController: {},
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
}, /autoFitController/);
|
||||
});
|
||||
|
||||
test('createMapCenterResetHandler throws when fitMapToBounds is not a function', () => {
|
||||
assert.throws(() => {
|
||||
createMapCenterResetHandler({
|
||||
getMap: () => mockMap(),
|
||||
autoFitController: mockController(),
|
||||
fitMapToBounds: null,
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
}, /fitMapToBounds/);
|
||||
});
|
||||
|
||||
test('createMapCenterResetHandler throws when mapCenterCoords is invalid', () => {
|
||||
assert.throws(() => {
|
||||
createMapCenterResetHandler({
|
||||
getMap: () => mockMap(),
|
||||
autoFitController: mockController(),
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: { lat: NaN, lon: 0 },
|
||||
});
|
||||
}, /mapCenterCoords/);
|
||||
|
||||
assert.throws(() => {
|
||||
createMapCenterResetHandler({
|
||||
getMap: () => mockMap(),
|
||||
autoFitController: mockController(),
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: null,
|
||||
});
|
||||
}, /mapCenterCoords/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// No-op when map is unavailable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('handler returns without throwing when getMap returns null', () => {
|
||||
const fitCalls = [];
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => null,
|
||||
autoFitController: mockController(),
|
||||
fitMapToBounds: (...args) => fitCalls.push(args),
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
assert.doesNotThrow(() => handler());
|
||||
assert.equal(fitCalls.length, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-fit checkbox re-enabling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('handler enables fitBoundsEl when present and not disabled', () => {
|
||||
const dispatched = [];
|
||||
const fitBoundsEl = {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
dispatchEvent(e) { dispatched.push(e.type); }
|
||||
};
|
||||
// Provide a lastFit so the fallback setView path does not run — this test
|
||||
// is only asserting the checkbox re-enable behaviour.
|
||||
const lastFit = { bounds: [[0, 0], [1, 1]], options: { paddingPx: 12 } };
|
||||
const controller = mockController({ bounds: lastFit });
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => mockMap(),
|
||||
autoFitController: controller,
|
||||
fitBoundsEl,
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
handler();
|
||||
assert.equal(fitBoundsEl.checked, true);
|
||||
assert.equal(controller.runCallCount, 1);
|
||||
assert.deepEqual(dispatched, ['change']);
|
||||
});
|
||||
|
||||
test('handler dispatches a bubbling change event when re-enabling fitBoundsEl', () => {
|
||||
let capturedEvent = null;
|
||||
const fitBoundsEl = {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
dispatchEvent(e) { capturedEvent = e; }
|
||||
};
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => mockMap(),
|
||||
autoFitController: mockController(),
|
||||
fitBoundsEl,
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
handler();
|
||||
assert.ok(capturedEvent, 'expected a change event to be dispatched');
|
||||
assert.equal(capturedEvent.type, 'change');
|
||||
assert.equal(capturedEvent.bubbles, true);
|
||||
});
|
||||
|
||||
test('handler does not modify fitBoundsEl.checked when element is disabled', () => {
|
||||
const fitBoundsEl = { checked: false, disabled: true };
|
||||
// Provide a lastFit so the fallback setView path does not run — this test
|
||||
// is only asserting the checkbox non-modification when disabled.
|
||||
const lastFit = { bounds: [[0, 0], [1, 1]], options: { paddingPx: 12 } };
|
||||
const controller = mockController({ bounds: lastFit });
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => mockMap(),
|
||||
autoFitController: controller,
|
||||
fitBoundsEl,
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
handler();
|
||||
assert.equal(fitBoundsEl.checked, false);
|
||||
assert.equal(controller.runCallCount, 0);
|
||||
});
|
||||
|
||||
test('handler does not throw when fitBoundsEl is null', () => {
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => mockMap(),
|
||||
autoFitController: mockController(),
|
||||
fitBoundsEl: null,
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
assert.doesNotThrow(() => handler());
|
||||
});
|
||||
|
||||
test('handler does not throw when fitBoundsEl is omitted', () => {
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => mockMap(),
|
||||
autoFitController: mockController(),
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
assert.doesNotThrow(() => handler());
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Last-fit path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('handler calls fitMapToBounds with last-fit bounds when available', () => {
|
||||
const fakeBounds = [[1, 2], [3, 4]];
|
||||
const lastFit = { bounds: fakeBounds, options: { paddingPx: 12, maxZoom: 13 } };
|
||||
const fitCalls = [];
|
||||
const map = mockMap();
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => map,
|
||||
autoFitController: mockController({ bounds: lastFit }),
|
||||
fitMapToBounds: (bounds, opts) => fitCalls.push({ bounds, opts }),
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
handler();
|
||||
assert.equal(fitCalls.length, 1);
|
||||
assert.deepEqual(fitCalls[0].bounds, fakeBounds);
|
||||
assert.equal(fitCalls[0].opts.animate, true);
|
||||
assert.equal(fitCalls[0].opts.paddingPx, 12);
|
||||
assert.equal(fitCalls[0].opts.maxZoom, 13);
|
||||
// setView must NOT be called when last-fit path is taken
|
||||
assert.equal(map.calls.length, 0);
|
||||
});
|
||||
|
||||
test('handler forwards paddingPx and maxZoom from last-fit options', () => {
|
||||
const lastFit = { bounds: [[0, 0], [1, 1]], options: { paddingPx: 8 } };
|
||||
const fitCalls = [];
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => mockMap(),
|
||||
autoFitController: mockController({ bounds: lastFit }),
|
||||
fitMapToBounds: (b, o) => fitCalls.push(o),
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
handler();
|
||||
assert.equal(fitCalls[0].paddingPx, 8);
|
||||
assert.equal(fitCalls[0].maxZoom, undefined);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback path (no last fit recorded)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('handler calls setView with configured centre when no last fit exists', () => {
|
||||
const map = mockMap();
|
||||
const controller = mockController({ bounds: null });
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => map,
|
||||
autoFitController: controller,
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: CENTER,
|
||||
mapZoomOverride: null,
|
||||
});
|
||||
handler();
|
||||
assert.equal(map.calls.length, 1);
|
||||
assert.deepEqual(map.calls[0].target, [CENTER.lat, CENTER.lon]);
|
||||
assert.equal(map.calls[0].zoom, DEFAULT_CENTER_RESET_ZOOM);
|
||||
assert.deepEqual(map.calls[0].options, { animate: true });
|
||||
});
|
||||
|
||||
test('fallback setView is wrapped in runAutoFitOperation to prevent movestart/zoomstart unchecking auto-fit', () => {
|
||||
// Without the wrapper, the programmatic setView triggers movestart which calls
|
||||
// handleUserInteraction, undoing the auto-fit re-enable. runAutoFitOperation
|
||||
// sets autoFitInProgress=true so handleUserInteraction returns early.
|
||||
const map = mockMap();
|
||||
const controller = mockController({ bounds: null });
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => map,
|
||||
autoFitController: controller,
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
handler();
|
||||
// runAutoFitOperation must have been called at least once for the setView fallback
|
||||
assert.ok(controller.runCallCount >= 1, 'expected runAutoFitOperation to be called');
|
||||
assert.equal(map.calls.length, 1);
|
||||
});
|
||||
|
||||
test('fallback uses mapZoomOverride when provided', () => {
|
||||
const map = mockMap();
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => map,
|
||||
autoFitController: mockController({ bounds: null }),
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: CENTER,
|
||||
mapZoomOverride: 15,
|
||||
});
|
||||
handler();
|
||||
assert.equal(map.calls[0].zoom, 15);
|
||||
});
|
||||
|
||||
test('fallback uses DEFAULT_CENTER_RESET_ZOOM when mapZoomOverride is null', () => {
|
||||
const map = mockMap();
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => map,
|
||||
autoFitController: mockController({ bounds: null }),
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: CENTER,
|
||||
mapZoomOverride: null,
|
||||
});
|
||||
handler();
|
||||
assert.equal(map.calls[0].zoom, DEFAULT_CENTER_RESET_ZOOM);
|
||||
});
|
||||
|
||||
test('fallback uses DEFAULT_CENTER_RESET_ZOOM when mapZoomOverride is zero', () => {
|
||||
const map = mockMap();
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => map,
|
||||
autoFitController: mockController({ bounds: null }),
|
||||
fitMapToBounds: () => {},
|
||||
mapCenterCoords: CENTER,
|
||||
mapZoomOverride: 0,
|
||||
});
|
||||
handler();
|
||||
assert.equal(map.calls[0].zoom, DEFAULT_CENTER_RESET_ZOOM);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutual exclusivity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('fitMapToBounds and setView are mutually exclusive per invocation (last fit wins)', () => {
|
||||
const fitCalls = [];
|
||||
const map = mockMap();
|
||||
const lastFit = { bounds: [[0, 0], [1, 1]], options: { paddingPx: 12 } };
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => map,
|
||||
autoFitController: mockController({ bounds: lastFit }),
|
||||
fitMapToBounds: (...args) => fitCalls.push(args),
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
handler();
|
||||
assert.equal(fitCalls.length, 1);
|
||||
assert.equal(map.calls.length, 0);
|
||||
});
|
||||
|
||||
test('fitMapToBounds and setView are mutually exclusive per invocation (fallback wins)', () => {
|
||||
const fitCalls = [];
|
||||
const map = mockMap();
|
||||
const handler = createMapCenterResetHandler({
|
||||
getMap: () => map,
|
||||
autoFitController: mockController({ bounds: null }),
|
||||
fitMapToBounds: (...args) => fitCalls.push(args),
|
||||
mapCenterCoords: CENTER,
|
||||
});
|
||||
handler();
|
||||
assert.equal(fitCalls.length, 0);
|
||||
assert.equal(map.calls.length, 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DEFAULT_CENTER_RESET_ZOOM export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('DEFAULT_CENTER_RESET_ZOOM is a positive finite number', () => {
|
||||
assert.ok(Number.isFinite(DEFAULT_CENTER_RESET_ZOOM));
|
||||
assert.ok(DEFAULT_CENTER_RESET_ZOOM > 0);
|
||||
});
|
||||
@@ -51,6 +51,7 @@ import { resolveAutoFitBoundsConfig } from './map-auto-fit-settings.js';
|
||||
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
|
||||
import { resolveLegendVisibility } from './map-legend-visibility.js';
|
||||
import { createMapFocusHandler, DEFAULT_NODE_FOCUS_ZOOM } from './nodes-map-focus.js';
|
||||
import { createMapCenterResetHandler } from './map-center-reset.js';
|
||||
import { enhanceCoordinateCell } from './nodes-coordinate-links.js';
|
||||
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
|
||||
import { createNodeDetailOverlayManager } from './node-detail-overlay.js';
|
||||
@@ -484,6 +485,7 @@ export function initializeApp(config) {
|
||||
const mapContainer = document.getElementById('map');
|
||||
const mapPanel = document.getElementById('mapPanel');
|
||||
const mapFullscreenToggle = document.getElementById('mapFullscreenToggle');
|
||||
const mapCenterResetEl = document.getElementById('mapCenterReset');
|
||||
const fullscreenContainer = mapPanel || mapContainer;
|
||||
const isFederationView = bodyClassList ? bodyClassList.contains('view-federation') : false;
|
||||
const legendDefaultCollapsed = mapPanel ? mapPanel.dataset.legendCollapsed === 'true' : false;
|
||||
@@ -536,6 +538,15 @@ export function initializeApp(config) {
|
||||
}
|
||||
});
|
||||
|
||||
const centerResetHandler = createMapCenterResetHandler({
|
||||
getMap: () => map,
|
||||
autoFitController,
|
||||
fitBoundsEl,
|
||||
fitMapToBounds,
|
||||
mapCenterCoords: MAP_CENTER_COORDS,
|
||||
mapZoomOverride,
|
||||
});
|
||||
|
||||
/**
|
||||
* Fit the Leaflet map to the provided geographic bounds.
|
||||
*
|
||||
@@ -834,6 +845,13 @@ export function initializeApp(config) {
|
||||
}
|
||||
}
|
||||
|
||||
if (mapCenterResetEl) {
|
||||
mapCenterResetEl.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
centerResetHandler();
|
||||
});
|
||||
}
|
||||
|
||||
syncInfoOverlayHost();
|
||||
|
||||
/** @type {Set<string>} Active compound role-filter keys, each ``"<protocol>:<roleKey>"``. */
|
||||
|
||||
111
web/public/assets/js/app/map-center-reset.js
Normal file
111
web/public/assets/js/app/map-center-reset.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default zoom level used when resetting to the configured map centre and no
|
||||
* last-fit bounds are available.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
export const DEFAULT_CENTER_RESET_ZOOM = 10;
|
||||
|
||||
/**
|
||||
* Build a handler that resets the map view to fit all known nodes.
|
||||
*
|
||||
* When invoked, the handler:
|
||||
* 1. Re-enables the auto-fit checkbox (unless the element is disabled, e.g.
|
||||
* when a hard zoom override is in effect).
|
||||
* 2. Re-applies the last recorded auto-fit bounds via ``fitMapToBounds`` when
|
||||
* available — this is the bounds computed during the most recent
|
||||
* ``renderMap`` call and covers all visible nodes.
|
||||
* 3. Falls back to ``map.setView`` at the configured centre coordinates when no
|
||||
* prior fit has been recorded (e.g. before the first data refresh).
|
||||
*
|
||||
* @param {object} options - Factory configuration.
|
||||
* @param {() => object | null} options.getMap - Returns the active Leaflet map instance.
|
||||
* @param {{
|
||||
* getLastFit(): { bounds: [[number,number],[number,number]], options: { paddingPx: number, maxZoom?: number } } | null,
|
||||
* runAutoFitOperation(fn: () => void): void
|
||||
* }} options.autoFitController - Auto-fit controller created by ``createMapAutoFitController``.
|
||||
* @param {HTMLInputElement | null} [options.fitBoundsEl] - The auto-fit toggle checkbox.
|
||||
* @param {(bounds: [[number,number],[number,number]], options?: object) => void} options.fitMapToBounds - Fits the map to bounds.
|
||||
* @param {{ lat: number, lon: number }} options.mapCenterCoords - Configured map centre coordinates.
|
||||
* @param {number | null} [options.mapZoomOverride] - Hard zoom level from server config, or null when absent.
|
||||
* @returns {() => void} Handler to call when the centre-reset button is clicked.
|
||||
*/
|
||||
export function createMapCenterResetHandler({
|
||||
getMap,
|
||||
autoFitController,
|
||||
fitBoundsEl = null,
|
||||
fitMapToBounds,
|
||||
mapCenterCoords,
|
||||
mapZoomOverride = null,
|
||||
}) {
|
||||
if (typeof getMap !== 'function') {
|
||||
throw new TypeError('getMap must be a function that returns the active map instance.');
|
||||
}
|
||||
if (!autoFitController || typeof autoFitController.getLastFit !== 'function') {
|
||||
throw new TypeError('autoFitController must expose getLastFit().');
|
||||
}
|
||||
if (typeof fitMapToBounds !== 'function') {
|
||||
throw new TypeError('fitMapToBounds must be a function.');
|
||||
}
|
||||
if (!mapCenterCoords || !Number.isFinite(mapCenterCoords.lat) || !Number.isFinite(mapCenterCoords.lon)) {
|
||||
throw new TypeError('mapCenterCoords must be an object with finite lat and lon.');
|
||||
}
|
||||
|
||||
return function handleCenterReset() {
|
||||
const map = getMap();
|
||||
if (!map) return;
|
||||
|
||||
// Re-enable autofit when the checkbox is present and not locked out by a
|
||||
// hard zoom override (the element is disabled in that case). Dispatch a
|
||||
// change event so any listeners that synchronise UI state (e.g. aria
|
||||
// attributes) are notified, mirroring the pattern in handleUserInteraction.
|
||||
if (fitBoundsEl && !fitBoundsEl.disabled) {
|
||||
autoFitController.runAutoFitOperation(() => {
|
||||
fitBoundsEl.checked = true;
|
||||
fitBoundsEl.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
// Re-apply the last known node-fit bounds produced by renderMap.
|
||||
// fitMapToBounds already calls runAutoFitOperation internally, so the
|
||||
// movestart/zoomstart handlers cannot uncheck auto-fit during the pan.
|
||||
const lastFit = autoFitController.getLastFit();
|
||||
if (lastFit) {
|
||||
fitMapToBounds(lastFit.bounds, {
|
||||
animate: true,
|
||||
paddingPx: lastFit.options.paddingPx,
|
||||
maxZoom: lastFit.options.maxZoom,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: no prior fit recorded yet — reset to the configured centre.
|
||||
// Wrap in runAutoFitOperation so the movestart/zoomstart handlers see
|
||||
// autoFitInProgress=true and do not immediately uncheck the auto-fit
|
||||
// checkbox as a side-effect of the programmatic setView call.
|
||||
const zoom = Number.isFinite(mapZoomOverride) && mapZoomOverride > 0
|
||||
? mapZoomOverride
|
||||
: DEFAULT_CENTER_RESET_ZOOM;
|
||||
if (typeof map.setView === 'function') {
|
||||
autoFitController.runAutoFitOperation(() => {
|
||||
map.setView([mapCenterCoords.lat, mapCenterCoords.lon], zoom, { animate: true });
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -141,4 +141,70 @@ RSpec.describe PotatoMesh::App::Helpers do
|
||||
expect(helper.normalize_json_object(42)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# meshcore_companion_display_short_name
|
||||
# ---------------------------------------------------------------------------
|
||||
describe "#meshcore_companion_display_short_name" do
|
||||
it "returns nil for nil input" do
|
||||
expect(helper.meshcore_companion_display_short_name(nil)).to be_nil
|
||||
end
|
||||
|
||||
it "returns nil for an empty string" do
|
||||
expect(helper.meshcore_companion_display_short_name("")).to be_nil
|
||||
end
|
||||
|
||||
it "returns nil for a whitespace-only string" do
|
||||
expect(helper.meshcore_companion_display_short_name(" ")).to be_nil
|
||||
end
|
||||
|
||||
it "returns ' A ' for a single-word name" do
|
||||
expect(helper.meshcore_companion_display_short_name("Alice")).to eq(" A ")
|
||||
end
|
||||
|
||||
it "returns ' AB ' for a two-word name" do
|
||||
expect(helper.meshcore_companion_display_short_name("Alice Bob")).to eq(" AB ")
|
||||
end
|
||||
|
||||
it "uses only the first two words for longer names" do
|
||||
expect(helper.meshcore_companion_display_short_name("Alice Bob Carol")).to eq(" AB ")
|
||||
end
|
||||
|
||||
it "uppercases the initials regardless of original case" do
|
||||
expect(helper.meshcore_companion_display_short_name("alice bob")).to eq(" AB ")
|
||||
end
|
||||
|
||||
it "strips leading and trailing whitespace before splitting" do
|
||||
expect(helper.meshcore_companion_display_short_name(" alice bob ")).to eq(" AB ")
|
||||
end
|
||||
|
||||
it "returns the first emoji from the SMP range (U+1F000–U+1FFFF)" do
|
||||
name = "Node \u{1F600}"
|
||||
expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ")
|
||||
end
|
||||
|
||||
it "returns the first emoji from the misc symbols range (U+2600–U+27BF)" do
|
||||
name = "\u{2600} Sun"
|
||||
expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{2600} ")
|
||||
end
|
||||
|
||||
it "returns the first emoji from the arrows range (U+2B00–U+2BFF)" do
|
||||
name = "\u{2B50} Star"
|
||||
expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{2B50} ")
|
||||
end
|
||||
|
||||
it "uses the FIRST emoji when multiple are present" do
|
||||
name = "\u{1F600}\u{1F601} Two"
|
||||
expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ")
|
||||
end
|
||||
|
||||
it "prefers emoji over initials when both are present" do
|
||||
name = "Alice \u{1F600} Bob"
|
||||
expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ")
|
||||
end
|
||||
|
||||
it "returns the single initial when the name is one word with no emoji" do
|
||||
expect(helper.meshcore_companion_display_short_name("Zigzag")).to eq(" Z ")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -418,6 +418,101 @@ RSpec.describe PotatoMesh::App::Queries do
|
||||
rows = queries.query_nodes(10, since: now + 9999)
|
||||
expect(rows).to be_empty
|
||||
end
|
||||
|
||||
context "COMPANION short name enrichment" do
|
||||
it "derives a two-initial short name for a COMPANION node with a two-word long name" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
["!cc000001", 0xcc000001, "CX", "Alice Bob", now, now, "COMPANION"],
|
||||
)
|
||||
end
|
||||
rows = queries.query_nodes(10, node_ref: "!cc000001")
|
||||
row = rows.find { |r| r["node_id"] == "!cc000001" }
|
||||
expect(row).not_to be_nil
|
||||
expect(row["short_name"]).to eq(" AB ")
|
||||
end
|
||||
|
||||
it "derives a single-initial short name for a COMPANION node with a one-word long name" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
["!cc000002", 0xcc000002, "CX", "Zigzag", now, now, "COMPANION"],
|
||||
)
|
||||
end
|
||||
rows = queries.query_nodes(10, node_ref: "!cc000002")
|
||||
row = rows.find { |r| r["node_id"] == "!cc000002" }
|
||||
expect(row["short_name"]).to eq(" Z ")
|
||||
end
|
||||
|
||||
it "derives an emoji short name for a COMPANION node whose long name contains an emoji" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
["!cc000003", 0xcc000003, "CX", "Node \u{1F600}", now, now, "COMPANION"],
|
||||
)
|
||||
end
|
||||
rows = queries.query_nodes(10, node_ref: "!cc000003")
|
||||
row = rows.find { |r| r["node_id"] == "!cc000003" }
|
||||
expect(row["short_name"]).to eq(" \u{1F600} ")
|
||||
end
|
||||
|
||||
it "does not overwrite short_name when long_name is blank for a COMPANION node" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
["!cc000004", 0xcc000004, "CX", "", now, now, "COMPANION"],
|
||||
)
|
||||
end
|
||||
rows = queries.query_nodes(10, node_ref: "!cc000004")
|
||||
row = rows.find { |r| r["node_id"] == "!cc000004" }
|
||||
# blank long_name → nil derived → original short_name preserved by compact_api_row
|
||||
expect(row["short_name"]).to eq("CX")
|
||||
end
|
||||
|
||||
it "leaves the short_name unchanged for a CLIENT node with a multi-word long name" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
["!cc000005", 0xcc000005, "XY", "Alice Bob", now, now, "CLIENT"],
|
||||
)
|
||||
end
|
||||
rows = queries.query_nodes(10, node_ref: "!cc000005")
|
||||
row = rows.find { |r| r["node_id"] == "!cc000005" }
|
||||
expect(row["short_name"]).to eq("XY")
|
||||
end
|
||||
|
||||
it "does not overwrite short_name when long_name is NULL in the DB for a COMPANION node" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
["!cc000007", 0xcc000007, "CX", nil, now, now, "COMPANION"],
|
||||
)
|
||||
end
|
||||
rows = queries.query_nodes(10, node_ref: "!cc000007")
|
||||
row = rows.find { |r| r["node_id"] == "!cc000007" }
|
||||
expect(row["short_name"]).to eq("CX")
|
||||
end
|
||||
|
||||
it "leaves the short_name unchanged for a node whose role defaults to CLIENT (nil in DB)" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
["!cc000006", 0xcc000006, "ZZ", "Alice Bob", now, now, nil],
|
||||
)
|
||||
end
|
||||
rows = queries.query_nodes(10, node_ref: "!cc000006")
|
||||
row = rows.find { |r| r["node_id"] == "!cc000006" }
|
||||
expect(row["short_name"]).to eq("ZZ")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#query_messages" do
|
||||
|
||||
@@ -22,6 +22,18 @@
|
||||
<div class="<%= map_classes.join(" ") %>" id="mapPanel" <%= data_attrs.join(" ") %>>
|
||||
<div id="map" role="region" aria-label="Nodes map"></div>
|
||||
<div class="map-toolbar" role="group" aria-label="Map view controls">
|
||||
<button id="mapCenterReset" type="button" aria-label="Re-center map to all nodes">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
d="M12 2v3m0 14v3M2 12h3m14 0h3m-5 0a5 5 0 1 1-10 0 5 5 0 0 1 10 0Zm-5-2v4m-2-2h4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="mapFullscreenToggle" type="button" aria-pressed="false" aria-label="Enter full screen map view">
|
||||
<svg class="icon-fullscreen-enter" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
|
||||
Reference in New Issue
Block a user