From e26a4b72abee80e33755e5bd7de1c4cb9eae5a56 Mon Sep 17 00:00:00 2001 From: Johan Cwiklinski Date: Fri, 25 Sep 2026 10:55:15 +0200 Subject: [PATCH] Snap members positions on the map for visitors and members --- .../Controllers/MapsController.php | 29 +++- lib/GaletteMaps/Coordinates.php | 26 ++-- lib/GaletteMaps/PluginGaletteMaps.php | 2 +- lib/GaletteMaps/Precision.php | 139 ++++++++++++++++++ templates/default/maps.html.twig | 3 +- templates/default/maps_preferences.html.twig | 11 ++ .../tests/units/MapsController.php | 53 ++++++- tests/GaletteMaps/tests/units/Coordinates.php | 72 +++++++++ .../tests/units/PluginGaletteMaps.php | 14 ++ tests/GaletteMaps/tests/units/Precision.php | 76 ++++++++++ webroot/galette_maps.js | 9 +- 11 files changed, 416 insertions(+), 18 deletions(-) create mode 100644 lib/GaletteMaps/Precision.php create mode 100644 tests/GaletteMaps/tests/units/Precision.php diff --git a/lib/GaletteMaps/Controllers/MapsController.php b/lib/GaletteMaps/Controllers/MapsController.php index 7fe2096..ed4d275 100644 --- a/lib/GaletteMaps/Controllers/MapsController.php +++ b/lib/GaletteMaps/Controllers/MapsController.php @@ -14,6 +14,7 @@ use Galette\Controllers\AbstractPluginController; use Galette\Entity\Adherent; use GaletteMaps\NominatimTowns; +use GaletteMaps\Precision; use GaletteMaps\Coordinates; use GaletteMaps\TileProviders; use Slim\Psr7\Request; @@ -80,11 +81,17 @@ public function map(Request $request, Response $response): Response 'page_title' => _T('Maps', 'maps'), 'module_id' => $this->getModuleId(), 'tiles' => TileProviders::resolve($this->preferences), - 'list' => [] + 'list' => [], + 'max_zoom' => null ]; + $precision = Precision::resolve($this->preferences); try { - $params['list'] = $this->coordinates->listVisible(); + $params['list'] = $this->coordinates->listVisible(Precision::getStep($precision)); + //zooming further would suggest a precision snapped positions have not + if (in_array(true, array_column($params['list'], 'approximate'), true)) { + $params['max_zoom'] = Precision::getMaxZoom($precision); + } } catch (\Throwable $e) { Analog::log('Unable to list coordinates | ' . $e->getMessage(), Analog::ERROR); $this->flash->addMessageNow( @@ -217,6 +224,8 @@ public function preferences(Request $request, Response $response): Response 'attribution' => $this->preferences->getPluginValue(TileProviders::PREF_ATTRIBUTION), 'maxzoom' => $this->preferences->getPluginValue(TileProviders::PREF_MAXZOOM), 'subdomains' => $this->preferences->getPluginValue(TileProviders::PREF_SUBDOMAINS), + 'precisions' => Precision::getSelectValues(), + 'precision' => Precision::resolve($this->preferences), ]; $this->view->render( @@ -238,7 +247,21 @@ public function storePreferences(Request $request, Response $response): Response $post = $request->getParsedBody(); $provider = $post[TileProviders::PREF_PROVIDER] ?? TileProviders::DEFAULT; - $values = [TileProviders::PREF_PROVIDER => $provider]; + $precision = (string)($post[Precision::PREF] ?? Precision::DEFAULT); + if (!Precision::isKnown($precision)) { + $this->flash->addMessage( + 'error_detected', + _T('Unknown position precision.', 'maps') + ); + return $response + ->withStatus(302) + ->withHeader('Location', $this->routeparser->urlFor('maps_preferences')); + } + + $values = [ + TileProviders::PREF_PROVIDER => $provider, + Precision::PREF => $precision, + ]; if ($provider === TileProviders::CUSTOM) { //own values are only meaningful along with the custom provider $values += [ diff --git a/lib/GaletteMaps/Coordinates.php b/lib/GaletteMaps/Coordinates.php index 17c2ce3..d1ab1ca 100644 --- a/lib/GaletteMaps/Coordinates.php +++ b/lib/GaletteMaps/Coordinates.php @@ -68,10 +68,13 @@ public function get(int $id): ?array * * Staff and administrators see every active member; others see active, * up-to-date members who display their information, and their own position. + * Positions are snapped to the grid for them, except their own one. * - * @return array + * @param ?float $step Grid step positions are snapped to, in degrees; null for exact positions + * + * @return array */ - public function listVisible(): array + public function listVisible(?float $step = null): array { $select = $this->zdb->select($this->getTableName(), 'c'); $select->join( @@ -85,11 +88,11 @@ public function listVisible(): array $where = $select->where; $where->equalTo('a.activite_adh', right: true); - if ( - !$this->login->isAdmin() - && !$this->login->isStaff() - && !$this->login->isSuperAdmin() - ) { + $privileged = $this->login->isAdmin() + || $this->login->isStaff() + || $this->login->isSuperAdmin(); + + if (!$privileged) { //limit query to public up-to-date profiles, and to logged-in member own one $visible = $where->nest(); $public = $visible->nest(); @@ -107,10 +110,13 @@ public function listVisible(): array $res = []; foreach ($this->zdb->execute($select) as $r) { + $id_adh = (int)$r[self::PK]; + $approximate = $step !== null && !$privileged && $id_adh !== (int)$this->login->id; $m = [ - 'id_adh' => (int)$r[self::PK], - 'lat' => (string)$r['latitude'], - 'lng' => (string)$r['longitude'], + 'id_adh' => $id_adh, + 'lat' => $approximate ? Precision::snap($r['latitude'], $step) : (string)$r['latitude'], + 'lng' => $approximate ? Precision::snap($r['longitude'], $step) : (string)$r['longitude'], + 'approximate' => $approximate, 'name' => Adherent::getNameWithCase($r['nom_adh'], $r['prenom_adh']), 'nickname' => $r['pseudo_adh'] ]; diff --git a/lib/GaletteMaps/PluginGaletteMaps.php b/lib/GaletteMaps/PluginGaletteMaps.php index 3b9d441..3a20d48 100644 --- a/lib/GaletteMaps/PluginGaletteMaps.php +++ b/lib/GaletteMaps/PluginGaletteMaps.php @@ -41,7 +41,7 @@ class PluginGaletteMaps extends GalettePlugin implements InstallableInterface, M */ public function getPreferences(): array { - return TileProviders::getSchema(); + return TileProviders::getSchema() + Precision::getSchema(); } /** diff --git a/lib/GaletteMaps/Precision.php b/lib/GaletteMaps/Precision.php new file mode 100644 index 0000000..fd63b42 --- /dev/null +++ b/lib/GaletteMaps/Precision.php @@ -0,0 +1,139 @@ + + */ +final class Precision +{ + public const string PREF = 'pref_maps_public_precision'; + + public const string EXACT = 'exact'; + public const string DEFAULT = '500m'; + + /** Grid step, in degrees */ + private const array STEPS = [ + '100km' => 2.0, + '50km' => 1.0, + '5km' => 0.1, + '500m' => 0.01, + '50m' => 0.001, + ]; + + /** Zoom beyond which a snapped position would suggest a precision it has not */ + private const array MAX_ZOOMS = [ + '100km' => 7, + '50km' => 8, + '5km' => 11, + '500m' => 14, + '50m' => 17, + ]; + + /** + * Get the preference the precision is stored in + * + * @return array> + */ + public static function getSchema(): array + { + return [ + self::PREF => [ + 'type' => PreferencesSchema::TYPE_STRING, + 'default' => self::DEFAULT, + ], + ]; + } + + /** + * Is given precision known? + * + * @param string $precision Precision + */ + public static function isKnown(string $precision): bool + { + return $precision === self::EXACT || isset(self::STEPS[$precision]); + } + + /** + * Get the configured precision; an unknown value falls back to the default + * + * @param Preferences $preferences Preferences instance + */ + public static function resolve(Preferences $preferences): string + { + $precision = (string)$preferences->getPluginValue(self::PREF); + return self::isKnown($precision) ? $precision : self::DEFAULT; + } + + /** + * Get the grid step of a precision, null for the exact position + * + * @param string $precision Precision + */ + public static function getStep(string $precision): ?float + { + return self::STEPS[$precision] ?? null; + } + + /** + * Get the maximum zoom of a map showing positions with given precision + * + * @param string $precision Precision + */ + public static function getMaxZoom(string $precision): ?int + { + return self::MAX_ZOOMS[$precision] ?? null; + } + + /** + * Snap a coordinate to the grid + * + * @param string|float $value Coordinate + * @param float $step Grid step, in degrees + */ + public static function snap(string|float $value, float $step): string + { + $decimals = max(0, (int)-floor(log10($step))); + $snapped = round((float)$value / $step) * $step; + //avoid a "-0.00" when rounding a small negative value + if ($snapped == 0) { + $snapped = 0.0; + } + return number_format($snapped, $decimals, '.', ''); + } + + /** + * Get precisions as the id => label map a select expects, finest last + * + * @return array + */ + public static function getSelectValues(): array + { + return [ + '100km' => _T('About 100 km', 'maps'), + '50km' => _T('About 50 km', 'maps'), + '5km' => _T('About 5 km', 'maps'), + '500m' => _T('About 500 m', 'maps'), + '50m' => _T('About 50 m', 'maps'), + self::EXACT => _T('Exact position', 'maps'), + ]; + } +} diff --git a/templates/default/maps.html.twig b/templates/default/maps.html.twig index 969c921..b9b0442 100644 --- a/templates/default/maps.html.twig +++ b/templates/default/maps.html.twig @@ -16,7 +16,8 @@ {% block javascripts %} {% include '@PluginGaletteMaps/common_scripts.html.twig' with { page_config: { - markers: list|map(l => {lat: l.lat, lng: l.lng, name: l.name, nickname: l.nickname, company: l.company ?? null}) + markers: list|map(l => {lat: l.lat, lng: l.lng, name: l.name, nickname: l.nickname, company: l.company ?? null}), + max_zoom: max_zoom } } %} {% endblock %} diff --git a/templates/default/maps_preferences.html.twig b/templates/default/maps_preferences.html.twig index 08cfb11..aa91714 100644 --- a/templates/default/maps_preferences.html.twig +++ b/templates/default/maps_preferences.html.twig @@ -8,6 +8,17 @@ {% block content %}
+
+ {% include "components/forms/select.html.twig" with { + id: 'pref_maps_public_precision', + value: precision, + values: precisions, + label: _T("Members positions precision", "maps"), + description: _T("How far from their stored position members may appear on the map. Staff, administrators and members themselves always see the exact position.", "maps")|e, + required: true + } %} +
+
{% include "components/forms/select.html.twig" with { id: 'pref_maps_tiles_provider', diff --git a/tests/GaletteMaps/Controllers/tests/units/MapsController.php b/tests/GaletteMaps/Controllers/tests/units/MapsController.php index 4819ad2..e7bb8a5 100644 --- a/tests/GaletteMaps/Controllers/tests/units/MapsController.php +++ b/tests/GaletteMaps/Controllers/tests/units/MapsController.php @@ -14,6 +14,7 @@ use Galette\Entity\Adherent; use Galette\Tests\GaletteRoutingTestCase; use GaletteMaps\Coordinates; +use GaletteMaps\Precision; use GaletteMaps\TileProviders; /** @@ -390,6 +391,48 @@ public function testPublicMap(): void $this->assertSame([], $this->getMapsConfig((string)$test_response->getBody())['markers']); } + /** + * Snapped positions cap the map zoom; exact ones do not + */ + public function testMapPrecision(): void + { + $member_one = $this->getMemberOne(); + $update = $this->zdb->update(Adherent::TABLE); + $update->set([ + 'bool_display_info' => new \Laminas\Db\Sql\Expression('true'), + 'bool_exempt_adh' => new \Laminas\Db\Sql\Expression('true'), + ])->where([Adherent::PK => $member_one->id]); + $this->zdb->execute($update); + (new Coordinates($this->zdb, $this->login))->set($member_one->id, 50.362038, 3.472998); + $this->preferences->pref_publicpages_visibility_generic = \Galette\Core\Preferences::PUBLIC_PAGES_VISIBILITY_PUBLIC; + $request = $this->createRequest('maps_map'); + + //visitor, default precision + $config = $this->getMapsConfig((string)$this->app->handle($request)->getBody()); + $this->assertSame(['50.36', '3.47'], [$config['markers'][0]['lat'], $config['markers'][0]['lng']]); + $this->assertSame(Precision::getMaxZoom(Precision::DEFAULT), $config['max_zoom']); + + try { + $this->assertTrue($this->preferences->setValue(Precision::PREF, '100km', $this->login)); + $config = $this->getMapsConfig((string)$this->app->handle($request)->getBody()); + $this->assertSame(['50', '4'], [$config['markers'][0]['lat'], $config['markers'][0]['lng']]); + $this->assertSame(Precision::getMaxZoom('100km'), $config['max_zoom']); + + $this->assertTrue($this->preferences->setValue(Precision::PREF, Precision::EXACT, $this->login)); + $config = $this->getMapsConfig((string)$this->app->handle($request)->getBody()); + $this->assertSame('50.362038', $config['markers'][0]['lat']); + $this->assertNull($config['max_zoom']); + } finally { + $this->assertTrue($this->preferences->setValue(Precision::PREF, Precision::DEFAULT, $this->login)); + } + + //member itself: exact, no cap + $this->logMember($this->dataAdherentOne()); + $config = $this->getMapsConfig((string)$this->app->handle($request)->getBody()); + $this->assertSame('50.362038', $config['markers'][0]['lat']); + $this->assertNull($config['max_zoom']); + } + /** * Own localization page, with and without coordinates */ @@ -461,10 +504,16 @@ public function testStorePreferences(): void }; try { - $store([TileProviders::PREF_PROVIDER => 'osm']); + $store([TileProviders::PREF_PROVIDER => 'osm', Precision::PREF => '50km']); $this->expectFlashData(['success_detected' => ['Maps settings have been saved.']]); $this->preferences->load(); $this->assertSame('osm', TileProviders::resolve($this->preferences)['id']); + $this->assertSame('50km', Precision::resolve($this->preferences)); + + $store([TileProviders::PREF_PROVIDER => 'osm', Precision::PREF => '1km']); + $this->expectFlashData(['error_detected' => ['Unknown position precision.']]); + $this->preferences->load(); + $this->assertSame('50km', Precision::resolve($this->preferences)); //own values need an address $store([TileProviders::PREF_PROVIDER => TileProviders::CUSTOM, TileProviders::PREF_URL => ' ']); @@ -499,7 +548,7 @@ public function testStorePreferences(): void $this->preferences->load(); $this->assertSame(17, TileProviders::resolve($this->preferences)['maxzoom']); } finally { - foreach (TileProviders::getSchema() as $name => $schema) { + foreach (TileProviders::getSchema() + Precision::getSchema() as $name => $schema) { $this->preferences->setValue($name, $schema['default'], $this->login); } } diff --git a/tests/GaletteMaps/tests/units/Coordinates.php b/tests/GaletteMaps/tests/units/Coordinates.php index 9713d80..66c0283 100644 --- a/tests/GaletteMaps/tests/units/Coordinates.php +++ b/tests/GaletteMaps/tests/units/Coordinates.php @@ -60,6 +60,7 @@ public function testCoordinates(): void 'id_adh' => $member->id, 'lat' => '50.362038', 'lng' => '3.472998', + 'approximate' => false, 'name' => 'DURAND René', 'nickname' => 'ubertrand' ] @@ -168,4 +169,75 @@ public function testSetMissingMember(): void //logged by Db $this->expectLogEntry(\Analog\Analog::ERROR, 'Query error: INSERT INTO'); } + + /** + * Sort positions by member + * + * @param array $positions Positions + * + * @return array + */ + private function sorted(array $positions): array + { + ksort($positions); + return $positions; + } + + /** + * Get listed positions, by member + * + * @param ?float $step Grid step + * + * @return array + */ + private function listedPositions(?float $step): array + { + $positions = []; + foreach ((new \GaletteMaps\Coordinates($this->zdb, $this->login))->listVisible($step) as $row) { + $positions[$row['id_adh']] = [$row['lat'], $row['lng'], $row['approximate']]; + } + //list has no order + ksort($positions); + return $positions; + } + + /** + * Positions are snapped for everyone but staff, administrators and the member itself + */ + public function testListPrecision(): void + { + $member_one = $this->getMemberOne(); + $member_two = $this->getMemberTwo(); + $coords = new \GaletteMaps\Coordinates($this->zdb, $this->login); + $coords->set($member_one->id, 50.362038, 3.472998); + $coords->set($member_two->id, 48.856614, 2.352222); + $this->setVisibility($member_one->id, active: true, public: true, uptodate: true); + $this->setVisibility($member_two->id, active: true, public: true, uptodate: true); + + $exact_one = ['50.362038', '3.472998', false]; + $exact_two = ['48.856614', '2.352222', false]; + $snapped_one = ['50.36', '3.47', true]; + $snapped_two = ['48.86', '2.35', true]; + + //visitor + $this->assertSame($this->sorted([$member_one->id => $snapped_one, $member_two->id => $snapped_two]), $this->listedPositions(0.01)); + //exact positions required + $this->assertSame($this->sorted([$member_one->id => $exact_one, $member_two->id => $exact_two]), $this->listedPositions(null)); + + //member sees its own position as is + $this->assertTrue($this->login->login($this->dataAdherentOne()['login_adh'], $this->dataAdherentOne()['mdp_adh'])); + $this->assertSame($this->sorted([$member_one->id => $exact_one, $member_two->id => $snapped_two]), $this->listedPositions(0.01)); + $this->login->logout(); + + //staff members see exact positions + $this->getStaffMember($member_one); + $this->assertTrue($this->login->login($this->dataAdherentOne()['login_adh'], $this->dataAdherentOne()['mdp_adh'])); + $this->assertTrue($this->login->isStaff()); + $this->assertSame($this->sorted([$member_one->id => $exact_one, $member_two->id => $exact_two]), $this->listedPositions(0.01)); + $this->login->logout(); + + $this->logSuperAdmin(); + $this->assertSame($this->sorted([$member_one->id => $exact_one, $member_two->id => $exact_two]), $this->listedPositions(0.01)); + $this->login->logout(); + } } diff --git a/tests/GaletteMaps/tests/units/PluginGaletteMaps.php b/tests/GaletteMaps/tests/units/PluginGaletteMaps.php index 37047da..90caf63 100644 --- a/tests/GaletteMaps/tests/units/PluginGaletteMaps.php +++ b/tests/GaletteMaps/tests/units/PluginGaletteMaps.php @@ -120,4 +120,18 @@ public function testIsInstalled(): void { $this->assertTrue($this->getPlugin()->isInstalled()); } + + /** + * Plugin declares background map and precision preferences + */ + public function testPreferences(): void + { + $this->assertSame( + array_merge( + array_keys(\GaletteMaps\TileProviders::getSchema()), + [\GaletteMaps\Precision::PREF] + ), + array_keys($this->getPlugin()->getPreferences()) + ); + } } diff --git a/tests/GaletteMaps/tests/units/Precision.php b/tests/GaletteMaps/tests/units/Precision.php new file mode 100644 index 0000000..bb2d191 --- /dev/null +++ b/tests/GaletteMaps/tests/units/Precision.php @@ -0,0 +1,76 @@ + + */ +class Precision extends GaletteTestCase +{ + /** + * Coordinates are snapped to the grid of the precision + */ + public function testSnap(): void + { + $this->assertSame('50.36', MapsPrecision::snap('50.362038', 0.01)); + $this->assertSame('3.47', MapsPrecision::snap(3.472998, 0.01)); + $this->assertSame('50.4', MapsPrecision::snap('50.362038', 0.1)); + $this->assertSame('50.362', MapsPrecision::snap('50.362038', 0.001)); + $this->assertSame('50', MapsPrecision::snap('50.362038', 1.0)); + //2 degrees steps: 50.36 is closer to 50 than to 52 + $this->assertSame('50', MapsPrecision::snap('50.362038', 2.0)); + $this->assertSame('52', MapsPrecision::snap('51.1', 2.0)); + $this->assertSame('-0.78', MapsPrecision::snap('-0.780029', 0.01)); + //no negative zero + $this->assertSame('0.00', MapsPrecision::snap('-0.001', 0.01)); + $this->assertSame('0', MapsPrecision::snap('-0.4', 1.0)); + } + + /** + * Every precision offered has a step and a zoom, except exact position + */ + public function testChoices(): void + { + $choices = array_keys(MapsPrecision::getSelectValues()); + $this->assertSame(['100km', '50km', '5km', '500m', '50m', MapsPrecision::EXACT], $choices); + foreach ($choices as $precision) { + $this->assertTrue(MapsPrecision::isKnown($precision)); + if ($precision === MapsPrecision::EXACT) { + $this->assertNull(MapsPrecision::getStep($precision)); + $this->assertNull(MapsPrecision::getMaxZoom($precision)); + } else { + $this->assertGreaterThan(0, MapsPrecision::getStep($precision)); + $this->assertGreaterThan(0, MapsPrecision::getMaxZoom($precision)); + } + } + $this->assertFalse(MapsPrecision::isKnown('1km')); + } + + /** + * Default precision, and fallback for an unknown stored value + */ + public function testResolve(): void + { + $this->assertSame(MapsPrecision::DEFAULT, MapsPrecision::resolve($this->preferences)); + + $this->assertTrue($this->preferences->setValue(MapsPrecision::PREF, 'retired', $this->login)); + $this->assertSame(MapsPrecision::DEFAULT, MapsPrecision::resolve($this->preferences)); + + $this->assertTrue($this->preferences->setValue(MapsPrecision::PREF, MapsPrecision::EXACT, $this->login)); + $this->assertSame(MapsPrecision::EXACT, MapsPrecision::resolve($this->preferences)); + $this->assertTrue($this->preferences->setValue(MapsPrecision::PREF, MapsPrecision::DEFAULT, $this->login)); + } +} diff --git a/webroot/galette_maps.js b/webroot/galette_maps.js index 3219647..4df37b6 100644 --- a/webroot/galette_maps.js +++ b/webroot/galette_maps.js @@ -280,9 +280,16 @@ marker.bindPopup(content); group.addLayer(marker); }); + //snapped positions must not look like street addresses + if (config.max_zoom) { + map.setMaxZoom(Math.min(map.getMaxZoom(), config.max_zoom)); + } map.addLayer(group); if (config.markers.length > 0) { - map.fitBounds(group.getBounds(), {padding: [50, 50], maxZoom: 12}); + map.fitBounds(group.getBounds(), { + padding: [50, 50], + maxZoom: config.max_zoom ? Math.min(12, config.max_zoom) : 12 + }); } }