goodmap 1.1.4__py3-none-any.whl → 1.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- goodmap/clustering.py +75 -0
- goodmap/core_api.py +88 -3
- goodmap/templates/map.html +1 -0
- {goodmap-1.1.4.dist-info → goodmap-1.1.6.dist-info}/METADATA +11 -7
- {goodmap-1.1.4.dist-info → goodmap-1.1.6.dist-info}/RECORD +7 -6
- {goodmap-1.1.4.dist-info → goodmap-1.1.6.dist-info}/WHEEL +0 -0
- {goodmap-1.1.4.dist-info → goodmap-1.1.6.dist-info}/licenses/LICENSE.md +0 -0
goodmap/clustering.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from scipy.spatial import KDTree
|
|
5
|
+
|
|
6
|
+
# Maximum distance to consider a point-cluster match (accounts for floating point errors)
|
|
7
|
+
DISTANCE_THRESHOLD = 1e-8
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def map_clustering_data_to_proper_lazy_loading_object(input_array):
|
|
13
|
+
response_array = []
|
|
14
|
+
for item in input_array:
|
|
15
|
+
if item["count"] == 1:
|
|
16
|
+
response_object = {
|
|
17
|
+
"position": [item["longitude"], item["latitude"]],
|
|
18
|
+
"uuid": item["uuid"],
|
|
19
|
+
"cluster_uuid": None,
|
|
20
|
+
"cluster_count": None,
|
|
21
|
+
"type": "point",
|
|
22
|
+
}
|
|
23
|
+
response_array.append(response_object)
|
|
24
|
+
continue
|
|
25
|
+
response_object = {
|
|
26
|
+
"position": [item["longitude"], item["latitude"]],
|
|
27
|
+
"uuid": None,
|
|
28
|
+
"cluster_uuid": str(uuid.uuid4()),
|
|
29
|
+
"cluster_count": item["count"],
|
|
30
|
+
"type": "cluster",
|
|
31
|
+
}
|
|
32
|
+
response_array.append(response_object)
|
|
33
|
+
return response_array
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Since there can be some floating point errors
|
|
37
|
+
# we need to check if the distance is close enough to 0
|
|
38
|
+
def match_clusters_uuids(points, clusters):
|
|
39
|
+
"""
|
|
40
|
+
Match single-point clusters to their original point UUIDs.
|
|
41
|
+
|
|
42
|
+
For clusters containing exactly one point, this function attempts to match the cluster
|
|
43
|
+
coordinates back to the original point to retrieve its UUID. The 'uuid' key is optional
|
|
44
|
+
and will only be present in single-point clusters where a matching point is found.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
points: List of point dicts with 'position' and 'uuid' keys
|
|
48
|
+
clusters: List of cluster dicts with 'longitude', 'latitude', and 'count' keys.
|
|
49
|
+
For single-point clusters (count=1), a 'uuid' key will be added if a
|
|
50
|
+
matching point is found (modified in place)
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The modified clusters list with 'uuid' keys added to matched single-point clusters
|
|
54
|
+
"""
|
|
55
|
+
points_coords = [(point["position"][0], point["position"][1]) for point in points]
|
|
56
|
+
tree = KDTree(points_coords)
|
|
57
|
+
for cluster in clusters:
|
|
58
|
+
if cluster["count"] == 1:
|
|
59
|
+
cluster_coords = (cluster["longitude"], cluster["latitude"])
|
|
60
|
+
dist, idx = tree.query(cluster_coords)
|
|
61
|
+
if dist < DISTANCE_THRESHOLD:
|
|
62
|
+
closest_point = points[idx]
|
|
63
|
+
cluster["uuid"] = closest_point["uuid"]
|
|
64
|
+
else:
|
|
65
|
+
# Log warning when no match is found - indicates data inconsistency
|
|
66
|
+
logger.warning(
|
|
67
|
+
"No matching UUID found for cluster at coordinates (%f, %f). "
|
|
68
|
+
"Distance to nearest point: %f (threshold: %f)",
|
|
69
|
+
cluster["longitude"],
|
|
70
|
+
cluster["latitude"],
|
|
71
|
+
dist,
|
|
72
|
+
DISTANCE_THRESHOLD,
|
|
73
|
+
)
|
|
74
|
+
cluster["uuid"] = None
|
|
75
|
+
return clusters
|
goodmap/core_api.py
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
import importlib.metadata
|
|
2
|
+
import logging
|
|
2
3
|
import uuid
|
|
3
4
|
|
|
5
|
+
import numpy
|
|
6
|
+
import pysupercluster
|
|
4
7
|
from flask import Blueprint, jsonify, make_response, request
|
|
5
8
|
from flask_babel import gettext
|
|
6
9
|
from flask_restx import Api, Resource, fields
|
|
7
10
|
from platzky.config import LanguagesMapping
|
|
8
11
|
|
|
12
|
+
from goodmap.clustering import (
|
|
13
|
+
map_clustering_data_to_proper_lazy_loading_object,
|
|
14
|
+
match_clusters_uuids,
|
|
15
|
+
)
|
|
9
16
|
from goodmap.formatter import prepare_pin
|
|
10
17
|
|
|
18
|
+
# SuperCluster configuration constants
|
|
19
|
+
MIN_ZOOM = 0
|
|
20
|
+
MAX_ZOOM = 16
|
|
21
|
+
CLUSTER_RADIUS = 200
|
|
22
|
+
CLUSTER_EXTENT = 512
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
11
26
|
|
|
12
27
|
def make_tuple_translation(keys_to_translate):
|
|
13
28
|
return [(x, gettext(x)) for x in keys_to_translate]
|
|
@@ -22,6 +37,27 @@ def get_or_none(data, *keys):
|
|
|
22
37
|
return data
|
|
23
38
|
|
|
24
39
|
|
|
40
|
+
def get_locations_from_request(database, request_args, as_basic_info=False):
|
|
41
|
+
"""
|
|
42
|
+
Shared helper to fetch locations from database based on request arguments.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
database: Database instance
|
|
46
|
+
request_args: Request arguments (flask.request.args)
|
|
47
|
+
as_basic_info: If True, returns list of basic_info dicts, otherwise returns Location objects
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List of locations (either as objects or basic_info dicts)
|
|
51
|
+
"""
|
|
52
|
+
query_params = request_args.to_dict(flat=False)
|
|
53
|
+
all_locations = database.get_locations(query_params)
|
|
54
|
+
|
|
55
|
+
if as_basic_info:
|
|
56
|
+
return [x.basic_info() for x in all_locations]
|
|
57
|
+
|
|
58
|
+
return all_locations
|
|
59
|
+
|
|
60
|
+
|
|
25
61
|
def core_pages(
|
|
26
62
|
database,
|
|
27
63
|
languages: LanguagesMapping,
|
|
@@ -106,9 +142,58 @@ def core_pages(
|
|
|
106
142
|
"""
|
|
107
143
|
Shows list of locations with uuid and position
|
|
108
144
|
"""
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
145
|
+
locations = get_locations_from_request(database, request.args, as_basic_info=True)
|
|
146
|
+
return jsonify(locations)
|
|
147
|
+
|
|
148
|
+
@core_api.route("/locations-clustered")
|
|
149
|
+
class GetLocationsClustered(Resource):
|
|
150
|
+
def get(self):
|
|
151
|
+
"""
|
|
152
|
+
Shows list of locations with uuid, position and clusters
|
|
153
|
+
"""
|
|
154
|
+
try:
|
|
155
|
+
query_params = request.args.to_dict(flat=False)
|
|
156
|
+
zoom = int(query_params.get("zoom", [7])[0])
|
|
157
|
+
|
|
158
|
+
# Validate zoom level (aligned with SuperCluster min_zoom/max_zoom)
|
|
159
|
+
if not MIN_ZOOM <= zoom <= MAX_ZOOM:
|
|
160
|
+
return make_response(
|
|
161
|
+
jsonify({"message": f"Zoom must be between {MIN_ZOOM} and {MAX_ZOOM}"}),
|
|
162
|
+
400,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
points = get_locations_from_request(database, request.args, as_basic_info=True)
|
|
166
|
+
if not points:
|
|
167
|
+
return jsonify([])
|
|
168
|
+
|
|
169
|
+
points_numpy = numpy.array(
|
|
170
|
+
[(point["position"][0], point["position"][1]) for point in points]
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
index = pysupercluster.SuperCluster(
|
|
174
|
+
points_numpy,
|
|
175
|
+
min_zoom=MIN_ZOOM,
|
|
176
|
+
max_zoom=MAX_ZOOM,
|
|
177
|
+
radius=CLUSTER_RADIUS,
|
|
178
|
+
extent=CLUSTER_EXTENT,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
clusters = index.getClusters(
|
|
182
|
+
top_left=(-180.0, 90.0),
|
|
183
|
+
bottom_right=(180.0, -90.0),
|
|
184
|
+
zoom=zoom,
|
|
185
|
+
)
|
|
186
|
+
clusters = match_clusters_uuids(points, clusters)
|
|
187
|
+
|
|
188
|
+
return jsonify(map_clustering_data_to_proper_lazy_loading_object(clusters))
|
|
189
|
+
except ValueError as e:
|
|
190
|
+
logger.warning("Invalid parameter in clustering request: %s", e)
|
|
191
|
+
return make_response(jsonify({"message": "Invalid parameters provided"}), 400)
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.error("Clustering operation failed: %s", e, exc_info=True)
|
|
194
|
+
return make_response(
|
|
195
|
+
jsonify({"message": "An error occurred during clustering"}), 500
|
|
196
|
+
)
|
|
112
197
|
|
|
113
198
|
@core_api.route("/location/<location_id>")
|
|
114
199
|
class GetLocation(Resource):
|
goodmap/templates/map.html
CHANGED
|
@@ -116,6 +116,7 @@ window.PRIMARY_COLOR = "{{ primary_color }}";
|
|
|
116
116
|
window.SHOW_SUGGEST_NEW_POINT_BUTTON = {{ feature_flags.SHOW_SUGGEST_NEW_POINT_BUTTON | default(false) | tojson }};
|
|
117
117
|
window.SHOW_SEARCH_BAR = {{ feature_flags.SHOW_SEARCH_BAR | default(false) | tojson }};
|
|
118
118
|
window.USE_LAZY_LOADING = {{ feature_flags.USE_LAZY_LOADING | default(false) | tojson }};
|
|
119
|
+
window.USE_SERVER_SIDE_CLUSTERING = {{ feature_flags.USE_SERVER_SIDE_CLUSTERING | default(false) | tojson }};
|
|
119
120
|
window.SHOW_ACCESSIBILITY_TABLE = {{ feature_flags.SHOW_ACCESSIBILITY_TABLE | default(false) | tojson }};
|
|
120
121
|
window.FEATURE_FLAGS = {{ feature_flags | tojson }};
|
|
121
122
|
</script>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: goodmap
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.6
|
|
4
4
|
Summary: Map engine to serve all the people :)
|
|
5
5
|
License-File: LICENSE.md
|
|
6
6
|
Author: Krzysztof Kolodzinski
|
|
@@ -26,8 +26,11 @@ Requires-Dist: gql (>=3.4.0,<4.0.0)
|
|
|
26
26
|
Requires-Dist: gunicorn (>=20.1.0,<21.0.0)
|
|
27
27
|
Requires-Dist: humanize (>=4.6.0,<5.0.0)
|
|
28
28
|
Requires-Dist: myst-parser (>=4.0.0,<5.0.0) ; extra == "docs"
|
|
29
|
+
Requires-Dist: numpy (>=2.2.0,<3.0.0)
|
|
29
30
|
Requires-Dist: platzky (>=1.0.0,<2.0.0)
|
|
30
31
|
Requires-Dist: pydantic (>=2.7.1,<3.0.0)
|
|
32
|
+
Requires-Dist: pysupercluster-problematy (>=0.7.8,<0.8.0)
|
|
33
|
+
Requires-Dist: scipy (>=1.15.1,<2.0.0)
|
|
31
34
|
Requires-Dist: sphinx (>=8.0.0,<9.0.0) ; extra == "docs"
|
|
32
35
|
Requires-Dist: sphinx-rtd-theme (>=3.0.0,<4.0.0) ; extra == "docs"
|
|
33
36
|
Requires-Dist: tomli (>=2.0.0,<3.0.0) ; extra == "docs"
|
|
@@ -82,9 +85,9 @@ poetry run <command>
|
|
|
82
85
|
|
|
83
86
|
### TL;DR
|
|
84
87
|
If you don't want to go through all the configuration, e.g. you just simply want to test if everything works,
|
|
85
|
-
you can simply run app with test dataset provided in `
|
|
88
|
+
you can simply run app with test dataset provided in `examples` directory:
|
|
86
89
|
|
|
87
|
-
> poetry run flask --app 'goodmap.goodmap:create_app(config_path="./
|
|
90
|
+
> poetry run flask --app 'goodmap.goodmap:create_app(config_path="./examples/e2e_test_config.yml")' run
|
|
88
91
|
|
|
89
92
|
### Configuration
|
|
90
93
|
|
|
@@ -102,7 +105,7 @@ Afterwards run it with:
|
|
|
102
105
|
|
|
103
106
|
## Database
|
|
104
107
|
|
|
105
|
-
The database is stored in JSON, in the `map` section. For an example database see `
|
|
108
|
+
The database is stored in JSON, in the `map` section. For an example database see `examples/e2e_test_data.json`. The first subsection `data` consists of the actual datapoints, representing points on a map.
|
|
106
109
|
|
|
107
110
|
Datapoints have fields. The next subsections define special types of fields:
|
|
108
111
|
- `obligatory_fields` - here are explicitely stated all the fields that the application assumes are presnt in all datapoints. E.g.
|
|
@@ -130,9 +133,10 @@ You can define the fields in all these subsections. Besides these types of field
|
|
|
130
133
|
|
|
131
134
|
## Examples
|
|
132
135
|
|
|
133
|
-
You can find examples of working configuration and database in `
|
|
134
|
-
- `e2e_test_config.yml`
|
|
135
|
-
- `e2e_test_data.json`
|
|
136
|
+
You can find examples of working configuration and database in `examples/` directory:
|
|
137
|
+
- `e2e_test_config.yml` - Basic configuration example
|
|
138
|
+
- `e2e_test_data.json` - Example database with sample location data
|
|
139
|
+
- `mongo_e2e_test_config.yml` - MongoDB configuration example
|
|
136
140
|
|
|
137
141
|
|
|
138
142
|
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
goodmap/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
2
|
+
goodmap/clustering.py,sha256=ULB-fPNOUDblgpBK4vzuo0o2yqIcvG84F3R6Za2X_l4,2905
|
|
2
3
|
goodmap/config.py,sha256=sseE4sceFB8OBuFsrRpQJvjv0Vg5KFdkzihBX_hbE2c,1313
|
|
3
4
|
goodmap/core.py,sha256=rzMhOIYnR1jxTX6uHQJKIPLYxdUm4_v2d6LrtHtJpHU,1465
|
|
4
|
-
goodmap/core_api.py,sha256=
|
|
5
|
+
goodmap/core_api.py,sha256=BkulLV2cZvws0CEAHNu4Svyptyol5TN0uLPW2Iw10NA,16294
|
|
5
6
|
goodmap/data_models/location.py,sha256=oPsxTiPSL0cABlQH2YjFGgioO-mROujtQvnkNiQQR-s,1149
|
|
6
7
|
goodmap/data_validator.py,sha256=lBmVAPxvSmEOdUGeVYSjUvVVmKfPyq4CWoHfczTtEMM,4090
|
|
7
8
|
goodmap/db.py,sha256=2yZIQO6KbN6y21gxwO0xmxXIcXXrk7wqd7mgSOAefJw,47491
|
|
8
9
|
goodmap/formatter.py,sha256=VlUHcK1HtM_IEU0VE3S5TOkZLVheMdakvUeW2tCKdq0,783
|
|
9
10
|
goodmap/goodmap.py,sha256=q6okPopWBH6jDkKJcGDegebaapHLFUVilJ3p3aKi97k,2960
|
|
10
11
|
goodmap/templates/goodmap-admin.html,sha256=39PJ1drk_xdkyzXgPZZNXYq9gA9oTVeR8hsgeae6E0g,35614
|
|
11
|
-
goodmap/templates/map.html,sha256=
|
|
12
|
-
goodmap-1.1.
|
|
13
|
-
goodmap-1.1.
|
|
14
|
-
goodmap-1.1.
|
|
15
|
-
goodmap-1.1.
|
|
12
|
+
goodmap/templates/map.html,sha256=aEIL6M7AlBZ34asV5R1syKq9IA1tBZNhiBMA9ovco7I,4105
|
|
13
|
+
goodmap-1.1.6.dist-info/METADATA,sha256=keFFJTCCtpImzKzTNRwgbbuGWrHT27MFAfYSjjoLCvg,5868
|
|
14
|
+
goodmap-1.1.6.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
15
|
+
goodmap-1.1.6.dist-info/licenses/LICENSE.md,sha256=nkCQOR7uheLRvHRfXmwx9LhBnMcPeBU9d4ebLojDiQU,1067
|
|
16
|
+
goodmap-1.1.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|