Compare commits
57 Commits
v0.1.0
...
f6d0cd5360
Author | SHA1 | Date | |
---|---|---|---|
f6d0cd5360 | |||
7a18830e99 | |||
ba14a0279e | |||
5a2c61d343 | |||
5e27dd9d79 | |||
d92001faaf | |||
73f0dc8361 | |||
05092e55f1 | |||
83be4b7616 | |||
8a9ec6b4d8 | |||
8c3145dfc9 | |||
2bf38119d6 | |||
ca711c614f | |||
357edf3000 | |||
444c47e3a4 | |||
da6ab207d9 | |||
c15e257dea | |||
5a698dd02c | |||
7e4a4b3dc7 | |||
84e5902436 | |||
81330e5eb3 | |||
9002483036 | |||
0271c3d7a7 | |||
4fd1272ea4 | |||
6bedd04a57 | |||
d31ca9f81f | |||
f6e396e54b | |||
d4de945df8 | |||
6f54522b8c | |||
080ecd28ae | |||
21706ea7e6 | |||
83c1533e78 | |||
1f4815c991 | |||
699737bc40 | |||
1240f86d6e | |||
2a5023df4b | |||
581644a108 | |||
f48dcf80c2 | |||
757773f433 | |||
25c2b6b0d1 | |||
b527318eec | |||
f2943eb3ad | |||
2ac8499dfb | |||
4a904c3d3c | |||
978cae290b | |||
bab6cfe74e | |||
71abeabbd2 | |||
f64e60ddf6 | |||
d6f723bee1 | |||
a3243431e0 | |||
3605408ebb | |||
d992b62533 | |||
e78bee4597 | |||
d186a51a87 | |||
4baf045c8c | |||
3f1fe463bf | |||
d58ef2562d |
@@ -28,7 +28,7 @@ jobs:
|
|||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: pipenv run pytest src --html=report.html --self-contained-html --log-cli-level=INFO
|
run: pipenv run pytest src --html=report.html --self-contained-html --log-cli-level=DEBUG
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|
||||||
- name: Upload HTML report
|
- name: Upload HTML report
|
||||||
|
30
LICENSE.md
Normal file
30
LICENSE.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# License
|
||||||
|
|
||||||
|
## Proprietary License
|
||||||
|
|
||||||
|
All code and resources in this repository are the property of AnyDev. The software and related documentation are provided solely for use with services provided by AnyDev. Redistribution, modification, or use of this software outside of its intended service is strictly prohibited without explicit permission.
|
||||||
|
|
||||||
|
### Copyright © 2024 AnyDev
|
||||||
|
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
### Restrictions
|
||||||
|
|
||||||
|
- You may not modify, distribute, copy, or reverse engineer any part of this codebase.
|
||||||
|
- This software is licensed for use solely in conjunction with services provided by AnyDev.
|
||||||
|
- Any commercial use of this software is strictly prohibited without explicit written consent from AnyDev.
|
||||||
|
|
||||||
|
## Third-Party Dependencies
|
||||||
|
|
||||||
|
This project uses third-party dependencies, which are subject to their respective licenses.
|
||||||
|
|
||||||
|
- Python backend dependencies: fastapi, pydantic, numpy, shapely, etc. – Licensed under their respective licenses.
|
||||||
|
- Flutter frontend dependencies: Cupertino Icons, sliding_up_panel, http, etc. – Licensed under their respective licenses.
|
||||||
|
|
||||||
|
Please refer to each project's documentation for the specific terms and conditions.
|
||||||
|
|
||||||
|
## OpenStreetMap Data Usage
|
||||||
|
|
||||||
|
This project uses data derived from **OpenStreetMap**. OpenStreetMap data is available under the [Open Database License (ODbL)](https://www.openstreetmap.org/copyright). We comply with the ODbL license, and some of the data displayed in the service may be derived from OpenStreetMap sources. We do not redistribute raw OpenStreetMap data; instead, it is processed and transformed before being used in our services.
|
||||||
|
|
||||||
|
More information about OpenStreetMap data usage can be found [here](https://www.openstreetmap.org/copyright).
|
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -1,6 +1,9 @@
|
|||||||
# osm-cache
|
# osm-cache
|
||||||
cache_XML/
|
cache_XML/
|
||||||
|
|
||||||
|
# secrets
|
||||||
|
*secrets.yaml
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
@@ -445,7 +445,9 @@ disable=raw-checker-failed,
|
|||||||
logging-fstring-interpolation,
|
logging-fstring-interpolation,
|
||||||
duplicate-code,
|
duplicate-code,
|
||||||
relative-beyond-top-level,
|
relative-beyond-top-level,
|
||||||
invalid-name
|
invalid-name,
|
||||||
|
too-many-arguments,
|
||||||
|
too-many-positional-arguments
|
||||||
|
|
||||||
# Enable the message, report, category or checker with the given id(s). You can
|
# Enable the message, report, category or checker with the given id(s). You can
|
||||||
# either give multiple identifier separated by comma (,) or put this option
|
# either give multiple identifier separated by comma (,) or put this option
|
||||||
|
363
backend/landmarks.json
Normal file
363
backend/landmarks.json
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Chinatown",
|
||||||
|
"type": "shopping",
|
||||||
|
"location": [
|
||||||
|
45.7554934,
|
||||||
|
4.8444852
|
||||||
|
],
|
||||||
|
"osm_type": "way",
|
||||||
|
"osm_id": 996515596,
|
||||||
|
"attractiveness": 129,
|
||||||
|
"n_tags": 0,
|
||||||
|
"image_url": null,
|
||||||
|
"website_url": null,
|
||||||
|
"wiki_url": null,
|
||||||
|
"keywords": {},
|
||||||
|
"description": null,
|
||||||
|
"duration": 30,
|
||||||
|
"name_en": null,
|
||||||
|
"uuid": "285d159c-68ee-4b37-8d71-f27ee3d38b02",
|
||||||
|
"must_do": false,
|
||||||
|
"must_avoid": false,
|
||||||
|
"is_secondary": false,
|
||||||
|
"time_to_reach_next": 0,
|
||||||
|
"next_uuid": null,
|
||||||
|
"is_viewpoint": false,
|
||||||
|
"is_place_of_worship": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Galeries Lafayette",
|
||||||
|
"type": "shopping",
|
||||||
|
"location": [
|
||||||
|
45.7627107,
|
||||||
|
4.8556833
|
||||||
|
],
|
||||||
|
"osm_type": "way",
|
||||||
|
"osm_id": 1069872743,
|
||||||
|
"attractiveness": 197,
|
||||||
|
"n_tags": 11,
|
||||||
|
"image_url": null,
|
||||||
|
"website_url": "http://www.galerieslafayette.com/",
|
||||||
|
"wiki_url": null,
|
||||||
|
"keywords": null,
|
||||||
|
"description": null,
|
||||||
|
"duration": 30,
|
||||||
|
"name_en": null,
|
||||||
|
"uuid": "28f1bc30-10d3-4944-8861-0ed9abca012d",
|
||||||
|
"must_do": false,
|
||||||
|
"must_avoid": false,
|
||||||
|
"is_secondary": false,
|
||||||
|
"time_to_reach_next": 0,
|
||||||
|
"next_uuid": null,
|
||||||
|
"is_viewpoint": false,
|
||||||
|
"is_place_of_worship": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Muji",
|
||||||
|
"type": "shopping",
|
||||||
|
"location": [
|
||||||
|
45.7615971,
|
||||||
|
4.8543781
|
||||||
|
],
|
||||||
|
"osm_type": "way",
|
||||||
|
"osm_id": 1044165817,
|
||||||
|
"attractiveness": 259,
|
||||||
|
"n_tags": 14,
|
||||||
|
"image_url": null,
|
||||||
|
"website_url": "https://www.muji.com/fr/",
|
||||||
|
"wiki_url": null,
|
||||||
|
"keywords": null,
|
||||||
|
"description": null,
|
||||||
|
"duration": 30,
|
||||||
|
"name_en": "Muji",
|
||||||
|
"uuid": "957f86a5-6c00-41a2-815d-d6f739052be4",
|
||||||
|
"must_do": false,
|
||||||
|
"must_avoid": false,
|
||||||
|
"is_secondary": false,
|
||||||
|
"time_to_reach_next": 0,
|
||||||
|
"next_uuid": null,
|
||||||
|
"is_viewpoint": false,
|
||||||
|
"is_place_of_worship": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "HEMA",
|
||||||
|
"type": "shopping",
|
||||||
|
"location": [
|
||||||
|
45.7619133,
|
||||||
|
4.8565239
|
||||||
|
],
|
||||||
|
"osm_type": "way",
|
||||||
|
"osm_id": 1069872750,
|
||||||
|
"attractiveness": 156,
|
||||||
|
"n_tags": 9,
|
||||||
|
"image_url": null,
|
||||||
|
"website_url": "https://fr.westfield.com/lapartdieu/store/HEMA/www.hema.fr",
|
||||||
|
"wiki_url": null,
|
||||||
|
"keywords": null,
|
||||||
|
"description": null,
|
||||||
|
"duration": 30,
|
||||||
|
"name_en": null,
|
||||||
|
"uuid": "8dae9d3e-e4c4-4e80-941d-0b106e22c85b",
|
||||||
|
"must_do": false,
|
||||||
|
"must_avoid": false,
|
||||||
|
"is_secondary": false,
|
||||||
|
"time_to_reach_next": 0,
|
||||||
|
"next_uuid": null,
|
||||||
|
"is_viewpoint": false,
|
||||||
|
"is_place_of_worship": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cordeliers",
|
||||||
|
"type": "shopping",
|
||||||
|
"location": [
|
||||||
|
45.7622752,
|
||||||
|
4.8337998
|
||||||
|
],
|
||||||
|
"osm_type": "node",
|
||||||
|
"osm_id": 5545183519,
|
||||||
|
"attractiveness": 813,
|
||||||
|
"n_tags": 0,
|
||||||
|
"image_url": null,
|
||||||
|
"website_url": null,
|
||||||
|
"wiki_url": null,
|
||||||
|
"keywords": {},
|
||||||
|
"description": null,
|
||||||
|
"duration": 30,
|
||||||
|
"name_en": null,
|
||||||
|
"uuid": "ba02adb5-e28f-4645-8c2d-25ead6232379",
|
||||||
|
"must_do": false,
|
||||||
|
"must_avoid": false,
|
||||||
|
"is_secondary": false,
|
||||||
|
"time_to_reach_next": 0,
|
||||||
|
"next_uuid": null,
|
||||||
|
"is_viewpoint": false,
|
||||||
|
"is_place_of_worship": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Halles de Lyon Paul Bocuse",
|
||||||
|
"type": "shopping",
|
||||||
|
"location": [
|
||||||
|
45.7628282,
|
||||||
|
4.8505601
|
||||||
|
],
|
||||||
|
"osm_type": "relation",
|
||||||
|
"osm_id": 971529,
|
||||||
|
"attractiveness": 272,
|
||||||
|
"n_tags": 12,
|
||||||
|
"image_url": null,
|
||||||
|
"website_url": "https://www.halles-de-lyon-paulbocuse.com/",
|
||||||
|
"wiki_url": "fr:Halles de Lyon-Paul Bocuse",
|
||||||
|
"keywords": {
|
||||||
|
"importance": "national",
|
||||||
|
"height": null,
|
||||||
|
"place_type": "marketplace",
|
||||||
|
"date": null
|
||||||
|
},
|
||||||
|
"description": "Halles de Lyon Paul Bocuse is a marketplace of national importance.",
|
||||||
|
"duration": 30,
|
||||||
|
"name_en": null,
|
||||||
|
"uuid": "bbd50de3-aa91-425d-90c2-d4abfd1b4abe",
|
||||||
|
"must_do": false,
|
||||||
|
"must_avoid": false,
|
||||||
|
"is_secondary": false,
|
||||||
|
"time_to_reach_next": 0,
|
||||||
|
"next_uuid": null,
|
||||||
|
"is_viewpoint": false,
|
||||||
|
"is_place_of_worship": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Grand Bazar",
|
||||||
|
"type": "shopping",
|
||||||
|
"location": [
|
||||||
|
45.7632141,
|
||||||
|
4.8361975
|
||||||
|
],
|
||||||
|
"osm_type": "way",
|
||||||
|
"osm_id": 82399951,
|
||||||
|
"attractiveness": 93,
|
||||||
|
"n_tags": 7,
|
||||||
|
"image_url": null,
|
||||||
|
"website_url": null,
|
||||||
|
"wiki_url": null,
|
||||||
|
"keywords": null,
|
||||||
|
"description": null,
|
||||||
|
"duration": 30,
|
||||||
|
"name_en": null,
|
||||||
|
"uuid": "3de9131c-87c5-4efb-9fa8-064896fb8b29",
|
||||||
|
"must_do": false,
|
||||||
|
"must_avoid": false,
|
||||||
|
"is_secondary": false,
|
||||||
|
"time_to_reach_next": 0,
|
||||||
|
"next_uuid": null,
|
||||||
|
"is_viewpoint": false,
|
||||||
|
"is_place_of_worship": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shopping Area",
|
||||||
|
"type": "shopping",
|
||||||
|
"location": [
|
||||||
|
45.7673452,
|
||||||
|
4.8438683
|
||||||
|
],
|
||||||
|
"osm_type": "node",
|
||||||
|
"osm_id": 0,
|
||||||
|
"attractiveness": 156,
|
||||||
|
"n_tags": 0,
|
||||||
|
"image_url": null,
|
||||||
|
"website_url": null,
|
||||||
|
"wiki_url": null,
|
||||||
|
"keywords": {},
|
||||||
|
"description": null,
|
||||||
|
"duration": 30,
|
||||||
|
"name_en": null,
|
||||||
|
"uuid": "df2482a8-7e2e-4536-aad3-564899b2fa65",
|
||||||
|
"must_do": false,
|
||||||
|
"must_avoid": false,
|
||||||
|
"is_secondary": false,
|
||||||
|
"time_to_reach_next": 0,
|
||||||
|
"next_uuid": null,
|
||||||
|
"is_viewpoint": false,
|
||||||
|
"is_place_of_worship": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cour Oxyg\u00e8ne",
|
||||||
|
"type": "shopping",
|
||||||
|
"location": [
|
||||||
|
45.7620905,
|
||||||
|
4.8568873
|
||||||
|
],
|
||||||
|
"osm_type": "way",
|
||||||
|
"osm_id": 132673030,
|
||||||
|
"attractiveness": 63,
|
||||||
|
"n_tags": 5,
|
||||||
|
"image_url": null,
|
||||||
|
"website_url": null,
|
||||||
|
"wiki_url": null,
|
||||||
|
"keywords": null,
|
||||||
|
"description": null,
|
||||||
|
"duration": 30,
|
||||||
|
"name_en": null,
|
||||||
|
"uuid": "ed134f76-9a02-4bee-9c10-78454f7bc4ce",
|
||||||
|
"must_do": false,
|
||||||
|
"must_avoid": false,
|
||||||
|
"is_secondary": false,
|
||||||
|
"time_to_reach_next": 0,
|
||||||
|
"next_uuid": null,
|
||||||
|
"is_viewpoint": false,
|
||||||
|
"is_place_of_worship": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P\u00f4le de Commerces et de Loisirs Confluence",
|
||||||
|
"type": "shopping",
|
||||||
|
"location": [
|
||||||
|
45.7410414,
|
||||||
|
4.8171031
|
||||||
|
],
|
||||||
|
"osm_type": "way",
|
||||||
|
"osm_id": 440270633,
|
||||||
|
"attractiveness": 259,
|
||||||
|
"n_tags": 14,
|
||||||
|
"image_url": null,
|
||||||
|
"website_url": "https://www.confluence.fr/",
|
||||||
|
"wiki_url": null,
|
||||||
|
"keywords": null,
|
||||||
|
"description": null,
|
||||||
|
"duration": 30,
|
||||||
|
"name_en": null,
|
||||||
|
"uuid": "dd7e2f5f-0e60-4560-b903-e5ded4b6e36a",
|
||||||
|
"must_do": false,
|
||||||
|
"must_avoid": false,
|
||||||
|
"is_secondary": false,
|
||||||
|
"time_to_reach_next": 0,
|
||||||
|
"next_uuid": null,
|
||||||
|
"is_viewpoint": false,
|
||||||
|
"is_place_of_worship": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Grand H\u00f4tel-Dieu",
|
||||||
|
"type": "shopping",
|
||||||
|
"location": [
|
||||||
|
45.7586955,
|
||||||
|
4.8364597
|
||||||
|
],
|
||||||
|
"osm_type": "relation",
|
||||||
|
"osm_id": 300128,
|
||||||
|
"attractiveness": 546,
|
||||||
|
"n_tags": 22,
|
||||||
|
"image_url": null,
|
||||||
|
"website_url": "https://grand-hotel-dieu.com",
|
||||||
|
"wiki_url": "fr:H\u00f4tel-Dieu de Lyon",
|
||||||
|
"keywords": {
|
||||||
|
"importance": "international",
|
||||||
|
"height": null,
|
||||||
|
"place_type": "building",
|
||||||
|
"date": "C17"
|
||||||
|
},
|
||||||
|
"description": "Grand H\u00f4tel-Dieu is an internationally famous building. It was constructed in C17.",
|
||||||
|
"duration": 30,
|
||||||
|
"name_en": null,
|
||||||
|
"uuid": "a91265a8-ffbd-44f7-a7ab-3ff75f08fbab",
|
||||||
|
"must_do": false,
|
||||||
|
"must_avoid": false,
|
||||||
|
"is_secondary": false,
|
||||||
|
"time_to_reach_next": 0,
|
||||||
|
"next_uuid": null,
|
||||||
|
"is_viewpoint": false,
|
||||||
|
"is_place_of_worship": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Westfield La Part-Dieu",
|
||||||
|
"type": "shopping",
|
||||||
|
"location": [
|
||||||
|
45.761331,
|
||||||
|
4.855676
|
||||||
|
],
|
||||||
|
"osm_type": "way",
|
||||||
|
"osm_id": 62338376,
|
||||||
|
"attractiveness": 546,
|
||||||
|
"n_tags": 22,
|
||||||
|
"image_url": null,
|
||||||
|
"website_url": "https://fr.westfield.com/lapartdieu",
|
||||||
|
"wiki_url": "fr:La Part-Dieu (centre commercial)",
|
||||||
|
"keywords": null,
|
||||||
|
"description": null,
|
||||||
|
"duration": 30,
|
||||||
|
"name_en": null,
|
||||||
|
"uuid": "7d60316f-d689-4fcf-be68-ffc09353b826",
|
||||||
|
"must_do": false,
|
||||||
|
"must_avoid": false,
|
||||||
|
"is_secondary": false,
|
||||||
|
"time_to_reach_next": 0,
|
||||||
|
"next_uuid": null,
|
||||||
|
"is_viewpoint": false,
|
||||||
|
"is_place_of_worship": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ainay",
|
||||||
|
"type": "shopping",
|
||||||
|
"location": [
|
||||||
|
45.7553105,
|
||||||
|
4.8312084
|
||||||
|
],
|
||||||
|
"osm_type": "node",
|
||||||
|
"osm_id": 5545126047,
|
||||||
|
"attractiveness": 132,
|
||||||
|
"n_tags": 0,
|
||||||
|
"image_url": null,
|
||||||
|
"website_url": null,
|
||||||
|
"wiki_url": null,
|
||||||
|
"keywords": {},
|
||||||
|
"description": null,
|
||||||
|
"duration": 30,
|
||||||
|
"name_en": null,
|
||||||
|
"uuid": "ad214f3d-a4b9-4078-876a-446caa7ab01c",
|
||||||
|
"must_do": false,
|
||||||
|
"must_avoid": false,
|
||||||
|
"is_secondary": false,
|
||||||
|
"time_to_reach_next": 0,
|
||||||
|
"next_uuid": null,
|
||||||
|
"is_viewpoint": false,
|
||||||
|
"is_place_of_worship": false
|
||||||
|
}
|
||||||
|
]
|
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import List, Literal, Tuple
|
||||||
|
|
||||||
|
|
||||||
LOCATION_PREFIX = Path('src')
|
LOCATION_PREFIX = Path('src')
|
||||||
@@ -14,6 +15,8 @@ OPTIMIZER_PARAMETERS_PATH = PARAMETERS_DIR / 'optimizer_parameters.yaml'
|
|||||||
cache_dir_string = os.getenv('OSM_CACHE_DIR', './cache')
|
cache_dir_string = os.getenv('OSM_CACHE_DIR', './cache')
|
||||||
OSM_CACHE_DIR = Path(cache_dir_string)
|
OSM_CACHE_DIR = Path(cache_dir_string)
|
||||||
|
|
||||||
|
OSM_TYPES = List[Literal['way', 'node', 'relation']]
|
||||||
|
BBOX = Tuple[float, float, float, float]
|
||||||
|
|
||||||
MEMCACHED_HOST_PATH = os.getenv('MEMCACHED_HOST_PATH', None)
|
MEMCACHED_HOST_PATH = os.getenv('MEMCACHED_HOST_PATH', None)
|
||||||
if MEMCACHED_HOST_PATH == "none":
|
if MEMCACHED_HOST_PATH == "none":
|
||||||
|
0
backend/src/landmarks/__init__.py
Normal file
0
backend/src/landmarks/__init__.py
Normal file
@@ -1,6 +1,6 @@
|
|||||||
"""Find clusters of interest to add more general areas of visit to the tour."""
|
"""Find clusters of interest to add more general areas of visit to the tour."""
|
||||||
import logging
|
import logging
|
||||||
from typing import Literal
|
from typing import Literal, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from sklearn.cluster import DBSCAN
|
from sklearn.cluster import DBSCAN
|
||||||
@@ -8,8 +8,9 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from ..overpass.overpass import Overpass, get_base_info
|
from ..overpass.overpass import Overpass, get_base_info
|
||||||
from ..structs.landmark import Landmark
|
from ..structs.landmark import Landmark
|
||||||
from .get_time_distance import get_distance
|
from ..utils.get_time_distance import get_distance
|
||||||
from ..constants import OSM_CACHE_DIR
|
from ..utils.bbox import create_bbox
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# silence the overpass logger
|
# silence the overpass logger
|
||||||
@@ -32,7 +33,7 @@ class Cluster(BaseModel):
|
|||||||
"""
|
"""
|
||||||
type: Literal['street', 'area']
|
type: Literal['street', 'area']
|
||||||
importance: int
|
importance: int
|
||||||
centroid: tuple
|
centroid: Tuple[float, float]
|
||||||
# start: Optional[list] = None # for later use if we want to have streets as well
|
# start: Optional[list] = None # for later use if we want to have streets as well
|
||||||
# end: Optional[list] = None
|
# end: Optional[list] = None
|
||||||
|
|
||||||
@@ -79,8 +80,7 @@ class ClusterManager:
|
|||||||
bbox: The bounding box coordinates (around:radius, center_lat, center_lon).
|
bbox: The bounding box coordinates (around:radius, center_lat, center_lon).
|
||||||
"""
|
"""
|
||||||
# Setup the caching in the Overpass class.
|
# Setup the caching in the Overpass class.
|
||||||
self.overpass = Overpass(caching_strategy='XML', cache_dir=OSM_CACHE_DIR)
|
self.overpass = Overpass()
|
||||||
|
|
||||||
|
|
||||||
self.cluster_type = cluster_type
|
self.cluster_type = cluster_type
|
||||||
if cluster_type == 'shopping' :
|
if cluster_type == 'shopping' :
|
||||||
@@ -95,27 +95,24 @@ class ClusterManager:
|
|||||||
raise NotImplementedError("Please choose only an available option for cluster detection")
|
raise NotImplementedError("Please choose only an available option for cluster detection")
|
||||||
|
|
||||||
# Initialize the points for cluster detection
|
# Initialize the points for cluster detection
|
||||||
query = self.overpass.build_query(
|
try:
|
||||||
area = bbox,
|
result = self.overpass.send_query(
|
||||||
|
bbox = bbox,
|
||||||
osm_types = osm_types,
|
osm_types = osm_types,
|
||||||
selector = sel,
|
selector = sel,
|
||||||
out = out
|
out = out
|
||||||
)
|
)
|
||||||
self.logger.debug(f"Cluster query: {query}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = self.overpass.send_query(query)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error fetching landmarks: {e}")
|
self.logger.warning(f"Error fetching clusters: {e}")
|
||||||
|
|
||||||
if result is None :
|
if result is None :
|
||||||
self.logger.error(f"Error fetching {cluster_type} clusters, overpass query returned None.")
|
self.logger.debug(f"Found no {cluster_type} clusters, overpass query returned no datapoints.")
|
||||||
self.valid = False
|
self.valid = False
|
||||||
|
|
||||||
else :
|
else :
|
||||||
points = []
|
points = []
|
||||||
for osm_type in osm_types :
|
for elem in result:
|
||||||
for elem in result.findall(osm_type):
|
osm_type = elem.get('type')
|
||||||
|
|
||||||
# Get coordinates and append them to the points list
|
# Get coordinates and append them to the points list
|
||||||
_, coords = get_base_info(elem, osm_type)
|
_, coords = get_base_info(elem, osm_type)
|
||||||
@@ -137,7 +134,7 @@ class ClusterManager:
|
|||||||
|
|
||||||
# Check that there are is least 1 cluster
|
# Check that there are is least 1 cluster
|
||||||
if len(set(labels)) > 1 :
|
if len(set(labels)) > 1 :
|
||||||
self.logger.debug(f"Found {len(set(labels))} different clusters.")
|
self.logger.info(f"Found {len(set(labels))} different {cluster_type} clusters.")
|
||||||
# Separate clustered points and noise points
|
# Separate clustered points and noise points
|
||||||
self.cluster_points = self.all_points[labels != -1]
|
self.cluster_points = self.all_points[labels != -1]
|
||||||
self.cluster_labels = labels[labels != -1]
|
self.cluster_labels = labels[labels != -1]
|
||||||
@@ -145,7 +142,7 @@ class ClusterManager:
|
|||||||
self.valid = True
|
self.valid = True
|
||||||
|
|
||||||
else :
|
else :
|
||||||
self.logger.debug(f"Detected 0 {cluster_type} clusters.")
|
self.logger.info(f"Found 0 {cluster_type} clusters.")
|
||||||
self.valid = False
|
self.valid = False
|
||||||
|
|
||||||
else :
|
else :
|
||||||
@@ -181,11 +178,12 @@ class ClusterManager:
|
|||||||
|
|
||||||
# Calculate the centroid as the mean of the points
|
# Calculate the centroid as the mean of the points
|
||||||
centroid = np.mean(current_cluster, axis=0)
|
centroid = np.mean(current_cluster, axis=0)
|
||||||
|
centroid = tuple((round(centroid[0], 7), round(centroid[1], 7)))
|
||||||
|
|
||||||
if self.cluster_type == 'shopping' :
|
if self.cluster_type == 'shopping' :
|
||||||
score = len(current_cluster)*2
|
score = len(current_cluster)*3
|
||||||
else :
|
else :
|
||||||
score = len(current_cluster)*8
|
score = len(current_cluster)*15
|
||||||
locations.append(Cluster(
|
locations.append(Cluster(
|
||||||
type='area',
|
type='area',
|
||||||
centroid=centroid,
|
centroid=centroid,
|
||||||
@@ -218,8 +216,7 @@ class ClusterManager:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Define the bounding box for a given radius around the coordinates
|
# Define the bounding box for a given radius around the coordinates
|
||||||
lat, lon = cluster.centroid
|
bbox = create_bbox(cluster.centroid, 300)
|
||||||
bbox = (1000, lat, lon)
|
|
||||||
|
|
||||||
# Query neighborhoods and shopping malls
|
# Query neighborhoods and shopping malls
|
||||||
selectors = ['"place"~"^(suburb|neighborhood|neighbourhood|quarter|city_block)$"']
|
selectors = ['"place"~"^(suburb|neighborhood|neighbourhood|quarter|city_block)$"']
|
||||||
@@ -227,10 +224,10 @@ class ClusterManager:
|
|||||||
if self.cluster_type == 'shopping' :
|
if self.cluster_type == 'shopping' :
|
||||||
selectors.append('"shop"="mall"')
|
selectors.append('"shop"="mall"')
|
||||||
new_name = 'Shopping Area'
|
new_name = 'Shopping Area'
|
||||||
t = 40
|
t = 30
|
||||||
else :
|
else :
|
||||||
new_name = 'Neighborhood'
|
new_name = 'Neighborhood'
|
||||||
t = 15
|
t = 20
|
||||||
|
|
||||||
min_dist = float('inf')
|
min_dist = float('inf')
|
||||||
osm_id = 0
|
osm_id = 0
|
||||||
@@ -238,37 +235,32 @@ class ClusterManager:
|
|||||||
osm_types = ['node', 'way', 'relation']
|
osm_types = ['node', 'way', 'relation']
|
||||||
|
|
||||||
for sel in selectors :
|
for sel in selectors :
|
||||||
query = self.overpass.build_query(
|
try:
|
||||||
area = bbox,
|
result = self.overpass.send_query(bbox = bbox,
|
||||||
osm_types = osm_types,
|
osm_types = osm_types,
|
||||||
selector = sel,
|
selector = sel,
|
||||||
out = 'ids center'
|
out = 'ids center tags'
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
|
||||||
result = self.overpass.send_query(query)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error fetching landmarks: {e}")
|
self.logger.warning(f"Error fetching clusters: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if result is None :
|
if result is None :
|
||||||
self.logger.error(f"Error fetching landmarks: {e}")
|
self.logger.warning(f"Error fetching clusters: query result is None")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for osm_type in osm_types :
|
for elem in result:
|
||||||
for elem in result.findall(osm_type):
|
# Get basic info
|
||||||
|
id, coords, name = get_base_info(elem, elem.get('type'), with_name=True)
|
||||||
id, coords, name = get_base_info(elem, osm_type, with_name=True)
|
|
||||||
|
|
||||||
if name is None or coords is None :
|
if name is None or coords is None :
|
||||||
continue
|
continue
|
||||||
|
|
||||||
d = get_distance(cluster.centroid, coords)
|
d = get_distance(cluster.centroid, coords)
|
||||||
if d < min_dist :
|
if d < min_dist :
|
||||||
min_dist = d
|
min_dist = d
|
||||||
new_name = name
|
new_name = name # add name
|
||||||
osm_type = osm_type # Add type: 'way' or 'relation'
|
osm_type = elem.get('type') # add type: 'way' or 'relation'
|
||||||
osm_id = id # Add OSM id
|
osm_id = id # add OSM id
|
||||||
|
|
||||||
return Landmark(
|
return Landmark(
|
||||||
name=new_name,
|
name=new_name,
|
@@ -1,19 +1,15 @@
|
|||||||
"""Module used to import data from OSM and arrange them in categories."""
|
"""Module used to import data from OSM and arrange them in categories."""
|
||||||
import logging
|
import logging
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
from ..structs.preferences import Preferences
|
from ..structs.preferences import Preferences
|
||||||
from ..structs.landmark import Landmark
|
from ..structs.landmark import Landmark
|
||||||
from .take_most_important import take_most_important
|
from ..utils.take_most_important import take_most_important
|
||||||
from .cluster_manager import ClusterManager
|
from .cluster_manager import ClusterManager
|
||||||
from ..overpass.overpass import Overpass, get_base_info
|
from ..overpass.overpass import Overpass, get_base_info
|
||||||
|
from ..utils.bbox import create_bbox
|
||||||
|
|
||||||
from ..constants import AMENITY_SELECTORS_PATH, LANDMARK_PARAMETERS_PATH, OPTIMIZER_PARAMETERS_PATH, OSM_CACHE_DIR
|
from ..constants import AMENITY_SELECTORS_PATH, LANDMARK_PARAMETERS_PATH, OPTIMIZER_PARAMETERS_PATH
|
||||||
|
|
||||||
# silence the overpass logger
|
|
||||||
logging.getLogger('Overpass').setLevel(level=logging.CRITICAL)
|
|
||||||
|
|
||||||
|
|
||||||
class LandmarkManager:
|
class LandmarkManager:
|
||||||
@@ -37,14 +33,12 @@ class LandmarkManager:
|
|||||||
|
|
||||||
with LANDMARK_PARAMETERS_PATH.open('r') as f:
|
with LANDMARK_PARAMETERS_PATH.open('r') as f:
|
||||||
parameters = yaml.safe_load(f)
|
parameters = yaml.safe_load(f)
|
||||||
self.max_bbox_side = parameters['city_bbox_side']
|
self.max_bbox_side = parameters['max_bbox_side']
|
||||||
self.radius_close_to = parameters['radius_close_to']
|
|
||||||
self.church_coeff = parameters['church_coeff']
|
self.church_coeff = parameters['church_coeff']
|
||||||
self.nature_coeff = parameters['nature_coeff']
|
self.nature_coeff = parameters['nature_coeff']
|
||||||
self.overall_coeff = parameters['overall_coeff']
|
self.overall_coeff = parameters['overall_coeff']
|
||||||
self.tag_exponent = parameters['tag_exponent']
|
self.tag_exponent = parameters['tag_exponent']
|
||||||
self.image_bonus = parameters['image_bonus']
|
self.image_bonus = parameters['image_bonus']
|
||||||
self.name_bonus = parameters['name_bonus']
|
|
||||||
self.wikipedia_bonus = parameters['wikipedia_bonus']
|
self.wikipedia_bonus = parameters['wikipedia_bonus']
|
||||||
self.viewpoint_bonus = parameters['viewpoint_bonus']
|
self.viewpoint_bonus = parameters['viewpoint_bonus']
|
||||||
self.pay_bonus = parameters['pay_bonus']
|
self.pay_bonus = parameters['pay_bonus']
|
||||||
@@ -56,7 +50,7 @@ class LandmarkManager:
|
|||||||
self.detour_factor = parameters['detour_factor']
|
self.detour_factor = parameters['detour_factor']
|
||||||
|
|
||||||
# Setup the caching in the Overpass class.
|
# Setup the caching in the Overpass class.
|
||||||
self.overpass = Overpass(caching_strategy='XML', cache_dir=OSM_CACHE_DIR)
|
self.overpass = Overpass()
|
||||||
|
|
||||||
self.logger.info('LandmakManager successfully initialized.')
|
self.logger.info('LandmakManager successfully initialized.')
|
||||||
|
|
||||||
@@ -80,39 +74,39 @@ class LandmarkManager:
|
|||||||
"""
|
"""
|
||||||
self.logger.debug('Starting to fetch landmarks...')
|
self.logger.debug('Starting to fetch landmarks...')
|
||||||
max_walk_dist = int((preferences.max_time_minute/2)/60*self.walking_speed*1000/self.detour_factor)
|
max_walk_dist = int((preferences.max_time_minute/2)/60*self.walking_speed*1000/self.detour_factor)
|
||||||
reachable_bbox_side = min(max_walk_dist, self.max_bbox_side)
|
radius = min(max_walk_dist, int(self.max_bbox_side/2))
|
||||||
|
|
||||||
# use set to avoid duplicates, this requires some __methods__ to be set in Landmark
|
# use set to avoid duplicates, this requires some __methods__ to be set in Landmark
|
||||||
all_landmarks = set()
|
all_landmarks = set()
|
||||||
|
|
||||||
# Create a bbox using the around technique, tuple of strings
|
# Create a bbox using the around technique, tuple of strings
|
||||||
bbox = tuple((min(2000, reachable_bbox_side/2), center_coordinates[0], center_coordinates[1]))
|
bbox = create_bbox(center_coordinates, radius)
|
||||||
|
|
||||||
# list for sightseeing
|
# list for sightseeing
|
||||||
if preferences.sightseeing.score != 0:
|
if preferences.sightseeing.score != 0:
|
||||||
self.logger.debug('Fetching sightseeing landmarks...')
|
self.logger.debug('Fetching sightseeing landmarks...')
|
||||||
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['sightseeing'], preferences.sightseeing.type, preferences.sightseeing.score)
|
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['sightseeing'], preferences.sightseeing.type, preferences.sightseeing.score)
|
||||||
all_landmarks.update(current_landmarks)
|
all_landmarks.update(current_landmarks)
|
||||||
self.logger.debug('Fetching sightseeing clusters...')
|
self.logger.info(f'Found {len(current_landmarks)} sightseeing landmarks')
|
||||||
|
|
||||||
# special pipeline for historic neighborhoods
|
# special pipeline for historic neighborhoods
|
||||||
neighborhood_manager = ClusterManager(bbox, 'sightseeing')
|
neighborhood_manager = ClusterManager(bbox, 'sightseeing')
|
||||||
historic_clusters = neighborhood_manager.generate_clusters()
|
historic_clusters = neighborhood_manager.generate_clusters()
|
||||||
all_landmarks.update(historic_clusters)
|
all_landmarks.update(historic_clusters)
|
||||||
self.logger.debug('Sightseeing clusters done')
|
|
||||||
|
|
||||||
# list for nature
|
# list for nature
|
||||||
if preferences.nature.score != 0:
|
if preferences.nature.score != 0:
|
||||||
self.logger.debug('Fetching nature landmarks...')
|
self.logger.debug('Fetching nature landmarks...')
|
||||||
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['nature'], preferences.nature.type, preferences.nature.score)
|
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['nature'], preferences.nature.type, preferences.nature.score)
|
||||||
all_landmarks.update(current_landmarks)
|
all_landmarks.update(current_landmarks)
|
||||||
|
self.logger.info(f'Found {len(current_landmarks)} nature landmarks')
|
||||||
|
|
||||||
|
|
||||||
# list for shopping
|
# list for shopping
|
||||||
if preferences.shopping.score != 0:
|
if preferences.shopping.score != 0:
|
||||||
self.logger.debug('Fetching shopping landmarks...')
|
self.logger.debug('Fetching shopping landmarks...')
|
||||||
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['shopping'], preferences.shopping.type, preferences.shopping.score)
|
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['shopping'], preferences.shopping.type, preferences.shopping.score)
|
||||||
self.logger.debug('Fetching shopping clusters...')
|
self.logger.info(f'Found {len(current_landmarks)} shopping landmarks')
|
||||||
|
|
||||||
# set time for all shopping activites :
|
# set time for all shopping activites :
|
||||||
for landmark in current_landmarks :
|
for landmark in current_landmarks :
|
||||||
@@ -123,8 +117,6 @@ class LandmarkManager:
|
|||||||
shopping_manager = ClusterManager(bbox, 'shopping')
|
shopping_manager = ClusterManager(bbox, 'shopping')
|
||||||
shopping_clusters = shopping_manager.generate_clusters()
|
shopping_clusters = shopping_manager.generate_clusters()
|
||||||
all_landmarks.update(shopping_clusters)
|
all_landmarks.update(shopping_clusters)
|
||||||
self.logger.debug('Shopping clusters done')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
landmarks_constrained = take_most_important(all_landmarks, self.n_important)
|
landmarks_constrained = take_most_important(all_landmarks, self.n_important)
|
||||||
@@ -154,6 +146,8 @@ class LandmarkManager:
|
|||||||
score *= self.wikipedia_bonus
|
score *= self.wikipedia_bonus
|
||||||
if landmark.is_place_of_worship :
|
if landmark.is_place_of_worship :
|
||||||
score *= self.church_coeff
|
score *= self.church_coeff
|
||||||
|
if landmark.is_viewpoint :
|
||||||
|
score *= self.viewpoint_bonus
|
||||||
if landmarktype == 'nature' :
|
if landmarktype == 'nature' :
|
||||||
score *= self.nature_coeff
|
score *= self.nature_coeff
|
||||||
|
|
||||||
@@ -179,7 +173,7 @@ class LandmarkManager:
|
|||||||
"""
|
"""
|
||||||
return_list = []
|
return_list = []
|
||||||
|
|
||||||
if landmarktype == 'nature' : query_conditions = []
|
if landmarktype == 'nature' : query_conditions = None
|
||||||
else : query_conditions = ['count_tags()>5']
|
else : query_conditions = ['count_tags()>5']
|
||||||
|
|
||||||
# caution, when applying a list of selectors, overpass will search for elements that match ALL selectors simultaneously
|
# caution, when applying a list of selectors, overpass will search for elements that match ALL selectors simultaneously
|
||||||
@@ -190,60 +184,58 @@ class LandmarkManager:
|
|||||||
osm_types = ['way', 'relation']
|
osm_types = ['way', 'relation']
|
||||||
|
|
||||||
if 'viewpoint' in sel :
|
if 'viewpoint' in sel :
|
||||||
query_conditions = []
|
query_conditions = None
|
||||||
osm_types.append('node')
|
osm_types.append('node')
|
||||||
|
|
||||||
query = self.overpass.build_query(
|
# Send the overpass query
|
||||||
area = bbox,
|
try:
|
||||||
|
result = self.overpass.send_query(
|
||||||
|
bbox = bbox,
|
||||||
osm_types = osm_types,
|
osm_types = osm_types,
|
||||||
selector = sel,
|
selector = sel,
|
||||||
conditions = query_conditions, # except for nature....
|
conditions = query_conditions, # except for nature....
|
||||||
out = 'center'
|
out = 'ids center tags'
|
||||||
)
|
)
|
||||||
self.logger.debug(f"Query: {query}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = self.overpass.send_query(query)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error fetching landmarks: {e}")
|
self.logger.debug(f"Failed to fetch landmarks, proceeding without: {str(e)}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return_list += self.xml_to_landmarks(result, landmarktype, preference_level)
|
return_list += self._to_landmarks(result, landmarktype, preference_level)
|
||||||
|
|
||||||
self.logger.debug(f"Fetched {len(return_list)} landmarks of type {landmarktype} in {bbox}")
|
# self.logger.debug(f"Fetched {len(return_list)} landmarks of type {landmarktype} in {bbox}")
|
||||||
|
|
||||||
return return_list
|
return return_list
|
||||||
|
|
||||||
|
|
||||||
def xml_to_landmarks(self, root: ET.Element, landmarktype, preference_level) -> list[Landmark]:
|
def _to_landmarks(self, elements: list, landmarktype, preference_level) -> list[Landmark]:
|
||||||
"""
|
"""
|
||||||
Parse the Overpass API result and extract landmarks.
|
Parse the Overpass API result and extract landmarks.
|
||||||
|
|
||||||
This method processes the XML root element returned by the Overpass API and
|
This method processes the JSON elements returned by the Overpass API and
|
||||||
extracts landmarks of types 'node', 'way', and 'relation'. It retrieves
|
extracts landmarks of types 'node', 'way', and 'relation'. It retrieves
|
||||||
relevant information such as name, coordinates, and tags, and converts them
|
relevant information such as name, coordinates, and tags, and converts them
|
||||||
into Landmark objects.
|
into Landmark objects.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
root (ET.Element): The root element of the XML response from Overpass API.
|
elements (list): The elements of json response from Overpass API.
|
||||||
elem_type (str): The type of landmark (e.g., node, way, relation).
|
elem_type (str): The type of landmark (e.g., node, way, relation).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[Landmark]: A list of Landmark objects extracted from the XML data.
|
list[Landmark]: A list of Landmark objects extracted from the JSON data.
|
||||||
"""
|
"""
|
||||||
if root is None :
|
if elements is None :
|
||||||
return []
|
return []
|
||||||
|
|
||||||
landmarks = []
|
landmarks = []
|
||||||
for osm_type in ['node', 'way', 'relation'] :
|
for elem in elements:
|
||||||
for elem in root.findall(osm_type):
|
osm_type = elem.get('type')
|
||||||
|
|
||||||
id, coords, name = get_base_info(elem, osm_type, with_name=True)
|
id, coords, name = get_base_info(elem, osm_type, with_name=True)
|
||||||
|
|
||||||
if name is None or coords is None :
|
if name is None or coords is None :
|
||||||
continue
|
continue
|
||||||
|
|
||||||
tags = elem.findall('tag')
|
tags = elem.get('tags')
|
||||||
|
|
||||||
# Convert this to Landmark object
|
# Convert this to Landmark object
|
||||||
landmark = Landmark(name=name,
|
landmark = Landmark(name=name,
|
||||||
@@ -255,9 +247,7 @@ class LandmarkManager:
|
|||||||
n_tags=len(tags))
|
n_tags=len(tags))
|
||||||
|
|
||||||
# Browse through tags to add information to landmark.
|
# Browse through tags to add information to landmark.
|
||||||
for tag in tags:
|
for key, value in tags.items():
|
||||||
key = tag.get('k')
|
|
||||||
value = tag.get('v')
|
|
||||||
|
|
||||||
# Skip this landmark if not suitable.
|
# Skip this landmark if not suitable.
|
||||||
if key == 'building:part' and value == 'yes' :
|
if key == 'building:part' and value == 'yes' :
|
||||||
@@ -276,7 +266,7 @@ class LandmarkManager:
|
|||||||
landmark.image_url = value
|
landmark.image_url = value
|
||||||
if key == 'website' :
|
if key == 'website' :
|
||||||
landmark.website_url = value
|
landmark.website_url = value
|
||||||
if key == 'place_of_worship' :
|
if value == 'place_of_worship' :
|
||||||
landmark.is_place_of_worship = True
|
landmark.is_place_of_worship = True
|
||||||
if key == 'wikipedia' :
|
if key == 'wikipedia' :
|
||||||
landmark.wiki_url = value
|
landmark.wiki_url = value
|
||||||
@@ -285,6 +275,7 @@ class LandmarkManager:
|
|||||||
if 'building:' in key or 'pay' in key :
|
if 'building:' in key or 'pay' in key :
|
||||||
landmark.n_tags -= 1
|
landmark.n_tags -= 1
|
||||||
|
|
||||||
|
|
||||||
# Set the duration.
|
# Set the duration.
|
||||||
if value in ['museum', 'aquarium', 'planetarium'] :
|
if value in ['museum', 'aquarium', 'planetarium'] :
|
||||||
landmark.duration = 60
|
landmark.duration = 60
|
||||||
@@ -295,14 +286,138 @@ class LandmarkManager:
|
|||||||
landmark.is_place_of_worship = False
|
landmark.is_place_of_worship = False
|
||||||
landmark.duration = 10
|
landmark.duration = 10
|
||||||
|
|
||||||
else:
|
landmark.description, landmark.keywords = self.description_and_keywords(tags)
|
||||||
self.set_landmark_score(landmark, landmarktype, preference_level)
|
self.set_landmark_score(landmark, landmarktype, preference_level)
|
||||||
landmarks.append(landmark)
|
landmarks.append(landmark)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
return landmarks
|
return landmarks
|
||||||
|
|
||||||
|
|
||||||
|
def description_and_keywords(self, tags: dict):
|
||||||
|
"""
|
||||||
|
Generates a description and a set of keywords for a given landmark based on its tags.
|
||||||
|
|
||||||
|
Params:
|
||||||
|
tags (dict): A dictionary containing metadata about the landmark, including its name,
|
||||||
|
importance, height, date of construction, and visitor information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
description (str): A string description of the landmark.
|
||||||
|
keywords (dict): A dictionary of keywords with fields such as 'importance', 'height',
|
||||||
|
'place_type', and 'date'.
|
||||||
|
"""
|
||||||
|
# Extract relevant fields
|
||||||
|
name = tags.get('name')
|
||||||
|
importance = tags.get('importance', None)
|
||||||
|
n_visitors = tags.get('tourism:visitors', None)
|
||||||
|
height = tags.get('height')
|
||||||
|
place_type = self.get_place_type(tags)
|
||||||
|
date = self.get_date(tags)
|
||||||
|
|
||||||
|
if place_type is None :
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Start the description.
|
||||||
|
if importance is None :
|
||||||
|
if len(tags.keys()) < 5 :
|
||||||
|
return None, None
|
||||||
|
if len(tags.keys()) < 10 :
|
||||||
|
description = f"{name} is a well known {place_type}."
|
||||||
|
elif len(tags.keys()) < 17 :
|
||||||
|
importance = 'national'
|
||||||
|
description = f"{name} is a {place_type} of national importance."
|
||||||
|
else :
|
||||||
|
importance = 'international'
|
||||||
|
description = f"{name} is an internationally famous {place_type}."
|
||||||
|
else :
|
||||||
|
description = f"{name} is a {place_type} of {importance} importance."
|
||||||
|
|
||||||
|
if height is not None and date is not None :
|
||||||
|
description += f" This {place_type} was constructed in {date} and is ca. {height} meters high."
|
||||||
|
elif height is not None :
|
||||||
|
description += f" This {place_type} stands ca. {height} meters tall."
|
||||||
|
elif date is not None:
|
||||||
|
description += f" It was constructed in {date}."
|
||||||
|
|
||||||
|
# Format the visitor number
|
||||||
|
if n_visitors is not None :
|
||||||
|
n_visitors = int(n_visitors)
|
||||||
|
if n_visitors < 1000000 :
|
||||||
|
description += f" It welcomes {int(n_visitors/1000)} thousand visitors every year."
|
||||||
|
else :
|
||||||
|
description += f" It welcomes {round(n_visitors/1000000, 1)} million visitors every year."
|
||||||
|
|
||||||
|
# Set the keywords.
|
||||||
|
keywords = {"importance": importance,
|
||||||
|
"height": height,
|
||||||
|
"place_type": place_type,
|
||||||
|
"date": date}
|
||||||
|
|
||||||
|
return description, keywords
|
||||||
|
|
||||||
|
|
||||||
|
def get_place_type(self, data):
|
||||||
|
"""
|
||||||
|
Determines the type of the place based on available tags such as 'amenity', 'building',
|
||||||
|
'historic', and 'leisure'. The priority order is: 'historic' > 'building' (if not generic) >
|
||||||
|
'amenity' > 'leisure'.
|
||||||
|
|
||||||
|
Params:
|
||||||
|
data (dict): A dictionary containing metadata about the place.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
place_type (str): The determined type of the place, or None if no relevant type is found.
|
||||||
|
"""
|
||||||
|
amenity = data.get('amenity', None)
|
||||||
|
building = data.get('building', None)
|
||||||
|
historic = data.get('historic', None)
|
||||||
|
leisure = data.get('leisure')
|
||||||
|
|
||||||
|
if historic and historic != "yes":
|
||||||
|
return historic
|
||||||
|
if building and building not in ["yes", "civic", "government", "apartments", "residential", "commericial", "industrial", "retail", "religious", "public", "service"]:
|
||||||
|
return building
|
||||||
|
if amenity:
|
||||||
|
return amenity
|
||||||
|
if leisure:
|
||||||
|
return leisure
|
||||||
|
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_date(self, data):
|
||||||
|
"""
|
||||||
|
Extracts the most relevant date from the available tags, prioritizing 'construction_date',
|
||||||
|
'start_date', 'year_of_construction', and 'opening_date' in that order.
|
||||||
|
|
||||||
|
Params:
|
||||||
|
data (dict): A dictionary containing metadata about the place.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
date (str): The most relevant date found, or None if no date is available.
|
||||||
|
"""
|
||||||
|
construction_date = data.get('construction_date', None)
|
||||||
|
opening_date = data.get('opening_date', None)
|
||||||
|
start_date = data.get('start_date', None)
|
||||||
|
year_of_construction = data.get('year_of_construction', None)
|
||||||
|
|
||||||
|
# Prioritize based on availability
|
||||||
|
if construction_date:
|
||||||
|
return construction_date
|
||||||
|
if start_date:
|
||||||
|
return start_date
|
||||||
|
if year_of_construction:
|
||||||
|
return year_of_construction
|
||||||
|
if opening_date:
|
||||||
|
return opening_date
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def dict_to_selector_list(d: dict) -> list:
|
def dict_to_selector_list(d: dict) -> list:
|
||||||
"""
|
"""
|
||||||
Convert a dictionary of key-value pairs to a list of Overpass query strings.
|
Convert a dictionary of key-value pairs to a list of Overpass query strings.
|
@@ -1,21 +1,22 @@
|
|||||||
"""Main app for backend api"""
|
"""Main app for backend api"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
||||||
|
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .structs.landmark import Landmark, Toilets
|
from .structs.landmark import Landmark
|
||||||
from .structs.preferences import Preferences
|
from .structs.preferences import Preferences
|
||||||
from .structs.linked_landmarks import LinkedLandmarks
|
from .structs.linked_landmarks import LinkedLandmarks
|
||||||
from .structs.trip import Trip
|
from .structs.trip import Trip
|
||||||
from .utils.landmarks_manager import LandmarkManager
|
from .landmarks.landmarks_manager import LandmarkManager
|
||||||
from .utils.toilets_manager import ToiletsManager
|
from .toilets.toilet_routes import router as toilets_router
|
||||||
from .optimization.optimizer import Optimizer
|
from .optimization.optimizer import Optimizer
|
||||||
from .optimization.refiner import Refiner
|
from .optimization.refiner import Refiner
|
||||||
|
from .overpass.overpass import fill_cache
|
||||||
from .cache import client as cache_client
|
from .cache import client as cache_client
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
manager = LandmarkManager()
|
manager = LandmarkManager()
|
||||||
@@ -36,10 +37,14 @@ app = FastAPI(lifespan=lifespan)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(toilets_router)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/trip/new")
|
@app.post("/trip/new")
|
||||||
def new_trip(preferences: Preferences,
|
def new_trip(preferences: Preferences,
|
||||||
start: tuple[float, float],
|
start: tuple[float, float],
|
||||||
end: tuple[float, float] | None = None) -> Trip:
|
end: tuple[float, float] | None = None,
|
||||||
|
background_tasks: BackgroundTasks = None) -> Trip:
|
||||||
"""
|
"""
|
||||||
Main function to call the optimizer.
|
Main function to call the optimizer.
|
||||||
|
|
||||||
@@ -64,6 +69,8 @@ def new_trip(preferences: Preferences,
|
|||||||
end = start
|
end = start
|
||||||
logger.info("No end coordinates provided. Using start=end.")
|
logger.info("No end coordinates provided. Using start=end.")
|
||||||
|
|
||||||
|
logger.info(f"Requested new trip generation. Details:\n\tCoordinates: {start}\n\tTime: {preferences.max_time_minute}\n\tSightseeing: {preferences.sightseeing.score}\n\tNature: {preferences.nature.score}\n\tShopping: {preferences.shopping.score}")
|
||||||
|
|
||||||
start_landmark = Landmark(name='start',
|
start_landmark = Landmark(name='start',
|
||||||
type='start',
|
type='start',
|
||||||
location=(start[0], start[1]),
|
location=(start[0], start[1]),
|
||||||
@@ -85,12 +92,16 @@ def new_trip(preferences: Preferences,
|
|||||||
n_tags=0)
|
n_tags=0)
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# Generate the landmarks from the start location
|
# Generate the landmarks from the start location
|
||||||
landmarks, landmarks_short = manager.generate_landmarks_list(
|
landmarks, landmarks_short = manager.generate_landmarks_list(
|
||||||
center_coordinates = start,
|
center_coordinates = start,
|
||||||
preferences = preferences
|
preferences = preferences
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if len(landmarks) == 0 :
|
||||||
|
raise HTTPException(status_code=500, detail="No landmarks were found.")
|
||||||
|
|
||||||
# insert start and finish to the landmarks list
|
# insert start and finish to the landmarks list
|
||||||
landmarks_short.insert(0, start_landmark)
|
landmarks_short.insert(0, start_landmark)
|
||||||
landmarks_short.append(end_landmark)
|
landmarks_short.append(end_landmark)
|
||||||
@@ -103,6 +114,7 @@ def new_trip(preferences: Preferences,
|
|||||||
try:
|
try:
|
||||||
base_tour = optimizer.solve_optimization(preferences.max_time_minute, landmarks_short)
|
base_tour = optimizer.solve_optimization(preferences.max_time_minute, landmarks_short)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.error(f"Trip generation failed: {str(exc)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Optimization failed: {str(exc)}") from exc
|
raise HTTPException(status_code=500, detail=f"Optimization failed: {str(exc)}") from exc
|
||||||
|
|
||||||
t_first_stage = time.time() - start_time
|
t_first_stage = time.time() - start_time
|
||||||
@@ -115,18 +127,23 @@ def new_trip(preferences: Preferences,
|
|||||||
preferences.max_time_minute,
|
preferences.max_time_minute,
|
||||||
preferences.detour_tolerance_minute)
|
preferences.detour_tolerance_minute)
|
||||||
except Exception as exc :
|
except Exception as exc :
|
||||||
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(exc)}") from exc
|
logger.warning(f"Refiner failed. Proceeding with base trip {str(exc)}")
|
||||||
|
refined_tour = base_tour
|
||||||
|
|
||||||
t_second_stage = time.time() - start_time
|
t_second_stage = time.time() - start_time
|
||||||
|
|
||||||
logger.debug(f'First stage optimization\t: {round(t_first_stage,3)} seconds')
|
logger.debug(f'First stage optimization\t: {round(t_first_stage,3)} seconds')
|
||||||
logger.debug(f'Second stage optimization\t: {round(t_second_stage,3)} seconds')
|
logger.debug(f'Second stage optimization\t: {round(t_second_stage,3)} seconds')
|
||||||
logger.info(f'Total computation time\t: {round(t_first_stage + t_second_stage,3)} seconds')
|
logger.info(f'Total computation time\t: {round(t_first_stage + t_second_stage,3)} seconds')
|
||||||
|
|
||||||
linked_tour = LinkedLandmarks(refined_tour)
|
linked_tour = LinkedLandmarks(refined_tour)
|
||||||
|
|
||||||
# upon creation of the trip, persistence of both the trip and its landmarks is ensured.
|
# upon creation of the trip, persistence of both the trip and its landmarks is ensured.
|
||||||
trip = Trip.from_linked_landmarks(linked_tour, cache_client)
|
trip = Trip.from_linked_landmarks(linked_tour, cache_client)
|
||||||
logger.info(f'Generated a trip of {trip.total_time} minutes with {len(refined_tour)} landmarks in {round(t_generate_landmarks + t_first_stage + t_second_stage,3)} seconds.')
|
logger.info(f'Generated a trip of {trip.total_time} minutes with {len(refined_tour)} landmarks in {round(t_generate_landmarks + t_first_stage + t_second_stage,3)} seconds.')
|
||||||
|
logger.debug('Detailed trip :\n\t' + '\n\t'.join(f'{landmark}' for landmark in refined_tour))
|
||||||
|
|
||||||
|
background_tasks.add_task(fill_cache)
|
||||||
|
|
||||||
return trip
|
return trip
|
||||||
|
|
||||||
|
|
||||||
@@ -146,6 +163,7 @@ def get_trip(trip_uuid: str) -> Trip:
|
|||||||
trip = cache_client.get(f"trip_{trip_uuid}")
|
trip = cache_client.get(f"trip_{trip_uuid}")
|
||||||
return trip
|
return trip
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
|
logger.error(f"Failed to fetch trip with UUID {trip_uuid}: {str(exc)}")
|
||||||
raise HTTPException(status_code=404, detail="Trip not found") from exc
|
raise HTTPException(status_code=404, detail="Trip not found") from exc
|
||||||
|
|
||||||
|
|
||||||
@@ -164,32 +182,45 @@ def get_landmark(landmark_uuid: str) -> Landmark:
|
|||||||
landmark = cache_client.get(f"landmark_{landmark_uuid}")
|
landmark = cache_client.get(f"landmark_{landmark_uuid}")
|
||||||
return landmark
|
return landmark
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
|
logger.error(f"Failed to fetch landmark with UUID {landmark_uuid}: {str(exc)}")
|
||||||
raise HTTPException(status_code=404, detail="Landmark not found") from exc
|
raise HTTPException(status_code=404, detail="Landmark not found") from exc
|
||||||
|
|
||||||
|
|
||||||
@app.post("/toilets/new")
|
@app.post("/trip/recompute-time/{trip_uuid}/{removed_landmark_uuid}")
|
||||||
def get_toilets(location: tuple[float, float] = Query(...), radius: int = 500) -> list[Toilets] :
|
def update_trip_time(trip_uuid: str, removed_landmark_uuid: str) -> Trip:
|
||||||
"""
|
"""
|
||||||
Endpoint to find toilets within a specified radius from a given location.
|
Updates the reaching times of a given trip when removing a landmark.
|
||||||
|
|
||||||
This endpoint expects the `location` and `radius` as **query parameters**, not in the request body.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
location (tuple[float, float]): The latitude and longitude of the location to search from.
|
landmark_uuid (str) : unique identifier for a Landmark.
|
||||||
radius (int, optional): The radius (in meters) within which to search for toilets. Defaults to 500 meters.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[Toilets]: A list of Toilets objects that meet the criteria.
|
(Landmark) : the corresponding Landmark.
|
||||||
"""
|
"""
|
||||||
if location is None:
|
# First, fetch the trip in the cache.
|
||||||
raise HTTPException(status_code=406, detail="Coordinates not provided or invalid")
|
try:
|
||||||
if not (-90 <= location[0] <= 90 or -180 <= location[1] <= 180):
|
trip = cache_client.get(f'trip_{trip_uuid}')
|
||||||
raise HTTPException(status_code=422, detail="Start coordinates not in range")
|
|
||||||
|
|
||||||
toilets_manager = ToiletsManager(location, radius)
|
|
||||||
|
|
||||||
try :
|
|
||||||
toilets_list = toilets_manager.generate_toilet_list()
|
|
||||||
return toilets_list
|
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(status_code=404, detail="No toilets found") from exc
|
logger.error(f"Failed to update trip with UUID {trip_uuid} (trip not found): {str(exc)}")
|
||||||
|
raise HTTPException(status_code=404, detail='Trip not found') from exc
|
||||||
|
|
||||||
|
landmarks = []
|
||||||
|
next_uuid = trip.first_landmark_uuid
|
||||||
|
|
||||||
|
# Extract landmarks
|
||||||
|
try :
|
||||||
|
while next_uuid is not None:
|
||||||
|
landmark = cache_client.get(f'landmark_{next_uuid}')
|
||||||
|
# Filter out the removed landmark.
|
||||||
|
if next_uuid != removed_landmark_uuid :
|
||||||
|
landmarks.append(landmark)
|
||||||
|
next_uuid = landmark.next_uuid # Prepare for the next iteration
|
||||||
|
except KeyError as exc:
|
||||||
|
logger.error(f"Failed to update trip with UUID {trip_uuid} : {str(exc)}")
|
||||||
|
raise HTTPException(status_code=404, detail=f'landmark {next_uuid} not found') from exc
|
||||||
|
|
||||||
|
# Re-link every thing and compute times again
|
||||||
|
linked_tour = LinkedLandmarks(landmarks)
|
||||||
|
trip = Trip.from_linked_landmarks(linked_tour, cache_client)
|
||||||
|
|
||||||
|
return trip
|
||||||
|
@@ -55,6 +55,9 @@ class Optimizer:
|
|||||||
self.average_walking_speed = parameters['average_walking_speed']
|
self.average_walking_speed = parameters['average_walking_speed']
|
||||||
self.max_landmarks = parameters['max_landmarks']
|
self.max_landmarks = parameters['max_landmarks']
|
||||||
self.overshoot = parameters['overshoot']
|
self.overshoot = parameters['overshoot']
|
||||||
|
self.time_limit = parameters['time_limit']
|
||||||
|
self.gap_rel = parameters['gap_rel']
|
||||||
|
self.max_iter = parameters['max_iter']
|
||||||
|
|
||||||
|
|
||||||
def init_ub_time(self, prob: pl.LpProblem, x: pl.LpVariable, L: int, landmarks: list[Landmark], max_time: int):
|
def init_ub_time(self, prob: pl.LpProblem, x: pl.LpVariable, L: int, landmarks: list[Landmark], max_time: int):
|
||||||
@@ -254,7 +257,6 @@ class Optimizer:
|
|||||||
Returns:
|
Returns:
|
||||||
None: This function modifies the `prob` object by adding L-2 equality constraints in-place.
|
None: This function modifies the `prob` object by adding L-2 equality constraints in-place.
|
||||||
"""
|
"""
|
||||||
# FIXME: weird 0 artifact in the coefficients popping up
|
|
||||||
# Loop through rows 1 to L-2 to prevent stacked ones
|
# Loop through rows 1 to L-2 to prevent stacked ones
|
||||||
for i in range(1, L-1):
|
for i in range(1, L-1):
|
||||||
# Add the constraint that sums across each "row" or "block" in the decision variables
|
# Add the constraint that sums across each "row" or "block" in the decision variables
|
||||||
@@ -490,7 +492,18 @@ class Optimizer:
|
|||||||
|
|
||||||
|
|
||||||
def warm_start(self, x: list[pl.LpVariable], L: int) :
|
def warm_start(self, x: list[pl.LpVariable], L: int) :
|
||||||
|
"""
|
||||||
|
This function sets the initial values of the decision variables to a feasible solution.
|
||||||
|
This can help the solver start with a feasible or heuristic solution,
|
||||||
|
potentially speeding up convergence.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x (list[pl.LpVariable]): A list of PuLP decision variables (binary variables).
|
||||||
|
L (int): The size parameter, representing a dimension (likely related to a grid or matrix).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[pl.LpVariable]: The modified list of PuLP decision variables with initial values set.
|
||||||
|
"""
|
||||||
for i in range(L*L) :
|
for i in range(L*L) :
|
||||||
x[i].setInitialValue(0)
|
x[i].setInitialValue(0)
|
||||||
|
|
||||||
@@ -573,37 +586,44 @@ class Optimizer:
|
|||||||
prob, x = self.pre_processing(L, landmarks, max_time, max_landmarks)
|
prob, x = self.pre_processing(L, landmarks, max_time, max_landmarks)
|
||||||
|
|
||||||
# Solve the problem and extract results.
|
# Solve the problem and extract results.
|
||||||
prob.solve(pl.PULP_CBC_CMD(msg=False, gapRel=0.1, timeLimit=10, warmStart=False))
|
try :
|
||||||
|
prob.solve(pl.PULP_CBC_CMD(msg=False, timeLimit=self.time_limit+1, gapRel=self.gap_rel))
|
||||||
|
except Exception as exc :
|
||||||
|
raise Exception(f"No solution found: {str(exc)}") from exc
|
||||||
status = pl.LpStatus[prob.status]
|
status = pl.LpStatus[prob.status]
|
||||||
solution = [pl.value(var) for var in x] # The values of the decision variables (will be 0 or 1)
|
solution = [pl.value(var) for var in x] # The values of the decision variables (will be 0 or 1)
|
||||||
|
|
||||||
self.logger.debug("First results are out. Looking out for circles and correcting.")
|
self.logger.debug("First results are out. Looking out for circles and correcting...")
|
||||||
|
|
||||||
# Raise error if no solution is found. FIXME: for now this throws the internal server error
|
# Raise error if no solution is found. FIXME: for now this throws the internal server error
|
||||||
if status != 'Optimal' :
|
if status != 'Optimal' :
|
||||||
self.logger.error("The problem is overconstrained, no solution on first try.")
|
self.logger.warning("The problem is overconstrained, no solution on first try.")
|
||||||
raise ArithmeticError("No solution could be found. Please try again with more time or different preferences.")
|
raise ArithmeticError("No solution could be found. Please try again with more time or different preferences.")
|
||||||
|
|
||||||
# If there is a solution, we're good to go, just check for connectiveness
|
# If there is a solution, we're good to go, just check for connectiveness
|
||||||
circles = self.is_connected(solution)
|
circles = self.is_connected(solution)
|
||||||
|
|
||||||
i = 0
|
i = 0
|
||||||
timeout = 40
|
|
||||||
while circles is not None :
|
while circles is not None :
|
||||||
i += 1
|
i += 1
|
||||||
if i == timeout :
|
if i == self.max_iter :
|
||||||
self.logger.error(f'Timeout: No solution found after {timeout} iterations.')
|
self.logger.warning(f'Timeout: No solution found after {self.max_iter} iterations.')
|
||||||
raise TimeoutError(f"Optimization took too long. No solution found after {timeout} iterations.")
|
raise TimeoutError(f"Optimization took too long. No solution found after {self.max_iter} iterations.")
|
||||||
|
|
||||||
for circle in circles :
|
for circle in circles :
|
||||||
self.prevent_circle(prob, x, circle, L)
|
self.prevent_circle(prob, x, circle, L)
|
||||||
|
|
||||||
# Solve the problem again
|
# Solve the problem again
|
||||||
prob.solve(pl.PULP_CBC_CMD(msg=False))
|
try :
|
||||||
|
prob.solve(pl.PULP_CBC_CMD(msg=False, timeLimit=self.time_limit, gapRel=self.gap_rel))
|
||||||
|
except Exception as exc :
|
||||||
|
self.logger.warning("No solution found: {str(exc)")
|
||||||
|
raise Exception(f"No solution found: {str(exc)}") from exc
|
||||||
|
|
||||||
solution = [pl.value(var) for var in x]
|
solution = [pl.value(var) for var in x]
|
||||||
|
|
||||||
if pl.LpStatus[prob.status] != 'Optimal' :
|
if pl.LpStatus[prob.status] != 'Optimal' :
|
||||||
self.logger.error("The problem is overconstrained, no solution after {i} cycles.")
|
self.logger.warning("The problem is overconstrained, no solution after {i} cycles.")
|
||||||
raise ArithmeticError("No solution could be found. Please try again with more time or different preferences.")
|
raise ArithmeticError("No solution could be found. Please try again with more time or different preferences.")
|
||||||
|
|
||||||
circles = self.is_connected(solution)
|
circles = self.is_connected(solution)
|
||||||
@@ -614,5 +634,5 @@ class Optimizer:
|
|||||||
order = self.get_order(solution)
|
order = self.get_order(solution)
|
||||||
tour = [landmarks[i] for i in order]
|
tour = [landmarks[i] for i in order]
|
||||||
|
|
||||||
self.logger.debug(f"Re-optimized {i} times, objective value : {int(pl.value(prob.objective))}")
|
self.logger.info(f"Re-optimized {i} times, objective value : {int(pl.value(prob.objective))}")
|
||||||
return tour
|
return tour
|
||||||
|
@@ -278,7 +278,7 @@ class Refiner :
|
|||||||
better_tour_poly = concave_hull(MultiPoint(coords)) # Create concave hull with "core" of tour leaving out start and finish
|
better_tour_poly = concave_hull(MultiPoint(coords)) # Create concave hull with "core" of tour leaving out start and finish
|
||||||
xs, ys = better_tour_poly.exterior.xy
|
xs, ys = better_tour_poly.exterior.xy
|
||||||
"""
|
"""
|
||||||
ERROR HERE :
|
FIXED : ERROR HERE :
|
||||||
Exception has occurred: AttributeError
|
Exception has occurred: AttributeError
|
||||||
'LineString' object has no attribute 'exterior'
|
'LineString' object has no attribute 'exterior'
|
||||||
"""
|
"""
|
||||||
@@ -356,7 +356,7 @@ class Refiner :
|
|||||||
|
|
||||||
# If unsuccessful optimization, use the base_tour.
|
# If unsuccessful optimization, use the base_tour.
|
||||||
if new_tour is None:
|
if new_tour is None:
|
||||||
self.logger.warning("No solution found for the refined tour. Returning the initial tour.")
|
self.logger.warning("Refiner failed: No solution found during second stage optimization.")
|
||||||
new_tour = base_tour
|
new_tour = base_tour
|
||||||
|
|
||||||
# If only one landmark, return it.
|
# If only one landmark, return it.
|
||||||
@@ -369,6 +369,7 @@ class Refiner :
|
|||||||
# Fix the tour using Polygons if the path looks weird.
|
# Fix the tour using Polygons if the path looks weird.
|
||||||
# Conditions : circular trip and invalid polygon.
|
# Conditions : circular trip and invalid polygon.
|
||||||
if base_tour[0].location == base_tour[-1].location and not better_poly.is_valid :
|
if base_tour[0].location == base_tour[-1].location and not better_poly.is_valid :
|
||||||
|
self.logger.debug("Tours might be funky, attempting to correct with polygons")
|
||||||
better_tour = self.fix_using_polygon(better_tour)
|
better_tour = self.fix_using_polygon(better_tour)
|
||||||
|
|
||||||
return better_tour
|
return better_tour
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
"""Module defining the caching strategy for overpass requests."""
|
"""Module defining the handling of cache data from Overpass requests."""
|
||||||
import os
|
import os
|
||||||
import xml.etree.ElementTree as ET
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from ..constants import OSM_CACHE_DIR
|
from ..constants import OSM_CACHE_DIR, OSM_TYPES
|
||||||
|
|
||||||
|
|
||||||
def get_cache_key(query: str) -> str:
|
def get_cache_key(query: str) -> str:
|
||||||
@@ -17,10 +17,6 @@ def get_cache_key(query: str) -> str:
|
|||||||
class CachingStrategyBase:
|
class CachingStrategyBase:
|
||||||
"""
|
"""
|
||||||
Base class for implementing caching strategies.
|
Base class for implementing caching strategies.
|
||||||
|
|
||||||
This class defines the structure for a caching strategy with basic methods
|
|
||||||
that must be implemented by subclasses. Subclasses should define how to
|
|
||||||
retrieve, store, and close the cache.
|
|
||||||
"""
|
"""
|
||||||
def get(self, key):
|
def get(self, key):
|
||||||
"""Retrieve the cached data associated with the provided key."""
|
"""Retrieve the cached data associated with the provided key."""
|
||||||
@@ -30,111 +26,111 @@ class CachingStrategyBase:
|
|||||||
"""Store data in the cache with the specified key."""
|
"""Store data in the cache with the specified key."""
|
||||||
raise NotImplementedError('Subclass should implement set')
|
raise NotImplementedError('Subclass should implement set')
|
||||||
|
|
||||||
|
def set_hollow(self, key, **kwargs):
|
||||||
|
"""Create a hollow (empty) cache entry with a specific key."""
|
||||||
|
raise NotImplementedError('Subclass should implement set_hollow')
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Clean up or close any resources used by the caching strategy."""
|
"""Clean up or close any resources used by the caching strategy."""
|
||||||
|
|
||||||
|
|
||||||
class XMLCache(CachingStrategyBase):
|
class JSONCache(CachingStrategyBase):
|
||||||
"""
|
"""
|
||||||
A caching strategy that stores and retrieves data in XML format.
|
A caching strategy that stores and retrieves data in JSON format.
|
||||||
|
|
||||||
This class provides methods to cache data as XML files in a specified directory.
|
|
||||||
The directory is automatically suffixed with '_XML' to distinguish it from other
|
|
||||||
caching strategies. The data is stored and retrieved using XML serialization.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cache_dir (str): The base directory where XML cache files will be stored.
|
|
||||||
Defaults to 'OSM_CACHE_DIR' with a '_XML' suffix.
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
get(key): Retrieve cached data from a XML file associated with the given key.
|
|
||||||
set(key, value): Store data in a XML file with the specified key.
|
|
||||||
"""
|
"""
|
||||||
def __init__(self, cache_dir=OSM_CACHE_DIR):
|
def __init__(self, cache_dir=OSM_CACHE_DIR):
|
||||||
# Add the class name as a suffix to the directory
|
# Add the class name as a suffix to the directory
|
||||||
self._cache_dir = f'{cache_dir}_XML'
|
self._cache_dir = f'{cache_dir}'
|
||||||
if not os.path.exists(self._cache_dir):
|
if not os.path.exists(self._cache_dir):
|
||||||
os.makedirs(self._cache_dir)
|
os.makedirs(self._cache_dir)
|
||||||
|
|
||||||
def _filename(self, key):
|
def _filename(self, key):
|
||||||
return os.path.join(self._cache_dir, f'{key}.xml')
|
return os.path.join(self._cache_dir, f'{key}.json')
|
||||||
|
|
||||||
def get(self, key):
|
def get(self, key):
|
||||||
"""Retrieve XML data from the cache and parse it as an ElementTree."""
|
"""Retrieve JSON data from the cache and parse it as an ElementTree."""
|
||||||
filename = self._filename(key)
|
filename = self._filename(key)
|
||||||
if os.path.exists(filename):
|
if os.path.exists(filename):
|
||||||
try:
|
try:
|
||||||
# Parse and return the cached XML data
|
# Open and parse the cached JSON data
|
||||||
tree = ET.parse(filename)
|
with open(filename, 'r', encoding='utf-8') as file:
|
||||||
return tree.getroot() # Return the root element of the parsed XML
|
data = json.load(file)
|
||||||
except ET.ParseError:
|
# Return the data as a list of dicts.
|
||||||
# print(f"Error parsing cached XML file: {filename}")
|
return data
|
||||||
return None
|
except json.JSONDecodeError:
|
||||||
|
return None # Return None if parsing fails
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def set(self, key, value):
|
def set(self, key, value):
|
||||||
"""Save the XML data as an ElementTree to the cache."""
|
"""Save the JSON data in the cache."""
|
||||||
filename = self._filename(key)
|
filename = self._filename(key)
|
||||||
tree = ET.ElementTree(value) # value is expected to be an ElementTree root element
|
|
||||||
try:
|
try:
|
||||||
# Write the XML data to a file
|
# Write the JSON data to the cache file
|
||||||
with open(filename, 'wb') as file:
|
with open(filename, 'w', encoding='utf-8') as file:
|
||||||
tree.write(file, encoding='utf-8', xml_declaration=True)
|
json.dump(value, file, ensure_ascii=False, indent=4)
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
raise IOError(f"Error writing to cache file: {filename} - {e}") from e
|
raise IOError(f"Error writing to cache file: {filename} - {e}") from e
|
||||||
|
|
||||||
|
def set_hollow(self, key, cell: tuple, osm_types: list,
|
||||||
|
selector: str, conditions: list=None, out='center'):
|
||||||
|
"""Create an empty placeholder cache entry for a future fill."""
|
||||||
|
hollow_key = f'hollow_{key}'
|
||||||
|
filename = self._filename(hollow_key)
|
||||||
|
|
||||||
|
# Create the hollow JSON structure
|
||||||
|
hollow_data = {
|
||||||
|
"key": key,
|
||||||
|
"cell": list(cell),
|
||||||
|
"osm_types": list(osm_types),
|
||||||
|
"selector": selector,
|
||||||
|
"conditions": conditions,
|
||||||
|
"out": out
|
||||||
|
}
|
||||||
|
# Write the hollow data to the cache file
|
||||||
|
try:
|
||||||
|
with open(filename, 'w', encoding='utf-8') as file:
|
||||||
|
json.dump(hollow_data, file, ensure_ascii=False, indent=4)
|
||||||
|
except IOError as e:
|
||||||
|
raise IOError(f"Error writing hollow cache to file: {filename} - {e}") from e
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Cleanup method, if needed."""
|
||||||
|
|
||||||
|
|
||||||
class CachingStrategy:
|
class CachingStrategy:
|
||||||
"""
|
"""
|
||||||
A class to manage different caching strategies.
|
A class to manage different caching strategies.
|
||||||
|
|
||||||
This class provides an interface to switch between different caching strategies
|
|
||||||
(e.g., XMLCache, JSONCache) dynamically. It allows caching data in different formats,
|
|
||||||
depending on the strategy being used. By default, it uses the XMLCache strategy.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
__strategy (CachingStrategyBase): The currently active caching strategy.
|
|
||||||
__strategies (dict): A mapping between strategy names (as strings) and their corresponding
|
|
||||||
classes, allowing dynamic selection of caching strategies.
|
|
||||||
"""
|
"""
|
||||||
__strategy = XMLCache() # Default caching strategy
|
__strategy = JSONCache() # Default caching strategy
|
||||||
__strategies = {
|
__strategies = {
|
||||||
'XML': XMLCache,
|
'JSON': JSONCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def use(cls, strategy_name='XML', **kwargs):
|
def use(cls, strategy_name='JSON', **kwargs):
|
||||||
"""
|
"""Define the caching strategy to use."""
|
||||||
Set the caching strategy based on the strategy_name provided.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
strategy_name (str): The name of the caching strategy (e.g., 'XML').
|
|
||||||
**kwargs: Additional keyword arguments to pass when initializing the strategy.
|
|
||||||
"""
|
|
||||||
# If a previous strategy exists, close it
|
|
||||||
if cls.__strategy:
|
if cls.__strategy:
|
||||||
cls.__strategy.close()
|
cls.__strategy.close()
|
||||||
|
|
||||||
# Retrieve the strategy class based on the strategy name
|
|
||||||
strategy_class = cls.__strategies.get(strategy_name)
|
strategy_class = cls.__strategies.get(strategy_name)
|
||||||
|
|
||||||
if not strategy_class:
|
if not strategy_class:
|
||||||
raise ValueError(f"Unknown caching strategy: {strategy_name}")
|
raise ValueError(f"Unknown caching strategy: {strategy_name}")
|
||||||
|
|
||||||
# Instantiate the new strategy with the provided arguments
|
|
||||||
cls.__strategy = strategy_class(**kwargs)
|
cls.__strategy = strategy_class(**kwargs)
|
||||||
return cls.__strategy
|
return cls.__strategy
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get(cls, key):
|
def get(cls, key):
|
||||||
"""Get data from the current strategy's cache."""
|
"""Get the data from the cache."""
|
||||||
if not cls.__strategy:
|
|
||||||
raise RuntimeError("Caching strategy has not been set.")
|
|
||||||
return cls.__strategy.get(key)
|
return cls.__strategy.get(key)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set(cls, key, value):
|
def set(cls, key, value):
|
||||||
"""Set data in the current strategy's cache."""
|
"""Save the data in the cache."""
|
||||||
if not cls.__strategy:
|
|
||||||
raise RuntimeError("Caching strategy has not been set.")
|
|
||||||
cls.__strategy.set(key, value)
|
cls.__strategy.set(key, value)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_hollow(cls, key, cell: tuple, osm_types: OSM_TYPES,
|
||||||
|
selector: str, conditions: list=None, out='center'):
|
||||||
|
"""Create a hollow cache entry."""
|
||||||
|
cls.__strategy.set_hollow(key, cell, osm_types, selector, conditions, out)
|
||||||
|
@@ -1,14 +1,18 @@
|
|||||||
"""Module allowing connexion to overpass api and fectch data from OSM."""
|
"""Module allowing connexion to overpass api and fectch data from OSM."""
|
||||||
from typing import Literal, List
|
import os
|
||||||
|
import time
|
||||||
import urllib
|
import urllib
|
||||||
|
import math
|
||||||
import logging
|
import logging
|
||||||
import xml.etree.ElementTree as ET
|
import json
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
from .caching_strategy import get_cache_key, CachingStrategy
|
from .caching_strategy import get_cache_key, CachingStrategy
|
||||||
from ..constants import OSM_CACHE_DIR
|
from ..constants import OSM_CACHE_DIR, OSM_TYPES, BBOX
|
||||||
|
|
||||||
logger = logging.getLogger('Overpass')
|
|
||||||
osm_types = List[Literal['way', 'node', 'relation']]
|
RESOLUTION = 0.05
|
||||||
|
CELL = Tuple[int, int]
|
||||||
|
|
||||||
|
|
||||||
class Overpass :
|
class Overpass :
|
||||||
@@ -16,7 +20,10 @@ class Overpass :
|
|||||||
Overpass class to manage the query building and sending to overpass api.
|
Overpass class to manage the query building and sending to overpass api.
|
||||||
The caching strategy is a part of this class and initialized upon creation of the Overpass object.
|
The caching strategy is a part of this class and initialized upon creation of the Overpass object.
|
||||||
"""
|
"""
|
||||||
def __init__(self, caching_strategy: str = 'XML', cache_dir: str = OSM_CACHE_DIR) :
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def __init__(self, caching_strategy: str = 'JSON', cache_dir: str = OSM_CACHE_DIR) :
|
||||||
"""
|
"""
|
||||||
Initialize the Overpass instance with the url, headers and caching strategy.
|
Initialize the Overpass instance with the url, headers and caching strategy.
|
||||||
"""
|
"""
|
||||||
@@ -25,17 +32,110 @@ class Overpass :
|
|||||||
self.caching_strategy = CachingStrategy.use(caching_strategy, cache_dir=cache_dir)
|
self.caching_strategy = CachingStrategy.use(caching_strategy, cache_dir=cache_dir)
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
def send_query(self, bbox: BBOX, osm_types: OSM_TYPES,
|
||||||
def build_query(self, area: tuple, osm_types: osm_types,
|
selector: str, conditions: list=None, out='center') -> List[dict]:
|
||||||
selector: str, conditions=[], out='center') -> str:
|
"""
|
||||||
|
Sends the Overpass QL query to the Overpass API and returns the parsed json response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bbox (tuple): Bounding box for the query.
|
||||||
|
osm_types (list[str]): List of OSM element types (e.g., 'node', 'way').
|
||||||
|
selector (str): Key or tag to filter OSM elements (e.g., 'highway').
|
||||||
|
conditions (list): Optional list of additional filter conditions in Overpass QL format.
|
||||||
|
out (str): Output format ('center', 'body', etc.). Defaults to 'center'.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: Parsed json response from the Overpass API, or cached data if available.
|
||||||
|
"""
|
||||||
|
# Determine which grid cells overlap with this bounding box.
|
||||||
|
overlapping_cells = Overpass._get_overlapping_cells(bbox)
|
||||||
|
|
||||||
|
# Retrieve cached data and identify missing cache entries
|
||||||
|
cached_responses, non_cached_cells = self._retrieve_cached_data(overlapping_cells, osm_types, selector, conditions, out)
|
||||||
|
|
||||||
|
self.logger.debug(f'Cache hit for {len(overlapping_cells)-len(non_cached_cells)}/{len(overlapping_cells)} quadrants.')
|
||||||
|
|
||||||
|
# If there is no missing data, return the cached responses after filtering.
|
||||||
|
if not non_cached_cells :
|
||||||
|
return Overpass._filter_landmarks(cached_responses, bbox)
|
||||||
|
|
||||||
|
# If there is no cached data, fetch all from Overpass.
|
||||||
|
if not cached_responses :
|
||||||
|
query_str = Overpass.build_query(bbox, osm_types, selector, conditions, out)
|
||||||
|
self.logger.debug(f'Query string: {query_str}')
|
||||||
|
return self.fetch_data_from_api(query_str)
|
||||||
|
|
||||||
|
# Resize the bbox for smaller search area and build new query string.
|
||||||
|
non_cached_bbox = Overpass._get_non_cached_bbox(non_cached_cells, bbox)
|
||||||
|
query_str = Overpass.build_query(non_cached_bbox, osm_types, selector, conditions, out)
|
||||||
|
self.logger.debug(f'Query string: {query_str}')
|
||||||
|
non_cached_responses = self.fetch_data_from_api(query_str)
|
||||||
|
return Overpass._filter_landmarks(cached_responses, bbox) + non_cached_responses
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_data_from_api(self, query_str: str) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Fetch data from the Overpass API and return the json data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query_str (str): The Overpass query string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Combined cached and fetched data.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = urllib.parse.urlencode({'data': query_str}).encode('utf-8')
|
||||||
|
request = urllib.request.Request(self.overpass_url, data=data, headers=self.headers)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(request) as response:
|
||||||
|
response_data = response.read().decode('utf-8') # Convert the HTTPResponse to a string
|
||||||
|
data = json.loads(response_data) # Load the JSON from the string
|
||||||
|
elements = data.get('elements', [])
|
||||||
|
# self.logger.debug(f'Query = {query_str}')
|
||||||
|
return elements
|
||||||
|
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
self.logger.error(f"Error connecting to Overpass API: {str(e)}")
|
||||||
|
raise ConnectionError(f"Error connecting to Overpass API: {str(e)}") from e
|
||||||
|
except Exception as exc :
|
||||||
|
self.logger.error(f"unexpected error while fetching data from Overpass: {str(exc)}")
|
||||||
|
raise Exception(f'An unexpected error occured: {str(exc)}') from exc
|
||||||
|
|
||||||
|
|
||||||
|
def fill_cache(self, json_data: dict) :
|
||||||
|
"""
|
||||||
|
Fill cache with data by using a hollow cache entry's information.
|
||||||
|
"""
|
||||||
|
query_str, cache_key = Overpass._build_query_from_hollow(json_data)
|
||||||
|
try:
|
||||||
|
data = urllib.parse.urlencode({'data': query_str}).encode('utf-8')
|
||||||
|
request = urllib.request.Request(self.overpass_url, data=data, headers=self.headers)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(request) as response:
|
||||||
|
|
||||||
|
# Convert the HTTPResponse to a string and load data
|
||||||
|
response_data = response.read().decode('utf-8')
|
||||||
|
data = json.loads(response_data)
|
||||||
|
|
||||||
|
# Get elements and set cache
|
||||||
|
elements = data.get('elements', [])
|
||||||
|
self.caching_strategy.set(cache_key, elements)
|
||||||
|
self.logger.debug(f'Cache set for {cache_key}')
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise ConnectionError(f"Error connecting to Overpass API: {str(e)}") from e
|
||||||
|
except Exception as exc :
|
||||||
|
raise Exception(f'An unexpected error occured: {str(exc)}') from exc
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_query(bbox: BBOX, osm_types: OSM_TYPES,
|
||||||
|
selector: str, conditions: list=None, out='center') -> str:
|
||||||
"""
|
"""
|
||||||
Constructs a query string for the Overpass API to retrieve OpenStreetMap (OSM) data.
|
Constructs a query string for the Overpass API to retrieve OpenStreetMap (OSM) data.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
area (tuple): A tuple representing the geographical search area, typically in the format
|
bbox (tuple): A tuple representing the geographical search area, typically in the format
|
||||||
(radius, latitude, longitude). The first element is a string like "around:2000"
|
(lat_min, lon_min, lat_max, lon_max).
|
||||||
specifying the search radius, and the second and third elements represent
|
|
||||||
the latitude and longitude as floats or strings.
|
|
||||||
osm_types (list[str]): A list of OSM element types to search for. Must be one or more of
|
osm_types (list[str]): A list of OSM element types to search for. Must be one or more of
|
||||||
'Way', 'Node', or 'Relation'.
|
'Way', 'Node', or 'Relation'.
|
||||||
selector (str): The key or tag to filter the OSM elements (e.g., 'amenity', 'highway', etc.).
|
selector (str): The key or tag to filter the OSM elements (e.g., 'amenity', 'highway', etc.).
|
||||||
@@ -52,82 +152,203 @@ class Overpass :
|
|||||||
Notes:
|
Notes:
|
||||||
- If no conditions are provided, the query will just use the `selector` to filter the OSM
|
- If no conditions are provided, the query will just use the `selector` to filter the OSM
|
||||||
elements without additional constraints.
|
elements without additional constraints.
|
||||||
- The search area must always formatted as "(radius, lat, lon)".
|
|
||||||
"""
|
"""
|
||||||
if not isinstance(conditions, list) :
|
query = '[out:json][timeout:20];('
|
||||||
conditions = [conditions]
|
|
||||||
if not isinstance(osm_types, list) :
|
|
||||||
osm_types = [osm_types]
|
|
||||||
|
|
||||||
query = '('
|
# convert the bbox to string.
|
||||||
|
bbox_str = f"({','.join(map(str, bbox))})"
|
||||||
|
|
||||||
# Round the radius to nearest 50 and coordinates to generate less queries
|
if conditions is not None and len(conditions) > 0:
|
||||||
if area[0] > 500 :
|
|
||||||
search_radius = round(area[0] / 50) * 50
|
|
||||||
loc = tuple((round(area[1], 2), round(area[2], 2)))
|
|
||||||
else :
|
|
||||||
search_radius = round(area[0] / 25) * 25
|
|
||||||
loc = tuple((round(area[1], 3), round(area[2], 3)))
|
|
||||||
|
|
||||||
search_area = f"(around:{search_radius}, {str(loc[0])}, {str(loc[1])})"
|
|
||||||
|
|
||||||
if conditions :
|
|
||||||
conditions = '(if: ' + ' && '.join(conditions) + ')'
|
conditions = '(if: ' + ' && '.join(conditions) + ')'
|
||||||
else :
|
else :
|
||||||
conditions = ''
|
conditions = ''
|
||||||
|
|
||||||
for elem in osm_types :
|
for elem in osm_types :
|
||||||
query += elem + '[' + selector + ']' + conditions + search_area + ';'
|
query += elem + '[' + selector + ']' + conditions + bbox_str + ';'
|
||||||
|
|
||||||
query += ');' + f'out {out};'
|
query += ');' + f'out {out};'
|
||||||
|
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
|
||||||
def send_query(self, query: str) -> ET:
|
def _retrieve_cached_data(self, overlapping_cells: CELL, osm_types: OSM_TYPES,
|
||||||
|
selector: str, conditions: list, out: str) -> Tuple[List[dict], list[CELL]]:
|
||||||
"""
|
"""
|
||||||
Sends the Overpass QL query to the Overpass API and returns the parsed JSON response.
|
Retrieve cached data and identify missing cache quadrants.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query (str): The Overpass QL query to be sent to the Overpass API.
|
overlapping_cells (list): Cells to check for cached data.
|
||||||
|
osm_types (list): OSM types (e.g., 'node', 'way').
|
||||||
|
selector (str): Key or tag to filter OSM elements.
|
||||||
|
conditions (list): Additional conditions to apply.
|
||||||
|
out (str): Output format.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: The parsed JSON response from the Overpass API, or None if the request fails.
|
tuple: A tuple containing:
|
||||||
|
- cached_responses (list): List of cached data found.
|
||||||
|
- non_cached_cells (list(tuple)): List of cells with missing data.
|
||||||
|
"""
|
||||||
|
cell_key_dict = {}
|
||||||
|
for cell in overlapping_cells :
|
||||||
|
for elem in osm_types :
|
||||||
|
key_str = f"{elem}[{selector}]{conditions}({','.join(map(str, cell))})"
|
||||||
|
|
||||||
|
cell_key_dict[cell] = get_cache_key(key_str)
|
||||||
|
|
||||||
|
cached_responses = []
|
||||||
|
non_cached_cells = []
|
||||||
|
|
||||||
|
# Retrieve the cached data and mark the missing entries as hollow
|
||||||
|
for cell, key in cell_key_dict.items():
|
||||||
|
cached_data = self.caching_strategy.get(key)
|
||||||
|
if cached_data is not None :
|
||||||
|
cached_responses += cached_data
|
||||||
|
else:
|
||||||
|
self.caching_strategy.set_hollow(key, cell, osm_types, selector, conditions, out)
|
||||||
|
non_cached_cells.append(cell)
|
||||||
|
|
||||||
|
return cached_responses, non_cached_cells
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_query_from_hollow(json_data: dict) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Build query string using information from a hollow cache entry.
|
||||||
|
"""
|
||||||
|
# Extract values from the JSON object
|
||||||
|
key = json_data.get('key')
|
||||||
|
cell = tuple(json_data.get('cell'))
|
||||||
|
bbox = Overpass._get_bbox_from_grid_cell(cell)
|
||||||
|
osm_types = json_data.get('osm_types')
|
||||||
|
selector = json_data.get('selector')
|
||||||
|
conditions = json_data.get('conditions')
|
||||||
|
out = json_data.get('out')
|
||||||
|
|
||||||
|
|
||||||
|
query_str = Overpass.build_query(bbox, osm_types, selector, conditions, out)
|
||||||
|
return query_str, key
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_overlapping_cells(query_bbox: tuple) -> List[CELL]:
|
||||||
|
"""
|
||||||
|
Returns a set of all grid cells that overlap with the given bounding box.
|
||||||
|
"""
|
||||||
|
# Extract location from the query bbox
|
||||||
|
lat_min, lon_min, lat_max, lon_max = query_bbox
|
||||||
|
|
||||||
|
min_lat_cell, min_lon_cell = Overpass._get_grid_cell(lat_min, lon_min)
|
||||||
|
max_lat_cell, max_lon_cell = Overpass._get_grid_cell(lat_max, lon_max)
|
||||||
|
|
||||||
|
overlapping_cells = set()
|
||||||
|
for lat_idx in range(min_lat_cell, max_lat_cell + 1):
|
||||||
|
for lon_idx in range(min_lon_cell, max_lon_cell + 1):
|
||||||
|
overlapping_cells.add((lat_idx, lon_idx))
|
||||||
|
|
||||||
|
return overlapping_cells
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_grid_cell(lat: float, lon: float) -> CELL:
|
||||||
|
"""
|
||||||
|
Returns the grid cell coordinates for a given latitude and longitude.
|
||||||
|
Each grid cell is 0.05°lat x 0.05°lon resolution in size.
|
||||||
|
"""
|
||||||
|
lat_index = math.floor(lat / RESOLUTION)
|
||||||
|
lon_index = math.floor(lon / RESOLUTION)
|
||||||
|
return (lat_index, lon_index)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_bbox_from_grid_cell(cell: CELL) -> BBOX:
|
||||||
|
"""
|
||||||
|
Returns the bounding box for a given grid cell index.
|
||||||
|
Each grid cell is resolution x resolution in size.
|
||||||
|
|
||||||
|
The bounding box is returned as (min_lat, min_lon, max_lat, max_lon).
|
||||||
|
"""
|
||||||
|
# Calculate the southwest (min_lat, min_lon) corner of the bounding box
|
||||||
|
min_lat = round(cell[0] * RESOLUTION, 2)
|
||||||
|
min_lon = round(cell[1] * RESOLUTION, 2)
|
||||||
|
|
||||||
|
# Calculate the northeast (max_lat, max_lon) corner of the bounding box
|
||||||
|
max_lat = round((cell[0] + 1) * RESOLUTION, 2)
|
||||||
|
max_lon = round((cell[1] + 1) * RESOLUTION, 2)
|
||||||
|
|
||||||
|
return (min_lat, min_lon, max_lat, max_lon)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_non_cached_bbox(non_cached_cells: List[CELL], original_bbox: BBOX):
|
||||||
|
"""
|
||||||
|
Calculate the non-cached bounding box by excluding cached cells.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
non_cached_cells (list): The list of cells that were not found in the cache.
|
||||||
|
original_bbox (tuple): The original bounding box (min_lat, min_lon, max_lat, max_lon).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: The new bounding box that excludes cached cells, or None if all cells are cached.
|
||||||
|
"""
|
||||||
|
if not non_cached_cells:
|
||||||
|
return None # All cells were cached
|
||||||
|
|
||||||
|
# Initialize the non-cached bounding box with extreme values
|
||||||
|
min_lat, min_lon, max_lat, max_lon = float('inf'), float('inf'), float('-inf'), float('-inf')
|
||||||
|
|
||||||
|
# Iterate over non-cached cells to find the new bounding box
|
||||||
|
for cell in non_cached_cells:
|
||||||
|
cell_min_lat, cell_min_lon, cell_max_lat, cell_max_lon = Overpass._get_bbox_from_grid_cell(cell)
|
||||||
|
|
||||||
|
min_lat = min(min_lat, cell_min_lat)
|
||||||
|
min_lon = min(min_lon, cell_min_lon)
|
||||||
|
max_lat = max(max_lat, cell_max_lat)
|
||||||
|
max_lon = max(max_lon, cell_max_lon)
|
||||||
|
|
||||||
|
# If no update to bounding box, return the original
|
||||||
|
if min_lat == float('inf') or min_lon == float('inf'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (max(min_lat, original_bbox[0]),
|
||||||
|
max(min_lon, original_bbox[1]),
|
||||||
|
min(max_lat, original_bbox[2]),
|
||||||
|
min(max_lon, original_bbox[3]))
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _filter_landmarks(elements: List[dict], bbox: BBOX) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Filters elements based on whether their coordinates are inside the given bbox.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- elements (list of dict): List of elements containing coordinates.
|
||||||
|
- bbox (tuple): A bounding box defined as (min_lat, min_lon, max_lat, max_lon).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- list: A list of elements whose coordinates are inside the bounding box.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Generate a cache key for the current query
|
filtered_elements = []
|
||||||
cache_key = get_cache_key(query)
|
min_lat, min_lon, max_lat, max_lon = bbox
|
||||||
|
|
||||||
# Try to fetch the result from the cache
|
for elem in elements:
|
||||||
cached_response = self.caching_strategy.get(cache_key)
|
# Extract coordinates based on the 'type' of element
|
||||||
if cached_response is not None :
|
if elem.get('type') != 'node':
|
||||||
logger.debug("Cache hit.")
|
center = elem.get('center', {})
|
||||||
return cached_response
|
lat = float(center.get('lat', 0))
|
||||||
|
lon = float(center.get('lon', 0))
|
||||||
|
else:
|
||||||
|
lat = float(elem.get('lat', 0))
|
||||||
|
lon = float(elem.get('lon', 0))
|
||||||
|
|
||||||
# Prepare the data to be sent as POST request, encoded as bytes
|
# Check if the coordinates fall within the given bounding box
|
||||||
data = urllib.parse.urlencode({'data': query}).encode('utf-8')
|
if min_lat <= lat <= max_lat and min_lon <= lon <= max_lon:
|
||||||
|
filtered_elements.append(elem)
|
||||||
|
|
||||||
try:
|
return filtered_elements
|
||||||
# Create a Request object with the specified URL, data, and headers
|
|
||||||
request = urllib.request.Request(self.overpass_url, data=data, headers=self.headers)
|
|
||||||
|
|
||||||
# Send the request and read the response
|
|
||||||
with urllib.request.urlopen(request) as response:
|
|
||||||
# Read and decode the response
|
|
||||||
response_data = response.read().decode('utf-8')
|
|
||||||
root = ET.fromstring(response_data)
|
|
||||||
|
|
||||||
# Cache the response data as an ElementTree root
|
|
||||||
self.caching_strategy.set(cache_key, root)
|
|
||||||
logger.debug("Response data added to cache.")
|
|
||||||
|
|
||||||
return root
|
|
||||||
|
|
||||||
except urllib.error.URLError as e:
|
|
||||||
raise ConnectionError(f"Error connecting to Overpass API: {e}") from e
|
|
||||||
|
|
||||||
|
|
||||||
def get_base_info(elem: ET.Element, osm_type: osm_types, with_name=False) :
|
def get_base_info(elem: dict, osm_type: OSM_TYPES, with_name=False) :
|
||||||
"""
|
"""
|
||||||
Extracts base information (coordinates, OSM ID, and optionally a name) from an OSM element.
|
Extracts base information (coordinates, OSM ID, and optionally a name) from an OSM element.
|
||||||
|
|
||||||
@@ -136,7 +357,7 @@ def get_base_info(elem: ET.Element, osm_type: osm_types, with_name=False) :
|
|||||||
extracting coordinates either directly or from a center tag, depending on the element type.
|
extracting coordinates either directly or from a center tag, depending on the element type.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
elem (ET.Element): The XML element representing the OSM entity.
|
elem (dict): The JSON element representing the OSM entity.
|
||||||
osm_type (str): The type of the OSM entity (e.g., 'node', 'way'). If 'node', the coordinates
|
osm_type (str): The type of the OSM entity (e.g., 'node', 'way'). If 'node', the coordinates
|
||||||
are extracted directly from the element; otherwise, from the 'center' tag.
|
are extracted directly from the element; otherwise, from the 'center' tag.
|
||||||
with_name (bool): Whether to extract and return the name of the element. If True, it attempts
|
with_name (bool): Whether to extract and return the name of the element. If True, it attempts
|
||||||
@@ -150,7 +371,7 @@ def get_base_info(elem: ET.Element, osm_type: osm_types, with_name=False) :
|
|||||||
"""
|
"""
|
||||||
# 1. extract coordinates
|
# 1. extract coordinates
|
||||||
if osm_type != 'node' :
|
if osm_type != 'node' :
|
||||||
center = elem.find('center')
|
center = elem.get('center')
|
||||||
lat = float(center.get('lat'))
|
lat = float(center.get('lat'))
|
||||||
lon = float(center.get('lon'))
|
lon = float(center.get('lon'))
|
||||||
|
|
||||||
@@ -165,7 +386,38 @@ def get_base_info(elem: ET.Element, osm_type: osm_types, with_name=False) :
|
|||||||
|
|
||||||
# 3. Extract name if specified and return
|
# 3. Extract name if specified and return
|
||||||
if with_name :
|
if with_name :
|
||||||
name = elem.find("tag[@k='name']").get('v') if elem.find("tag[@k='name']") is not None else None
|
name = elem.get('tags', {}).get('name')
|
||||||
return osm_id, coords, name
|
return osm_id, coords, name
|
||||||
else :
|
|
||||||
return osm_id, coords
|
return osm_id, coords
|
||||||
|
|
||||||
|
|
||||||
|
def fill_cache():
|
||||||
|
"""
|
||||||
|
Scans the specified cache directory for files starting with 'hollow_' and attempts to load
|
||||||
|
their contents as JSON to fill the cache of the Overpass system.
|
||||||
|
"""
|
||||||
|
overpass = Overpass()
|
||||||
|
|
||||||
|
n_files = 0
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
with os.scandir(OSM_CACHE_DIR) as it:
|
||||||
|
for entry in it:
|
||||||
|
if entry.is_file() and entry.name.startswith('hollow_'):
|
||||||
|
total += 1
|
||||||
|
try :
|
||||||
|
# Read the whole file content as a string
|
||||||
|
with open(entry.path, 'r', encoding='utf-8') as f:
|
||||||
|
# load data and fill the cache with the query and key
|
||||||
|
json_data = json.load(f)
|
||||||
|
overpass.fill_cache(json_data)
|
||||||
|
n_files += 1
|
||||||
|
time.sleep(1)
|
||||||
|
# Now delete the file as the cache is filled
|
||||||
|
os.remove(entry.path)
|
||||||
|
|
||||||
|
except Exception as exc :
|
||||||
|
overpass.logger.error(f'An error occured while parsing file {entry.path} as .json file: {str(exc)}')
|
||||||
|
|
||||||
|
overpass.logger.info(f"Successfully filled {n_files}/{total} cache files.")
|
||||||
|
@@ -72,6 +72,7 @@ sightseeing:
|
|||||||
# - castle
|
# - castle
|
||||||
# - museum
|
# - museum
|
||||||
|
|
||||||
|
|
||||||
museums:
|
museums:
|
||||||
tourism:
|
tourism:
|
||||||
- museum
|
- museum
|
||||||
|
@@ -1,12 +1,11 @@
|
|||||||
city_bbox_side: 7500 #m
|
max_bbox_side: 4000 #m
|
||||||
radius_close_to: 50
|
radius_close_to: 50
|
||||||
church_coeff: 0.55
|
church_coeff: 0.75
|
||||||
nature_coeff: 1.4
|
nature_coeff: 1.6
|
||||||
overall_coeff: 10
|
overall_coeff: 10
|
||||||
tag_exponent: 1.15
|
tag_exponent: 1.15
|
||||||
image_bonus: 1.1
|
image_bonus: 1.1
|
||||||
viewpoint_bonus: 5
|
viewpoint_bonus: 10
|
||||||
wikipedia_bonus: 1.25
|
wikipedia_bonus: 1.25
|
||||||
name_bonus: 3
|
N_important: 60
|
||||||
N_important: 40
|
|
||||||
pay_bonus: -1
|
pay_bonus: -1
|
||||||
|
@@ -4,3 +4,6 @@ average_walking_speed: 4.8
|
|||||||
max_landmarks: 10
|
max_landmarks: 10
|
||||||
max_landmarks_refiner: 20
|
max_landmarks_refiner: 20
|
||||||
overshoot: 0.0016
|
overshoot: 0.0016
|
||||||
|
time_limit: 1
|
||||||
|
gap_rel: 0.025
|
||||||
|
max_iter: 80
|
@@ -1,5 +1,4 @@
|
|||||||
"""Definition of the Landmark class to handle visitable objects across the world."""
|
"""Definition of the Landmark class to handle visitable objects across the world."""
|
||||||
|
|
||||||
from typing import Optional, Literal
|
from typing import Optional, Literal
|
||||||
from uuid import uuid4, UUID
|
from uuid import uuid4, UUID
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -50,7 +49,8 @@ class Landmark(BaseModel) :
|
|||||||
image_url : Optional[str] = None
|
image_url : Optional[str] = None
|
||||||
website_url : Optional[str] = None
|
website_url : Optional[str] = None
|
||||||
wiki_url : Optional[str] = None
|
wiki_url : Optional[str] = None
|
||||||
description : Optional[str] = None # TODO future
|
keywords: Optional[dict] = {}
|
||||||
|
description : Optional[str] = None
|
||||||
duration : Optional[int] = 5
|
duration : Optional[int] = 5
|
||||||
name_en : Optional[str] = None
|
name_en : Optional[str] = None
|
||||||
|
|
||||||
@@ -69,6 +69,7 @@ class Landmark(BaseModel) :
|
|||||||
is_viewpoint : Optional[bool] = False
|
is_viewpoint : Optional[bool] = False
|
||||||
is_place_of_worship : Optional[bool] = False
|
is_place_of_worship : Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""
|
"""
|
||||||
String representation of the Landmark object.
|
String representation of the Landmark object.
|
||||||
@@ -122,30 +123,3 @@ class Landmark(BaseModel) :
|
|||||||
return (self.uuid == value.uuid or
|
return (self.uuid == value.uuid or
|
||||||
self.osm_id == value.osm_id or
|
self.osm_id == value.osm_id or
|
||||||
(self.name == value.name and self.distance(value) < 0.001))
|
(self.name == value.name and self.distance(value) < 0.001))
|
||||||
|
|
||||||
|
|
||||||
class Toilets(BaseModel) :
|
|
||||||
"""
|
|
||||||
Model for toilets. When false/empty the information is either false either not known.
|
|
||||||
"""
|
|
||||||
location : tuple
|
|
||||||
wheelchair : Optional[bool] = False
|
|
||||||
changing_table : Optional[bool] = False
|
|
||||||
fee : Optional[bool] = False
|
|
||||||
opening_hours : Optional[str] = ""
|
|
||||||
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
"""
|
|
||||||
String representation of the Toilets object.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: A formatted string with the toilets location.
|
|
||||||
"""
|
|
||||||
return f'Toilets @{self.location}'
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
"""
|
|
||||||
This allows us to easily convert the model to and from dictionaries
|
|
||||||
"""
|
|
||||||
from_attributes = True
|
|
||||||
|
26
backend/src/structs/toilets.py
Normal file
26
backend/src/structs/toilets.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Definition of the Toilets class."""
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Toilets(BaseModel) :
|
||||||
|
"""
|
||||||
|
Model for toilets. When false/empty the information is either false either not known.
|
||||||
|
"""
|
||||||
|
location : tuple
|
||||||
|
wheelchair : Optional[bool] = False
|
||||||
|
changing_table : Optional[bool] = False
|
||||||
|
fee : Optional[bool] = False
|
||||||
|
opening_hours : Optional[str] = ""
|
||||||
|
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""
|
||||||
|
String representation of the Toilets object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: A formatted string with the toilets location.
|
||||||
|
"""
|
||||||
|
return f'Toilets @{self.location}'
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
@@ -27,11 +27,13 @@ def test_turckheim(client, request): # pylint: disable=redefined-outer-name
|
|||||||
"/trip/new",
|
"/trip/new",
|
||||||
json={
|
json={
|
||||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||||
"nature": {"type": "nature", "score": 5},
|
"nature": {"type": "nature", "score": 0},
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
"shopping": {"type": "shopping", "score": 0},
|
||||||
"max_time_minute": duration_minutes,
|
"max_time_minute": duration_minutes,
|
||||||
"detour_tolerance_minute": 0},
|
"detour_tolerance_minute": 0},
|
||||||
"start": [48.084588, 7.280405]
|
"start": [48.084588, 7.280405]
|
||||||
|
# "start": [45.74445023349939, 4.8222687890538865]
|
||||||
|
# "start": [45.75156398104873, 4.827154464827647]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result = response.json()
|
result = response.json()
|
||||||
@@ -44,18 +46,16 @@ def test_turckheim(client, request): # pylint: disable=redefined-outer-name
|
|||||||
# Add details to report
|
# Add details to report
|
||||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
||||||
|
|
||||||
# for elem in landmarks :
|
|
||||||
# print(elem)
|
|
||||||
|
|
||||||
# checks :
|
# checks :
|
||||||
assert response.status_code == 200 # check for successful planning
|
assert response.status_code == 200 # check for successful planning
|
||||||
assert isinstance(landmarks, list) # check that the return type is a list
|
assert isinstance(landmarks, list) # check that the return type is a list
|
||||||
assert len(landmarks) > 2 # check that there is something to visit
|
assert len(landmarks) > 2 # check that there is something to visit
|
||||||
|
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
||||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
||||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
||||||
# assert 2!= 3
|
# assert 2!= 3
|
||||||
|
|
||||||
|
|
||||||
def test_bellecour(client, request) : # pylint: disable=redefined-outer-name
|
def test_bellecour(client, request) : # pylint: disable=redefined-outer-name
|
||||||
"""
|
"""
|
||||||
Test n°2 : Custom test in Lyon centre to ensure proper decision making in crowded area.
|
Test n°2 : Custom test in Lyon centre to ensure proper decision making in crowded area.
|
||||||
@@ -97,10 +97,9 @@ def test_bellecour(client, request) : # pylint: disable=redefined-outer-name
|
|||||||
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
assert duration_minutes*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {duration_minutes}"
|
||||||
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
assert duration_minutes*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
|
||||||
|
|
||||||
|
|
||||||
def test_cologne(client, request) : # pylint: disable=redefined-outer-name
|
def test_cologne(client, request) : # pylint: disable=redefined-outer-name
|
||||||
"""
|
"""
|
||||||
Test n°2 : Custom test in Lyon centre to ensure proper decision making in crowded area.
|
Test n°3 : Custom test in Cologne to ensure proper decision making in crowded area.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client:
|
client:
|
||||||
@@ -141,7 +140,7 @@ def test_cologne(client, request) : # pylint: disable=redefined-outer-name
|
|||||||
|
|
||||||
def test_strasbourg(client, request) : # pylint: disable=redefined-outer-name
|
def test_strasbourg(client, request) : # pylint: disable=redefined-outer-name
|
||||||
"""
|
"""
|
||||||
Test n°2 : Custom test in Lyon centre to ensure proper decision making in crowded area.
|
Test n°4 : Custom test in Strasbourg to ensure proper decision making in crowded area.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client:
|
client:
|
||||||
@@ -182,7 +181,7 @@ def test_strasbourg(client, request) : # pylint: disable=redefined-outer-name
|
|||||||
|
|
||||||
def test_zurich(client, request) : # pylint: disable=redefined-outer-name
|
def test_zurich(client, request) : # pylint: disable=redefined-outer-name
|
||||||
"""
|
"""
|
||||||
Test n°2 : Custom test in Lyon centre to ensure proper decision making in crowded area.
|
Test n°5 : Custom test in Zurich to ensure proper decision making in crowded area.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client:
|
client:
|
||||||
@@ -223,24 +222,24 @@ def test_zurich(client, request) : # pylint: disable=redefined-outer-name
|
|||||||
|
|
||||||
def test_paris(client, request) : # pylint: disable=redefined-outer-name
|
def test_paris(client, request) : # pylint: disable=redefined-outer-name
|
||||||
"""
|
"""
|
||||||
Test n°2 : Custom test in Paris (les Halles) centre to ensure proper decision making in crowded area.
|
Test n°6 : Custom test in Paris (les Halles) centre to ensure proper decision making in crowded area.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client:
|
client:
|
||||||
request:
|
request:
|
||||||
"""
|
"""
|
||||||
start_time = time.time() # Start timer
|
start_time = time.time() # Start timer
|
||||||
duration_minutes = 300
|
duration_minutes = 200
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/trip/new",
|
"/trip/new",
|
||||||
json={
|
json={
|
||||||
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
|
||||||
"nature": {"type": "nature", "score": 5},
|
"nature": {"type": "nature", "score": 0},
|
||||||
"shopping": {"type": "shopping", "score": 5},
|
"shopping": {"type": "shopping", "score": 5},
|
||||||
"max_time_minute": duration_minutes,
|
"max_time_minute": duration_minutes,
|
||||||
"detour_tolerance_minute": 0},
|
"detour_tolerance_minute": 0},
|
||||||
"start": [48.86248803298562, 2.346451131285925]
|
"start": [48.85468881798671, 2.3423925755998374]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result = response.json()
|
result = response.json()
|
||||||
@@ -264,7 +263,7 @@ def test_paris(client, request) : # pylint: disable=redefined-outer-name
|
|||||||
|
|
||||||
def test_new_york(client, request) : # pylint: disable=redefined-outer-name
|
def test_new_york(client, request) : # pylint: disable=redefined-outer-name
|
||||||
"""
|
"""
|
||||||
Test n°2 : Custom test in New York (les Halles) centre to ensure proper decision making in crowded area.
|
Test n°7 : Custom test in New York to ensure proper decision making in crowded area.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client:
|
client:
|
||||||
@@ -305,7 +304,7 @@ def test_new_york(client, request) : # pylint: disable=redefined-outer-name
|
|||||||
|
|
||||||
def test_shopping(client, request) : # pylint: disable=redefined-outer-name
|
def test_shopping(client, request) : # pylint: disable=redefined-outer-name
|
||||||
"""
|
"""
|
||||||
Test n°3 : Custom test in Lyon centre to ensure shopping clusters are found.
|
Test n°8 : Custom test in Lyon centre to ensure shopping clusters are found.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client:
|
client:
|
||||||
@@ -334,8 +333,8 @@ def test_shopping(client, request) : # pylint: disable=redefined-outer-name
|
|||||||
# Add details to report
|
# Add details to report
|
||||||
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
|
||||||
|
|
||||||
for elem in landmarks :
|
# for elem in landmarks :
|
||||||
print(elem)
|
# print(elem)
|
||||||
|
|
||||||
# checks :
|
# checks :
|
||||||
assert response.status_code == 200 # check for successful planning
|
assert response.status_code == 200 # check for successful planning
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ..structs.landmark import Toilets
|
from ..structs.toilets import Toilets
|
||||||
from ..main import app
|
from ..main import app
|
||||||
|
|
||||||
|
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Helper methods for testing."""
|
"""Helper methods for testing."""
|
||||||
import logging
|
import logging
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from ..structs.landmark import Landmark
|
from ..structs.landmark import Landmark
|
||||||
from ..cache import client as cache_client
|
from ..cache import client as cache_client
|
||||||
@@ -39,7 +38,7 @@ def fetch_landmark(landmark_uuid: str):
|
|||||||
try:
|
try:
|
||||||
landmark = cache_client.get(f'landmark_{landmark_uuid}')
|
landmark = cache_client.get(f'landmark_{landmark_uuid}')
|
||||||
if not landmark :
|
if not landmark :
|
||||||
logger.warning(f'Cache miss for landmark UUID: {landmark_uuid}')
|
logger.error(f'Cache miss for landmark UUID: {landmark_uuid}')
|
||||||
raise HTTPException(status_code=404, detail=f'Landmark with UUID {landmark_uuid} not found in cache.')
|
raise HTTPException(status_code=404, detail=f'Landmark with UUID {landmark_uuid} not found in cache.')
|
||||||
|
|
||||||
# Validate that the fetched data is a dictionary
|
# Validate that the fetched data is a dictionary
|
||||||
|
0
backend/src/toilets/__init__.py
Normal file
0
backend/src/toilets/__init__.py
Normal file
38
backend/src/toilets/toilet_routes.py
Normal file
38
backend/src/toilets/toilet_routes.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Defines the endpoint for fetching toilet locations."""
|
||||||
|
from fastapi import HTTPException, APIRouter, Query
|
||||||
|
|
||||||
|
from ..structs.toilets import Toilets
|
||||||
|
from .toilets_manager import ToiletsManager
|
||||||
|
|
||||||
|
|
||||||
|
# Define the API router
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/toilets/new")
|
||||||
|
def get_toilets(location: tuple[float, float] = Query(...), radius: int = 500) -> list[Toilets] :
|
||||||
|
"""
|
||||||
|
Endpoint to find toilets within a specified radius from a given location.
|
||||||
|
|
||||||
|
This endpoint expects the `location` and `radius` as **query parameters**, not in the request body.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location (tuple[float, float]): The latitude and longitude of the location to search from.
|
||||||
|
radius (int, optional): The radius (in meters) within which to search for toilets. Defaults to 500 meters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[Toilets]: A list of Toilets objects that meet the criteria.
|
||||||
|
"""
|
||||||
|
if location is None:
|
||||||
|
raise HTTPException(status_code=406, detail="Coordinates not provided or invalid")
|
||||||
|
if not (-90 <= location[0] <= 90 or -180 <= location[1] <= 180):
|
||||||
|
raise HTTPException(status_code=422, detail="Start coordinates not in range")
|
||||||
|
|
||||||
|
toilets_manager = ToiletsManager(location, radius)
|
||||||
|
|
||||||
|
try :
|
||||||
|
toilets_list = toilets_manager.generate_toilet_list()
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="No toilets found") from exc
|
||||||
|
|
||||||
|
return toilets_list
|
@@ -1,10 +1,9 @@
|
|||||||
"""Module for finding public toilets around given coordinates."""
|
"""Module for finding public toilets around given coordinates."""
|
||||||
import logging
|
import logging
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
|
|
||||||
from ..overpass.overpass import Overpass, get_base_info
|
from ..overpass.overpass import Overpass, get_base_info
|
||||||
from ..structs.landmark import Toilets
|
from ..structs.toilets import Toilets
|
||||||
from ..constants import OSM_CACHE_DIR
|
from ..utils.bbox import create_bbox
|
||||||
|
|
||||||
|
|
||||||
# silence the overpass logger
|
# silence the overpass logger
|
||||||
@@ -41,7 +40,7 @@ class ToiletsManager:
|
|||||||
self.location = location
|
self.location = location
|
||||||
|
|
||||||
# Setup the caching in the Overpass class.
|
# Setup the caching in the Overpass class.
|
||||||
self.overpass = Overpass(caching_strategy='XML', cache_dir=OSM_CACHE_DIR)
|
self.overpass = Overpass()
|
||||||
|
|
||||||
|
|
||||||
def generate_toilet_list(self) -> list[Toilets] :
|
def generate_toilet_list(self) -> list[Toilets] :
|
||||||
@@ -53,51 +52,49 @@ class ToiletsManager:
|
|||||||
list[Toilets]: A list of `Toilets` objects containing detailed information
|
list[Toilets]: A list of `Toilets` objects containing detailed information
|
||||||
about the toilets found around the given coordinates.
|
about the toilets found around the given coordinates.
|
||||||
"""
|
"""
|
||||||
bbox = tuple((self.radius, self.location[0], self.location[1]))
|
bbox = create_bbox(self.location, self.radius)
|
||||||
osm_types = ['node', 'way', 'relation']
|
osm_types = ['node', 'way', 'relation']
|
||||||
toilets_list = []
|
toilets_list = []
|
||||||
|
|
||||||
query = self.overpass.build_query(
|
query = Overpass.build_query(
|
||||||
area = bbox,
|
bbox = bbox,
|
||||||
osm_types = osm_types,
|
osm_types = osm_types,
|
||||||
selector = '"amenity"="toilets"',
|
selector = '"amenity"="toilets"',
|
||||||
out = 'ids center tags'
|
out = 'ids center tags'
|
||||||
)
|
)
|
||||||
self.logger.debug(f"Query: {query}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self.overpass.send_query(query)
|
result = self.overpass.fetch_data_from_api(query_str=query)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error fetching landmarks: {e}")
|
self.logger.error(f"Error fetching toilets: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
toilets_list = self.xml_to_toilets(result)
|
toilets_list = self.to_toilets(result)
|
||||||
|
|
||||||
return toilets_list
|
return toilets_list
|
||||||
|
|
||||||
|
|
||||||
def xml_to_toilets(self, root: ET.Element) -> list[Toilets]:
|
def to_toilets(self, elements: list) -> list[Toilets]:
|
||||||
"""
|
"""
|
||||||
Parse the Overpass API result and extract landmarks.
|
Parse the Overpass API result and extract landmarks.
|
||||||
|
|
||||||
This method processes the XML root element returned by the Overpass API and
|
This method processes the JSON elements returned by the Overpass API and
|
||||||
extracts landmarks of types 'node', 'way', and 'relation'. It retrieves
|
extracts landmarks of types 'node', 'way', and 'relation'. It retrieves
|
||||||
relevant information such as name, coordinates, and tags, and converts them
|
relevant information such as name, coordinates, and tags, and converts them
|
||||||
into Landmark objects.
|
into Landmark objects.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
root (ET.Element): The root element of the XML response from Overpass API.
|
list (osm elements): The root element of the JSON response from Overpass API.
|
||||||
elem_type (str): The type of landmark (e.g., node, way, relation).
|
elem_type (str): The type of landmark (e.g., node, way, relation).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[Landmark]: A list of Landmark objects extracted from the XML data.
|
list[Landmark]: A list of Landmark objects extracted from the JSON data.
|
||||||
"""
|
"""
|
||||||
if root is None :
|
if elements is None :
|
||||||
return []
|
return []
|
||||||
|
|
||||||
toilets_list = []
|
toilets_list = []
|
||||||
for osm_type in ['node', 'way', 'relation'] :
|
for elem in elements:
|
||||||
for elem in root.findall(osm_type):
|
osm_type = elem.get('type')
|
||||||
# Get coordinates and append them to the points list
|
# Get coordinates and append them to the points list
|
||||||
_, coords = get_base_info(elem, osm_type)
|
_, coords = get_base_info(elem, osm_type)
|
||||||
if coords is None :
|
if coords is None :
|
||||||
@@ -106,7 +103,7 @@ class ToiletsManager:
|
|||||||
toilets = Toilets(location=coords)
|
toilets = Toilets(location=coords)
|
||||||
|
|
||||||
# Extract tags as a dictionary
|
# Extract tags as a dictionary
|
||||||
tags = {tag.get('k'): tag.get('v') for tag in elem.findall('tag')}
|
tags = elem.get('tags')
|
||||||
|
|
||||||
if 'wheelchair' in tags.keys() and tags['wheelchair'] == 'yes':
|
if 'wheelchair' in tags.keys() and tags['wheelchair'] == 'yes':
|
||||||
toilets.wheelchair = True
|
toilets.wheelchair = True
|
27
backend/src/utils/bbox.py
Normal file
27
backend/src/utils/bbox.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Various helper functions"""
|
||||||
|
import math as m
|
||||||
|
|
||||||
|
def create_bbox(coords: tuple[float, float], radius: int):
|
||||||
|
"""
|
||||||
|
Create a bounding box around the given coordinates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coords (tuple[float, float]): The latitude and longitude of the center of the bounding box.
|
||||||
|
radius (int): The half-side length of the bounding box in meters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[float, float, float, float]: The minimum latitude, minimum longitude, maximum latitude, and maximum longitude
|
||||||
|
defining the bounding box.
|
||||||
|
"""
|
||||||
|
# Earth's radius in meters
|
||||||
|
R = 6378137
|
||||||
|
lat, lon = coords
|
||||||
|
d_lat = radius / R
|
||||||
|
d_lon = radius / (R * m.cos(m.pi * lat / 180))
|
||||||
|
|
||||||
|
lat_min = lat - d_lat * 180 / m.pi
|
||||||
|
lat_max = lat + d_lat * 180 / m.pi
|
||||||
|
lon_min = lon - d_lon * 180 / m.pi
|
||||||
|
lon_max = lon + d_lon * 180 / m.pi
|
||||||
|
|
||||||
|
return (lat_min, lon_min, lat_max, lon_max)
|
427
frontend/assets/confused.svg
Normal file
427
frontend/assets/confused.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 40 KiB |
@@ -1,10 +1,12 @@
|
|||||||
|
import 'package:anyway/utils/get_first_page.dart';
|
||||||
|
import 'package:anyway/utils/load_trips.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:anyway/constants.dart';
|
import 'package:anyway/constants.dart';
|
||||||
import 'package:anyway/layout.dart';
|
|
||||||
|
|
||||||
void main() => runApp(const App());
|
void main() => runApp(const App());
|
||||||
|
|
||||||
final GlobalKey<ScaffoldMessengerState> rootScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
final GlobalKey<ScaffoldMessengerState> rootScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
|
final SavedTrips savedTrips = SavedTrips();
|
||||||
|
|
||||||
class App extends StatelessWidget {
|
class App extends StatelessWidget {
|
||||||
const App({super.key});
|
const App({super.key});
|
||||||
@@ -14,7 +16,7 @@ class App extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: APP_NAME,
|
title: APP_NAME,
|
||||||
home: BasePage(mainScreen: "map"),
|
home: getFirstPage(),
|
||||||
theme: APP_THEME,
|
theme: APP_THEME,
|
||||||
scaffoldMessengerKey: rootScaffoldMessengerKey
|
scaffoldMessengerKey: rootScaffoldMessengerKey
|
||||||
);
|
);
|
||||||
|
@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:anyway/modules/landmark_card.dart';
|
import 'package:anyway/modules/landmark_card.dart';
|
||||||
import 'package:anyway/structs/landmark.dart';
|
import 'package:anyway/structs/landmark.dart';
|
||||||
import 'package:anyway/structs/trip.dart';
|
import 'package:anyway/structs/trip.dart';
|
||||||
import 'package:anyway/main.dart';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -25,30 +24,7 @@ List<Widget> landmarksList(Trip trip) {
|
|||||||
|
|
||||||
for (Landmark landmark in trip.landmarks) {
|
for (Landmark landmark in trip.landmarks) {
|
||||||
children.add(
|
children.add(
|
||||||
Dismissible(
|
LandmarkCard(landmark, trip),
|
||||||
key: ValueKey<int>(landmark.hashCode),
|
|
||||||
child: LandmarkCard(landmark),
|
|
||||||
dismissThresholds: {DismissDirection.endToStart: 0.95, DismissDirection.startToEnd: 0.95},
|
|
||||||
onDismissed: (direction) {
|
|
||||||
log('Removing ${landmark.name}');
|
|
||||||
trip.removeLandmark(landmark);
|
|
||||||
|
|
||||||
rootScaffoldMessengerKey.currentState!.showSnackBar(
|
|
||||||
SnackBar(content: Text("We won't show ${landmark.name} again"))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
background: Container(color: Colors.red),
|
|
||||||
secondaryBackground: Container(
|
|
||||||
color: Colors.red,
|
|
||||||
child: Icon(
|
|
||||||
Icons.delete,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
padding: EdgeInsets.all(15),
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (landmark.next != null) {
|
if (landmark.next != null) {
|
||||||
|
@@ -1,9 +1,20 @@
|
|||||||
|
import 'package:anyway/constants.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:auto_size_text/auto_size_text.dart';
|
import 'package:auto_size_text/auto_size_text.dart';
|
||||||
|
|
||||||
import 'package:anyway/structs/trip.dart';
|
import 'package:anyway/structs/trip.dart';
|
||||||
import 'package:anyway/pages/current_trip.dart';
|
import 'package:anyway/pages/current_trip.dart';
|
||||||
|
|
||||||
|
|
||||||
|
final List<String> statusTexts = [
|
||||||
|
'Parsing your preferences...',
|
||||||
|
'Finding the best places...',
|
||||||
|
'Crunching the numbers...',
|
||||||
|
'Calculating the best route...',
|
||||||
|
'Making sure you have a great time...',
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
class CurrentTripLoadingIndicator extends StatefulWidget {
|
class CurrentTripLoadingIndicator extends StatefulWidget {
|
||||||
final Trip trip;
|
final Trip trip;
|
||||||
const CurrentTripLoadingIndicator({
|
const CurrentTripLoadingIndicator({
|
||||||
@@ -15,46 +26,137 @@ class CurrentTripLoadingIndicator extends StatefulWidget {
|
|||||||
State<CurrentTripLoadingIndicator> createState() => _CurrentTripLoadingIndicatorState();
|
State<CurrentTripLoadingIndicator> createState() => _CurrentTripLoadingIndicatorState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _CurrentTripLoadingIndicatorState extends State<CurrentTripLoadingIndicator> {
|
class _CurrentTripLoadingIndicatorState extends State<CurrentTripLoadingIndicator> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Center(
|
Widget build(BuildContext context) => Stack(
|
||||||
child: FutureBuilder(
|
fit: StackFit.expand,
|
||||||
future: widget.trip.cityName,
|
children: [
|
||||||
|
// In the very center of the panel, show the greeter which tells the user that the trip is being generated
|
||||||
|
Center(child: loadingText(widget.trip)),
|
||||||
|
// As a gimmick, and a way to show that the app is still working, show a few loading dots
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: statusText(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// automatically cycle through the greeter texts
|
||||||
|
class statusText extends StatefulWidget {
|
||||||
|
const statusText({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_statusTextState createState() => _statusTextState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _statusTextState extends State<statusText> {
|
||||||
|
int statusIndex = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
Future.delayed(Duration(seconds: 5), () {
|
||||||
|
setState(() {
|
||||||
|
statusIndex = (statusIndex + 1) % statusTexts.length;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AutoSizeText(
|
||||||
|
statusTexts[statusIndex],
|
||||||
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Widget loadingText(Trip trip) => FutureBuilder(
|
||||||
|
future: trip.cityName,
|
||||||
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
|
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
|
||||||
Widget greeter;
|
Widget greeter;
|
||||||
Widget loadingIndicator = const Padding(
|
|
||||||
padding: EdgeInsets.only(top: 10),
|
|
||||||
child: CircularProgressIndicator()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
greeter = AutoSizeText(
|
greeter = AnimatedGradientText(
|
||||||
maxLines: 1,
|
text: 'Creating your trip to ${snapshot.data}...',
|
||||||
'Generating your trip to ${snapshot.data}...',
|
|
||||||
style: greeterStyle,
|
style: greeterStyle,
|
||||||
);
|
);
|
||||||
} else if (snapshot.hasError) {
|
} else if (snapshot.hasError) {
|
||||||
// the exact error is shown in the central part of the trip overview. No need to show it here
|
// the exact error is shown in the central part of the trip overview. No need to show it here
|
||||||
greeter = AutoSizeText(
|
greeter = AnimatedGradientText(
|
||||||
maxLines: 1,
|
text: 'Error while loading trip.',
|
||||||
'Error while loading trip.',
|
|
||||||
style: greeterStyle,
|
style: greeterStyle,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
greeter = AutoSizeText(
|
greeter = AnimatedGradientText(
|
||||||
maxLines: 1,
|
text: 'Creating your trip...',
|
||||||
'Generating your trip...',
|
|
||||||
style: greeterStyle,
|
style: greeterStyle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Column(
|
return greeter;
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
}
|
||||||
children: [
|
);
|
||||||
greeter,
|
|
||||||
loadingIndicator,
|
class AnimatedGradientText extends StatefulWidget {
|
||||||
|
final String text;
|
||||||
|
final TextStyle style;
|
||||||
|
|
||||||
|
const AnimatedGradientText({
|
||||||
|
Key? key,
|
||||||
|
required this.text,
|
||||||
|
required this.style,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_AnimatedGradientTextState createState() => _AnimatedGradientTextState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnimatedGradientTextState extends State<AnimatedGradientText> with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
vsync: this,
|
||||||
|
)..repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
return ShaderMask(
|
||||||
|
shaderCallback: (bounds) {
|
||||||
|
return LinearGradient(
|
||||||
|
colors: [GRADIENT_START, GRADIENT_END, GRADIENT_START],
|
||||||
|
stops: [
|
||||||
|
_controller.value - 1.0,
|
||||||
|
_controller.value,
|
||||||
|
_controller.value + 1.0,
|
||||||
],
|
],
|
||||||
|
tileMode: TileMode.mirror,
|
||||||
|
).createShader(bounds);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
widget.text,
|
||||||
|
style: widget.style,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -36,7 +36,7 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
// reuse the exact same height as the panel has when collapsed
|
// reuse the exact same height as the panel has when collapsed
|
||||||
// this way the greeter will be centered when the panel is collapsed
|
// this way the greeter will be centered when the panel is collapsed
|
||||||
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20,
|
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT,
|
||||||
child: CurrentTripErrorMessage(trip: widget.trip)
|
child: CurrentTripErrorMessage(trip: widget.trip)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -46,19 +46,20 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
// reuse the exact same height as the panel has when collapsed
|
// reuse the exact same height as the panel has when collapsed
|
||||||
// this way the greeter will be centered when the panel is collapsed
|
// this way the greeter will be centered when the panel is collapsed
|
||||||
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20,
|
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT,
|
||||||
child: CurrentTripLoadingIndicator(trip: widget.trip),
|
child: CurrentTripLoadingIndicator(trip: widget.trip),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return ListView(
|
return ListView(
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
padding: const EdgeInsets.only(bottom: 30),
|
padding: const EdgeInsets.only(top: 10, left: 10, right: 10, bottom: 30),
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
// reuse the exact same height as the panel has when collapsed
|
// reuse the exact same height as the panel has when collapsed
|
||||||
// this way the greeter will be centered when the panel is collapsed
|
// this way the greeter will be centered when the panel is collapsed
|
||||||
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20,
|
// note that we need to account for the padding above
|
||||||
|
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 10,
|
||||||
child: CurrentTripGreeter(trip: widget.trip),
|
child: CurrentTripGreeter(trip: widget.trip),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
|
|||||||
|
|
||||||
const Padding(padding: EdgeInsets.only(top: 10)),
|
const Padding(padding: EdgeInsets.only(top: 10)),
|
||||||
|
|
||||||
Center(child: saveButton(widget.trip)),
|
Center(child: saveButton(trip: widget.trip)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -3,12 +3,24 @@ import 'package:anyway/main.dart';
|
|||||||
import 'package:anyway/structs/trip.dart';
|
import 'package:anyway/structs/trip.dart';
|
||||||
import 'package:auto_size_text/auto_size_text.dart';
|
import 'package:auto_size_text/auto_size_text.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
Widget saveButton(Trip trip) => ElevatedButton(
|
|
||||||
|
class saveButton extends StatefulWidget {
|
||||||
|
Trip trip;
|
||||||
|
saveButton({super.key, required this.trip});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<saveButton> createState() => _saveButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _saveButtonState extends State<saveButton> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
savedTrips.addTrip(widget.trip);
|
||||||
trip.toPrefs(prefs);
|
// SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
|
// setState(() => widget.trip.toPrefs(prefs));
|
||||||
rootScaffoldMessengerKey.currentState!.showSnackBar(
|
rootScaffoldMessengerKey.currentState!.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Trip saved'),
|
content: Text('Trip saved'),
|
||||||
@@ -37,5 +49,7 @@ Widget saveButton(Trip trip) => ElevatedButton(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
25
frontend/lib/modules/help_dialog.dart
Normal file
25
frontend/lib/modules/help_dialog.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
Future<void> helpDialog(BuildContext context, String title, String content) {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text(content),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
textStyle: Theme.of(context).textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
child: const Text('Got it!'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:anyway/main.dart';
|
||||||
|
import 'package:anyway/structs/trip.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -6,8 +8,12 @@ import 'package:anyway/structs/landmark.dart';
|
|||||||
|
|
||||||
class LandmarkCard extends StatefulWidget {
|
class LandmarkCard extends StatefulWidget {
|
||||||
final Landmark landmark;
|
final Landmark landmark;
|
||||||
|
final Trip parentTrip;
|
||||||
|
|
||||||
LandmarkCard(this.landmark);
|
LandmarkCard(
|
||||||
|
this.landmark,
|
||||||
|
this.parentTrip,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_LandmarkCardState createState() => _LandmarkCardState();
|
_LandmarkCardState createState() => _LandmarkCardState();
|
||||||
@@ -17,34 +23,54 @@ class LandmarkCard extends StatefulWidget {
|
|||||||
class _LandmarkCardState extends State<LandmarkCard> {
|
class _LandmarkCardState extends State<LandmarkCard> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
ThemeData theme = Theme.of(context);
|
if (widget.landmark.type == typeStart || widget.landmark.type == typeFinish) {
|
||||||
|
return TextButton.icon(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: widget.landmark.type.icon,
|
||||||
|
label: Text(widget.landmark.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
// else:
|
||||||
return Container(
|
return Container(
|
||||||
height: 160,
|
|
||||||
child: Card(
|
child: Card(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(15.0),
|
borderRadius: BorderRadius.circular(15.0),
|
||||||
),
|
),
|
||||||
elevation: 5,
|
elevation: 5,
|
||||||
clipBehavior: Clip.antiAliasWithSaveLayer,
|
clipBehavior: Clip.antiAliasWithSaveLayer,
|
||||||
child: Row(
|
// if the image is available, display it on the left side of the card, otherwise only display the text
|
||||||
|
child: widget.landmark.imageURL != null ? splitLayout() : textLayout(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget splitLayout() {
|
||||||
|
// If an image is available, display it on the left side of the card
|
||||||
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container( // the image on the left
|
Container(
|
||||||
// inherit the height of the parent container
|
// the image on the left
|
||||||
height: double.infinity,
|
|
||||||
// force a fixed width
|
|
||||||
width: 160,
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
imageUrl: widget.landmark.imageURL ?? '',
|
imageUrl: widget.landmark.imageURL ?? '',
|
||||||
placeholder: (context, url) => Center(child: CircularProgressIndicator()),
|
placeholder: (context, url) => Center(child: CircularProgressIndicator()),
|
||||||
errorWidget: (context, error, stackTrace) => Icon(Icons.question_mark_outlined),
|
errorWidget: (context, error, stackTrace) => Icon(Icons.question_mark_outlined),
|
||||||
// TODO: make this a switch statement to load a placeholder if null
|
|
||||||
// cover the whole container meaning the image will be cropped
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Padding(
|
child: textLayout(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget textLayout() {
|
||||||
|
return Padding(
|
||||||
padding: EdgeInsets.all(10),
|
padding: EdgeInsets.all(10),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -76,7 +102,10 @@ class _LandmarkCardState extends State<LandmarkCard> {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SingleChildScrollView(
|
Padding(padding: EdgeInsets.only(top: 10)),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: SingleChildScrollView(
|
||||||
// allows the buttons to be scrolled
|
// allows the buttons to be scrolled
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
@@ -103,25 +132,41 @@ class _LandmarkCardState extends State<LandmarkCard> {
|
|||||||
icon: Icon(Icons.link),
|
icon: Icon(Icons.link),
|
||||||
label: Text('Website'),
|
label: Text('Website'),
|
||||||
),
|
),
|
||||||
if (widget.landmark.wikipediaURL != null)
|
PopupMenuButton(
|
||||||
TextButton.icon(
|
icon: Icon(Icons.settings),
|
||||||
onPressed: () async {
|
style: TextButtonTheme.of(context).style,
|
||||||
// open a browser with the wikipedia link
|
itemBuilder: (context) => [
|
||||||
await launchUrl(Uri.parse(widget.landmark.wikipediaURL!));
|
PopupMenuItem(
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.delete),
|
||||||
|
title: Text('Delete'),
|
||||||
|
onTap: () async {
|
||||||
|
widget.parentTrip.removeLandmark(widget.landmark);
|
||||||
|
rootScaffoldMessengerKey.currentState!.showSnackBar(
|
||||||
|
SnackBar(content: Text("We won't show ${widget.landmark.name} again"))
|
||||||
|
);
|
||||||
},
|
},
|
||||||
icon: Icon(Icons.book),
|
),
|
||||||
label: Text('Wikipedia'),
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.star),
|
||||||
|
title: Text('Favorite'),
|
||||||
|
onTap: () async {
|
||||||
|
// delete the landmark
|
||||||
|
// await deleteLandmark(widget.landmark);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
)
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import 'package:anyway/layout.dart';
|
|
||||||
import 'package:anyway/main.dart';
|
import 'package:anyway/main.dart';
|
||||||
|
import 'package:anyway/pages/current_trip.dart';
|
||||||
import 'package:anyway/structs/preferences.dart';
|
import 'package:anyway/structs/preferences.dart';
|
||||||
import 'package:anyway/structs/trip.dart';
|
import 'package:anyway/structs/trip.dart';
|
||||||
import 'package:anyway/utils/fetch_trip.dart';
|
import 'package:anyway/utils/fetch_trip.dart';
|
||||||
@@ -57,7 +57,7 @@ class _NewTripButtonState extends State<NewTripButton> {
|
|||||||
fetchTrip(trip, widget.preferences);
|
fetchTrip(trip, widget.preferences);
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => BasePage(mainScreen: "map", trip: trip)
|
builder: (context) => TripPage(trip: trip)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -9,6 +9,15 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
const Map<String, List> debugLocations = {
|
||||||
|
'paris': [48.8575, 2.3514],
|
||||||
|
'london': [51.5074, -0.1278],
|
||||||
|
'new york': [40.7128, -74.0060],
|
||||||
|
'tokyo': [35.6895, 139.6917],
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class NewTripLocationSearch extends StatefulWidget {
|
class NewTripLocationSearch extends StatefulWidget {
|
||||||
Future<SharedPreferences> prefs = SharedPreferences.getInstance();
|
Future<SharedPreferences> prefs = SharedPreferences.getInstance();
|
||||||
Trip trip;
|
Trip trip;
|
||||||
@@ -27,26 +36,35 @@ class _NewTripLocationSearchState extends State<NewTripLocationSearch> {
|
|||||||
|
|
||||||
setTripLocation (String query) async {
|
setTripLocation (String query) async {
|
||||||
List<Location> locations = [];
|
List<Location> locations = [];
|
||||||
|
Location startLocation;
|
||||||
log('Searching for: $query');
|
log('Searching for: $query');
|
||||||
|
if (GeocodingPlatform.instance != null) {
|
||||||
try{
|
locations.addAll(await locationFromAddress(query));
|
||||||
locations = await locationFromAddress(query);
|
|
||||||
} catch (e) {
|
|
||||||
log('No results found for: $query : $e');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (locations.isNotEmpty) {
|
if (locations.isNotEmpty) {
|
||||||
Location location = locations.first;
|
startLocation = locations.first;
|
||||||
|
} else {
|
||||||
|
log('No results found for: $query. Is geocoding available?');
|
||||||
|
log('Setting Fallback location');
|
||||||
|
List coordinates = debugLocations[query.toLowerCase()] ?? [48.8575, 2.3514];
|
||||||
|
startLocation = Location(
|
||||||
|
latitude: coordinates[0],
|
||||||
|
longitude: coordinates[1],
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
widget.trip.landmarks.clear();
|
widget.trip.landmarks.clear();
|
||||||
widget.trip.addLandmark(
|
widget.trip.addLandmark(
|
||||||
Landmark(
|
Landmark(
|
||||||
uuid: 'pending',
|
uuid: 'pending',
|
||||||
name: query,
|
name: query,
|
||||||
location: [location.latitude, location.longitude],
|
location: [startLocation.latitude, startLocation.longitude],
|
||||||
type: typeStart
|
type: typeStart
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
late Widget locationSearchBar = SearchBar(
|
late Widget locationSearchBar = SearchBar(
|
||||||
|
@@ -26,7 +26,7 @@ class _NewTripMapState extends State<NewTripMap> {
|
|||||||
target: LatLng(48.8566, 2.3522),
|
target: LatLng(48.8566, 2.3522),
|
||||||
zoom: 11.0,
|
zoom: 11.0,
|
||||||
);
|
);
|
||||||
late GoogleMapController _mapController;
|
GoogleMapController? _mapController;
|
||||||
final Set<Marker> _markers = <Marker>{};
|
final Set<Marker> _markers = <Marker>{};
|
||||||
|
|
||||||
_onLongPress(LatLng location) {
|
_onLongPress(LatLng location) {
|
||||||
@@ -56,11 +56,15 @@ class _NewTripMapState extends State<NewTripMap> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
_mapController.moveCamera(
|
// check if the controller is ready
|
||||||
|
|
||||||
|
if (_mapController != null) {
|
||||||
|
_mapController!.animateCamera(
|
||||||
CameraUpdate.newLatLng(
|
CameraUpdate.newLatLng(
|
||||||
LatLng(landmark.location[0], landmark.location[1])
|
LatLng(landmark.location[0], landmark.location[1])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,13 +2,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
class OnboardingCard extends StatelessWidget {
|
class OnboardingCard extends StatelessWidget {
|
||||||
int index;
|
final String title;
|
||||||
String title;
|
final String description;
|
||||||
String description;
|
final String imagePath;
|
||||||
String imagePath;
|
|
||||||
|
|
||||||
OnboardingCard({
|
const OnboardingCard({
|
||||||
required this.index,
|
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.description,
|
required this.description,
|
||||||
required this.imagePath,
|
required this.imagePath,
|
||||||
@@ -16,13 +14,8 @@ class OnboardingCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Color baseColor = Theme.of(context).colorScheme.secondary;
|
|
||||||
// have a different color for each card, incrementing the hue
|
return Padding(
|
||||||
Color currentColor = baseColor.withAlpha(baseColor.alpha - index * 30);
|
|
||||||
return Container(
|
|
||||||
color: currentColor,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(20),
|
padding: EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -50,7 +43,6 @@ class OnboardingCard extends StatelessWidget {
|
|||||||
|
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -19,8 +19,7 @@ class StepBetweenLandmarks extends StatefulWidget {
|
|||||||
class _StepBetweenLandmarksState extends State<StepBetweenLandmarks> {
|
class _StepBetweenLandmarksState extends State<StepBetweenLandmarks> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
int timeRounded = 5 * ((widget.current.tripTime?.inMinutes ?? 0) ~/ 5);
|
int time = widget.current.tripTime?.inMinutes ?? 0;
|
||||||
// ~/ is integer division (rounding)
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: EdgeInsets.all(10),
|
margin: EdgeInsets.all(10),
|
||||||
padding: EdgeInsets.all(10),
|
padding: EdgeInsets.all(10),
|
||||||
@@ -34,7 +33,7 @@ class _StepBetweenLandmarksState extends State<StepBetweenLandmarks> {
|
|||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.directions_walk),
|
Icon(Icons.directions_walk),
|
||||||
Text("~$timeRounded min", style: TextStyle(fontSize: 10)),
|
Text("$time min", style: TextStyle(fontSize: 10)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Spacer(),
|
Spacer(),
|
||||||
|
@@ -1,11 +1,12 @@
|
|||||||
|
import 'package:anyway/pages/current_trip.dart';
|
||||||
|
import 'package:anyway/utils/load_trips.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:anyway/layout.dart';
|
|
||||||
import 'package:anyway/structs/trip.dart';
|
import 'package:anyway/structs/trip.dart';
|
||||||
|
|
||||||
|
|
||||||
class TripsOverview extends StatefulWidget {
|
class TripsOverview extends StatefulWidget {
|
||||||
final Future<List<Trip>> trips;
|
final SavedTrips trips;
|
||||||
const TripsOverview({
|
const TripsOverview({
|
||||||
super.key,
|
super.key,
|
||||||
required this.trips,
|
required this.trips,
|
||||||
@@ -16,12 +17,11 @@ class TripsOverview extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TripsOverviewState extends State<TripsOverview> {
|
class _TripsOverviewState extends State<TripsOverview> {
|
||||||
|
Widget listBuild (BuildContext context, SavedTrips trips) {
|
||||||
Widget listBuild (BuildContext context, AsyncSnapshot<List<Trip>> snapshot) {
|
|
||||||
List<Widget> children;
|
List<Widget> children;
|
||||||
if (snapshot.hasData) {
|
List<Trip> items = trips.trips;
|
||||||
children = List<Widget>.generate(snapshot.data!.length, (index) {
|
children = List<Widget>.generate(items.length, (index) {
|
||||||
Trip trip = snapshot.data![index];
|
Trip trip = items[index];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: FutureBuilder(
|
title: FutureBuilder(
|
||||||
future: trip.cityName,
|
future: trip.cityName,
|
||||||
@@ -39,27 +39,12 @@ class _TripsOverviewState extends State<TripsOverview> {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => BasePage(mainScreen: "map", trip: trip)
|
builder: (context) => TripPage(trip: trip)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
} else if (snapshot.hasError) {
|
|
||||||
children = [
|
|
||||||
const Icon(
|
|
||||||
Icons.error_outline,
|
|
||||||
color: Colors.red,
|
|
||||||
size: 60,
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 16),
|
|
||||||
child: Text('Error: ${snapshot.error}'),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
children = [Center(child: CircularProgressIndicator())];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
children: children,
|
children: children,
|
||||||
@@ -69,9 +54,11 @@ class _TripsOverviewState extends State<TripsOverview> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FutureBuilder(
|
return ListenableBuilder(
|
||||||
future: widget.trips,
|
listenable: widget.trips,
|
||||||
builder: listBuild,
|
builder: (BuildContext context, Widget? child) {
|
||||||
|
return listBuild(context, widget.trips);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,3 +1,6 @@
|
|||||||
|
import 'package:anyway/main.dart';
|
||||||
|
import 'package:anyway/modules/help_dialog.dart';
|
||||||
|
import 'package:anyway/pages/current_trip.dart';
|
||||||
import 'package:anyway/pages/settings.dart';
|
import 'package:anyway/pages/settings.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -8,22 +11,24 @@ import 'package:anyway/modules/trips_saved_list.dart';
|
|||||||
import 'package:anyway/utils/load_trips.dart';
|
import 'package:anyway/utils/load_trips.dart';
|
||||||
|
|
||||||
import 'package:anyway/pages/new_trip_location.dart';
|
import 'package:anyway/pages/new_trip_location.dart';
|
||||||
import 'package:anyway/pages/current_trip.dart';
|
|
||||||
import 'package:anyway/pages/onboarding.dart';
|
import 'package:anyway/pages/onboarding.dart';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// BasePage is the scaffold that holds all other pages
|
// BasePage is the scaffold that holds a child page and a side drawer
|
||||||
// A side drawer is used to switch between pages
|
// The side drawer is the main way to switch between pages
|
||||||
|
|
||||||
class BasePage extends StatefulWidget {
|
class BasePage extends StatefulWidget {
|
||||||
final String mainScreen;
|
final Widget mainScreen;
|
||||||
final Trip? trip;
|
final Widget title;
|
||||||
|
final List<String> helpTexts;
|
||||||
|
|
||||||
const BasePage({
|
const BasePage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.mainScreen,
|
required this.mainScreen,
|
||||||
this.trip,
|
this.title = const Text(APP_NAME),
|
||||||
|
this.helpTexts = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,53 +39,25 @@ class _BasePageState extends State<BasePage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Widget currentView = const Text("loading...");
|
savedTrips.loadTrips();
|
||||||
Future<List<Trip>> trips = loadTrips();
|
|
||||||
|
|
||||||
|
|
||||||
if (widget.mainScreen == "map") {
|
|
||||||
if (widget.trip != null) {
|
|
||||||
currentView = TripPage(trip: widget.trip!);
|
|
||||||
} else {
|
|
||||||
currentView = FutureBuilder(
|
|
||||||
future: trips,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.hasData) {
|
|
||||||
List<Trip> availableTrips = snapshot.data!;
|
|
||||||
if (availableTrips.isNotEmpty) {
|
|
||||||
return TripPage(trip: availableTrips[0]);
|
|
||||||
} else {
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Center(
|
appBar: AppBar(
|
||||||
child: Text("Wow, so empty!"),
|
title: widget.title,
|
||||||
),
|
actions: [
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
IconButton(
|
||||||
|
icon: const Icon(Icons.help),
|
||||||
|
tooltip: 'Help',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).push(
|
if (widget.helpTexts.isNotEmpty) {
|
||||||
MaterialPageRoute(
|
helpDialog(context, widget.helpTexts[0], widget.helpTexts[1]);
|
||||||
builder: (context) => const NewTripPage()
|
}
|
||||||
)
|
}
|
||||||
);
|
|
||||||
},
|
|
||||||
label: Text("Plan a trip"),
|
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
}
|
),
|
||||||
} else {
|
body: Center(child: widget.mainScreen),
|
||||||
return const Text("loading...");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (widget.mainScreen == "tutorial") {
|
|
||||||
currentView = OnboardingPage();
|
|
||||||
} else if (widget.mainScreen == "settings") {
|
|
||||||
currentView = SettingsPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: Text(APP_NAME)),
|
|
||||||
body: Center(child: currentView),
|
|
||||||
drawer: Drawer(
|
drawer: Drawer(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -104,7 +81,8 @@ class _BasePageState extends State<BasePage> {
|
|||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('Your Trips'),
|
title: const Text('Your Trips'),
|
||||||
leading: const Icon(Icons.map),
|
leading: const Icon(Icons.map),
|
||||||
selected: widget.mainScreen == "map",
|
// TODO: this is not working!
|
||||||
|
selected: widget.mainScreen is TripPage,
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
trailing: ElevatedButton(
|
trailing: ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -122,11 +100,11 @@ class _BasePageState extends State<BasePage> {
|
|||||||
// through the options in the drawer if there isn't enough vertical
|
// through the options in the drawer if there isn't enough vertical
|
||||||
// space to fit everything.
|
// space to fit everything.
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TripsOverview(trips: trips),
|
child: TripsOverview(trips: savedTrips),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
removeAllTripsFromPrefs();
|
savedTrips.clearTrips();
|
||||||
},
|
},
|
||||||
child: const Text('Clear trips'),
|
child: const Text('Clear trips'),
|
||||||
),
|
),
|
||||||
@@ -134,11 +112,12 @@ class _BasePageState extends State<BasePage> {
|
|||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('How to use'),
|
title: const Text('How to use'),
|
||||||
leading: Icon(Icons.help),
|
leading: Icon(Icons.help),
|
||||||
selected: widget.mainScreen == "tutorial",
|
// TODO: this is not working!
|
||||||
|
selected: widget.mainScreen is OnboardingPage,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => BasePage(mainScreen: "tutorial")
|
builder: (context) => OnboardingPage()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -148,11 +127,12 @@ class _BasePageState extends State<BasePage> {
|
|||||||
ListTile(
|
ListTile(
|
||||||
title: const Text('Settings'),
|
title: const Text('Settings'),
|
||||||
leading: const Icon(Icons.settings),
|
leading: const Icon(Icons.settings),
|
||||||
selected: widget.mainScreen == "settings",
|
// TODO: this is not working!
|
||||||
|
selected: widget.mainScreen is SettingsPage,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => BasePage(mainScreen: "settings")
|
builder: (context) => SettingsPage()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
@@ -1,4 +1,5 @@
|
|||||||
import 'package:anyway/constants.dart';
|
import 'package:anyway/constants.dart';
|
||||||
|
import 'package:anyway/pages/base_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:sliding_up_panel/sliding_up_panel.dart';
|
import 'package:sliding_up_panel/sliding_up_panel.dart';
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ final Shader textGradient = APP_GRADIENT.createShader(Rect.fromLTWH(0.0, 0.0, 20
|
|||||||
TextStyle greeterStyle = TextStyle(
|
TextStyle greeterStyle = TextStyle(
|
||||||
foreground: Paint()..shader = textGradient,
|
foreground: Paint()..shader = textGradient,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 26
|
fontSize: 25
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +32,8 @@ class _TripPageState extends State<TripPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SlidingUpPanel(
|
return BasePage(
|
||||||
|
mainScreen: SlidingUpPanel(
|
||||||
// use panelBuilder instead of panel so that we can reuse the scrollcontroller for the listview
|
// use panelBuilder instead of panel so that we can reuse the scrollcontroller for the listview
|
||||||
panelBuilder: (scrollcontroller) => CurrentTripPanel(controller: scrollcontroller, trip: widget.trip),
|
panelBuilder: (scrollcontroller) => CurrentTripPanel(controller: scrollcontroller, trip: widget.trip),
|
||||||
// using collapsed and panelBuilder seems to show both at the same time, so we include the greeter in the panelBuilder
|
// using collapsed and panelBuilder seems to show both at the same time, so we include the greeter in the panelBuilder
|
||||||
@@ -41,7 +43,7 @@ class _TripPageState extends State<TripPage> {
|
|||||||
maxHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MAX_HEIGHT,
|
maxHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MAX_HEIGHT,
|
||||||
// padding in this context is annoying: it offsets the notion of vertical alignment.
|
// padding in this context is annoying: it offsets the notion of vertical alignment.
|
||||||
// children that want to be centered vertically need to have their size adjusted by 2x the padding
|
// children that want to be centered vertically need to have their size adjusted by 2x the padding
|
||||||
padding: const EdgeInsets.all(10.0),
|
// padding: const EdgeInsets.all(10.0),
|
||||||
// Panel snapping should not be disabled because it significantly improves the user experience
|
// Panel snapping should not be disabled because it significantly improves the user experience
|
||||||
// panelSnapping: false
|
// panelSnapping: false
|
||||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(25), topRight: Radius.circular(25)),
|
borderRadius: const BorderRadius.only(topLeft: Radius.circular(25), topRight: Radius.circular(25)),
|
||||||
@@ -52,6 +54,13 @@ class _TripPageState extends State<TripPage> {
|
|||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
title: FutureBuilder(
|
||||||
|
future: widget.trip.cityName,
|
||||||
|
builder: (context, snapshot) => Text(
|
||||||
|
'Your trip to ${snapshot.hasData ? snapshot.data! : "..."}',
|
||||||
|
)
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import 'package:anyway/modules/new_trip_button.dart';
|
|
||||||
import 'package:anyway/modules/new_trip_options_button.dart';
|
import 'package:anyway/modules/new_trip_options_button.dart';
|
||||||
|
import 'package:anyway/pages/base_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import "package:anyway/structs/trip.dart";
|
import "package:anyway/structs/trip.dart";
|
||||||
@@ -19,13 +19,12 @@ class _NewTripPageState extends State<NewTripPage> {
|
|||||||
final TextEditingController lonController = TextEditingController();
|
final TextEditingController lonController = TextEditingController();
|
||||||
Trip trip = Trip();
|
Trip trip = Trip();
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// floating search bar and map as a background
|
// floating search bar and map as a background
|
||||||
return Scaffold(
|
return BasePage(
|
||||||
appBar: AppBar(
|
mainScreen: Scaffold(
|
||||||
title: const Text('New Trip'),
|
|
||||||
),
|
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
NewTripMap(trip),
|
NewTripMap(trip),
|
||||||
@@ -36,6 +35,12 @@ class _NewTripPageState extends State<NewTripPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: NewTripOptionsButton(trip: trip),
|
floatingActionButton: NewTripOptionsButton(trip: trip),
|
||||||
|
),
|
||||||
|
title: Text("New Trip"),
|
||||||
|
helpTexts: [
|
||||||
|
"Setting the start location",
|
||||||
|
"To set the starting point, type a city name in the search bar. You can also navigate the map like you're used to and long press anywhere to set a starting point."
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import 'package:anyway/modules/new_trip_button.dart';
|
import 'package:anyway/modules/new_trip_button.dart';
|
||||||
|
import 'package:anyway/pages/base_page.dart';
|
||||||
import 'package:anyway/structs/preferences.dart';
|
import 'package:anyway/structs/preferences.dart';
|
||||||
import 'package:anyway/structs/trip.dart';
|
import 'package:anyway/structs/trip.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
@@ -19,7 +20,8 @@ class _NewTripPreferencesPageState extends State<NewTripPreferencesPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return BasePage(
|
||||||
|
mainScreen: Scaffold(
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
// Center(
|
// Center(
|
||||||
@@ -28,16 +30,16 @@ class _NewTripPreferencesPageState extends State<NewTripPreferencesPage> {
|
|||||||
// child: Icon(Icons.person, size: 100),
|
// child: Icon(Icons.person, size: 100),
|
||||||
// )
|
// )
|
||||||
// ),
|
// ),
|
||||||
Padding(padding: EdgeInsets.only(top: 30)),
|
// Padding(padding: EdgeInsets.only(top: 30)),
|
||||||
Center(
|
// Center(
|
||||||
child: FutureBuilder(
|
// child: FutureBuilder(
|
||||||
future: widget.trip.cityName,
|
// future: widget.trip.cityName,
|
||||||
builder: (context, snapshot) => Text(
|
// builder: (context, snapshot) => Text(
|
||||||
'Your trip to ${snapshot.hasData ? snapshot.data! : "..."}',
|
// 'Your trip to ${snapshot.hasData ? snapshot.data! : "..."}',
|
||||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)
|
// style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)
|
||||||
)
|
// )
|
||||||
)
|
// )
|
||||||
),
|
// ),
|
||||||
|
|
||||||
Center(
|
Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -54,6 +56,18 @@ class _NewTripPreferencesPageState extends State<NewTripPreferencesPage> {
|
|||||||
]
|
]
|
||||||
),
|
),
|
||||||
floatingActionButton: NewTripButton(trip: widget.trip, preferences: preferences),
|
floatingActionButton: NewTripButton(trip: widget.trip, preferences: preferences),
|
||||||
|
),
|
||||||
|
|
||||||
|
title: FutureBuilder(
|
||||||
|
future: widget.trip.cityName,
|
||||||
|
builder: (context, snapshot) => Text(
|
||||||
|
'Your trip to ${snapshot.hasData ? snapshot.data! : "..."}',
|
||||||
|
)
|
||||||
|
),
|
||||||
|
helpTexts: [
|
||||||
|
'Trip preferences',
|
||||||
|
'Set your preferences for this trip. These will be used to generate a custom itinerary.'
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,7 +1,33 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:anyway/constants.dart';
|
||||||
import 'package:anyway/modules/onboarding_card.dart';
|
import 'package:anyway/modules/onboarding_card.dart';
|
||||||
import 'package:anyway/pages/new_trip_location.dart';
|
import 'package:anyway/pages/new_trip_location.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const List<Widget> onboardingCards = [
|
||||||
|
OnboardingCard(
|
||||||
|
title: "Welcome to anyway!",
|
||||||
|
description: "Anyway helps you plan a city trip that suits your wishes.",
|
||||||
|
imagePath: "assets/city.svg"
|
||||||
|
),
|
||||||
|
OnboardingCard(
|
||||||
|
title: "Find your way",
|
||||||
|
description: "Bored by churches? No problem! Hate shopping? No worries! Instead of suggesting the generic trips that bore you, anyway will try to give you recommendations that really suit you.",
|
||||||
|
imagePath: "assets/plan.svg"
|
||||||
|
),
|
||||||
|
OnboardingCard(
|
||||||
|
title: "Change your mind",
|
||||||
|
description: "Feet get sore, the weather changes. Anyway understands that! Move or remove destinations, visit hidden gems along your journey, do your own thing. Anyway adapts to your spontaneous decisions.",
|
||||||
|
imagePath: "assets/cat.svg"
|
||||||
|
),
|
||||||
|
OnboardingCard(
|
||||||
|
title: "Feeling lost?",
|
||||||
|
description: "Whenever you are confused or need help with the app, look out for the question mark in the top right corner. Help is just a tap away!",
|
||||||
|
imagePath: "assets/confused.svg"
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
class OnboardingPage extends StatefulWidget {
|
class OnboardingPage extends StatefulWidget {
|
||||||
const OnboardingPage({super.key});
|
const OnboardingPage({super.key});
|
||||||
|
|
||||||
@@ -10,27 +36,58 @@ class OnboardingPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _OnboardingPageState extends State<OnboardingPage> {
|
class _OnboardingPageState extends State<OnboardingPage> {
|
||||||
|
final PageController _controller = PageController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final PageController _controller = PageController();
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
PageView(
|
AnimatedBuilder(
|
||||||
// horizontally scrollable list of pages
|
animation: _controller,
|
||||||
controller: _controller,
|
builder: (context, child) {
|
||||||
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
OnboardingCard(index: 1, title: "Welcome to anyway!", description: "Anyway helps you plan a city trip that suits your wishes.", imagePath: "assets/city.svg"),
|
Container(
|
||||||
OnboardingCard(index: 2, title: "Find your way", description: "Bored by churches? No problem! Hate shopping? No worries! More than showing you the typical 'must-sees' of a city, anyway will try to give you recommendations that really suit you.", imagePath: "assets/plan.svg"),
|
decoration: BoxDecoration(
|
||||||
OnboardingCard(index: 3, title: "Change your mind", description: "Life happens when you're busy making plans. Anyway understands that! Move or remove destinations, visit hidden gems along your journey, do your own thing. Anyway adapts to your spontaneous decisions.", imagePath: "assets/cat.svg"),
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: APP_GRADIENT.colors,
|
||||||
|
stops: [
|
||||||
|
(_controller.hasClients ? _controller.page ?? _controller.initialPage : _controller.initialPage) / onboardingCards.length,
|
||||||
|
(_controller.hasClients ? _controller.page ?? _controller.initialPage + 1 : _controller.initialPage + 1) / onboardingCards.length,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withOpacity(0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
PageView(
|
||||||
|
controller: _controller,
|
||||||
|
children: List.generate(
|
||||||
|
onboardingCards.length,
|
||||||
|
(index) {
|
||||||
|
return Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: onboardingCards[index],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_controller.page == 2) {
|
if (_controller.page == onboardingCards.length - 1) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const NewTripPage()
|
builder: (context) => const NewTripPage()
|
||||||
@@ -40,7 +97,22 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
_controller.nextPage(duration: Duration(milliseconds: 500), curve: Curves.ease);
|
_controller.nextPage(duration: Duration(milliseconds: 500), curve: Curves.ease);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Icon(Icons.arrow_forward),
|
label: AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
if ((_controller.page ?? _controller.initialPage) == onboardingCards.length - 1) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const Text("Start planning!"),
|
||||||
|
Padding(padding: const EdgeInsets.only(right: 8.0)),
|
||||||
|
const Icon(Icons.map_outlined)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return const Icon(Icons.arrow_forward);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import 'package:anyway/constants.dart';
|
import 'package:anyway/constants.dart';
|
||||||
import 'package:anyway/main.dart';
|
import 'package:anyway/main.dart';
|
||||||
|
import 'package:anyway/pages/base_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -16,7 +17,8 @@ class SettingsPage extends StatefulWidget {
|
|||||||
class _SettingsPageState extends State<SettingsPage> {
|
class _SettingsPageState extends State<SettingsPage> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListView(
|
return BasePage(
|
||||||
|
mainScreen: ListView(
|
||||||
padding: EdgeInsets.all(15),
|
padding: EdgeInsets.all(15),
|
||||||
children: [
|
children: [
|
||||||
// First a round, centered image
|
// First a round, centered image
|
||||||
@@ -40,6 +42,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
|
|
||||||
privacyInfo(),
|
privacyInfo(),
|
||||||
]
|
]
|
||||||
|
),
|
||||||
|
title: Text('Settings'),
|
||||||
|
helpTexts: [
|
||||||
|
'Settings',
|
||||||
|
'Preferences set in this page are global and will affect the entire application.'
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +177,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text('Our privacy policy is available under:'),
|
Text('AnyWay does not collect or store any of the data that is submitted via the app. The location of your trip is not stored. The location feature is only used to show your current location on the map, it is not transmitted to our servers.', textAlign: TextAlign.center),
|
||||||
|
Padding(padding: EdgeInsets.only(top: 3)),
|
||||||
|
Text('Our full privacy policy is available under:', textAlign: TextAlign.center),
|
||||||
|
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
icon: Icon(Icons.info),
|
icon: Icon(Icons.info),
|
||||||
|
@@ -24,8 +24,7 @@ final class Landmark extends LinkedListEntry<Landmark>{
|
|||||||
// description to be shown in the overview
|
// description to be shown in the overview
|
||||||
final String? nameEN;
|
final String? nameEN;
|
||||||
final String? websiteURL;
|
final String? websiteURL;
|
||||||
final String? wikipediaURL;
|
String? imageURL; // not final because it can be patched
|
||||||
final String? imageURL;
|
|
||||||
final String? description;
|
final String? description;
|
||||||
final Duration? duration;
|
final Duration? duration;
|
||||||
final bool? visited;
|
final bool? visited;
|
||||||
@@ -44,7 +43,6 @@ final class Landmark extends LinkedListEntry<Landmark>{
|
|||||||
|
|
||||||
this.nameEN,
|
this.nameEN,
|
||||||
this.websiteURL,
|
this.websiteURL,
|
||||||
this.wikipediaURL,
|
|
||||||
this.imageURL,
|
this.imageURL,
|
||||||
this.description,
|
this.description,
|
||||||
this.duration,
|
this.duration,
|
||||||
@@ -70,7 +68,6 @@ final class Landmark extends LinkedListEntry<Landmark>{
|
|||||||
final isSecondary = json['is_secondary'] as bool?;
|
final isSecondary = json['is_secondary'] as bool?;
|
||||||
final nameEN = json['name_en'] as String?;
|
final nameEN = json['name_en'] as String?;
|
||||||
final websiteURL = json['website_url'] as String?;
|
final websiteURL = json['website_url'] as String?;
|
||||||
final wikipediaURL = json['wikipedia_url'] as String?;
|
|
||||||
final imageURL = json['image_url'] as String?;
|
final imageURL = json['image_url'] as String?;
|
||||||
final description = json['description'] as String?;
|
final description = json['description'] as String?;
|
||||||
var duration = Duration(minutes: json['duration'] ?? 0) as Duration?;
|
var duration = Duration(minutes: json['duration'] ?? 0) as Duration?;
|
||||||
@@ -85,7 +82,6 @@ final class Landmark extends LinkedListEntry<Landmark>{
|
|||||||
isSecondary: isSecondary,
|
isSecondary: isSecondary,
|
||||||
nameEN: nameEN,
|
nameEN: nameEN,
|
||||||
websiteURL: websiteURL,
|
websiteURL: websiteURL,
|
||||||
wikipediaURL: wikipediaURL,
|
|
||||||
imageURL: imageURL,
|
imageURL: imageURL,
|
||||||
description: description,
|
description: description,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
@@ -112,7 +108,6 @@ final class Landmark extends LinkedListEntry<Landmark>{
|
|||||||
'is_secondary': isSecondary,
|
'is_secondary': isSecondary,
|
||||||
'name_en': nameEN,
|
'name_en': nameEN,
|
||||||
'website_url': websiteURL,
|
'website_url': websiteURL,
|
||||||
'wikipedia_url': wikipediaURL,
|
|
||||||
'image_url': imageURL,
|
'image_url': imageURL,
|
||||||
'description': description,
|
'description': description,
|
||||||
'duration': duration?.inMinutes,
|
'duration': duration?.inMinutes,
|
||||||
@@ -130,7 +125,7 @@ class LandmarkType {
|
|||||||
LandmarkType({required this.name, this.icon = const Icon(Icons.location_on)}) {
|
LandmarkType({required this.name, this.icon = const Icon(Icons.location_on)}) {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case 'sightseeing':
|
case 'sightseeing':
|
||||||
icon = const Icon(Icons.church);
|
icon = const Icon(Icons.castle);
|
||||||
break;
|
break;
|
||||||
case 'nature':
|
case 'nature':
|
||||||
icon = const Icon(Icons.eco);
|
icon = const Icon(Icons.eco);
|
||||||
|
@@ -113,10 +113,3 @@ LinkedList<Landmark> readLandmarks(SharedPreferences prefs, String? firstUUID) {
|
|||||||
}
|
}
|
||||||
return landmarks;
|
return landmarks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
void removeAllTripsFromPrefs () async {
|
|
||||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
|
||||||
prefs.clear();
|
|
||||||
}
|
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
import "dart:developer";
|
import "dart:developer";
|
||||||
|
import "package:anyway/utils/load_landmark_image.dart";
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import 'package:anyway/constants.dart';
|
import 'package:anyway/constants.dart';
|
||||||
@@ -85,6 +86,20 @@ fetchTrip(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
patchLandmarkImage(Landmark landmark) async {
|
||||||
|
// patch the landmark to include an image from an external source
|
||||||
|
if (landmark.imageURL == null) {
|
||||||
|
String? newUrl = await getImageUrlFromName(landmark.name);
|
||||||
|
if (newUrl != null) {
|
||||||
|
landmark.imageURL = newUrl;
|
||||||
|
}
|
||||||
|
} else if (landmark.imageURL!.contains("photos.app.goo.gl")) {
|
||||||
|
// the image is a google photos link, we should get the image behind the link
|
||||||
|
String? newUrl = await getImageUrlFromGooglePhotos(landmark.imageURL!);
|
||||||
|
// also set the new url if it is null
|
||||||
|
landmark.imageURL = newUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<(Landmark, String?)> fetchLandmark(String uuid) async {
|
Future<(Landmark, String?)> fetchLandmark(String uuid) async {
|
||||||
final response = await dio.get(
|
final response = await dio.get(
|
||||||
@@ -101,5 +116,7 @@ Future<(Landmark, String?)> fetchLandmark(String uuid) async {
|
|||||||
log(response.data.toString());
|
log(response.data.toString());
|
||||||
Map<String, dynamic> json = response.data;
|
Map<String, dynamic> json = response.data;
|
||||||
String? nextUUID = json["next_uuid"];
|
String? nextUUID = json["next_uuid"];
|
||||||
return (Landmark.fromJson(json), nextUUID);
|
Landmark landmark = Landmark.fromJson(json);
|
||||||
|
patchLandmarkImage(landmark);
|
||||||
|
return (landmark, nextUUID);
|
||||||
}
|
}
|
||||||
|
41
frontend/lib/utils/get_first_page.dart
Normal file
41
frontend/lib/utils/get_first_page.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:anyway/pages/current_trip.dart';
|
||||||
|
import 'package:anyway/pages/onboarding.dart';
|
||||||
|
import 'package:anyway/structs/trip.dart';
|
||||||
|
import 'package:anyway/utils/load_trips.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
Widget getFirstPage() {
|
||||||
|
SavedTrips trips = SavedTrips();
|
||||||
|
trips.loadTrips();
|
||||||
|
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: trips,
|
||||||
|
builder: (BuildContext context, Widget? child) {
|
||||||
|
List<Trip> items = trips.trips;
|
||||||
|
if (items.isNotEmpty) {
|
||||||
|
return TripPage(trip: items[0]);
|
||||||
|
} else {
|
||||||
|
return OnboardingPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Future<List<Trip>> trips = loadTrips();
|
||||||
|
// // test if there are any active trips
|
||||||
|
// // if there are, return the trip list
|
||||||
|
// // if there are not, return the onboarding page
|
||||||
|
// return FutureBuilder(
|
||||||
|
// future: trips,
|
||||||
|
// builder: (context, snapshot) {
|
||||||
|
// if (snapshot.hasData) {
|
||||||
|
// List<Trip> availableTrips = snapshot.data!;
|
||||||
|
// if (availableTrips.isNotEmpty) {
|
||||||
|
// return TripPage(trip: availableTrips[0]);
|
||||||
|
// } else {
|
||||||
|
// return OnboardingPage();
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// return CircularProgressIndicator();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// );
|
||||||
|
}
|
71
frontend/lib/utils/load_landmark_image.dart
Normal file
71
frontend/lib/utils/load_landmark_image.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:fuzzywuzzy/model/extracted_result.dart';
|
||||||
|
|
||||||
|
const String baseUrl = "https://en.wikipedia.org/w/api.php";
|
||||||
|
final Dio dio = Dio();
|
||||||
|
|
||||||
|
Future<int?> bestPageMatch(String title) async {
|
||||||
|
final response = await dio.get(baseUrl, queryParameters: {
|
||||||
|
"action": "query",
|
||||||
|
"format": "json",
|
||||||
|
"list": "prefixsearch",
|
||||||
|
"pssearch": title,
|
||||||
|
});
|
||||||
|
|
||||||
|
final data = jsonDecode(response.toString());
|
||||||
|
log(data.toString());
|
||||||
|
final List<dynamic> results = data["query"]["prefixsearch"] ?? {};
|
||||||
|
final Map<String, int> titlesAndIds = {
|
||||||
|
for (var d in results) d["title"]: d["pageid"]
|
||||||
|
};
|
||||||
|
if (titlesAndIds.isEmpty) {
|
||||||
|
log("No pages found for $title");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// after the empty check, we can safely assume that there is a best match
|
||||||
|
final ExtractedResult<String> bestMatch = extractOne(
|
||||||
|
query: title,
|
||||||
|
choices: titlesAndIds.keys.toList(),
|
||||||
|
cutoff: 70,
|
||||||
|
);
|
||||||
|
return titlesAndIds[bestMatch.choice];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> getImageUrl(int pageId) async {
|
||||||
|
final response = await dio.get(baseUrl, queryParameters: {
|
||||||
|
"action": "query",
|
||||||
|
"format": "json",
|
||||||
|
"prop": "pageimages",
|
||||||
|
"pageids": pageId,
|
||||||
|
"pithumbsize": 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
final data = jsonDecode(response.toString());
|
||||||
|
final pageData = data["query"]["pages"][pageId.toString()];
|
||||||
|
return pageData["thumbnail"]?["source"];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> getImageUrlFromName(String title) async {
|
||||||
|
int? pageId = await bestPageMatch(title);
|
||||||
|
if (pageId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await getImageUrl(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Future<String?> getImageUrlFromGooglePhotos(String url) async {
|
||||||
|
// this is a very simple implementation that just gets the image behind the link
|
||||||
|
// it is not guaranteed to work for all google photos links
|
||||||
|
final response = await dio.get(url);
|
||||||
|
final data = response.toString();
|
||||||
|
final int start = data.indexOf("https://lh3.googleusercontent.com");
|
||||||
|
final int end = data.indexOf('"', start);
|
||||||
|
return data.substring(start, end);
|
||||||
|
}
|
@@ -1,10 +1,14 @@
|
|||||||
import 'dart:collection';
|
|
||||||
|
|
||||||
import 'package:anyway/structs/trip.dart';
|
import 'package:anyway/structs/trip.dart';
|
||||||
import 'package:anyway/structs/landmark.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
Future<List<Trip>> loadTrips() async {
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class SavedTrips extends ChangeNotifier {
|
||||||
|
List<Trip> _trips = [];
|
||||||
|
|
||||||
|
List<Trip> get trips => _trips;
|
||||||
|
|
||||||
|
void loadTrips() async {
|
||||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
List<Trip> trips = [];
|
List<Trip> trips = [];
|
||||||
@@ -15,5 +19,21 @@ Future<List<Trip>> loadTrips() async {
|
|||||||
trips.add(Trip.fromPrefs(prefs, uuid));
|
trips.add(Trip.fromPrefs(prefs, uuid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return trips;
|
_trips = trips;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addTrip(Trip trip) async {
|
||||||
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
|
trip.toPrefs(prefs);
|
||||||
|
_trips.add(trip);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearTrips () async {
|
||||||
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
|
prefs.clear();
|
||||||
|
_trips = [];
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -101,10 +101,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: collection
|
name: collection
|
||||||
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
|
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.19.0"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -232,6 +232,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
fuzzywuzzy:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: fuzzywuzzy
|
||||||
|
sha256: "3004379ffd6e7f476a0c2091f38f16588dc45f67de7adf7c41aa85dec06b432c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
geocoding:
|
geocoding:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -404,18 +412,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
|
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.5"
|
version: "10.0.7"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
|
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.5"
|
version: "3.0.8"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -700,7 +708,7 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.99"
|
version: "0.0.0"
|
||||||
sliding_up_panel:
|
sliding_up_panel:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -745,10 +753,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: stack_trace
|
name: stack_trace
|
||||||
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
|
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.11.1"
|
version: "1.12.0"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -769,10 +777,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: string_scanner
|
name: string_scanner
|
||||||
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
|
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
version: "1.3.0"
|
||||||
synchronized:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -793,10 +801,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
|
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
version: "0.7.3"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -913,10 +921,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
|
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "14.2.5"
|
version: "14.3.0"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@@ -51,6 +51,7 @@ dependencies:
|
|||||||
flutter_launcher_icons: ^0.13.1
|
flutter_launcher_icons: ^0.13.1
|
||||||
permission_handler: ^11.3.1
|
permission_handler: ^11.3.1
|
||||||
geolocator: ^13.0.1
|
geolocator: ^13.0.1
|
||||||
|
fuzzywuzzy: ^1.2.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
@@ -1,30 +0,0 @@
|
|||||||
// This is a basic Flutter widget test.
|
|
||||||
//
|
|
||||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
|
||||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
// import 'package:anyway/main.dart';
|
|
||||||
import 'package:anyway/layout.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
|
||||||
// Build our app and trigger a frame.
|
|
||||||
await tester.pumpWidget(BasePage(mainScreen: "map",));
|
|
||||||
|
|
||||||
// Verfiy that the title is displayed
|
|
||||||
expect(find.text('City Nav'), findsOneWidget);
|
|
||||||
|
|
||||||
// Tap the '+' icon and trigger a frame.
|
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
|
||||||
expect(find.text('0'), findsNothing);
|
|
||||||
expect(find.text('1'), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
|
48
status
Normal file
48
status
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
error: wrong number of arguments, should be from 1 to 2
|
||||||
|
usage: git config [<options>]
|
||||||
|
|
||||||
|
Config file location
|
||||||
|
--[no-]global use global config file
|
||||||
|
--[no-]system use system config file
|
||||||
|
--[no-]local use repository config file
|
||||||
|
--[no-]worktree use per-worktree config file
|
||||||
|
-f, --[no-]file <file>
|
||||||
|
use given config file
|
||||||
|
--[no-]blob <blob-id> read config from given blob object
|
||||||
|
|
||||||
|
Action
|
||||||
|
--[no-]get get value: name [value-pattern]
|
||||||
|
--[no-]get-all get all values: key [value-pattern]
|
||||||
|
--[no-]get-regexp get values for regexp: name-regex [value-pattern]
|
||||||
|
--[no-]get-urlmatch get value specific for the URL: section[.var] URL
|
||||||
|
--[no-]replace-all replace all matching variables: name value [value-pattern]
|
||||||
|
--[no-]add add a new variable: name value
|
||||||
|
--[no-]unset remove a variable: name [value-pattern]
|
||||||
|
--[no-]unset-all remove all matches: name [value-pattern]
|
||||||
|
--[no-]rename-section rename section: old-name new-name
|
||||||
|
--[no-]remove-section remove a section: name
|
||||||
|
-l, --[no-]list list all
|
||||||
|
--[no-]fixed-value use string equality when comparing values to 'value-pattern'
|
||||||
|
-e, --[no-]edit open an editor
|
||||||
|
--[no-]get-color find the color configured: slot [default]
|
||||||
|
--[no-]get-colorbool find the color setting: slot [stdout-is-tty]
|
||||||
|
|
||||||
|
Type
|
||||||
|
-t, --[no-]type <type>
|
||||||
|
value is given this type
|
||||||
|
--bool value is "true" or "false"
|
||||||
|
--int value is decimal number
|
||||||
|
--bool-or-int value is --bool or --int
|
||||||
|
--bool-or-str value is --bool or string
|
||||||
|
--path value is a path (file or directory name)
|
||||||
|
--expiry-date value is an expiry date
|
||||||
|
|
||||||
|
Other
|
||||||
|
-z, --[no-]null terminate values with NUL byte
|
||||||
|
--[no-]name-only show variable names only
|
||||||
|
--[no-]includes respect include directives on lookup
|
||||||
|
--[no-]show-origin show origin of config (file, standard input, blob, command line)
|
||||||
|
--[no-]show-scope show scope of config (worktree, local, global, system, command)
|
||||||
|
--[no-]default <value>
|
||||||
|
with --get, use default value when missing entry
|
||||||
|
|
Reference in New Issue
Block a user