Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions lib/GaletteMaps/Controllers/MapsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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 += [
Expand Down
26 changes: 16 additions & 10 deletions lib/GaletteMaps/Coordinates.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, array{id_adh: int, lat: string, lng: string, name: string, nickname: ?string, company?: string}>
* @param ?float $step Grid step positions are snapped to, in degrees; null for exact positions
*
* @return array<int, array{id_adh: int, lat: string, lng: string, approximate: bool, name: string, nickname: ?string, company?: string}>
*/
public function listVisible(): array
public function listVisible(?float $step = null): array
{
$select = $this->zdb->select($this->getTableName(), 'c');
$select->join(
Expand All @@ -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();
Expand All @@ -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']
];
Expand Down
2 changes: 1 addition & 1 deletion lib/GaletteMaps/PluginGaletteMaps.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class PluginGaletteMaps extends GalettePlugin implements InstallableInterface, M
*/
public function getPreferences(): array
{
return TileProviders::getSchema();
return TileProviders::getSchema() + Precision::getSchema();
}

/**
Expand Down
139 changes: 139 additions & 0 deletions lib/GaletteMaps/Precision.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

/**
* This file is part of Galette Maps plugin (https://galette.eu).
* SPDX-FileCopyrightText: Copyright © 2012-2026 The Galette Team
* SPDX-License-Identifier: GPL-3.0-or-later
*/

declare(strict_types=1);

namespace GaletteMaps;

use Galette\Core\Preferences;
use Galette\Core\PreferencesSchema;

/**
* Precision of members positions on the map
*
* Positions are snapped to a grid; the displayed marker is at most half a
* step away from the stored position. Staff, administrators and members
* themselves always get the exact position.
*
* @author Johan Cwiklinski <johan@x-tnd.be>
*/
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<string, array<string, mixed>>
*/
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<string, string>
*/
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'),
];
}
}
3 changes: 2 additions & 1 deletion templates/default/maps.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
11 changes: 11 additions & 0 deletions templates/default/maps_preferences.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@

{% block content %}
<form id="maps_settings" action="{{ url_for("maps_store_preferences") }}" method="post" class="ui form">
<div class="ui segment">
{% 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
} %}
</div>

<div class="ui segment">
{% include "components/forms/select.html.twig" with {
id: 'pref_maps_tiles_provider',
Expand Down
53 changes: 51 additions & 2 deletions tests/GaletteMaps/Controllers/tests/units/MapsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Galette\Entity\Adherent;
use Galette\Tests\GaletteRoutingTestCase;
use GaletteMaps\Coordinates;
use GaletteMaps\Precision;
use GaletteMaps\TileProviders;

/**
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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 => ' ']);
Expand Down Expand Up @@ -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);
}
}
Expand Down
Loading
Loading