Compare commits

..

6 Commits

Author SHA1 Message Date
8b34f3727b remove prints 2025-01-15 10:11:22 +01:00
bb40743db2 test assertion for formatting 2025-01-15 09:09:55 +01:00
7a74606c03 formatting for tests 2025-01-15 09:00:16 +01:00
ce4b331c0a better logs 2025-01-15 08:04:09 +01:00
c5a08125f6 better clusters 2025-01-15 07:10:00 +01:00
85f70d835a parallel test to compare speeds 2025-01-15 06:58:25 +01:00
167 changed files with 203451 additions and 6910 deletions

View File

@ -28,5 +28,5 @@ jobs:
working-directory: backend
- name: Run linter
run: pipenv run pylint src --fail-under=9
run: pipenv run pylint src --fail-under=9
working-directory: backend

View File

@ -28,7 +28,7 @@ jobs:
working-directory: backend
- name: Run Tests
run: pipenv run pytest src --html=report.html --self-contained-html --log-cli-level=DEBUG
run: pipenv run pytest src --html=report.html --self-contained-html
working-directory: backend
- name: Upload HTML report

View File

@ -0,0 +1,67 @@
on:
pull_request:
branches:
- main
paths:
- frontend/**
name: Build and release debug APK
jobs:
build:
name: Build APK
runs-on: ubuntu-latest
steps:
- name: Install prerequisites
run: |
apt-get update
apt-get install -y jq
- uses: https://gitea.com/actions/checkout@v4
- uses: https://github.com/actions/setup-java@v4
with:
java-version: '17'
distribution: 'zulu'
- name: Fix flutter SDK folder permission
run: git config --global --add safe.directory "*"
- uses: https://github.com/subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.22.0
cache: true
- name: Setup Android SDK
uses: https://github.com/android-actions/setup-android@v3
- run: flutter pub get
working-directory: ./frontend
- name: Add required secrets
env:
ANDROID_SECRETS_PROPERTIES: ${{ secrets.ANDROID_SECRETS_PROPERTIES }}
run: |
echo "$ANDROID_SECRETS_PROPERTIES" >> ./android/secrets.properties
working-directory: ./frontend
- name: Sanity check
run: |
ls
ls -lah android
working-directory: ./frontend
- run: flutter build apk --debug --split-per-abi --build-number=${{ gitea.run_number }}
working-directory: ./frontend
- name: Upload APKs to artifacts
uses: https://gitea.com/actions/upload-artifact@v3
with:
name: app-release
path: frontend/build/app/outputs/flutter-apk/
if-no-files-found: error
retention-days: 15

View File

@ -1,74 +0,0 @@
on:
pull_request:
branches:
- main
paths:
- frontend/**
name: Build and release debug APK
defaults:
run:
working-directory: frontend/android
jobs:
build:
runs-on: macos
env:
# $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
BUNDLE_GEMFILE: ${{ gitea.workspace }}/frontend/android/Gemfile
steps:
- uses: https://gitea.com/actions/checkout@v4
- uses: https://github.com/actions/setup-java@v4
with:
java-version: '17'
distribution: 'zulu'
- name: Setup Android SDK
uses: https://github.com/android-actions/setup-android@v3
- name: Fix flutter SDK folder permission
run: git config --global --add safe.directory "*"
- uses: https://github.com/subosito/flutter-action@v2
with:
channel: stable
flutter-version-file: ${{ gitea.workspace }}/frontend/pubspec.yaml
architecture: x64
cache: true
- name: Install dependencies and clean up
run: |
flutter pub get
flutter clean
- name: Set up ruby env and install fastlane
uses: https://github.com/ruby/setup-ruby@v1
with:
ruby-version: 3.3
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: Infer version number from git tag
id: version
env:
REF_NAME: ${{ gitea.ref_name }}
run:
# remove the 'v' prefix from the tag name
echo "BUILD_NAME=${REF_NAME//v}" >> $GITHUB_ENV
- name: Add required secret files
run: |
echo "${{ secrets.ANDROID_SECRET_PROPERTIES_BASE64 }}" | base64 -d > secrets.properties
echo "${{ secrets.ANDROID_GOOGLE_PLAY_JSON_BASE64 }}" | base64 -d > google-key.json
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > release.keystore
- name: Run fastlane lane
run: bundle exec fastlane deploy_beta
env:
BUILD_NUMBER: ${{ gitea.run_number }}
# BUILD_NAME is implicitly available
ANDROID_GOOGLE_MAPS_API_KEY: ${{ secrets.ANDROID_GOOGLE_MAPS_API_KEY }}

View File

@ -1,72 +0,0 @@
on:
pull_request:
branches:
- main
paths:
- frontend/**
name: Build and release debugging app to ios testflight
defaults:
run:
working-directory: frontend/ios
jobs:
build:
runs-on: macos
env:
# $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
BUNDLE_GEMFILE: ${{ gitea.workspace }}/frontend/ios/Gemfile
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Install Flutter
uses: https://github.com/subosito/flutter-action@v2
with:
channel: stable
flutter-version-file: ${{ gitea.workspace }}/frontend/pubspec.yaml
architecture: x64
cache: true
- name: Install dependencies and clean up
run: |
flutter pub get
bundle exec pod install
flutter clean
bundle exec pod cache clean --all
- name: Set up ruby env
uses: https://github.com/ruby/setup-ruby@v1
with:
ruby-version: 3.3
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: Infer version number from git tag
id: version
env:
REF_NAME: ${{ gitea.ref_name }}
run:
# remove the 'v' prefix from the tag name
echo "BUILD_NAME=${REF_NAME//v}" >> $GITHUB_ENV
- name: Setup SSH key for match git repo
# and mark the host as known
run: |
echo $MATCH_REPO_SSH_KEY | base64 --decode > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -p 2222 git.kluster.moll.re > ~/.ssh/known_hosts
env:
MATCH_REPO_SSH_KEY: ${{ secrets.IOS_MATCH_REPO_SSH_KEY_BASE64 }}
- name: Run fastlane lane
run: bundle exec fastlane deploy_beta
env:
BUILD_NUMBER: ${{ gitea.run_number }}
# BUILD_NAME is implicitly available
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
IOS_ASC_KEY_ID: ${{ secrets.IOS_ASC_KEY_ID }}
IOS_ASC_ISSUER_ID: ${{ secrets.IOS_ASC_ISSUER_ID }}
IOS_ASC_KEY: ${{ secrets.IOS_ASC_KEY }}
MATCH_PASSWORD: ${{ secrets.IOS_MATCH_PASSWORD }}
IOS_GOOGLE_MAPS_API_KEY: ${{ secrets.IOS_GOOGLE_MAPS_API_KEY }}

View File

@ -0,0 +1,34 @@
# on:
# pull_request:
# branches:
# - main
# paths:
# - frontend/**
# name: Build web
# jobs:
# build:
# name: Build Web
# runs-on: ubuntu-latest
# steps:
# - name: Install prerequisites
# run: |
# sudo apt-get update
# sudo apt-get install -y xz-utils
# - uses: actions/checkout@v4
# - uses: https://github.com/subosito/flutter-action@v2
# with:
# channel: stable
# flutter-version: 3.19.6
# cache: true
# - run: flutter pub get
# working-directory: ./frontend
# - run: flutter build web
# working-directory: ./frontend

View File

@ -0,0 +1,39 @@
on:
push:
tags:
- v*
jobs:
push-to-remote:
# We want to use the macos runner provided by github actions. This requires to push to a remote first.
# After the push we can use the action under frontend/.github/actions/ to deploy properly using fastlane on macos.
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
path: 'src'
- name: Checkout remote repository
uses: actions/checkout@v3
with:
path: 'dest'
ref: 'main'
github-server-url: 'https://github.com'
repository: 'moll-re/anyway-frontend-builder'
token: ${{ secrets.PUSH_GITHUB_API_TOKEN }}
fetch-depth: 0
persist-credentials: true
- name: Copy files to remote repository
run: cp -r src/frontend/. dest/
- name: Commit and push changes
run: |
cd dest
git config --global user.email "me@moll.re"
git config --global user.name "[bot]"
git add .
git commit -m "Automatic code update for tag"
git tag -a ${{ github.ref_name }} -m "mirrored tag"
git push origin main --tags

7
.vscode/launch.json vendored
View File

@ -36,10 +36,7 @@
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"cwd": "${workspaceFolder}/frontend",
"env": {
"GOOGLE_MAPS_API_KEY": "testing"
}
"cwd": "${workspaceFolder}/frontend"
},
{
"name": "Frontend - profile",
@ -50,4 +47,4 @@
"cwd": "${workspaceFolder}/frontend"
}
]
}
}

View File

@ -1,30 +0,0 @@
# 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).

View File

@ -15,7 +15,7 @@ This project is divided into two main components: a frontend and a backend. The
See the [frontend README](frontend/README.md) for more information. The application is centered around its map view, which displays the user's itinerary. This is based on the Google Maps API.
### Backend
See the [backend README](backend/README.md) for more information. The backend is responsible for generating the itinerary based on the user's preferences and constraints. Rather than using google maps, we use the OpenStreetMap database through the Overpass API, which is much more flexible.
See the [backend README](backend/README.md) for more information. The backend is responsible for generating the itinerary based on the user's preferences and constraints. Rather than using google maps, we use the OpenStreetMap API, which is much more flexible.
## Getting Started
@ -24,8 +24,8 @@ Refer to the READMEs in the `frontend` and `backend` directories for instruction
- `google_maps_flutter` plugin
- Python 3
- `fastapi`
- `numpy`
- `pydantic`
- `OSMPythonTools`
- `numpy, scipy`
- Docker

9
backend/.gitignore vendored
View File

@ -1,8 +1,9 @@
# osm-cache
cache_XML/
# osm-cache and wikidata cache
cache/
apicache/
# secrets
*secrets.yaml
# wikidata throttle
*.ctrl
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@ -293,7 +293,7 @@ ignored-parents=
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=20
max-attributes=7
# Maximum number of boolean expressions in an if statement (see R0916).
max-bool-expr=5
@ -302,7 +302,7 @@ max-bool-expr=5
max-branches=12
# Maximum number of locals for function / method body.
max-locals=30
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
@ -402,7 +402,7 @@ preferred-modules=
# The type of string formatting that logging methods do. `old` means using %
# formatting, `new` is for `{}` formatting.
logging-format-style=new
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
@ -440,14 +440,7 @@ disable=raw-checker-failed,
use-implicit-booleaness-not-comparison-to-string,
use-implicit-booleaness-not-comparison-to-zero,
import-error,
multiple-statements,
line-too-long,
logging-fstring-interpolation,
duplicate-code,
relative-beyond-top-level,
invalid-name,
too-many-arguments,
too-many-positional-arguments
line-too-long
# 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

View File

@ -18,10 +18,11 @@ numpy = "*"
fastapi = "*"
pydantic = "*"
shapely = "*"
scipy = "*"
osmpythontools = "*"
pywikibot = "*"
pymemcache = "*"
fastapi-cli = "*"
scikit-learn = "*"
pyqt6 = "*"
loki-logger-handler = "*"
pulp = "*"
scipy = "*"
requests = "*"

1884
backend/Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -38,19 +38,7 @@ To deploy the backend docker container, we use kubernetes. Modifications to the
The deployment configuration is included as a submodule in the `deployment` directory. The standalone repository is under [https://git.kluster.moll.re/anydev/anyway-backend-deployment/](https://git.kluster.moll.re/anydev/anyway-backend-deployment/).
## Development
TBD
The backend application is structured around the `src` directory, which contains the core components for handling route optimization and API logic. Development generally involves working with key modules such as the optimization engine, Overpass API integration, and utilities for managing landmarks and trip data.
### Key Areas:
- **API Endpoints**: The main interaction with the backend is through the endpoints defined in `src/main.py`. FastAPI simplifies the creation of RESTful services that manage trip and landmark data.
- **Optimization Logic**: The trip optimization and refinement are handled in the `src/optimization` module. This is where the core algorithms are implemented.
- **Landmark Management**: Fetching and prioritizing points of interest (POIs) based on user preferences happens in `src/utils/LandmarkManager`.
- **Testing**: The `src/tests` directory includes tests in various scenarii, ensuring that the logic works as expected.
For detailed information, refer to the [src README](backend/src/README.md).
### Running the Application:
To run the backend locally, ensure that the virtual environment is activated and all dependencies are installed as outlined in the "Getting Started" section. You can start the FastAPI server with:
```bash
uvicorn src.main:app --reload

View File

@ -1,363 +0,0 @@
[
{
"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

View File

@ -1,65 +0,0 @@
# Overview of backend/src
This project is structured into several components that handle different aspects of the application's functionality. Below is a high-level overview of each folder and the key Python files in the |src| directory.
## Folders
### src/optimization
This folder contains modules related to the optimization algorithm used to compute the optimal trip. It comprises the optimizer for the first rough trip and a refiner to include less famous landmarks as well.
### src/overpass
This folder handles interactions with the Overpass API, including constructing and sending queries, caching responses, and parsing results from the Overpass database.
### src/parameters
The modules in this folder define and manage parameters for various parts of the application. This includes configuration values for the optimizer or the list of selectors for Overpass queries.
### src/structs
This folder defines the commonly used data structures used within the project. The models leverage Pydantic's `BaseModel` to ensure data validation, serialization, and easy interaction between different components of the application. The main classes are:
- **Landmark**:
- Represents a point of interest in the context of a trip. It stores various attributes like the landmark's name, type, location (latitude and longitude), and its OSM details.
- It also includes other optional fields like image URLs, website links, and descriptions. Additionally, the class has properties to track its attractiveness score or elative importance.
- **Preferences**:
- This class captures user-defined preferences needed to personalize a trip. Preferences are provided for sightseeing (history and culture), nature (parks and gardens), and shopping. These preferences guide the trip optimization process.
- **Trip**:
- The `Trip` class represents the complete travel plan generated by the system. It holds key information like the trip's total time and the first landmark's UUID.
### src/tests
This folder contains unit tests and test cases for the application's various modules. It is used to ensure the correctness and stability of the code.
### src/utils
The `utils` folder contains utility classes and functions that provide core functionality for the application. The main component in this folder is the `LandmarkManager`, which is central to the process of fetching and organizing landmarks.
- **LandmarkManager**:
- The `LandmarkManager` is responsible for fetching landmarks from OpenStreetMap (via the Overpass API) and managing their classification based on user preferences. It processes raw geographical data, filters landmarks into relevant categories (such as sightseeing, nature, shopping), and prioritizes them for trip planning.
## Files
### src/cache.py
This file manages the caching mechanisms used throughout the application. It defines the caching strategy for storing and retrieving data, improving the performance of repeated operations by avoiding redundant API calls or computations.
### src/constants.py
This module defines global constants used throughout the project. These constants may include API endpoints, fixed configuration values, or reusable strings and integers that need to remain consistent.
### src/logging_config.py
This file configures the logging system for the application. It defines how logs are formatted, where they are output (e.g., console or file), and the logging levels (e.g., debug, info, error).
### src/main.py
This file contains the main application logic and API endpoints for interacting with the system. The application is built using the FastAPI framework, which provides several endpoints for creating trips, fetching trips, and retrieving landmarks or nearby facilities. The key endpoints include:
- **POST /trip/new**:
- This endpoint allows users to create a new trip by specifying preferences, start coordinates, and optionally end coordinates. The preferences guide the optimization process for selecting landmarks.
- Returns: A `Trip` object containing the optimized route, landmarks, and trip details.
- **GET /trip/{trip_uuid}**:
- This endpoint fetches an already generated trip by its unique identifier (`trip_uuid`). It retrieves the trip data from the cache.
- Returns: A `Trip` object corresponding to the given `trip_uuid`.
- **GET /landmark/{landmark_uuid}**:
- This endpoint retrieves a specific landmark by its unique identifier (`landmark_uuid`) from the cache.
- Returns: A `Landmark` object containing the details of the requested landmark.
- **POST /toilets/new**:
- This endpoint searches for public toilets near a specified location within a given radius. The location and radius are passed as query parameters.
- Returns: A list of `Toilets` objects located within the specified radius of the provided coordinates.

View File

@ -70,6 +70,6 @@ else:
MEMCACHED_HOST_PATH,
timeout=1,
allow_unicode_keys=True,
encoding='utf-8',
encoding='utf-8',
serde=serde.pickle_serde
)

View File

@ -2,7 +2,6 @@
import os
from pathlib import Path
from typing import List, Literal, Tuple
LOCATION_PREFIX = Path('src')
@ -15,8 +14,6 @@ OPTIMIZER_PARAMETERS_PATH = PARAMETERS_DIR / 'optimizer_parameters.yaml'
cache_dir_string = os.getenv('OSM_CACHE_DIR', './cache')
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)
if MEMCACHED_HOST_PATH == "none":

View File

@ -1,440 +0,0 @@
"""Module used to import data from OSM and arrange them in categories."""
import logging
import yaml
from ..structs.preferences import Preferences
from ..structs.landmark import Landmark
from ..utils.take_most_important import take_most_important
from .cluster_manager import ClusterManager
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
class LandmarkManager:
"""
Use this to manage landmarks.
Uses the overpass api to fetch landmarks and classify them.
"""
logger = logging.getLogger(__name__)
radius_close_to: int # radius in meters
church_coeff: float # coeff to adjsut score of churches
nature_coeff: float # coeff to adjust score of parks
overall_coeff: float # coeff to adjust weight of tags
n_important: int # number of important landmarks to consider
def __init__(self) -> None:
with AMENITY_SELECTORS_PATH.open('r') as f:
self.amenity_selectors = yaml.safe_load(f)
with LANDMARK_PARAMETERS_PATH.open('r') as f:
parameters = yaml.safe_load(f)
self.max_bbox_side = parameters['max_bbox_side']
self.church_coeff = parameters['church_coeff']
self.nature_coeff = parameters['nature_coeff']
self.overall_coeff = parameters['overall_coeff']
self.tag_exponent = parameters['tag_exponent']
self.image_bonus = parameters['image_bonus']
self.wikipedia_bonus = parameters['wikipedia_bonus']
self.viewpoint_bonus = parameters['viewpoint_bonus']
self.pay_bonus = parameters['pay_bonus']
self.n_important = parameters['N_important']
with OPTIMIZER_PARAMETERS_PATH.open('r') as f:
parameters = yaml.safe_load(f)
self.walking_speed = parameters['average_walking_speed']
self.detour_factor = parameters['detour_factor']
# Setup the caching in the Overpass class.
self.overpass = Overpass()
self.logger.info('LandmakManager successfully initialized.')
def generate_landmarks_list(self, center_coordinates: tuple[float, float], preferences: Preferences) -> tuple[list[Landmark], list[Landmark]]:
"""
Generate and prioritize a list of landmarks based on user preferences.
This method fetches landmarks from various categories (sightseeing, nature, shopping) based on the user's preferences
and current location. It scores and corrects these landmarks, removes duplicates, and then selects the most important
landmarks based on a predefined criterion.
Args:
center_coordinates (tuple[float, float]): The latitude and longitude of the center location around which to search.
preferences (Preferences): The user's preference settings that influence the landmark selection.
Returns:
tuple[list[Landmark], list[Landmark]]:
- A list of all existing landmarks.
- A list of the most important landmarks based on the user's preferences.
"""
self.logger.debug('Starting to fetch landmarks...')
max_walk_dist = int((preferences.max_time_minute/2)/60*self.walking_speed*1000/self.detour_factor)
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
all_landmarks = set()
# Create a bbox using the around technique, tuple of strings
bbox = create_bbox(center_coordinates, radius)
# list for sightseeing
if preferences.sightseeing.score != 0:
self.logger.debug('Fetching sightseeing landmarks...')
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['sightseeing'], preferences.sightseeing.type, preferences.sightseeing.score)
all_landmarks.update(current_landmarks)
self.logger.info(f'Found {len(current_landmarks)} sightseeing landmarks')
# special pipeline for historic neighborhoods
neighborhood_manager = ClusterManager(bbox, 'sightseeing')
historic_clusters = neighborhood_manager.generate_clusters()
all_landmarks.update(historic_clusters)
# list for nature
if preferences.nature.score != 0:
self.logger.debug('Fetching nature landmarks...')
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['nature'], preferences.nature.type, preferences.nature.score)
all_landmarks.update(current_landmarks)
self.logger.info(f'Found {len(current_landmarks)} nature landmarks')
# list for shopping
if preferences.shopping.score != 0:
self.logger.debug('Fetching shopping landmarks...')
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['shopping'], preferences.shopping.type, preferences.shopping.score)
self.logger.info(f'Found {len(current_landmarks)} shopping landmarks')
# set time for all shopping activites :
for landmark in current_landmarks :
landmark.duration = 30
all_landmarks.update(current_landmarks)
# special pipeline for shopping malls
shopping_manager = ClusterManager(bbox, 'shopping')
shopping_clusters = shopping_manager.generate_clusters()
all_landmarks.update(shopping_clusters)
landmarks_constrained = take_most_important(all_landmarks, self.n_important)
# self.logger.info(f'All landmarks generated : {len(all_landmarks)} landmarks around {center_coordinates}, and constrained to {len(landmarks_constrained)} most important ones.')
return all_landmarks, landmarks_constrained
def set_landmark_score(self, landmark: Landmark, landmarktype: str, preference_level: int) :
"""
Calculate and set the attractiveness score for a given landmark.
This method evaluates the landmark's attractiveness based on its properties
(number of tags, presence of Wikipedia URL, image, website, and whether it's
a place of worship) and adjusts the score using the user's preference level.
Args:
landmark (Landmark): The landmark object to score.
landmarktype (str): The type of the landmark (currently unused).
preference_level (int): The user's preference level for this landmark type.
"""
score = landmark.n_tags**self.tag_exponent
if landmark.wiki_url :
score *= self.wikipedia_bonus
if landmark.image_url :
score *= self.image_bonus
if landmark.website_url :
score *= self.wikipedia_bonus
if landmark.is_place_of_worship :
score *= self.church_coeff
if landmark.is_viewpoint :
score *= self.viewpoint_bonus
if landmarktype == 'nature' :
score *= self.nature_coeff
landmark.attractiveness = int(score * preference_level * 2)
def fetch_landmarks(self, bbox: tuple, amenity_selector: dict, landmarktype: str, preference_level: int) -> list[Landmark]:
"""
Fetches landmarks of a specified type from OpenStreetMap (OSM) within a bounding box centered on given coordinates.
Args:
bbox (tuple[float, float, float, float]): The bounding box coordinates (around:radius, center_lat, center_lon).
amenity_selector (dict): The Overpass API query selector for the desired landmark type.
landmarktype (str): The type of the landmark (e.g., 'sightseeing', 'nature', 'shopping').
Returns:
list[Landmark]: A list of Landmark objects that were fetched and filtered based on the provided criteria.
Notes:
- Landmarks are fetched using Overpass API queries.
- Selectors are translated from the dictionary to the Overpass query format. (e.g., 'amenity'='place_of_worship')
- Landmarks are filtered based on various conditions including tags and type.
"""
return_list = []
if landmarktype == 'nature' : query_conditions = None
else : query_conditions = ['count_tags()>5']
# caution, when applying a list of selectors, overpass will search for elements that match ALL selectors simultaneously
# we need to split the selectors into separate queries and merge the results
for sel in dict_to_selector_list(amenity_selector):
# self.logger.debug(f"Current selector: {sel}")
osm_types = ['way', 'relation']
if 'viewpoint' in sel :
query_conditions = None
osm_types.append('node')
# Send the overpass query
try:
result = self.overpass.send_query(
bbox = bbox,
osm_types = osm_types,
selector = sel,
conditions = query_conditions, # except for nature....
out = 'ids center tags'
)
except Exception as e:
self.logger.debug(f"Failed to fetch landmarks, proceeding without: {str(e)}")
continue
return_list += self._to_landmarks(result, landmarktype, preference_level)
# self.logger.debug(f"Fetched {len(return_list)} landmarks of type {landmarktype} in {bbox}")
return return_list
def _to_landmarks(self, elements: list, landmarktype, preference_level) -> list[Landmark]:
"""
Parse the Overpass API result and extract landmarks.
This method processes the JSON elements returned by the Overpass API and
extracts landmarks of types 'node', 'way', and 'relation'. It retrieves
relevant information such as name, coordinates, and tags, and converts them
into Landmark objects.
Args:
elements (list): The elements of json response from Overpass API.
elem_type (str): The type of landmark (e.g., node, way, relation).
Returns:
list[Landmark]: A list of Landmark objects extracted from the JSON data.
"""
if elements is None :
return []
landmarks = []
for elem in elements:
osm_type = elem.get('type')
id, coords, name = get_base_info(elem, osm_type, with_name=True)
if name is None or coords is None :
continue
tags = elem.get('tags')
# Convert this to Landmark object
landmark = Landmark(name=name,
type=landmarktype,
location=coords,
osm_id=id,
osm_type=osm_type,
attractiveness=0,
n_tags=len(tags))
# Browse through tags to add information to landmark.
for key, value in tags.items():
# Skip this landmark if not suitable.
if key == 'building:part' and value == 'yes' :
break
if 'disused:' in key :
break
if 'boundary:' in key :
break
if 'shop' in key and landmarktype != 'shopping' :
break
# if value == 'apartments' :
# break
# Fill in the other attributes.
if key == 'image' :
landmark.image_url = value
if key == 'website' :
landmark.website_url = value
if value == 'place_of_worship' :
landmark.is_place_of_worship = True
if key == 'wikipedia' :
landmark.wiki_url = value
if key == 'name:en' :
landmark.name_en = value
if 'building:' in key or 'pay' in key :
landmark.n_tags -= 1
# Set the duration.
if value in ['museum', 'aquarium', 'planetarium'] :
landmark.duration = 60
elif value == 'viewpoint' :
landmark.is_viewpoint = True
landmark.duration = 10
elif value == 'cathedral' :
landmark.is_place_of_worship = False
landmark.duration = 10
landmark.description, landmark.keywords = self.description_and_keywords(tags)
self.set_landmark_score(landmark, landmarktype, preference_level)
landmarks.append(landmark)
continue
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:
"""
Convert a dictionary of key-value pairs to a list of Overpass query strings.
Args:
d (dict): A dictionary of key-value pairs representing the selector.
Returns:
list: A list of strings representing the Overpass query selectors.
"""
return_list = []
for key, value in d.items():
if isinstance(value, list):
val = '|'.join(value)
return_list.append(f'{key}~"^({val})$"')
elif isinstance(value, str) and len(value) == 0:
return_list.append(f'{key}')
else:
return_list.append(f'{key}={value}')
return return_list

View File

@ -19,9 +19,10 @@ def configure_logging():
# in that case we want to log to stdout and also to loki
from loki_logger_handler.loki_logger_handler import LokiLoggerHandler
loki_url = os.getenv('LOKI_URL')
loki_url = "http://localhost:3100/loki/api/v1/push"
if loki_url is None:
raise ValueError("LOKI_URL environment variable is not set")
loki_handler = LokiLoggerHandler(
url = loki_url,
labels = {'app': 'anyway', 'environment': 'staging' if is_debug else 'production'}
@ -54,3 +55,4 @@ def configure_logging():
logging.getLogger('uvicorn').handlers = logging_handlers
logging.getLogger('uvicorn.access').handlers = logging_handlers
logging.getLogger('uvicorn.error').handlers = logging_handlers

View File

@ -1,22 +1,21 @@
"""Main app for backend api"""
import logging
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi import FastAPI, HTTPException, Query
from .logging_config import configure_logging
from .structs.landmark import Landmark
from .structs.landmark import Landmark, Toilets
from .structs.preferences import Preferences
from .structs.linked_landmarks import LinkedLandmarks
from .structs.trip import Trip
from .landmarks.landmarks_manager import LandmarkManager
from .toilets.toilet_routes import router as toilets_router
from .optimization.optimizer import Optimizer
from .optimization.refiner import Refiner
from .overpass.overpass import fill_cache
from .utils.landmarks_manager import LandmarkManager
from .utils.toilets_manager import ToiletsManager
from .utils.optimizer import Optimizer
from .utils.refiner import Refiner
from .cache import client as cache_client
logger = logging.getLogger(__name__)
manager = LandmarkManager()
@ -37,14 +36,10 @@ app = FastAPI(lifespan=lifespan)
app.include_router(toilets_router)
@app.post("/trip/new")
def new_trip(preferences: Preferences,
start: tuple[float, float],
end: tuple[float, float] | None = None,
background_tasks: BackgroundTasks = None) -> Trip:
end: tuple[float, float] | None = None) -> Trip:
"""
Main function to call the optimizer.
@ -69,15 +64,12 @@ def new_trip(preferences: Preferences,
end = start
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',
type='start',
location=(start[0], start[1]),
osm_type='start',
osm_id=0,
attractiveness=0,
duration=0,
must_do=True,
n_tags = 0)
@ -87,63 +79,52 @@ def new_trip(preferences: Preferences,
osm_type='end',
osm_id=0,
attractiveness=0,
duration=0,
must_do=True,
n_tags=0)
start_time = time.time()
# Generate the landmarks from the start location
landmarks, landmarks_short = manager.generate_landmarks_list(
center_coordinates = start,
preferences = preferences
)
if len(landmarks) == 0 :
raise HTTPException(status_code=500, detail="No landmarks were found.")
# insert start and finish to the landmarks list
landmarks_short.insert(0, start_landmark)
landmarks_short.append(end_landmark)
t_generate_landmarks = time.time() - start_time
logger.info(f'Fetched {len(landmarks)} landmarks in \t: {round(t_generate_landmarks,3)} seconds')
start_time = time.time()
# First stage optimization
try:
base_tour = optimizer.solve_optimization(preferences.max_time_minute, landmarks_short)
except ArithmeticError as exc:
raise HTTPException(status_code=500) from exc
except TimeoutError as exc:
raise HTTPException(status_code=500, detail="Optimzation took too long") from 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"An unexpected error occurred: {str(exc)}") from exc
t_first_stage = time.time() - start_time
start_time = time.time()
# Second stage optimization
# TODO : only if necessary (not enough landmarks for ex.)
try :
refined_tour = refiner.refine_optimization(landmarks, base_tour,
preferences.max_time_minute,
preferences.detour_tolerance_minute)
except Exception as exc :
logger.warning(f"Refiner failed. Proceeding with base trip {str(exc)}")
refined_tour = base_tour
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(exc)}") from exc
t_second_stage = time.time() - start_time
logger.debug(f'Generating landmarks : {round(t_generate_landmarks,3)} seconds')
logger.debug(f'First stage optimization : {round(t_first_stage,3)} seconds')
logger.debug(f'Second stage optimization : {round(t_second_stage,3)} seconds')
logger.info(f'Total computation time : {round(t_generate_landmarks + t_first_stage + t_second_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.info(f'Total computation time\t: {round(t_first_stage + t_second_stage,3)} seconds')
linked_tour = LinkedLandmarks(refined_tour)
# upon creation of the trip, persistence of both the trip and its landmarks is ensured.
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.debug('Detailed trip :\n\t' + '\n\t'.join(f'{landmark}' for landmark in refined_tour))
background_tasks.add_task(fill_cache)
return trip
@ -163,7 +144,6 @@ def get_trip(trip_uuid: str) -> Trip:
trip = cache_client.get(f"trip_{trip_uuid}")
return trip
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
@ -182,45 +162,32 @@ def get_landmark(landmark_uuid: str) -> Landmark:
landmark = cache_client.get(f"landmark_{landmark_uuid}")
return landmark
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
@app.post("/trip/recompute-time/{trip_uuid}/{removed_landmark_uuid}")
def update_trip_time(trip_uuid: str, removed_landmark_uuid: str) -> Trip:
@app.post("/toilets/new")
def get_toilets(location: tuple[float, float] = Query(...), radius: int = 500) -> list[Toilets] :
"""
Updates the reaching times of a given trip when removing a landmark.
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:
landmark_uuid (str) : unique identifier for a Landmark.
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:
(Landmark) : the corresponding Landmark.
list[Toilets]: A list of Toilets objects that meet the criteria.
"""
# First, fetch the trip in the cache.
try:
trip = cache_client.get(f'trip_{trip_uuid}')
except KeyError as 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
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")
landmarks = []
next_uuid = trip.first_landmark_uuid
toilets_manager = ToiletsManager(location, radius)
# 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
toilets_list = toilets_manager.generate_toilet_list()
return toilets_list
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
raise HTTPException(status_code=404, detail="No toilets found") from exc

View File

@ -1,638 +0,0 @@
"""Module responsible for sloving an MILP to find best tour around the given landmarks."""
import logging
from collections import defaultdict, deque
import yaml
import numpy as np
import pulp as pl
from ..structs.landmark import Landmark
from ..utils.get_time_distance import get_time
from ..constants import OPTIMIZER_PARAMETERS_PATH
# Silence the pupl logger
logging.getLogger('pulp').setLevel(level=logging.CRITICAL)
class Optimizer:
"""
Optimizes the balance between the efficiency of a tour and the inclusion of landmarks.
The `Optimizer` class is responsible for calculating the best possible detour adjustments
to a tour based on specific parameters such as detour time, walking speed, and the maximum
number of landmarks to visit. It helps refine a tour by determining whether adding additional
landmarks would significantly reduce the overall efficiency.
Responsibilities:
- Calculates the maximum detour time allowed for a given tour.
- Considers the detour factor, which accounts for real-world walking paths versus straight-line distance.
- Takes into account the average walking speed to estimate walking times.
- Limits the number of landmarks that can be added to the tour to prevent excessive detouring.
- Allows some overflow (overshoot) in the maximum detour time to accommodate for slight inefficiencies.
Attributes:
logger (logging.Logger): Logger for capturing relevant events and errors.
detour (int): The accepted maximum detour time in minutes.
detour_factor (float): The ratio between straight-line distance and actual walking distance in cities.
average_walking_speed (float): The average walking speed of an adult (in meters per second or kilometers per hour).
max_landmarks (int): The maximum number of landmarks to include in the tour.
overshoot (float): The overshoot allowance for exceeding the maximum detour time in a restrictive manner.
"""
logger = logging.getLogger(__name__)
detour: int = None # accepted max detour time (in minutes)
detour_factor: float # detour factor of straight line vs real distance in cities
average_walking_speed: float # average walking speed of adult
max_landmarks: int # max number of landmarks to visit
overshoot: float # overshoot to allow maxtime to overflow. Optimizer is a bit restrictive
def __init__(self) :
# load parameters from file
with OPTIMIZER_PARAMETERS_PATH.open('r') as f:
parameters = yaml.safe_load(f)
self.detour_factor = parameters['detour_factor']
self.average_walking_speed = parameters['average_walking_speed']
self.max_landmarks = parameters['max_landmarks']
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):
"""
Initialize the objective function and inequality constraints for the linear program.
This function sets up the objective to maximize the attractiveness of visiting landmarks,
while ensuring that the total time (including travel and visit duration) does not exceed
the maximum allowed time. It calculates the pairwise travel times between landmarks and
incorporates visit duration to form the inequality constraints.
The objective is to maximize sightseeing by selecting the most attractive landmarks within
the time limit.
Args:
prob (pl.LpProblem): The linear programming problem where constraints and the objective will be added.
x (pl.LpVariable): A decision variable representing whether a landmark is visited.
L (int): The number of landmarks.
landmarks (list[Landmark]): List of landmarks to visit.
max_time (int): Maximum allowable time for sightseeing, including travel and visit duration.
Returns:
None: Adds the objective function and constraints to the LP problem directly.
constraint coefficients, and the right-hand side of the inequality constraint.
"""
L = len(landmarks)
# Objective function coefficients. a*x1 + b*x2 + c*x3 + ...
c = np.zeros(L, dtype=np.int16)
# inequality matrix and vector
A_ub = np.zeros(L*L, dtype=np.int16)
b_ub = round(max_time*(1.1+max_time*self.overshoot))
for i, spot1 in enumerate(landmarks) :
c[i] = spot1.attractiveness
for j in range(i+1, L) :
if i !=j :
t = get_time(spot1.location, landmarks[j].location)
A_ub[i*L + j] = t + spot1.duration
A_ub[j*L + i] = t + landmarks[j].duration
# Expand 'c' to L*L for every decision variable and ad
c = np.tile(c, L)
# Now sort and modify A_ub for each row
if L > 22 :
for i in range(L):
# Get indices of the 4 smallest values in row i
row_values = A_ub[i*L:i*L+L]
closest_indices = np.argpartition(row_values, 22)[:22]
# Create a mask for non-closest landmarks
mask = np.ones(L, dtype=bool)
mask[closest_indices] = False
# Set non-closest landmarks to 32765
row_values[mask] = 32765
A_ub[i*L:i*L+L] = row_values
# Add the objective and the 1 distance constraint
prob += pl.lpSum([c[j] * x[j] for j in range(L*L)])
prob += (pl.lpSum([A_ub[j] * x[j] for j in range(L*L)]) <= b_ub)
def respect_number(self, prob: pl.LpProblem, x: pl.LpVariable, L: int, max_landmarks: int):
"""
Generate constraints to ensure each landmark is visited at most once and cap the total number of visited landmarks.
This function adds the following constraints to the linear program:
1. Each landmark is visited at most once by creating L-2 constraints (one for each landmark).
2. The total number of visited landmarks is capped by the specified maximum number (`max_landmarks`) plus 2.
Args:
prob (pl.LpProblem): The linear programming problem where constraints will be added.
x (pl.LpVariable): Decision variable indicating whether a landmark is visited.
L (int): The total number of landmarks.
max_landmarks (int): The maximum number of landmarks that can be visited.
Returns:
None: This function directly modifies the `prob` object by adding constraints.
"""
# L-2 constraints: each landmark is visited exactly once
for i in range(1, L-1):
prob += (pl.lpSum([x[L*i + j] for j in range(L)]) <= 1)
# 1 constraint: cap the total number of visits
prob += (pl.lpSum([1 * x[j] for j in range(L*L)]) <= max_landmarks+2)
def break_sym(self, prob: pl.LpProblem, x: pl.LpVariable, L: int):
"""
Generate constraints to prevent simultaneous travel between two landmarks
in both directions. This constraint ensures that, for any pair of landmarks,
travel from landmark i to landmark j (dij) and travel from landmark j to landmark i (dji)
cannot happen simultaneously.
This method adds constraints to break symmetry, specifically to prevent
cyclic paths with only two elements. It does not prevent cyclic paths involving more than two elements.
Args:
prob (pl.LpProblem): The linear programming problem where constraints will be added.
x (pl.LpVariable): Decision variable representing travel between landmarks.
L (int): The total number of landmarks.
Returns:
None: This function modifies the `prob` object by adding constraints in-place.
"""
upper_ind = np.triu_indices(L, 0, L) # Get the upper triangular indices
up_ind_x = upper_ind[0]
up_ind_y = upper_ind[1]
# Loop over the upper triangular indices, excluding diagonal elements
for i, up_ind in enumerate(up_ind_x):
if up_ind != up_ind_y[i]:
# Add (L*L-L)/2 constraints to break symmetry
prob += (x[up_ind*L + up_ind_y[i]] + x[up_ind_y[i]*L + up_ind] <= 1)
def init_eq_not_stay(self, prob: pl.LpProblem, x: pl.LpVariable, L: int):
"""
Generate constraints to prevent staying at the same position during travel.
Specifically, it removes travel from a landmark to itself (e.g., d11, d22, d33, etc.).
This function adds one equality constraint to the optimization problem that ensures
no decision variable corresponding to staying at the same landmark is included
in the solution. This helps in ensuring that the path does not include self-loops.
Args:
prob (pl.LpProblem): The linear programming problem where constraints will be added.
x (pl.LpVariable): Decision variable representing travel between landmarks.
L (int): The total number of landmarks.
Returns:
None: This function modifies the `prob` object by adding an equality constraint in-place.
"""
A_eq = np.zeros((L, L), dtype=np.int8)
# Set diagonal elements to 1 (to prevent staying in the same position)
np.fill_diagonal(A_eq, 1)
A_eq = A_eq.flatten()
# First equality constraint
prob += (pl.lpSum([A_eq[j] * x[j] for j in range(L*L)]) == 0)
def respect_start_finish(self, prob: pl.LpProblem, x: pl.LpVariable, L: int):
"""
Generate constraints to ensure that the optimization starts at the designated
start landmark and finishes at the goal landmark.
Specifically, this function adds three equality constraints:
1. Ensures that the path starts at the designated start landmark (row 0).
2. Ensures that the path finishes at the designated goal landmark (row 1).
3. Prevents any arrivals at the start landmark or departures from the goal landmark (row 2).
Args:
prob (pl.LpProblem): The linear programming problem where constraints will be added.
x (pl.LpVariable): Decision variable representing travel between landmarks.
L (int): The total number of landmarks.
Returns:
None: This function modifies the `prob` object by adding three equality constraints in-place.
"""
# Fill-in row 0.
A_eq = np.zeros((3,L*L), dtype=np.int8)
A_eq[0, :L] = np.ones(L, dtype=np.int8) # sets departures only for start (horizontal ones)
for k in range(L-1) :
if k != 0 :
# Fill-in row 1
A_eq[1, k*L+L-1] = 1 # sets arrivals only for finish (vertical ones)
# Fill-in row 1
A_eq[2, k*L] = 1
A_eq[2, L*(L-1):] = np.ones(L, dtype=np.int8) # prevents arrivals at start and departures from goal
b_eq= [1, 1, 0]
# Add the constraints to pulp
for i in range(3) :
prob += (pl.lpSum([A_eq[i][j] * x[j] for j in range(L*L)]) == b_eq[i])
def respect_order(self, prob: pl.LpProblem, x: pl.LpVariable, L: int):
"""
Generate constraints to tie the optimization problem together and prevent
stacked ones, although this does not fully prevent circles.
This function adds constraints to the optimization problem that prevent
simultaneous travel between landmarks in a way that would result in stacked ones.
However, it does not fully prevent circular paths.
Args:
prob (pl.LpProblem): The linear programming problem where constraints will be added.
x (pl.LpVariable): Decision variable representing travel between landmarks.
L (int): The total number of landmarks.
Returns:
None: This function modifies the `prob` object by adding L-2 equality constraints in-place.
"""
# Loop through rows 1 to L-2 to prevent stacked ones
for i in range(1, L-1):
# Add the constraint that sums across each "row" or "block" in the decision variables
row_sum = -pl.lpSum(x[i + j*L] for j in range(L)) + pl.lpSum(x[i*L:(i+1)*L])
prob += (row_sum == 0)
def respect_user_must(self, prob: pl.LpProblem, x: pl.LpVariable, L: int, landmarks: list[Landmark]) :
"""
Generate constraints to ensure that landmarks marked as 'must_do' are included in the optimization.
This function adds constraints to the optimization problem to ensure that landmarks marked as
'must_do' are included in the solution. It precomputes the constraints and adds them to the
problem accordingly.
Args:
prob (pl.LpProblem): The linear programming problem where constraints will be added.
x (pl.LpVariable): Decision variable representing travel between landmarks.
L (int): The total number of landmarks.
landmarks (list[Landmark]): List of landmarks, where some are marked as 'must_do'.
Returns:
None: This function modifies the `prob` object by adding equality constraints in-place.
"""
ones = np.ones(L, dtype=np.int8)
A_eq = np.zeros(L*L, dtype=np.int8)
for i, elem in enumerate(landmarks) :
if elem.must_do is True and i not in [0, L-1]:
A_eq[i*L:i*L+L] = ones
prob += (pl.lpSum([A_eq[j] * x[j] for j in range(L*L)]) == 1)
if elem.must_avoid is True and i not in [0, L-1]:
A_eq[i*L:i*L+L] = ones
prob += (pl.lpSum([A_eq[j] * x[j] for j in range(L*L)]) == 2)
def prevent_circle(self, prob: pl.LpProblem, x: pl.LpVariable, circle_vertices: list, L: int) :
"""
Prevent circular paths by adding constraints to the optimization.
This function ensures that circular paths in both directions (i.e., forward and reverse)
between landmarks are avoided in the optimization problem by adding the corresponding constraints.
Args:
prob (pl.LpProblem): The linear programming problem instance to which the constraints will be added.
x (pl.LpVariable): Decision variable representing the travel between landmarks in the problem.
circle_vertices (list): List of indices representing the landmarks that form a circular path.
L (int): The total number of landmarks.
Returns:
None: This function modifies the `prob` object by adding two equality constraints that
prevent circular paths in both directions for the specified circle vertices.
"""
l = np.zeros((2, L*L), dtype=np.int8)
for i, node in enumerate(circle_vertices[:-1]) :
next = circle_vertices[i+1]
l[0, node*L + next] = 1
l[1, next*L + node] = 1
s = circle_vertices[0]
g = circle_vertices[-1]
l[0, g*L + s] = 1
l[1, s*L + g] = 1
# Add the constraints
prob += (pl.lpSum([l[0][j] * x[j] for j in range(L*L)]) == 0)
prob += (pl.lpSum([l[1][j] * x[j] for j in range(L*L)]) == 0)
def is_connected(self, resx) :
"""
Determine the order of visits and detect any circular paths in the given configuration.
Args:
resx (list): List of edge weights.
Returns:
tuple[list[int], Optional[list[list[int]]]]: A tuple containing the visit order and a list of any detected circles.
"""
resx = np.round(resx).astype(np.int8) # round all elements and cast them to int
N = len(resx) # length of res
L = int(np.sqrt(N)) # number of landmarks. CAST INTO INT but should not be a problem because N = L**2 by def.
nonzeroind = np.nonzero(resx)[0] # the return is a little funny so I use the [0]
nonzero_tup = np.unravel_index(nonzeroind, (L,L))
ind_a = nonzero_tup[0]
ind_b = nonzero_tup[1]
# Extract all journeys
all_journeys_nodes = []
visited_nodes = set()
for node in ind_a:
if node not in visited_nodes:
journey_nodes = self.get_journey(node, ind_a, ind_b)
all_journeys_nodes.append(journey_nodes)
visited_nodes.update(journey_nodes)
for l in all_journeys_nodes :
if 0 in l :
all_journeys_nodes.remove(l)
break
if not all_journeys_nodes :
return None
return all_journeys_nodes
def get_journey(self, start, ind_a, ind_b):
"""
Trace the journey starting from a given node and follow the connections between landmarks.
This method constructs a graph from two lists of landmark connections, `ind_a` and `ind_b`,
where each element in `ind_a` is connected to the corresponding element in `ind_b`.
It then performs a depth-first search (DFS) starting from the `start` node to determine
the path (journey) by following the connections.
Args:
start (int): The starting node of the journey.
ind_a (list[int]): List of "from" nodes, representing the starting points of each connection.
ind_b (list[int]): List of "to" nodes, representing the endpoints of each connection.
Returns:
list[int]: A list of nodes representing the order of the journey, starting from the `start` node.
Example:
If `ind_a = [0, 1, 2]` and `ind_b = [1, 2, 3]`, starting from node 0, the journey would be `[0, 1, 2, 3]`.
"""
graph = defaultdict(list)
for a, b in zip(ind_a, ind_b):
graph[a].append(b)
journey_nodes = []
visited = set()
stack = deque([start])
while stack:
node = stack.pop()
if node not in visited:
visited.add(node)
journey_nodes.append(node)
for neighbor in graph[node]:
if neighbor not in visited:
stack.append(neighbor)
return journey_nodes
def get_order(self, resx):
"""
Determine the order of visits given the result of the optimization.
Args:
resx (list): List of edge weights.
Returns:
list[int]: A list containing the visit order.
"""
resx = np.round(resx).astype(np.uint8) # must contain only 0 and 1
N = len(resx) # length of res
L = int(np.sqrt(N)) # number of landmarks. CAST INTO INT but should not be a problem because N = L**2 by def.
nonzeroind = np.nonzero(resx)[0] # the return is a little funny so I use the [0]
nonzero_tup = np.unravel_index(nonzeroind, (L,L))
ind_a = nonzero_tup[0].tolist()
ind_b = nonzero_tup[1].tolist()
order = [0]
current = 0
used_indices = set() # Track visited index pairs
while True:
# Find index of the current node in ind_a
try:
i = ind_a.index(current)
except ValueError:
break # No more links, stop the search
if i in used_indices:
break # Prevent infinite loops
used_indices.add(i) # Mark this index as visited
next_node = ind_b[i] # Get the corresponding node in ind_b
order.append(next_node) # Add it to the path
# Switch roles, now look for next_node in ind_a
try:
current = next_node
except ValueError:
break # No further connections, end the path
return order
def link_list(self, order: list[int], landmarks: list[Landmark])->list[Landmark] :
"""
Compute the time to reach from each landmark to the next and create a list of landmarks with updated travel times.
Args:
order (list[int]): List of indices representing the order of landmarks to visit.
landmarks (list[Landmark]): List of all landmarks.
Returns:
list[Landmark]]: The updated linked list of landmarks with travel times
"""
L = []
j = 0
while j < len(order)-1 :
# get landmarks involved
elem = landmarks[order[j]]
next = landmarks[order[j+1]]
# get attributes
elem.time_to_reach_next = get_time(elem.location, next.location)
elem.must_do = True
elem.location = (round(elem.location[0], 5), round(elem.location[1], 5))
elem.next_uuid = next.uuid
L.append(elem)
j += 1
next.location = (round(next.location[0], 5), round(next.location[1], 5))
next.must_do = True
L.append(next)
return L
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) :
x[i].setInitialValue(0)
x[1].setInitialValue(1)
x[2*L-1].setInitialValue(1)
return x
def pre_processing(self, L: int, landmarks: list[Landmark], max_time: int, max_landmarks: int | None) :
"""
Preprocesses the optimization problem by setting up constraints and variables for the tour optimization.
This method initializes and prepares the linear programming problem to optimize a tour that includes landmarks,
while respecting various constraints such as time limits, the number of landmarks to visit, and user preferences.
The pre-processing step sets up the problem before solving it using a linear programming solver.
Responsibilities:
- Defines the optimization problem using linear programming (LP) with the objective to maximize the tour value.
- Creates binary decision variables for each potential transition between landmarks.
- Sets up inequality constraints to respect the maximum time available for the tour and the maximum number of landmarks.
- Implements equality constraints to ensure the tour respects the start and finish positions, avoids staying in the same place,
and adheres to a visit order.
- Forces inclusion or exclusion of specific landmarks based on user preferences.
Attributes:
prob (pl.LpProblem): The linear programming problem to be solved.
x (list): A list of binary variables representing transitions between landmarks.
L (int): The total number of landmarks considered in the optimization.
landmarks (list[Landmark]): The list of landmarks to be visited in the tour.
max_time (int): The maximum allowable time for the entire tour.
max_landmarks (int | None): The maximum number of landmarks to visit in the tour, or None if no limit is set.
Returns:
prob (pl.LpProblem): The linear programming problem setup for optimization.
x (list): The list of binary variables for transitions between landmarks in the tour.
"""
if max_landmarks is None :
max_landmarks = self.max_landmarks
# Initalize the optimization problem
prob = pl.LpProblem("OptimizationProblem", pl.LpMaximize)
# Define the problem
x_bounds = [(0, 1)]*L*L
x = [pl.LpVariable(f"x_{i}", lowBound=x_bounds[i][0], upBound=x_bounds[i][1], cat='Binary') for i in range(L*L)]
# Setup the inequality constraints
self.init_ub_time(prob, x, L, landmarks, max_time) # Adds the distances from each landmark to the other.
self.respect_number(prob, x, L, max_landmarks) # Respects max number of visits (no more possible stops than landmarks).
self.break_sym(prob, x, L) # Breaks the 'zig-zag' symmetry. Avoids d12 and d21 but not larger cirlces.
# Setup the equality constraints
self.init_eq_not_stay(prob, x, L) # Force solution not to stay in same place
self.respect_start_finish(prob, x, L) # Force start and finish positions
self.respect_order(prob, x, L) # Respect order of visit (only works when max_time is limiting factor)
self.respect_user_must(prob, x, L, landmarks) # Force to do/avoid landmarks set by user.
# return prob, self.warm_start(x, L)
return prob, x
def solve_optimization(self, max_time: int, landmarks: list[Landmark], max_landmarks: int = None) -> list[Landmark]:
"""
Main optimization pipeline to solve the landmark visiting problem.
This method sets up and solves a linear programming problem with constraints to find an optimal tour of landmarks,
considering user-defined must-visit landmarks, start and finish points, and ensuring no cycles are present.
Args:
max_time (int): Maximum time allowed for the tour in minutes.
landmarks (list[Landmark]): List of landmarks to visit.
max_landmarks (int): Maximum number of landmarks visited
Returns:
list[Landmark]: The optimized tour of landmarks with updated travel times, or None if no valid solution is found.
"""
# Setup the optimization proplem.
L = len(landmarks)
prob, x = self.pre_processing(L, landmarks, max_time, max_landmarks)
# Solve the problem and extract results.
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]
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...")
# Raise error if no solution is found. FIXME: for now this throws the internal server error
if status != 'Optimal' :
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.")
# If there is a solution, we're good to go, just check for connectiveness
circles = self.is_connected(solution)
i = 0
while circles is not None :
i += 1
if i == self.max_iter :
self.logger.warning(f'Timeout: No solution found after {self.max_iter} iterations.')
raise TimeoutError(f"Optimization took too long. No solution found after {self.max_iter} iterations.")
for circle in circles :
self.prevent_circle(prob, x, circle, L)
# Solve the problem again
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]
if pl.LpStatus[prob.status] != 'Optimal' :
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.")
circles = self.is_connected(solution)
if circles is None :
break
# Sort the landmarks in the order of the solution
order = self.get_order(solution)
tour = [landmarks[i] for i in order]
self.logger.info(f"Re-optimized {i} times, objective value : {int(pl.value(prob.objective))}")
return tour

View File

@ -1,136 +0,0 @@
"""Module defining the handling of cache data from Overpass requests."""
import os
import json
import hashlib
from ..constants import OSM_CACHE_DIR, OSM_TYPES
def get_cache_key(query: str) -> str:
"""
Generate a unique cache key for the query using a hash function.
This ensures that queries with different parameters are cached separately.
"""
return hashlib.md5(query.encode('utf-8')).hexdigest()
class CachingStrategyBase:
"""
Base class for implementing caching strategies.
"""
def get(self, key):
"""Retrieve the cached data associated with the provided key."""
raise NotImplementedError('Subclass should implement get')
def set(self, key, value):
"""Store data in the cache with the specified key."""
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):
"""Clean up or close any resources used by the caching strategy."""
class JSONCache(CachingStrategyBase):
"""
A caching strategy that stores and retrieves data in JSON format.
"""
def __init__(self, cache_dir=OSM_CACHE_DIR):
# Add the class name as a suffix to the directory
self._cache_dir = f'{cache_dir}'
if not os.path.exists(self._cache_dir):
os.makedirs(self._cache_dir)
def _filename(self, key):
return os.path.join(self._cache_dir, f'{key}.json')
def get(self, key):
"""Retrieve JSON data from the cache and parse it as an ElementTree."""
filename = self._filename(key)
if os.path.exists(filename):
try:
# Open and parse the cached JSON data
with open(filename, 'r', encoding='utf-8') as file:
data = json.load(file)
# Return the data as a list of dicts.
return data
except json.JSONDecodeError:
return None # Return None if parsing fails
return None
def set(self, key, value):
"""Save the JSON data in the cache."""
filename = self._filename(key)
try:
# Write the JSON data to the cache file
with open(filename, 'w', encoding='utf-8') as file:
json.dump(value, file, ensure_ascii=False, indent=4)
except IOError as 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:
"""
A class to manage different caching strategies.
"""
__strategy = JSONCache() # Default caching strategy
__strategies = {
'JSON': JSONCache,
}
@classmethod
def use(cls, strategy_name='JSON', **kwargs):
"""Define the caching strategy to use."""
if cls.__strategy:
cls.__strategy.close()
strategy_class = cls.__strategies.get(strategy_name)
if not strategy_class:
raise ValueError(f"Unknown caching strategy: {strategy_name}")
cls.__strategy = strategy_class(**kwargs)
return cls.__strategy
@classmethod
def get(cls, key):
"""Get the data from the cache."""
return cls.__strategy.get(key)
@classmethod
def set(cls, key, value):
"""Save the data in the cache."""
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)

View File

@ -1,423 +0,0 @@
"""Module allowing connexion to overpass api and fectch data from OSM."""
import os
import time
import urllib
import math
import logging
import json
from typing import List, Tuple
from .caching_strategy import get_cache_key, CachingStrategy
from ..constants import OSM_CACHE_DIR, OSM_TYPES, BBOX
RESOLUTION = 0.05
CELL = Tuple[int, int]
class Overpass :
"""
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.
"""
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.
"""
self.overpass_url = "https://overpass-api.de/api/interpreter"
self.headers = {'User-Agent': 'Mozilla/5.0 (compatible; OverpassQuery/1.0; +http://example.com)',}
self.caching_strategy = CachingStrategy.use(caching_strategy, cache_dir=cache_dir)
def send_query(self, bbox: BBOX, osm_types: OSM_TYPES,
selector: str, conditions: list=None, out='center') -> List[dict]:
"""
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.
Args:
bbox (tuple): A tuple representing the geographical search area, typically in the format
(lat_min, lon_min, lat_max, lon_max).
osm_types (list[str]): A list of OSM element types to search for. Must be one or more of
'Way', 'Node', or 'Relation'.
selector (str): The key or tag to filter the OSM elements (e.g., 'amenity', 'highway', etc.).
conditions (list, optional): A list of conditions to apply as additional filters for the
selected OSM elements. The conditions should be written in
the Overpass QL format, and they are combined with '&&' if
multiple are provided. Defaults to an empty list.
out (str, optional): Specifies the output type, such as 'center', 'body', or 'tags'.
Defaults to 'center'.
Returns:
str: The constructed Overpass QL query string.
Notes:
- If no conditions are provided, the query will just use the `selector` to filter the OSM
elements without additional constraints.
"""
query = '[out:json][timeout:20];('
# convert the bbox to string.
bbox_str = f"({','.join(map(str, bbox))})"
if conditions is not None and len(conditions) > 0:
conditions = '(if: ' + ' && '.join(conditions) + ')'
else :
conditions = ''
for elem in osm_types :
query += elem + '[' + selector + ']' + conditions + bbox_str + ';'
query += ');' + f'out {out};'
return query
def _retrieve_cached_data(self, overlapping_cells: CELL, osm_types: OSM_TYPES,
selector: str, conditions: list, out: str) -> Tuple[List[dict], list[CELL]]:
"""
Retrieve cached data and identify missing cache quadrants.
Args:
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:
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.
"""
filtered_elements = []
min_lat, min_lon, max_lat, max_lon = bbox
for elem in elements:
# Extract coordinates based on the 'type' of element
if elem.get('type') != 'node':
center = elem.get('center', {})
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))
# Check if the coordinates fall within the given bounding box
if min_lat <= lat <= max_lat and min_lon <= lon <= max_lon:
filtered_elements.append(elem)
return filtered_elements
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.
This function retrieves the latitude and longitude coordinates, OSM ID, and optionally the name
of a given OpenStreetMap (OSM) element. It handles different OSM types (e.g., 'node', 'way') by
extracting coordinates either directly or from a center tag, depending on the element type.
Args:
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
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
to find the 'name' tag within the element and return its value. Defaults to False.
Returns:
tuple: A tuple containing:
- osm_id (str): The OSM ID of the element.
- coords (tuple): A tuple of (latitude, longitude) coordinates.
- name (str, optional): The name of the element if `with_name` is True; otherwise, not included.
"""
# 1. extract coordinates
if osm_type != 'node' :
center = elem.get('center')
lat = float(center.get('lat'))
lon = float(center.get('lon'))
else :
lat = float(elem.get('lat'))
lon = float(elem.get('lon'))
coords = tuple((lat, lon))
# 2. Extract OSM id
osm_id = elem.get('id')
# 3. Extract name if specified and return
if with_name :
name = elem.get('tags', {}).get('name')
return osm_id, coords, name
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.")

View File

@ -51,27 +51,25 @@ sightseeing:
- place_of_worship
- fountain
- townhall
water: reflecting_pool
water:
- reflecting_pool
bridge:
- aqueduct
- viaduct
- boardwalk
- cantilever
- abandoned
building: cathedral
# unused sightseeing/buildings:
# - church
# - chapel
# - mosque
# - synagogue
# - ruins
# - temple
# - government
# - cathedral
# - castle
# - museum
building:
- church
- chapel
- mosque
- synagogue
- ruins
- temple
- government
- cathedral
- castle
- museum
museums:
tourism:

View File

@ -1,11 +1,12 @@
max_bbox_side: 4000 #m
city_bbox_side: 7500 #m
radius_close_to: 50
church_coeff: 0.75
nature_coeff: 1.6
church_coeff: 0.65
nature_coeff: 1.35
overall_coeff: 10
tag_exponent: 1.15
image_bonus: 1.1
viewpoint_bonus: 10
wikipedia_bonus: 1.25
N_important: 60
image_bonus: 10
viewpoint_bonus: 5
wikipedia_bonus: 4
name_bonus: 3
N_important: 40
pay_bonus: -1

View File

@ -2,8 +2,5 @@ detour_factor: 1.4
detour_corridor_width: 300
average_walking_speed: 4.8
max_landmarks: 10
max_landmarks_refiner: 20
overshoot: 0.0016
time_limit: 1
gap_rel: 0.025
max_iter: 80
max_landmarks_refiner: 30
overshoot: 1.1

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,698 @@
{
"type": "FeatureCollection",
"generator": "overpass-turbo",
"copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL.",
"timestamp": "2024-12-02T21:14:59Z",
"features": [
{
"type": "Feature",
"properties": {
"@id": "node/1345741798",
"name": "Cordonnerie Saint-Joseph",
"shop": "shoes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3481705,
48.0816462
]
},
"id": "node/1345741798"
},
{
"type": "Feature",
"properties": {
"@id": "node/2659184738",
"brand": "Armand Thiery",
"brand:wikidata": "Q2861975",
"brand:wikipedia": "fr:Armand Thiery",
"name": "Armand Thiery",
"opening_hours": "Mo-Sa 09:30-19:00",
"shop": "clothes",
"wheelchair": "limited"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3594454,
48.0785574
]
},
"id": "node/2659184738"
},
{
"type": "Feature",
"properties": {
"@id": "node/3618136290",
"name": "Chez Dominique",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3362362,
48.0712174
]
},
"id": "node/3618136290"
},
{
"type": "Feature",
"properties": {
"@id": "node/3618136605",
"name": "Divamod",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3304253,
48.0782989
]
},
"id": "node/3618136605"
},
{
"type": "Feature",
"properties": {
"@id": "node/3618284507",
"name": "Star tendances et voyages",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3474029,
48.0830993
]
},
"id": "node/3618284507"
},
{
"type": "Feature",
"properties": {
"@id": "node/3619696125",
"brand": "Zeeman",
"brand:wikidata": "Q184399",
"name": "Zeeman",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3413834,
48.0638444
]
},
"id": "node/3619696125"
},
{
"type": "Feature",
"properties": {
"@id": "node/4594398129",
"name": "Miss et Mister",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3308309,
48.0779118
]
},
"id": "node/4594398129"
},
{
"type": "Feature",
"properties": {
"@id": "node/4907320441",
"brand": "Sergent Major",
"brand:wikidata": "Q62521738",
"clothes": "babies;children",
"name": "Sergent Major",
"opening_hours": "Mo-Sa 09:30-19:00",
"shop": "clothes",
"wheelchair": "no"
},
"geometry": {
"type": "Point",
"coordinates": [
7.359116,
48.0787229
]
},
"id": "node/4907320441"
},
{
"type": "Feature",
"properties": {
"@id": "node/4907364791",
"brand": "Armand Thiery",
"brand:wikidata": "Q2861975",
"brand:wikipedia": "fr:Armand Thiery",
"clothes": "women",
"name": "Armand Thiery",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3601857,
48.0783373
]
},
"id": "node/4907364791"
},
{
"type": "Feature",
"properties": {
"@id": "node/4907385675",
"check_date": "2024-05-19",
"clothes": "children",
"name": "Du Pareil...au même",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3604521,
48.0779726
]
},
"id": "node/4907385675"
},
{
"type": "Feature",
"properties": {
"@id": "node/4922191645",
"name": "Abilos",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3566167,
48.0794136
]
},
"id": "node/4922191645"
},
{
"type": "Feature",
"properties": {
"@id": "node/4922191648",
"brand": "Esprit",
"brand:wikidata": "Q532746",
"brand:wikipedia": "en:Esprit Holdings",
"name": "Esprit",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3554004,
48.0787549
]
},
"id": "node/4922191648"
},
{
"type": "Feature",
"properties": {
"@id": "node/4922191972",
"brand": "Guess",
"brand:wikidata": "Q2470307",
"brand:wikipedia": "en:Guess (clothing)",
"name": "Guess",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.355273,
48.0788003
]
},
"id": "node/4922191972"
},
{
"type": "Feature",
"properties": {
"@id": "node/4922192001",
"name": "Lingerie",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3575453,
48.0779317
]
},
"id": "node/4922192001"
},
{
"type": "Feature",
"properties": {
"@id": "node/5359915869",
"name": "Al Assil",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3305665,
48.0780902
]
},
"id": "node/5359915869"
},
{
"type": "Feature",
"properties": {
"@id": "node/9089360040",
"brand": "Grain de Malice",
"brand:wikidata": "Q66757157",
"clothes": "women",
"name": "Grain de Malice",
"shop": "clothes",
"short_name": "GDM"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3593125,
48.0786234
]
},
"id": "node/9089360040"
},
{
"type": "Feature",
"properties": {
"@id": "node/9095193153",
"brand": "Undiz",
"brand:wikidata": "Q105306275",
"clothes": "underwear",
"name": "Undiz",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3599579,
48.0782846
]
},
"id": "node/9095193153"
},
{
"type": "Feature",
"properties": {
"@id": "node/9095193154",
"branch": "Lingerie",
"brand": "RougeGorge",
"brand:wikidata": "Q104600739",
"clothes": "underwear",
"name": "RougeGorge",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3604883,
48.0781607
]
},
"id": "node/9095193154"
},
{
"type": "Feature",
"properties": {
"@id": "node/9095212690",
"alt_name": "North Face",
"brand": "The North Face",
"brand:wikidata": "Q152784",
"brand:wikipedia": "en:The North Face",
"check_date": "2024-05-19",
"name": "The North Face",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3603923,
48.0773727
]
},
"id": "node/9095212690"
},
{
"type": "Feature",
"properties": {
"@id": "node/9095270059",
"air_conditioning": "no",
"clothes": "men",
"level": "0",
"name": "Maison Aume",
"second_hand": "no",
"shop": "clothes",
"wheelchair": "no"
},
"geometry": {
"type": "Point",
"coordinates": [
7.361364,
48.0799999
]
},
"id": "node/9095270059"
},
{
"type": "Feature",
"properties": {
"@id": "node/9098624272",
"name": "Destock Place",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3575161,
48.0793009
]
},
"id": "node/9098624272"
},
{
"type": "Feature",
"properties": {
"@id": "node/9123861652",
"name": "Weackers",
"shop": "shoes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.361329,
48.0785972
]
},
"id": "node/9123861652"
},
{
"type": "Feature",
"properties": {
"@id": "node/9162179887",
"brand": "Calzedonia",
"brand:wikidata": "Q1027874",
"brand:wikipedia": "en:Calzedonia",
"name": "Calzedonia",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3606374,
48.0780809
]
},
"id": "node/9162179887"
},
{
"type": "Feature",
"properties": {
"@id": "node/9162206449",
"clothes": "women",
"name": "Cop. Copine",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3600947,
48.078399
]
},
"id": "node/9162206449"
},
{
"type": "Feature",
"properties": {
"@id": "node/9162226360",
"brand": "Okaïdi",
"brand:wikidata": "Q3350027",
"brand:wikipedia": "fr:Okaïdi",
"name": "Okaïdi",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3596986,
48.078428
]
},
"id": "node/9162226360"
},
{
"type": "Feature",
"properties": {
"@id": "node/9162227010",
"brand": "Jules",
"brand:wikidata": "Q3188386",
"brand:wikipedia": "fr:Jules (enseigne)",
"clothes": "men",
"name": "Jules",
"opening_hours": "Mo-Sa 09:30-19:00",
"phone": "+33 3 89 41 03 62",
"shop": "clothes",
"website": "https://www.jules.com/fr-fr/magasins/1600133/"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3600323,
48.0782229
]
},
"id": "node/9162227010"
},
{
"type": "Feature",
"properties": {
"@id": "node/10151865029",
"name": "Atelier Cinq",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3571756,
48.0772657
]
},
"id": "node/10151865029"
},
{
"type": "Feature",
"properties": {
"@id": "node/10862176110",
"name": "L'hexagone",
"shop": "bag"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3808571,
48.0814138
]
},
"id": "node/10862176110"
},
{
"type": "Feature",
"properties": {
"@id": "node/11150877331",
"brand": "Punt Roma",
"brand:wikidata": "Q101423290",
"clothes": "women",
"name": "Punt Roma",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3571859,
48.0779406
]
},
"id": "node/11150877331"
},
{
"type": "Feature",
"properties": {
"@id": "node/11150959880",
"name": "Caroll",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3579354,
48.0779291
]
},
"id": "node/11150959880"
},
{
"type": "Feature",
"properties": {
"@id": "node/11302242094",
"branch": "Wintzenheim",
"name": "Label Fripe",
"opening_hours": "Mo-Sa 09:00-18:45",
"phone": "+33 3 89 27 39 25",
"second_hand": "only",
"shop": "clothes",
"website": "https://labelfripe.fr/label-fripe-wintzenheim/"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3109899,
48.0850362
]
},
"id": "node/11302242094"
},
{
"type": "Feature",
"properties": {
"@id": "node/11392247003",
"name": "Lingerie Sipp",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3111507,
48.0841835
]
},
"id": "node/11392247003"
},
{
"type": "Feature",
"properties": {
"@id": "node/11778819781",
"addr:city": "Colmar",
"addr:housenumber": "10",
"addr:postcode": "68000",
"addr:street": "Rue des Têtes",
"clothes": "suits;hats;men",
"name": "Phillipe",
"phone": "0389411983",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3559389,
48.0789064
]
},
"id": "node/11778819781"
},
{
"type": "Feature",
"properties": {
"@id": "node/11799215969",
"brand": "Petit Bateau",
"brand:wikidata": "Q3377090",
"name": "Petit Bateau",
"opening_hours": "Mo-Sa 10:00-19:00; Su 10:00-18:00",
"phone": "+33 3 89 24 97 85",
"shop": "clothes",
"website": "https://stores.petit-bateau.com/france/colmar/9-rue-des-boulangers"
},
"geometry": {
"type": "Point",
"coordinates": [
7.355149,
48.0780213
]
},
"id": "node/11799215969"
},
{
"type": "Feature",
"properties": {
"@id": "node/11816704669",
"addr:housenumber": "10",
"addr:street": "Rue des Boulangers",
"name": "des petits hauts",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3555001,
48.0780768
]
},
"id": "node/11816704669"
},
{
"type": "Feature",
"properties": {
"@id": "node/12320343534",
"addr:city": "Colmar",
"addr:housenumber": "44",
"addr:postcode": "68000",
"addr:street": "Rue des Clefs",
"brand": "Un Jour Ailleurs",
"brand:wikidata": "Q105106211",
"clothes": "women",
"name": "Un Jour Ailleurs",
"opening_hours": "Mo-Fr 10:00-19:00; Sa 10:00-18:30",
"phone": "+33368318572",
"shop": "clothes",
"website": "https://boutique.unjourailleurs.com/fr/mode-femme/boutique-colmar-76"
},
"geometry": {
"type": "Point",
"coordinates": [
7.35897,
48.0789807
]
},
"id": "node/12320343534"
},
{
"type": "Feature",
"properties": {
"@id": "node/12320343536",
"addr:city": "Colmar",
"addr:housenumber": "38",
"addr:postcode": "68000",
"addr:street": "Rue des Clefs",
"brand": "Timberland",
"brand:wikidata": "Q1539185",
"name": "Timberland",
"opening_hours": "Mo-Sa 10:00-19:00",
"phone": "+33389298650",
"shop": "clothes"
},
"geometry": {
"type": "Point",
"coordinates": [
7.3592409,
48.0788785
]
},
"id": "node/12320343536"
}
]
}

View File

@ -0,0 +1,350 @@
# pylint: skip-file
import numpy as np
import json
import os
from typing import Optional, Literal
from sklearn.cluster import DBSCAN
from sklearn.decomposition import PCA
import matplotlib.pyplot as plt
from pydantic import BaseModel
from OSMPythonTools.overpass import Overpass, overpassQueryBuilder
from OSMPythonTools.cachingStrategy import CachingStrategy, JSON
from math import sin, cos, sqrt, atan2, radians
EARTH_RADIUS_KM = 6373
class ShoppingLocation(BaseModel):
type: Literal['street', 'area']
importance: int
centroid: tuple
start: Optional[list] = None
end: Optional[list] = None
# Output to frontend
class Landmark(BaseModel) :
# Properties of the landmark
name : str
type: Literal['sightseeing', 'nature', 'shopping', 'start', 'finish']
location : tuple
osm_type : str
osm_id : int
attractiveness : int
n_tags : int
image_url : Optional[str] = None
website_url : Optional[str] = None
description : Optional[str] = None # TODO future
duration : Optional[int] = 0
name_en : Optional[str] = None
# Additional properties depending on specific tour
must_do : Optional[bool] = False
must_avoid : Optional[bool] = False
is_secondary : Optional[bool] = False
time_to_reach_next : Optional[int] = 0
next_uuid : Optional[str] = None
def extract_points(filestr: str) :
"""
Extract points from geojson file.
Returns :
np.array containing the points
"""
points = []
with open(os.path.dirname(__file__) + '/' + filestr, 'r') as f:
geojson = json.load(f)
for feature in geojson['features']:
if feature['geometry']['type'] == 'Point':
centroid = feature['geometry']['coordinates']
points.append(centroid)
elif feature['geometry']['type'] == 'Polygon':
centroid = np.array(feature['geometry']['coordinates'][0][0])
points.append(centroid)
# Convert the list of points to a NumPy array
return np.array(points)
def get_distance(p1: tuple[float, float], p2: tuple[float, float]) -> int:
"""
Calculate the time in minutes to travel from one location to another.
Args:
p1 (tuple[float, float]): Coordinates of the starting location.
p2 (tuple[float, float]): Coordinates of the destination.
Returns:
int: Time to travel from p1 to p2 in minutes.
"""
if p1 == p2:
return 0
else:
# Compute the distance in km along the surface of the Earth
# (assume spherical Earth)
# this is the haversine formula, stolen from stackoverflow
# in order to not use any external libraries
lat1, lon1 = radians(p1[0]), radians(p1[1])
lat2, lon2 = radians(p2[0]), radians(p2[1])
dlon = lon2 - lon1
dlat = lat2 - lat1
a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2
c = 2 * atan2(sqrt(a), sqrt(1 - a))
return EARTH_RADIUS_KM * c
def filter_clusters(cluster_points, cluster_labels):
"""
Remove clusters of less importance.
"""
label_counts = np.bincount(cluster_labels)
# Step 3: Get the indices (labels) of the 5 largest clusters
top_5_labels = np.argsort(label_counts)[-5:] # Get the largest 5 clusters
# Step 4: Filter points to keep only the points in the top 5 clusters
filtered_cluster_points = []
filtered_cluster_labels = []
for label in top_5_labels:
filtered_cluster_points.append(cluster_points[cluster_labels == label])
filtered_cluster_labels.append(np.full((label_counts[label],), label)) # Replicate the label
# Concatenate filtered clusters into a single array
return np.vstack(filtered_cluster_points), np.concatenate(filtered_cluster_labels)
def fit_lines(points, labels):
"""
Fit lines to identified clusters.
"""
all_x = []
all_y = []
lines = []
locations = []
for label in set(labels):
cluster_points = points[labels == label]
# If there's not enough points, skip
if len(cluster_points) < 2:
continue
# Apply PCA to find the principal component (i.e., the line of best fit)
pca = PCA(n_components=1)
pca.fit(cluster_points)
direction = pca.components_[0]
centroid = pca.mean_
# Project the cluster points onto the principal direction (line direction)
projections = np.dot(cluster_points - centroid, direction)
# Get the range of the projections to find the approximate length of the cluster
cluster_length = projections.max() - projections.min()
# Now adjust `t` so that it scales with the cluster length
t = np.linspace(-cluster_length / 2.75, cluster_length / 2.75, 10)
# Calculate the start and end of the line based on min/max projections
start_point = centroid[0] + t*direction[0]
end_point = centroid[1] + t*direction[1]
# Store the line
lines.append((start_point, end_point))
# For visualization, store the points
all_x.append(min(start_point))
all_x.append(max(start_point))
all_y.append(min(end_point))
all_y.append(max(end_point))
if np.linalg.norm(t) <= 0.0045 :
loc = ShoppingLocation(
type='area',
centroid=tuple((centroid[1], centroid[0])),
importance = len(cluster_points),
)
else :
loc = ShoppingLocation(
type='street',
centroid=tuple((centroid[1], centroid[0])),
importance = len(cluster_points),
start=start_point,
end=end_point
)
locations.append(loc)
xmin = min(all_x)
xmax = max(all_x)
ymin = min(all_y)
ymax = max(all_y)
corners = (xmin, xmax, ymin, ymax)
return corners, locations
def create_landmark(shopping_location: ShoppingLocation):
# Define the bounding box for a given radius around the coordinates
lat, lon = shopping_location.centroid
bbox = ("around:1000", str(lat), str(lon))
overpass = Overpass()
# CachingStrategy.use(JSON, cacheDir=OSM_CACHE_DIR)
# Query neighborhoods and shopping malls
selectors = ['"place"~"^(suburb|neighborhood|neighbourhood|quarter|city_block)$"', '"shop"="mall"']
min_dist = float('inf')
new_name = 'Shopping Area'
new_name_en = None
osm_id = 0
osm_type = 'node'
for sel in selectors :
query = overpassQueryBuilder(
bbox = bbox,
elementType = ['node', 'way', 'relation'],
selector = sel,
includeCenter = True,
out = 'center'
)
try:
result = overpass.query(query)
except Exception as e:
raise Exception("query unsuccessful")
for elem in result.elements():
location = (elem.centerLat(), elem.centerLon())
if location[0] is None :
location = (elem.lat(), elem.lon())
if location[0] is None :
# print(f"Fetching coordinates failed with {elem.type()}/{elem.id()}")
continue
# print(f"Distance : {get_distance(shopping_location.centroid, location)}")
d = get_distance(shopping_location.centroid, location)
if d < min_dist :
min_dist = d
new_name = elem.tag('name')
osm_type = elem.type() # Add type: 'way' or 'relation'
osm_id = elem.id() # Add OSM id
# add english name if it exists
try :
new_name_en = elem.tag('name:en')
except:
pass
return Landmark(
name=new_name,
type='shopping',
location=shopping_location.centroid, # TODO: use the fact the we can also recognize streets.
attractiveness=shopping_location.importance,
n_tags=0,
osm_id=osm_id,
osm_type=osm_type,
name_en=new_name_en
)
# Extract points
points = extract_points('vienna_data.json')
# print(len(points))
######## Create a figure with 1 row and 3 columns for side-by-side plots
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
# Plot Raw data points
axes[0].set_title('Raw Data')
axes[0].scatter(points[:, 0], points[:, 1], color='blue', s=20)
# Apply DBSCAN to find clusters. Choose different settings for different cities.
if len(points) > 400 :
dbscan = DBSCAN(eps=0.00118, min_samples=15, algorithm='kd_tree') # for large cities
else :
dbscan = DBSCAN(eps=0.00075, min_samples=10, algorithm='kd_tree') # for small cities
labels = dbscan.fit_predict(points)
# Separate clustered points and noise points
clustered_points = points[labels != -1]
clustered_labels = labels[labels != -1]
noise_points = points[labels == -1]
######## Plot n°1: DBSCAN Clustering Results
axes[1].set_title('DBSCAN Clusters')
axes[1].scatter(clustered_points[:, 0], clustered_points[:, 1], c=clustered_labels, cmap='rainbow', s=20)
axes[1].scatter(noise_points[:, 0], noise_points[:, 1], c='blue', s=7, label='Noise')
# Keep the 5 biggest clusters
clustered_points, clustered_labels = filter_clusters(clustered_points, clustered_labels)
# Fit lines
corners, locations = fit_lines(clustered_points, clustered_labels)
(xmin, xmax, ymin, ymax) = corners
######## Plot clustered points in normal size and noise points separately
axes[2].scatter(clustered_points[:, 0], clustered_points[:, 1], c=clustered_labels, cmap='rainbow', s=30)
axes[2].set_title('PCA Fitted Lines on Clusters')
# Create a list of Landmarks for the shopping things
shopping_landmarks = []
for loc in locations :
axes[2].scatter(loc.centroid[1], loc.centroid[0], color='red', marker='x', s=200, linewidth=3)
landmark = create_landmark(loc)
shopping_landmarks.append(landmark)
axes[2].text(loc.centroid[1], loc.centroid[0], landmark.name,
ha='center', va='top', fontsize=6,
bbox=dict(facecolor='white', edgecolor='black', boxstyle='round,pad=0.2'),
zorder=3)
####### Plot the detected lines in the final plot #######
# for loc in locations:
# if loc.type == 'street' :
# line_x = loc.start
# line_y = loc.end
# axes[2].plot(line_x, line_y, color='lime', linewidth=3)
# else :
axes[0].set_xlim(xmin-0.01, xmax+0.01)
axes[0].set_ylim(ymin-0.01, ymax+0.01)
axes[1].set_xlim(xmin-0.01, xmax+0.01)
axes[1].set_ylim(ymin-0.01, ymax+0.01)
axes[2].set_xlim(xmin-0.01, xmax+0.01)
axes[2].set_ylim(ymin-0.01, ymax+0.01)
print("\n\n\n")
for landmark in shopping_landmarks :
print(f"{landmark.name} is a shopping area with a score of {landmark.attractiveness}")
plt.tight_layout()
plt.show()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
"""Definition of the Landmark class to handle visitable objects across the world."""
from typing import Optional, Literal
from uuid import uuid4, UUID
from pydantic import BaseModel, Field
@ -44,14 +45,10 @@ class Landmark(BaseModel) :
osm_id : int
attractiveness : int
n_tags : int
# Optional properties to gather more information.
image_url : Optional[str] = None
website_url : Optional[str] = None
wiki_url : Optional[str] = None
keywords: Optional[dict] = {}
description : Optional[str] = None
duration : Optional[int] = 5
description : Optional[str] = None # TODO future
duration : Optional[int] = 0
name_en : Optional[str] = None
# Unique ID of a given landmark
@ -65,11 +62,6 @@ class Landmark(BaseModel) :
time_to_reach_next : Optional[int] = 0
next_uuid : Optional[UUID] = None
# More properties to define the score
is_viewpoint : Optional[bool] = False
is_place_of_worship : Optional[bool] = False
def __str__(self) -> str:
"""
String representation of the Landmark object.
@ -123,3 +115,28 @@ class Landmark(BaseModel) :
return (self.uuid == value.uuid or
self.osm_id == value.osm_id or
(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

View File

@ -1,7 +1,7 @@
"""Linked and ordered list of Landmarks that represents the visiting order."""
from .landmark import Landmark
from ..utils.get_time_distance import get_time
from ..utils.get_time_separation import get_time
class LinkedLandmarks:
"""

View File

@ -1,26 +0,0 @@
"""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)

View File

@ -0,0 +1,42 @@
"""Collection of tests to ensure correct handling of invalid input."""
from fastapi.testclient import TestClient
import pytest
from .test_utils import load_trip_landmarks
from ..main import app
@pytest.fixture(scope="module")
def client():
"""Client used to call the app."""
return TestClient(app)
def test_cache(client, request): # pylint: disable=redefined-outer-name
"""
Test n°1 : Custom test in Turckheim to ensure small villages are also supported.
Args:
client:
request:
"""
duration_minutes = 15
response = client.post(
"/trip/new",
json={
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
"nature": {"type": "nature", "score": 5},
"shopping": {"type": "shopping", "score": 5},
"max_time_minute": duration_minutes,
"detour_tolerance_minute": 0},
"start": [48.084588, 7.280405]
}
)
result = response.json()
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
landmarks_cached = load_trip_landmarks(client, result['first_landmark_uuid'], True)
# checks :
assert response.status_code == 200 # check for successful planning
assert landmarks_cached == landmarks

View File

@ -21,19 +21,17 @@ def test_turckheim(client, request): # pylint: disable=redefined-outer-name
request:
"""
start_time = time.time() # Start timer
duration_minutes = 20
duration_minutes = 15
response = client.post(
"/trip/new",
json={
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
"nature": {"type": "nature", "score": 0},
"shopping": {"type": "shopping", "score": 0},
"nature": {"type": "nature", "score": 5},
"shopping": {"type": "shopping", "score": 5},
"max_time_minute": duration_minutes,
"detour_tolerance_minute": 0},
"start": [48.084588, 7.280405]
# "start": [45.74445023349939, 4.8222687890538865]
# "start": [45.75156398104873, 4.827154464827647]
}
)
result = response.json()
@ -46,16 +44,17 @@ def test_turckheim(client, request): # pylint: disable=redefined-outer-name
# Add details to report
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
# for elem in landmarks :
# print(elem)
# checks :
assert response.status_code == 200 # check for successful planning
assert isinstance(landmarks, list) # check that the return type is a list
assert duration_minutes*0.8 < int(result['total_time']) < duration_minutes*1.2
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*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
"""
Test n°2 : Custom test in Lyon centre to ensure proper decision making in crowded area.
@ -67,7 +66,6 @@ def test_bellecour(client, request) : # pylint: disable=redefined-outer-name
start_time = time.time() # Start timer
duration_minutes = 120
response = client.post(
"/trip/new",
json={
@ -94,19 +92,20 @@ def test_bellecour(client, request) : # pylint: disable=redefined-outer-name
# checks :
assert response.status_code == 200 # check for successful planning
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*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
assert duration_minutes*0.8 < int(result['total_time']) < duration_minutes*1.2
# assert 2 == 3
def test_cologne(client, request) : # pylint: disable=redefined-outer-name
def test_Paris(client, request) : # pylint: disable=redefined-outer-name
"""
Test n°3 : Custom test in Cologne to ensure proper decision making in crowded area.
Test n°2 : Custom test in Paris (les Halles) centre to ensure proper decision making in crowded area.
Args:
client:
request:
"""
start_time = time.time() # Start timer
duration_minutes = 240
duration_minutes = 300
response = client.post(
"/trip/new",
@ -116,7 +115,7 @@ def test_cologne(client, request) : # pylint: disable=redefined-outer-name
"shopping": {"type": "shopping", "score": 5},
"max_time_minute": duration_minutes,
"detour_tolerance_minute": 0},
"start": [50.942352665, 6.957777972392]
"start": [48.86248803298562, 2.346451131285925]
}
)
result = response.json()
@ -134,136 +133,12 @@ def test_cologne(client, request) : # pylint: disable=redefined-outer-name
# checks :
assert response.status_code == 200 # check for successful planning
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*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
assert duration_minutes*0.8 < int(result['total_time']) < duration_minutes*1.2
def test_strasbourg(client, request) : # pylint: disable=redefined-outer-name
def test_New_York(client, request) : # pylint: disable=redefined-outer-name
"""
Test n°4 : Custom test in Strasbourg to ensure proper decision making in crowded area.
Args:
client:
request:
"""
start_time = time.time() # Start timer
duration_minutes = 180
response = client.post(
"/trip/new",
json={
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
"nature": {"type": "nature", "score": 5},
"shopping": {"type": "shopping", "score": 5},
"max_time_minute": duration_minutes,
"detour_tolerance_minute": 0},
"start": [48.5846589226, 7.74078715721]
}
)
result = response.json()
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
# Get computation time
comp_time = time.time() - start_time
# Add details to report
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
# for elem in landmarks :
# print(elem)
# checks :
assert response.status_code == 200 # check for successful planning
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*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
def test_zurich(client, request) : # pylint: disable=redefined-outer-name
"""
Test n°5 : Custom test in Zurich to ensure proper decision making in crowded area.
Args:
client:
request:
"""
start_time = time.time() # Start timer
duration_minutes = 180
response = client.post(
"/trip/new",
json={
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
"nature": {"type": "nature", "score": 5},
"shopping": {"type": "shopping", "score": 5},
"max_time_minute": duration_minutes,
"detour_tolerance_minute": 0},
"start": [47.377884227, 8.5395114066]
}
)
result = response.json()
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
# Get computation time
comp_time = time.time() - start_time
# Add details to report
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
# for elem in landmarks :
# print(elem)
# checks :
assert response.status_code == 200 # check for successful planning
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*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
def test_paris(client, request) : # pylint: disable=redefined-outer-name
"""
Test n°6 : Custom test in Paris (les Halles) centre to ensure proper decision making in crowded area.
Args:
client:
request:
"""
start_time = time.time() # Start timer
duration_minutes = 200
response = client.post(
"/trip/new",
json={
"preferences": {"sightseeing": {"type": "sightseeing", "score": 5},
"nature": {"type": "nature", "score": 0},
"shopping": {"type": "shopping", "score": 5},
"max_time_minute": duration_minutes,
"detour_tolerance_minute": 0},
"start": [48.85468881798671, 2.3423925755998374]
}
)
result = response.json()
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
# Get computation time
comp_time = time.time() - start_time
# Add details to report
log_trip_details(request, landmarks, result['total_time'], duration_minutes)
# for elem in landmarks :
# print(elem)
# checks :
assert response.status_code == 200 # check for successful planning
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*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
def test_new_york(client, request) : # pylint: disable=redefined-outer-name
"""
Test n°7 : Custom test in New York to ensure proper decision making in crowded area.
Test n°2 : Custom test in New York (les Halles) centre to ensure proper decision making in crowded area.
Args:
client:
@ -298,13 +173,12 @@ def test_new_york(client, request) : # pylint: disable=redefined-outer-name
# checks :
assert response.status_code == 200 # check for successful planning
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*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
assert duration_minutes*0.8 < int(result['total_time']) < duration_minutes*1.2
def test_shopping(client, request) : # pylint: disable=redefined-outer-name
"""
Test n°8 : Custom test in Lyon centre to ensure shopping clusters are found.
Test n°3 : Custom test in Lyon centre to ensure shopping clusters are found.
Args:
client:
@ -339,5 +213,23 @@ def test_shopping(client, request) : # pylint: disable=redefined-outer-name
# checks :
assert response.status_code == 200 # check for successful planning
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*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {duration_minutes}"
assert duration_minutes*0.8 < int(result['total_time']) < duration_minutes*1.2
'''
# def test_new_trip_single_prefs(client):
# response = client.post(
# "/trip/new",
# json={
# "preferences": {"sightseeing": {"type": "sightseeing", "score": 1},
# "nature": {"type": "nature", "score": 1},
# "shopping": {"type": "shopping", "score": 1},
# "max_time_minute": 360,
# "detour_tolerance_minute": 0},
# "start": [48.8566, 2.3522]
# }
# )
# assert response.status_code == 200
# def test_new_trip_matches_prefs(client):
# pass

View File

@ -3,16 +3,14 @@
from fastapi.testclient import TestClient
import pytest
from ..structs.toilets import Toilets
from ..structs.landmark import Toilets
from ..main import app
@pytest.fixture(scope="module")
def client():
"""Client used to call the app."""
return TestClient(app)
@pytest.mark.parametrize(
"location,radius,status_code",
[
@ -41,6 +39,8 @@ def test_invalid_input(client, location, radius, status_code): # pylint: disa
assert response.status_code == status_code
@pytest.mark.parametrize(
"location,status_code",
[
@ -66,10 +66,11 @@ def test_no_toilets(client, location, status_code): # pylint: disable=redefin
toilets_list = [Toilets.model_validate(toilet) for toilet in response.json()]
# checks :
assert response.status_code == status_code # check for successful planning
assert response.status_code == 200 # check for successful planning
assert isinstance(toilets_list, list) # check that the return type is a list
@pytest.mark.parametrize(
"location,status_code",
[
@ -96,6 +97,6 @@ def test_toilets(client, location, status_code): # pylint: disable=redefined-
toilets_list = [Toilets.model_validate(toilet) for toilet in response.json()]
# checks :
assert response.status_code == status_code # check for successful planning
assert response.status_code == 200 # check for successful planning
assert isinstance(toilets_list, list) # check that the return type is a list
assert len(toilets_list) > 0
assert len(toilets_list) > 0

View File

@ -1,6 +1,7 @@
"""Helper methods for testing."""
import logging
from fastapi import HTTPException
from pydantic import ValidationError
from ..structs.landmark import Landmark
from ..cache import client as cache_client
@ -22,7 +23,45 @@ def landmarks_to_osmid(landmarks: list[Landmark]) -> list[int] :
return ids
def fetch_landmark(landmark_uuid: str):
def fetch_landmark(client, landmark_uuid: str):
"""
Fetch landmark data from the API based on the landmark UUID.
Args:
landmark_uuid (str): The UUID of the landmark.
Returns:
dict: Landmark data fetched from the API.
"""
logger = logging.getLogger(__name__)
response = client.get(f"/landmark/{landmark_uuid}")
if response.status_code != 200:
raise HTTPException(status_code=500,
detail=f"Failed to fetch landmark with UUID {landmark_uuid}: {response.status_code}")
try:
json_data = response.json()
logger.info(f"API Response: {json_data}")
except ValueError as e:
logger.error(f"Failed to parse response as JSON: {response.text}")
raise HTTPException(status_code=500, detail="Invalid response format from API")
# Try validating against the Landmark model here to ensure consistency
try:
landmark = Landmark(**json_data)
except ValidationError as ve:
logging.error(f"Validation error: {ve}")
raise HTTPException(status_code=500, detail="Invalid data format received from API")
if "detail" in json_data:
raise HTTPException(status_code=500, detail=json_data["detail"])
return Landmark(**json_data)
def fetch_landmark_cache(landmark_uuid: str):
"""
Fetch landmark data from the cache based on the landmark UUID.
@ -36,24 +75,26 @@ def fetch_landmark(landmark_uuid: str):
# Try to fetch the landmark data from the cache
try:
landmark = cache_client.get(f'landmark_{landmark_uuid}')
landmark = cache_client.get(f"landmark_{landmark_uuid}")
if not landmark :
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.')
logger.warning(f"Cache miss for landmark UUID: {landmark_uuid}")
raise HTTPException(status_code=404, detail=f"Landmark with UUID {landmark_uuid} not found in cache.")
# Validate that the fetched data is a dictionary
if not isinstance(landmark, Landmark):
logger.error(f'Invalid cache data format for landmark UUID: {landmark_uuid}. Expected dict, got {type(landmark).__name__}.')
logger.error(f"Invalid cache data format for landmark UUID: {landmark_uuid}. Expected dict, got {type(landmark).__name__}.")
raise HTTPException(status_code=500, detail="Invalid cache data format.")
return landmark
except Exception as exc:
logger.error(f'Unexpected error occurred while fetching landmark UUID {landmark_uuid}: {exc}')
logger.error(f"Unexpected error occurred while fetching landmark UUID {landmark_uuid}: {exc}")
raise HTTPException(status_code=500, detail="An unexpected error occurred while fetching the landmark from the cache") from exc
def load_trip_landmarks(client, first_uuid: str) -> list[Landmark]:
def load_trip_landmarks(client, first_uuid: str, from_cache=None) -> list[Landmark]:
"""
Load all landmarks for a trip using the response from the API.
@ -67,7 +108,10 @@ def load_trip_landmarks(client, first_uuid: str) -> list[Landmark]:
next_uuid = first_uuid
while next_uuid is not None:
landmark = fetch_landmark(next_uuid)
if from_cache :
landmark = fetch_landmark_cache(next_uuid)
else :
landmark = fetch_landmark(client, next_uuid)
landmarks.append(landmark)
next_uuid = landmark.next_uuid # Prepare for the next iteration
@ -78,14 +122,14 @@ def load_trip_landmarks(client, first_uuid: str) -> list[Landmark]:
def log_trip_details(request, landmarks: list[Landmark], duration: int, target_duration: int) :
"""
Allows to show the detailed trip in the html test report.
Args:
request:
landmarks (list): the ordered list of visited landmarks
duration (int): the total duration of this trip
target_duration(int): the target duration of this trip
"""
trip_string = [f'{landmark.name} ({landmark.attractiveness} | {landmark.duration}) - {landmark.time_to_reach_next}' for landmark in landmarks]
trip_string = [f"{landmark.name} ({landmark.attractiveness} | {landmark.duration}) - {landmark.time_to_reach_next}" for landmark in landmarks]
# Pass additional info to pytest for reporting
request.node.trip_details = trip_string

View File

@ -1,38 +0,0 @@
"""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

View File

@ -1,122 +0,0 @@
"""Module for finding public toilets around given coordinates."""
import logging
from ..overpass.overpass import Overpass, get_base_info
from ..structs.toilets import Toilets
from ..utils.bbox import create_bbox
# silence the overpass logger
logging.getLogger('Overpass').setLevel(level=logging.CRITICAL)
class ToiletsManager:
"""
Manages the process of fetching and caching toilet information from
OpenStreetMap (OSM) based on a specified location and radius.
This class is responsible for:
- Fetching toilet data from OSM using Overpass API around a given set of
coordinates (latitude, longitude).
- Using a caching strategy to optimize requests by saving and retrieving
data from a local cache.
- Logging important events and errors related to data fetching.
Attributes:
logger (logging.Logger): Logger for the class to capture events.
location (tuple[float, float]): Latitude and longitude representing the
location to search around.
radius (int): The search radius in meters for finding nearby toilets.
overpass (Overpass): The Overpass API instance used to query OSM.
"""
logger = logging.getLogger(__name__)
location: tuple[float, float]
radius: int # radius in meters
def __init__(self, location: tuple[float, float], radius : int) -> None:
self.radius = radius
self.location = location
# Setup the caching in the Overpass class.
self.overpass = Overpass()
def generate_toilet_list(self) -> list[Toilets] :
"""
Generates a list of toilet locations by fetching data from OpenStreetMap (OSM)
around the given coordinates stored in `self.location`.
Returns:
list[Toilets]: A list of `Toilets` objects containing detailed information
about the toilets found around the given coordinates.
"""
bbox = create_bbox(self.location, self.radius)
osm_types = ['node', 'way', 'relation']
toilets_list = []
query = Overpass.build_query(
bbox = bbox,
osm_types = osm_types,
selector = '"amenity"="toilets"',
out = 'ids center tags'
)
try:
result = self.overpass.fetch_data_from_api(query_str=query)
except Exception as e:
self.logger.error(f"Error fetching toilets: {e}")
return None
toilets_list = self.to_toilets(result)
return toilets_list
def to_toilets(self, elements: list) -> list[Toilets]:
"""
Parse the Overpass API result and extract landmarks.
This method processes the JSON elements returned by the Overpass API and
extracts landmarks of types 'node', 'way', and 'relation'. It retrieves
relevant information such as name, coordinates, and tags, and converts them
into Landmark objects.
Args:
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).
Returns:
list[Landmark]: A list of Landmark objects extracted from the JSON data.
"""
if elements is None :
return []
toilets_list = []
for elem in elements:
osm_type = elem.get('type')
# Get coordinates and append them to the points list
_, coords = get_base_info(elem, osm_type)
if coords is None :
continue
toilets = Toilets(location=coords)
# Extract tags as a dictionary
tags = elem.get('tags')
if 'wheelchair' in tags.keys() and tags['wheelchair'] == 'yes':
toilets.wheelchair = True
if 'changing_table' in tags.keys() and tags['changing_table'] == 'yes':
toilets.changing_table = True
if 'fee' in tags.keys() and tags['fee'] == 'yes':
toilets.fee = True
if 'opening_hours' in tags.keys() :
toilets.opening_hours = tags['opening_hours']
toilets_list.append(toilets)
return toilets_list

View File

@ -1,27 +0,0 @@
"""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)

View File

@ -1,20 +1,19 @@
"""Find clusters of interest to add more general areas of visit to the tour."""
import logging
from typing import Literal, Tuple
from typing import Literal
import numpy as np
from sklearn.cluster import DBSCAN
from pydantic import BaseModel
from OSMPythonTools.overpass import Overpass, overpassQueryBuilder
from OSMPythonTools.cachingStrategy import CachingStrategy, JSON
from ..overpass.overpass import Overpass, get_base_info
from ..structs.landmark import Landmark
from ..utils.get_time_distance import get_distance
from ..utils.bbox import create_bbox
from ..utils.get_time_separation import get_distance
from ..constants import OSM_CACHE_DIR
# silence the overpass logger
logging.getLogger('Overpass').setLevel(level=logging.CRITICAL)
logging.getLogger('OSMPythonTools').setLevel(level=logging.CRITICAL)
class Cluster(BaseModel):
@ -33,30 +32,17 @@ class Cluster(BaseModel):
"""
type: Literal['street', 'area']
importance: int
centroid: Tuple[float, float]
centroid: tuple
# start: Optional[list] = None # for later use if we want to have streets as well
# end: Optional[list] = None
class ClusterManager:
"""
A manager responsible for clustering points of interest, such as shops or historic sites,
to identify areas worth visiting. It uses the DBSCAN algorithm to detect clusters
based on a set of points retrieved from OpenStreetMap (OSM).
Attributes:
logger (logging.Logger): Logger for capturing relevant events and errors.
valid (bool): Indicates whether clusters were successfully identified.
all_points (list): All points retrieved from OSM, representing locations of interest.
cluster_points (list): Points identified as part of a cluster.
cluster_labels (list): Labels corresponding to the clusters each point belongs to.
cluster_type (Literal['sightseeing', 'shopping']): Type of clustering, either for sightseeing
landmarks or shopping areas.
"""
logger = logging.getLogger(__name__)
# NOTE: all points are in (lat, lon) format
valid: bool # Ensure the manager is valid (ie there are some clusters to be found)
valid: bool # Ensure the manager is valid (ie there are some clusters to be found)
all_points: list
cluster_points: list
cluster_labels: list
@ -79,75 +65,69 @@ class ClusterManager:
Args:
bbox: The bounding box coordinates (around:radius, center_lat, center_lon).
"""
# Setup the caching in the Overpass class.
# Initialize overpass and cache
self.overpass = Overpass()
CachingStrategy.use(JSON, cacheDir=OSM_CACHE_DIR)
self.cluster_type = cluster_type
if cluster_type == 'shopping' :
osm_types = ['node']
sel = '"shop"~"^(bag|boutique|clothes)$"'
out = 'ids center'
elif cluster_type == 'sightseeing' :
osm_types = ['way']
sel = '"historic"~"^(monument|building|yes)$"'
out = 'ids center'
elem_type = ['node']
sel = ['"shop"~"^(bag|boutique|clothes)$"']
out = 'skel'
else :
raise NotImplementedError("Please choose only an available option for cluster detection")
elem_type = ['way']
sel = ['"historic"="building"']
out = 'center'
# Initialize the points for cluster detection
try:
result = self.overpass.send_query(
query = overpassQueryBuilder(
bbox = bbox,
osm_types = osm_types,
elementType = elem_type,
selector = sel,
includeCenter = True,
out = out
)
try:
result = self.overpass.query(query)
except Exception as e:
self.logger.warning(f"Error fetching clusters: {e}")
self.logger.error(f"Error fetching landmarks: {e}")
if result is None :
self.logger.debug(f"Found no {cluster_type} clusters, overpass query returned no datapoints.")
if len(result.elements()) == 0 :
self.valid = False
else :
points = []
for elem in result:
osm_type = elem.get('type')
for elem in result.elements() :
coords = tuple((elem.lat(), elem.lon()))
if coords[0] is None :
coords = tuple((elem.centerLat(), elem.centerLon()))
points.append(coords)
# Get coordinates and append them to the points list
_, coords = get_base_info(elem, osm_type)
if coords is not None :
points.append(coords)
if points :
self.all_points = np.array(points)
# Apply DBSCAN to find clusters. Choose different settings for different cities.
if self.cluster_type == 'shopping' and len(self.all_points) > 200 :
dbscan = DBSCAN(eps=0.00118, min_samples=15, algorithm='kd_tree') # for large cities
elif self.cluster_type == 'sightseeing' :
dbscan = DBSCAN(eps=0.0025, min_samples=15, algorithm='kd_tree') # for historic neighborhoods
else :
dbscan = DBSCAN(eps=0.00075, min_samples=10, algorithm='kd_tree') # for small cities
labels = dbscan.fit_predict(self.all_points)
# Check that there are is least 1 cluster
if len(set(labels)) > 1 :
self.logger.info(f"Found {len(set(labels))} different {cluster_type} clusters.")
# Separate clustered points and noise points
self.cluster_points = self.all_points[labels != -1]
self.cluster_labels = labels[labels != -1]
self.filter_clusters() # ValueError here sometimes. I dont know why. # Filter the clusters to keep only the largest ones.
self.valid = True
else :
self.logger.info(f"Found 0 {cluster_type} clusters.")
self.valid = False
self.all_points = np.array(points)
# Apply DBSCAN to find clusters. Choose different settings for different cities.
if self.cluster_type == 'shopping' and len(self.all_points) > 200 :
dbscan = DBSCAN(eps=0.00118, min_samples=15, algorithm='kd_tree') # for large cities
elif self.cluster_type == 'sightseeing' :
dbscan = DBSCAN(eps=0.0025, min_samples=15, algorithm='kd_tree') # for historic neighborhoods
else :
self.logger.debug(f"Detected 0 {cluster_type} clusters.")
self.valid = False
dbscan = DBSCAN(eps=0.00075, min_samples=10, algorithm='kd_tree') # for small cities
labels = dbscan.fit_predict(self.all_points)
# Check that there are at least 2 different clusters
if len(set(labels)) > 2 :
self.logger.debug(f"Found {len(set(labels))} different clusters.")
# Separate clustered points and noise points
self.cluster_points = self.all_points[labels != -1]
self.cluster_labels = labels[labels != -1]
self.filter_clusters() # ValueError here sometimes. I dont know why. # Filter the clusters to keep only the largest ones.
self.valid = True
else :
self.valid = False
def generate_clusters(self) -> list[Landmark]:
@ -175,15 +155,14 @@ class ClusterManager:
# Extract points belonging to the current cluster
current_cluster = self.cluster_points[self.cluster_labels == label]
# Calculate the centroid as the mean of the points
centroid = np.mean(current_cluster, axis=0)
centroid = tuple((round(centroid[0], 7), round(centroid[1], 7)))
if self.cluster_type == 'shopping' :
score = len(current_cluster)*3
score = len(current_cluster)*2
else :
score = len(current_cluster)*15
score = len(current_cluster)*8
locations.append(Cluster(
type='area',
centroid=centroid,
@ -216,7 +195,8 @@ class ClusterManager:
"""
# Define the bounding box for a given radius around the coordinates
bbox = create_bbox(cluster.centroid, 300)
lat, lon = cluster.centroid
bbox = ("around:1000", str(lat), str(lon))
# Query neighborhoods and shopping malls
selectors = ['"place"~"^(suburb|neighborhood|neighbourhood|quarter|city_block)$"']
@ -224,52 +204,64 @@ class ClusterManager:
if self.cluster_type == 'shopping' :
selectors.append('"shop"="mall"')
new_name = 'Shopping Area'
t = 30
else :
t = 40
else :
new_name = 'Neighborhood'
t = 20
t = 15
min_dist = float('inf')
new_name_en = None
osm_id = 0
osm_type = 'node'
osm_types = ['node', 'way', 'relation']
for sel in selectors :
for sel in selectors :
query = overpassQueryBuilder(
bbox = bbox,
elementType = ['node', 'way', 'relation'],
selector = sel,
includeCenter = True,
out = 'center'
)
try:
result = self.overpass.send_query(bbox = bbox,
osm_types = osm_types,
selector = sel,
out = 'ids center tags'
)
result = self.overpass.query(query)
except Exception as e:
self.logger.warning(f"Error fetching clusters: {e}")
self.logger.error(f"Error fetching landmarks: {e}")
continue
if result is None :
self.logger.warning(f"Error fetching clusters: query result is None")
continue
for elem in result.elements():
location = (elem.centerLat(), elem.centerLon())
for elem in result:
# Get basic info
id, coords, name = get_base_info(elem, elem.get('type'), with_name=True)
if name is None or coords is None :
# Skip if element has neither name or location
if elem.tag('name') is None :
continue
if location[0] is None :
location = (elem.lat(), elem.lon())
if location[0] is None :
continue
d = get_distance(cluster.centroid, coords)
d = get_distance(cluster.centroid, location)
if d < min_dist :
min_dist = d
new_name = name # add name
osm_type = elem.get('type') # add type: 'way' or 'relation'
osm_id = id # add OSM id
new_name = elem.tag('name')
osm_type = elem.type() # Add type: 'way' or 'relation'
osm_id = elem.id() # Add OSM id
# Add english name if it exists
try :
new_name_en = elem.tag('name:en')
except:
pass
return Landmark(
name=new_name,
type=self.cluster_type,
location=cluster.centroid, # later: use the fact the we can also recognize streets.
location=cluster.centroid, # TODO: use the fact the we can also recognize streets.
attractiveness=cluster.importance,
n_tags=0,
osm_id=osm_id,
osm_type=osm_type,
name_en=new_name_en,
duration=t
)
@ -298,3 +290,4 @@ class ClusterManager:
# update the cluster points and labels with the filtered data
self.cluster_points = np.vstack(filtered_cluster_points) # ValueError here
self.cluster_labels = np.concatenate(filtered_cluster_labels)

View File

@ -1,10 +1,8 @@
"""Contains various helper functions to help with distance or score computations."""
from math import sin, cos, sqrt, atan2, radians
import yaml
from math import sin, cos, sqrt, atan2, radians
from ..constants import OPTIMIZER_PARAMETERS_PATH
with OPTIMIZER_PARAMETERS_PATH.open('r') as f:
parameters = yaml.safe_load(f)
DETOUR_FACTOR = parameters['detour_factor']
@ -12,7 +10,6 @@ with OPTIMIZER_PARAMETERS_PATH.open('r') as f:
EARTH_RADIUS_KM = 6373
def get_time(p1: tuple[float, float], p2: tuple[float, float]) -> int:
"""
Calculate the time in minutes to travel from one location to another.
@ -24,23 +21,25 @@ def get_time(p1: tuple[float, float], p2: tuple[float, float]) -> int:
Returns:
int: Time to travel from p1 to p2 in minutes.
"""
# if p1 == p2:
# return 0
# else:
# Compute the distance in km along the surface of the Earth
# (assume spherical Earth)
# this is the haversine formula, stolen from stackoverflow
# in order to not use any external libraries
lat1, lon1 = radians(p1[0]), radians(p1[1])
lat2, lon2 = radians(p2[0]), radians(p2[1])
dlon = lon2 - lon1
dlat = lat2 - lat1
a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2
c = 2 * atan2(sqrt(a), sqrt(1 - a))
if p1 == p2:
return 0
else:
# Compute the distance in km along the surface of the Earth
# (assume spherical Earth)
# this is the haversine formula, stolen from stackoverflow
# in order to not use any external libraries
lat1, lon1 = radians(p1[0]), radians(p1[1])
lat2, lon2 = radians(p2[0]), radians(p2[1])
distance = EARTH_RADIUS_KM * c
dlon = lon2 - lon1
dlat = lat2 - lat1
a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2
c = 2 * atan2(sqrt(a), sqrt(1 - a))
distance = EARTH_RADIUS_KM * c
# Consider the detour factor for average an average city
walk_distance = distance * DETOUR_FACTOR
@ -48,7 +47,7 @@ def get_time(p1: tuple[float, float], p2: tuple[float, float]) -> int:
# Time to walk this distance (in minutes)
walk_time = walk_distance / AVERAGE_WALKING_SPEED * 60
return min(round(walk_time), 32765)
return round(walk_time)
def get_distance(p1: tuple[float, float], p2: tuple[float, float]) -> int:
@ -62,19 +61,22 @@ def get_distance(p1: tuple[float, float], p2: tuple[float, float]) -> int:
Returns:
int: Time to travel from p1 to p2 in minutes.
"""
if p1 == p2:
return 0
# Compute the distance in km along the surface of the Earth
# (assume spherical Earth)
# this is the haversine formula, stolen from stackoverflow
# in order to not use any external libraries
lat1, lon1 = radians(p1[0]), radians(p1[1])
lat2, lon2 = radians(p2[0]), radians(p2[1])
else:
# Compute the distance in km along the surface of the Earth
# (assume spherical Earth)
# this is the haversine formula, stolen from stackoverflow
# in order to not use any external libraries
lat1, lon1 = radians(p1[0]), radians(p1[1])
lat2, lon2 = radians(p2[0]), radians(p2[1])
dlon = lon2 - lon1
dlat = lat2 - lat1
dlon = lon2 - lon1
dlat = lat2 - lat1
a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2
c = 2 * atan2(sqrt(a), sqrt(1 - a))
a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2
c = 2 * atan2(sqrt(a), sqrt(1 - a))
return EARTH_RADIUS_KM * c
return EARTH_RADIUS_KM * c

View File

@ -0,0 +1,397 @@
import math, yaml, logging
from OSMPythonTools.overpass import Overpass, overpassQueryBuilder
from OSMPythonTools.cachingStrategy import CachingStrategy, JSON
from ..structs.preferences import Preferences
from ..structs.landmark import Landmark
from .take_most_important import take_most_important
from .cluster_manager import ClusterManager
from ..constants import AMENITY_SELECTORS_PATH, LANDMARK_PARAMETERS_PATH, OPTIMIZER_PARAMETERS_PATH, OSM_CACHE_DIR
# silence the overpass logger
logging.getLogger('OSMPythonTools').setLevel(level=logging.CRITICAL)
class LandmarkManager:
logger = logging.getLogger(__name__)
radius_close_to: int # radius in meters
church_coeff: float # coeff to adjsut score of churches
nature_coeff: float # coeff to adjust score of parks
overall_coeff: float # coeff to adjust weight of tags
N_important: int # number of important landmarks to consider
def __init__(self) -> None:
with AMENITY_SELECTORS_PATH.open('r') as f:
self.amenity_selectors = yaml.safe_load(f)
with LANDMARK_PARAMETERS_PATH.open('r') as f:
parameters = yaml.safe_load(f)
self.max_bbox_side = parameters['city_bbox_side']
self.radius_close_to = parameters['radius_close_to']
self.church_coeff = parameters['church_coeff']
self.nature_coeff = parameters['nature_coeff']
self.overall_coeff = parameters['overall_coeff']
self.tag_exponent = parameters['tag_exponent']
self.image_bonus = parameters['image_bonus']
self.name_bonus = parameters['name_bonus']
self.wikipedia_bonus = parameters['wikipedia_bonus']
self.viewpoint_bonus = parameters['viewpoint_bonus']
self.pay_bonus = parameters['pay_bonus']
self.N_important = parameters['N_important']
with OPTIMIZER_PARAMETERS_PATH.open('r') as f:
parameters = yaml.safe_load(f)
self.walking_speed = parameters['average_walking_speed']
self.detour_factor = parameters['detour_factor']
self.overpass = Overpass()
CachingStrategy.use(JSON, cacheDir=OSM_CACHE_DIR)
def generate_landmarks_list(self, center_coordinates: tuple[float, float], preferences: Preferences) -> tuple[list[Landmark], list[Landmark]]:
"""
Generate and prioritize a list of landmarks based on user preferences.
This method fetches landmarks from various categories (sightseeing, nature, shopping) based on the user's preferences
and current location. It scores and corrects these landmarks, removes duplicates, and then selects the most important
landmarks based on a predefined criterion.
Args:
center_coordinates (tuple[float, float]): The latitude and longitude of the center location around which to search.
preferences (Preferences): The user's preference settings that influence the landmark selection.
Returns:
tuple[list[Landmark], list[Landmark]]:
- A list of all existing landmarks.
- A list of the most important landmarks based on the user's preferences.
"""
max_walk_dist = (preferences.max_time_minute/2)/60*self.walking_speed*1000/self.detour_factor
reachable_bbox_side = min(max_walk_dist, self.max_bbox_side)
# use set to avoid duplicates, this requires some __methods__ to be set in Landmark
all_landmarks = set()
# Create a bbox using the around technique
bbox = tuple((f"around:{reachable_bbox_side/2}", str(center_coordinates[0]), str(center_coordinates[1])))
# list for sightseeing
if preferences.sightseeing.score != 0:
score_function = lambda score: score * 10 * preferences.sightseeing.score / 5
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['sightseeing'], preferences.sightseeing.type, score_function)
all_landmarks.update(current_landmarks)
# special pipeline for historic neighborhoods
neighborhood_manager = ClusterManager(bbox, 'sightseeing')
historic_clusters = neighborhood_manager.generate_clusters()
all_landmarks.update(historic_clusters)
# list for nature
if preferences.nature.score != 0:
score_function = lambda score: score * 10 * self.nature_coeff * preferences.nature.score / 5
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['nature'], preferences.nature.type, score_function)
all_landmarks.update(current_landmarks)
# list for shopping
if preferences.shopping.score != 0:
score_function = lambda score: score * 10 * preferences.shopping.score / 5
current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['shopping'], preferences.shopping.type, score_function)
# set time for all shopping activites :
for landmark in current_landmarks : landmark.duration = 30
all_landmarks.update(current_landmarks)
# special pipeline for shopping malls
shopping_manager = ClusterManager(bbox, 'shopping')
shopping_clusters = shopping_manager.generate_clusters()
all_landmarks.update(shopping_clusters)
landmarks_constrained = take_most_important(all_landmarks, self.N_important)
self.logger.info(f'Generated {len(all_landmarks)} landmarks around {center_coordinates}, and constrained to {len(landmarks_constrained)} most important ones.')
return all_landmarks, landmarks_constrained
def count_elements_close_to(self, coordinates: tuple[float, float]) -> int:
"""
Count the number of OpenStreetMap elements (nodes, ways, relations) within a specified radius of the given location.
This function constructs a bounding box around the specified coordinates based on the radius. It then queries
OpenStreetMap data to count the number of elements within that bounding box.
Args:
coordinates (tuple[float, float]): The latitude and longitude of the location to search around.
Returns:
int: The number of elements (nodes, ways, relations) within the specified radius. Returns 0 if no elements
are found or if an error occurs during the query.
"""
lat = coordinates[0]
lon = coordinates[1]
radius = self.radius_close_to
alpha = (180 * radius) / (6371000 * math.pi)
bbox = {'latLower':lat-alpha,'lonLower':lon-alpha,'latHigher':lat+alpha,'lonHigher': lon+alpha}
# Build the query to find elements within the radius
radius_query = overpassQueryBuilder(
bbox=[bbox['latLower'],
bbox['lonLower'],
bbox['latHigher'],
bbox['lonHigher']],
elementType=['node', 'way', 'relation']
)
try:
radius_result = self.overpass.query(radius_query)
N_elem = radius_result.countWays() + radius_result.countRelations()
self.logger.debug(f"There are {N_elem} ways/relations within 50m")
if N_elem is None:
return 0
return N_elem
except:
return 0
# def create_bbox(self, coordinates: tuple[float, float], reachable_bbox_side: int) -> tuple[float, float, float, float]:
# """
# Create a bounding box around the given coordinates.
# Args:
# coordinates (tuple[float, float]): The latitude and longitude of the center of the bounding box.
# reachable_bbox_side (int): The 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.
# """
# # Half the side length in m (since it's a square bbox)
# half_side_length_m = reachable_bbox_side / 2
# return tuple((f"around:{half_side_length_m}", str(coordinates[0]), str(coordinates[1])))
def fetch_landmarks(self, bbox: tuple, amenity_selector: dict, landmarktype: str, score_function: callable) -> list[Landmark]:
"""
Fetches landmarks of a specified type from OpenStreetMap (OSM) within a bounding box centered on given coordinates.
Args:
bbox (tuple[float, float, float, float]): The bounding box coordinates (around:radius, center_lat, center_lon).
amenity_selector (dict): The Overpass API query selector for the desired landmark type.
landmarktype (str): The type of the landmark (e.g., 'sightseeing', 'nature', 'shopping').
score_function (callable): The function to compute the score of the landmark based on its attributes.
Returns:
list[Landmark]: A list of Landmark objects that were fetched and filtered based on the provided criteria.
Notes:
- Landmarks are fetched using Overpass API queries.
- Selectors are translated from the dictionary to the Overpass query format. (e.g., 'amenity'='place_of_worship')
- Landmarks are filtered based on various conditions including tags and type.
- Scores are assigned to landmarks based on their attributes and surrounding elements.
"""
return_list = []
if landmarktype == 'nature' : query_conditions = []
else : query_conditions = ['count_tags()>5']
# caution, when applying a list of selectors, overpass will search for elements that match ALL selectors simultaneously
# we need to split the selectors into separate queries and merge the results
for sel in dict_to_selector_list(amenity_selector):
# self.logger.debug(f"Current selector: {sel}")
# query_conditions = ['count_tags()>5']
# if landmarktype == 'shopping' : # use this later for shopping clusters
# element_types = ['node']
element_types = ['way', 'relation']
if 'viewpoint' in sel :
query_conditions = []
element_types.append('node')
query = overpassQueryBuilder(
bbox = bbox,
elementType = element_types,
# selector can in principle be a list already,
# but it generates the intersection of the queries
# we want the union
selector = sel,
conditions = query_conditions, # except for nature....
includeCenter = True,
out = 'center'
)
# self.logger.debug(f"Query: {query}")
try:
result = self.overpass.query(query)
except Exception as e:
self.logger.error(f"Error fetching landmarks: {e}")
continue
for elem in result.elements():
name = elem.tag('name')
location = (elem.centerLat(), elem.centerLon())
osm_type = elem.type() # Add type: 'way' or 'relation'
osm_id = elem.id() # Add OSM id
# TODO: exclude these from the get go
# handle unprecise and no-name locations
if name is None or location[0] is None:
if osm_type == 'node' and 'viewpoint' in elem.tags().values():
name = 'Viewpoint'
name_en = 'Viewpoint'
location = (elem.lat(), elem.lon())
else :
continue
# skip if part of another building
if 'building:part' in elem.tags().keys() and elem.tag('building:part') == 'yes':
continue
elem_type = landmarktype # Add the landmark type as 'sightseeing,
n_tags = len(elem.tags().keys()) # Add number of tags
score = n_tags**self.tag_exponent # Add score
website_url = None
image_url = None
name_en = None
# Adjust scoring, browse through tag keys
skip = False
for tag_key in elem.tags().keys():
if "pay" in tag_key:
# payment options are misleading and should not count for the scoring.
score += self.pay_bonus
if "disused" in tag_key:
# skip disused amenities
skip = True
break
if "building:" in tag_key:
# do not count the building description as being particularly useful
n_tags -= 1
if "boundary" in tag_key:
# skip "areas" like administrative boundaries and stuff
skip = True
break
if "historic" in tag_key and elem.tag('historic') in ['manor', 'optical_telegraph', 'pound', 'shieling', 'wayside_cross']:
# skip useless amenities
skip = True
break
if "name" in tag_key :
score += self.name_bonus
if "wiki" in tag_key:
# wikipedia entries count more
score += self.wikipedia_bonus
if "image" in tag_key:
# images must count more
score += self.image_bonus
if elem_type != "nature":
if "leisure" in tag_key and elem.tag('leisure') == "park":
elem_type = "nature"
if landmarktype != "shopping":
if "shop" in tag_key:
skip = True
break
if tag_key == "building" and elem.tag('building') in ['retail', 'supermarket', 'parking']:
skip = True
break
# Extract image, website and english name
if tag_key in ['website', 'contact:website']:
website_url = elem.tag(tag_key)
if tag_key == 'image':
image_url = elem.tag('image')
if tag_key =='name:en':
name_en = elem.tag('name:en')
if skip:
continue
# Don't visit random apartments
if 'apartments' in elem.tags().values():
continue
score = score_function(score)
if "place_of_worship" in elem.tags().values() :
if "cathedral" not in elem.tags().values() :
score = score * self.church_coeff
duration = 5
else :
duration = 10
elif 'viewpoint' in elem.tags().values() :
# viewpoints must count more
score = score * self.viewpoint_bonus
duration = 10
elif "museum" in elem.tags().values() or "aquarium" in elem.tags().values() or "planetarium" in elem.tags().values():
duration = 60
else:
duration = 5
# finally create our own landmark object
landmark = Landmark(
name = name,
type = elem_type,
location = location,
osm_type = osm_type,
osm_id = osm_id,
attractiveness = int(score),
must_do = False,
n_tags = int(n_tags),
duration = int(duration),
name_en = name_en,
image_url = image_url,
website_url = website_url
)
return_list.append(landmark)
self.logger.debug(f"Fetched {len(return_list)} landmarks of type {landmarktype} in {bbox}")
return return_list
def dict_to_selector_list(d: dict) -> list:
"""
Convert a dictionary of key-value pairs to a list of Overpass query strings.
Args:
d (dict): A dictionary of key-value pairs representing the selector.
Returns:
list: A list of strings representing the Overpass query selectors.
"""
return_list = []
for key, value in d.items():
if type(value) == list:
val = '|'.join(value)
return_list.append(f'{key}~"^({val})$"')
elif type(value) == str and len(value) == 0:
return_list.append(f'{key}')
else:
return_list.append(f'{key}={value}')
return return_list

View File

@ -0,0 +1,529 @@
import yaml, logging
import numpy as np
from scipy.optimize import linprog
from collections import defaultdict, deque
from ..structs.landmark import Landmark
from .get_time_separation import get_time
from ..constants import OPTIMIZER_PARAMETERS_PATH
class Optimizer:
logger = logging.getLogger(__name__)
detour: int = None # accepted max detour time (in minutes)
detour_factor: float # detour factor of straight line vs real distance in cities
average_walking_speed: float # average walking speed of adult
max_landmarks: int # max number of landmarks to visit
overshoot: float # overshoot to allow maxtime to overflow. Optimizer is a bit restrictive
def __init__(self) :
# load parameters from file
with OPTIMIZER_PARAMETERS_PATH.open('r') as f:
parameters = yaml.safe_load(f)
self.detour_factor = parameters['detour_factor']
self.average_walking_speed = parameters['average_walking_speed']
self.max_landmarks = parameters['max_landmarks']
self.overshoot = parameters['overshoot']
# Prevent the use of a particular solution
def prevent_config(self, resx):
"""
Prevent the use of a particular solution by adding constraints to the optimization.
Args:
resx (list[float]): List of edge weights.
Returns:
tuple[list[int], list[int]]: A tuple containing a new row for constraint matrix and new value for upper bound vector.
"""
for i, elem in enumerate(resx):
resx[i] = round(elem)
N = len(resx) # Number of edges
L = int(np.sqrt(N)) # Number of landmarks
nonzeroind = np.nonzero(resx)[0] # the return is a little funky so I use the [0]
nonzero_tup = np.unravel_index(nonzeroind, (L,L))
ind_a = nonzero_tup[0].tolist()
vertices_visited = ind_a
vertices_visited.remove(0)
ones = [1]*L
h = [0]*N
for i in range(L) :
if i in vertices_visited :
h[i*L:i*L+L] = ones
return h, [len(vertices_visited)-1]
# Prevents the creation of the same circle (both directions)
def prevent_circle(self, circle_vertices: list, L: int) :
"""
Prevent circular paths by by adding constraints to the optimization.
Args:
circle_vertices (list): List of vertices forming a circle.
L (int): Number of landmarks.
Returns:
tuple[np.ndarray, list[int]]: A tuple containing a new row for constraint matrix and new value for upper bound vector.
"""
l1 = [0]*L*L
l2 = [0]*L*L
for i, node in enumerate(circle_vertices[:-1]) :
next = circle_vertices[i+1]
l1[node*L + next] = 1
l2[next*L + node] = 1
s = circle_vertices[0]
g = circle_vertices[-1]
l1[g*L + s] = 1
l2[s*L + g] = 1
return np.vstack((l1, l2)), [0, 0]
def is_connected(self, resx) :
"""
Determine the order of visits and detect any circular paths in the given configuration.
Args:
resx (list): List of edge weights.
Returns:
tuple[list[int], Optional[list[list[int]]]]: A tuple containing the visit order and a list of any detected circles.
"""
# first round the results to have only 0-1 values
for i, elem in enumerate(resx):
resx[i] = round(elem)
N = len(resx) # length of res
L = int(np.sqrt(N)) # number of landmarks. CAST INTO INT but should not be a problem because N = L**2 by def.
nonzeroind = np.nonzero(resx)[0] # the return is a little funny so I use the [0]
nonzero_tup = np.unravel_index(nonzeroind, (L,L))
ind_a = nonzero_tup[0].tolist()
ind_b = nonzero_tup[1].tolist()
# Step 1: Create a graph representation
graph = defaultdict(list)
for a, b in zip(ind_a, ind_b):
graph[a].append(b)
# Step 2: Function to perform BFS/DFS to extract journeys
def get_journey(start):
journey_nodes = []
visited = set()
stack = deque([start])
while stack:
node = stack.pop()
if node not in visited:
visited.add(node)
journey_nodes.append(node)
for neighbor in graph[node]:
if neighbor not in visited:
stack.append(neighbor)
return journey_nodes
# Step 3: Extract all journeys
all_journeys_nodes = []
visited_nodes = set()
for node in ind_a:
if node not in visited_nodes:
journey_nodes = get_journey(node)
all_journeys_nodes.append(journey_nodes)
visited_nodes.update(journey_nodes)
for l in all_journeys_nodes :
if 0 in l :
order = l
all_journeys_nodes.remove(l)
break
if len(all_journeys_nodes) == 0 :
return order, None
return order, all_journeys_nodes
def init_ub_dist(self, landmarks: list[Landmark], max_time: int):
"""
Initialize the objective function coefficients and inequality constraints for the optimization problem.
This function computes the distances between all landmarks and stores their attractiveness to maximize sightseeing.
The goal is to maximize the objective function subject to the constraints A*x < b and A_eq*x = b_eq.
Args:
landmarks (list[Landmark]): List of landmarks.
max_time (int): Maximum time of visit allowed.
Returns:
tuple[list[float], list[float], list[int]]: Objective function coefficients, inequality constraint coefficients, and the right-hand side of the inequality constraint.
"""
# Objective function coefficients. a*x1 + b*x2 + c*x3 + ...
c = []
# Coefficients of inequality constraints (left-hand side)
A_ub = []
for spot1 in landmarks :
dist_table = [0]*len(landmarks)
c.append(-spot1.attractiveness)
for j, spot2 in enumerate(landmarks) :
t = get_time(spot1.location, spot2.location) + spot1.duration
dist_table[j] = t
closest = sorted(dist_table)[:25]
for i, dist in enumerate(dist_table) :
if dist not in closest :
dist_table[i] = 32700
A_ub += dist_table
c = c*len(landmarks)
return c, A_ub, [max_time*self.overshoot]
def respect_number(self, L, max_landmarks: int):
"""
Generate constraints to ensure each landmark is visited only once and cap the total number of visited landmarks.
Args:
L (int): Number of landmarks.
Returns:
tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
"""
ones = [1]*L
zeros = [0]*L
A = ones + zeros*(L-1)
b = [1]
for i in range(L-1) :
h_new = zeros*i + ones + zeros*(L-1-i)
A = np.vstack((A, h_new))
b.append(1)
A = np.vstack((A, ones*L))
b.append(max_landmarks+1)
return A, b
# Constraint to not have d14 and d41 simultaneously. Does not prevent cyclic paths with more elements
def break_sym(self, L):
"""
Generate constraints to prevent simultaneous travel between two landmarks in both directions.
Args:
L (int): Number of landmarks.
Returns:
tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
"""
upper_ind = np.triu_indices(L,0,L)
up_ind_x = upper_ind[0]
up_ind_y = upper_ind[1]
A = [0]*L*L
b = [1]
for i, _ in enumerate(up_ind_x[1:]) :
l = [0]*L*L
if up_ind_x[i] != up_ind_y[i] :
l[up_ind_x[i]*L + up_ind_y[i]] = 1
l[up_ind_y[i]*L + up_ind_x[i]] = 1
A = np.vstack((A,l))
b.append(1)
return A, b
def init_eq_not_stay(self, L: int):
"""
Generate constraints to prevent staying in the same position (e.g., removing d11, d22, d33, etc.).
Args:
L (int): Number of landmarks.
Returns:
tuple[list[np.ndarray], list[int]]: Equality constraint coefficients and the right-hand side of the equality constraints.
"""
l = [0]*L*L
for i in range(L) :
for j in range(L) :
if j == i :
l[j + i*L] = 1
l = np.array(np.array(l), dtype=np.int8)
return [l], [0]
def respect_user_must_do(self, landmarks: list[Landmark]) :
"""
Generate constraints to ensure that landmarks marked as 'must_do' are included in the optimization.
Args:
landmarks (list[Landmark]): List of landmarks, where some are marked as 'must_do'.
Returns:
tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
"""
L = len(landmarks)
A = [0]*L*L
b = [0]
for i, elem in enumerate(landmarks[1:]) :
if elem.must_do is True and elem.name not in ['finish', 'start']:
l = [0]*L*L
l[i*L:i*L+L] = [1]*L # set mandatory departures from landmarks tagged as 'must_do'
A = np.vstack((A,l))
b.append(1)
return A, b
def respect_user_must_avoid(self, landmarks: list[Landmark]) :
"""
Generate constraints to ensure that landmarks marked as 'must_avoid' are skipped in the optimization.
Args:
landmarks (list[Landmark]): List of landmarks, where some are marked as 'must_avoid'.
Returns:
tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
"""
L = len(landmarks)
A = [0]*L*L
b = [0]
for i, elem in enumerate(landmarks[1:]) :
if elem.must_avoid is True and elem.name not in ['finish', 'start']:
l = [0]*L*L
l[i*L:i*L+L] = [1]*L
A = np.vstack((A,l))
b.append(0) # prevent departures from landmarks tagged as 'must_do'
return A, b
# Constraint to ensure start at start and finish at goal
def respect_start_finish(self, L: int):
"""
Generate constraints to ensure that the optimization starts at the designated start landmark and finishes at the goal landmark.
Args:
L (int): Number of landmarks.
Returns:
tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
"""
l_start = [1]*L + [0]*L*(L-1) # sets departures only for start (horizontal ones)
l_start[L-1] = 0 # prevents the jump from start to finish
l_goal = [0]*L*L # sets arrivals only for finish (vertical ones)
l_L = [0]*L*(L-1) + [1]*L # prevents arrivals at start and departures from goal
for k in range(L-1) : # sets only vertical ones for goal (go to)
l_L[k*L] = 1
if k != 0 :
l_goal[k*L+L-1] = 1
A = np.vstack((l_start, l_goal))
b = [1, 1]
A = np.vstack((A,l_L))
b.append(0)
return A, b
def respect_order(self, L: int):
"""
Generate constraints to tie the optimization problem together and prevent stacked ones, although this does not fully prevent circles.
Args:
L (int): Number of landmarks.
Returns:
tuple[np.ndarray, list[int]]: Inequality constraint coefficients and the right-hand side of the inequality constraints.
"""
A = [0]*L*L
b = [0]
for i in range(L-1) : # Prevent stacked ones
if i == 0 or i == L-1: # Don't touch start or finish
continue
else :
l = [0]*L
l[i] = -1
l = l*L
for j in range(L) :
l[i*L + j] = 1
A = np.vstack((A,l))
b.append(0)
return A, b
def link_list(self, order: list[int], landmarks: list[Landmark])->list[Landmark] :
"""
Compute the time to reach from each landmark to the next and create a list of landmarks with updated travel times.
Args:
order (list[int]): List of indices representing the order of landmarks to visit.
landmarks (list[Landmark]): List of all landmarks.
Returns:
list[Landmark]]: The updated linked list of landmarks with travel times
"""
L = []
j = 0
while j < len(order)-1 :
# get landmarks involved
elem = landmarks[order[j]]
next = landmarks[order[j+1]]
# get attributes
elem.time_to_reach_next = get_time(elem.location, next.location)
elem.must_do = True
elem.location = (round(elem.location[0], 5), round(elem.location[1], 5))
elem.next_uuid = next.uuid
L.append(elem)
j += 1
next.location = (round(next.location[0], 5), round(next.location[1], 5))
next.must_do = True
L.append(next)
return L
# Main optimization pipeline
def solve_optimization(
self,
max_time: int,
landmarks: list[Landmark],
max_landmarks: int = None
) -> list[Landmark]:
"""
Main optimization pipeline to solve the landmark visiting problem.
This method sets up and solves a linear programming problem with constraints to find an optimal tour of landmarks,
considering user-defined must-visit landmarks, start and finish points, and ensuring no cycles are present.
Args:
max_time (int): Maximum time allowed for the tour in minutes.
landmarks (list[Landmark]): List of landmarks to visit.
max_landmarks (int): Maximum number of landmarks visited
Returns:
list[Landmark]: The optimized tour of landmarks with updated travel times, or None if no valid solution is found.
"""
if max_landmarks is None :
max_landmarks = self.max_landmarks
L = len(landmarks)
# SET CONSTRAINTS FOR INEQUALITY
c, A_ub, b_ub = self.init_ub_dist(landmarks, max_time) # Add the distances from each landmark to the other
A, b = self.respect_number(L, max_landmarks) # Respect max number of visits (no more possible stops than landmarks).
A_ub = np.vstack((A_ub, A), dtype=np.int16)
b_ub += b
A, b = self.break_sym(L) # break the 'zig-zag' symmetry
A_ub = np.vstack((A_ub, A), dtype=np.int16)
b_ub += b
# SET CONSTRAINTS FOR EQUALITY
A_eq, b_eq = self.init_eq_not_stay(L) # Force solution not to stay in same place
A, b = self.respect_start_finish(L) # Force start and finish positions
A_eq = np.vstack((A_eq, A), dtype=np.int8)
b_eq += b
A, b = self.respect_order(L) # Respect order of visit (only works when max_time is limiting factor)
A_eq = np.vstack((A_eq, A), dtype=np.int8)
b_eq += b
A, b = self.respect_user_must_do(landmarks) # Check if there are user_defined must_see. Also takes care of start/goal
A_eq = np.vstack((A_eq, A), dtype=np.int8)
b_eq += b
A, b = self.respect_user_must_avoid(landmarks) # Check if there are user_defined must_see. Also takes care of start/goal
A_eq = np.vstack((A_eq, A), dtype=np.int8)
b_eq += b
print(A_eq)
print('\n\n')
print(b_eq)
print('\n\n')
# SET BOUNDS FOR DECISION VARIABLE (x can only be 0 or 1)
x_bounds = [(0, 1)]*L*L
# Solve linear programming problem
res = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq = b_eq, bounds=x_bounds, method='highs', integrality=3)
# Raise error if no solution is found
if not res.success :
raise ArithmeticError("No solution could be found, the problem is overconstrained. Try with a longer trip (>30 minutes).")
# If there is a solution, we're good to go, just check for connectiveness
order, circles = self.is_connected(res.x)
#nodes, edges = is_connected(res.x)
i = 0
timeout = 80
while circles is not None and i < timeout:
A, b = self.prevent_config(res.x)
A_ub = np.vstack((A_ub, A))
b_ub += b
#A_ub, b_ub = prevent_circle(order, len(landmarks), A_ub, b_ub)
for circle in circles :
A, b = self.prevent_circle(circle, L)
A_eq = np.vstack((A_eq, A))
b_eq += b
res = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq = b_eq, bounds=x_bounds, method='highs', integrality=3)
if not res.success :
raise ArithmeticError("Solving failed because of overconstrained problem")
return None
order, circles = self.is_connected(res.x)
#nodes, edges = is_connected(res.x)
if circles is None :
break
# print(i)
i += 1
if i == timeout :
raise TimeoutError(f"Optimization took too long. No solution found after {timeout} iterations.")
#sort the landmarks in the order of the solution
tour = [landmarks[i] for i in order]
self.logger.debug(f"Re-optimized {i} times, score: {int(-res.fun)}")
return tour

View File

@ -1,32 +1,23 @@
"""Allows to refine the tour by adding more landmarks and making the path easier to follow."""
import logging
from math import pi
import yaml
import yaml, logging
from shapely import buffer, LineString, Point, Polygon, MultiPoint, concave_hull
from math import pi
from ..structs.landmark import Landmark
from ..utils.get_time_distance import get_time
from ..utils.take_most_important import take_most_important
from . import take_most_important, get_time_separation
from .optimizer import Optimizer
from ..constants import OPTIMIZER_PARAMETERS_PATH
class Refiner :
"""
Refines a tour by incorporating smaller landmarks along the path to enhance the experience.
This class is designed to adjust an existing tour by considering additional,
smaller points of interest (landmarks) that may require minor detours but
improve the overall quality of the tour. It balances the efficiency of travel
with the added value of visiting these landmarks.
"""
logger = logging.getLogger(__name__)
detour_factor: float # detour factor of straight line vs real distance in cities
detour_corridor_width: float # width of the corridor around the path
average_walking_speed: float # average walking speed of adult
max_landmarks_refiner: int # max number of landmarks to visit
max_landmarks_refiner: int # max number of landmarks to visit
optimizer: Optimizer # optimizer object
def __init__(self, optimizer: Optimizer) :
@ -54,7 +45,7 @@ class Refiner :
"""
corrected_width = (180*width)/(6371000*pi)
path = self.create_linestring(landmarks)
obj = buffer(path, corrected_width, join_style="mitre", cap_style="square", mitre_limit=2)
@ -79,7 +70,7 @@ class Refiner :
return LineString(points)
# Check if some coordinates are in area. Used for the corridor
# Check if some coordinates are in area. Used for the corridor
def is_in_area(self, area: Polygon, coordinates) -> bool :
"""
Check if a given point is within a specified area.
@ -95,7 +86,7 @@ class Refiner :
return point.within(area)
# Function to determine if two landmarks are close to each other
# Function to determine if two landmarks are close to each other
def is_close_to(self, location1: tuple[float], location2: tuple[float]):
"""
Determine if two locations are close to each other by rounding their coordinates to 3 decimal places.
@ -128,7 +119,7 @@ class Refiner :
Returns:
list[Landmark]: The rearranged list of landmarks with grouped nearby visits.
"""
i = 1
while i < len(tour):
j = i+1
@ -140,9 +131,9 @@ class Refiner :
break # Move to the next i-th element after rearrangement
j += 1
i += 1
return tour
def integrate_landmarks(self, sub_list: list[Landmark], main_list: list[Landmark]) :
"""
Inserts 'sub_list' of Landmarks inside the 'main_list' by leaving the ends untouched.
@ -175,27 +166,27 @@ class Refiner :
should be visited, and the second element is a `Polygon` representing
the path connecting all landmarks.
"""
# Step 1: Find 'start' and 'finish' landmarks
start_idx = next(i for i, lm in enumerate(landmarks) if lm.type == 'start')
finish_idx = next(i for i, lm in enumerate(landmarks) if lm.type == 'finish')
start_landmark = landmarks[start_idx]
finish_landmark = landmarks[finish_idx]
# Step 2: Create a list of unvisited landmarks excluding 'start' and 'finish'
unvisited_landmarks = [lm for i, lm in enumerate(landmarks) if i not in [start_idx, finish_idx]]
# Step 3: Initialize the path with the 'start' landmark
path = [start_landmark]
coordinates = [landmarks[start_idx].location]
current_landmark = start_landmark
# Step 4: Use nearest neighbor heuristic to visit all landmarks
while unvisited_landmarks:
nearest_landmark = min(unvisited_landmarks, key=lambda lm: get_time(current_landmark.location, lm.location))
nearest_landmark = min(unvisited_landmarks, key=lambda lm: get_time_separation.get_time(current_landmark.location, lm.location))
path.append(nearest_landmark)
coordinates.append(nearest_landmark.location)
current_landmark = nearest_landmark
@ -233,12 +224,12 @@ class Refiner :
for visited in visited_landmarks :
visited_names.append(visited.name)
for landmark in all_landmarks :
if self.is_in_area(area, landmark.location) and landmark.name not in visited_names:
second_order_landmarks.append(landmark)
return take_most_important(second_order_landmarks, int(self.max_landmarks_refiner*0.75))
return take_most_important.take_most_important(second_order_landmarks, int(self.max_landmarks_refiner*0.75))
# Try fix the shortest path using shapely
@ -265,7 +256,7 @@ class Refiner :
coords_dict[landmark.location] = landmark
tour_poly = Polygon(coords)
better_tour_poly = tour_poly.buffer(0)
try :
xs, ys = better_tour_poly.exterior.xy
@ -274,11 +265,11 @@ class Refiner :
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
except Exception:
except :
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
"""
FIXED : ERROR HERE :
ERROR HERE :
Exception has occurred: AttributeError
'LineString' object has no attribute 'exterior'
"""
@ -308,7 +299,7 @@ class Refiner :
# Rearrange only if polygon still not simple
if not better_tour_poly.is_simple :
better_tour = self.rearrange(better_tour)
return better_tour
@ -339,10 +330,10 @@ class Refiner :
# No need to refine if no detour is taken
# if detour == 0:
# return base_tour
minor_landmarks = self.get_minor_landmarks(all_landmarks, base_tour, self.detour_corridor_width)
self.logger.debug(f"Using {len(minor_landmarks)} minor landmarks around the predicted path")
self.logger.info(f"Using {len(minor_landmarks)} minor landmarks around the predicted path")
# Full set of visitable landmarks.
full_set = self.integrate_landmarks(minor_landmarks, base_tour) # could probably be optimized with less overhead
@ -350,13 +341,13 @@ class Refiner :
# Generate a new tour with the optimizer.
new_tour = self.optimizer.solve_optimization(
max_time = max_time + detour,
landmarks = full_set,
landmarks = full_set,
max_landmarks = self.max_landmarks_refiner
)
# If unsuccessful optimization, use the base_tour.
if new_tour is None:
self.logger.warning("Refiner failed: No solution found during second stage optimization.")
self.logger.warning("No solution found for the refined tour. Returning the initial tour.")
new_tour = base_tour
# If only one landmark, return it.
@ -366,10 +357,9 @@ class Refiner :
# Find shortest path using the nearest neighbor heuristic.
better_tour, better_poly = self.find_shortest_path_through_all_landmarks(new_tour)
# 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.
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)
return better_tour

View File

@ -1,4 +1,3 @@
"""Helper function to return only the major landmarks from a large list."""
from ..structs.landmark import Landmark
def take_most_important(landmarks: list[Landmark], n_important) -> list[Landmark]:

View File

@ -0,0 +1,78 @@
import logging, yaml
from OSMPythonTools.overpass import Overpass, overpassQueryBuilder
from OSMPythonTools.cachingStrategy import CachingStrategy, JSON
from ..structs.landmark import Toilets
from ..constants import LANDMARK_PARAMETERS_PATH, OSM_CACHE_DIR
# silence the overpass logger
logging.getLogger('OSMPythonTools').setLevel(level=logging.CRITICAL)
class ToiletsManager:
logger = logging.getLogger(__name__)
location: tuple[float, float]
radius: int # radius in meters
def __init__(self, location: tuple[float, float], radius : int) -> None:
self.radius = radius
self.location = location
self.overpass = Overpass()
CachingStrategy.use(JSON, cacheDir=OSM_CACHE_DIR)
def generate_toilet_list(self) -> list[Toilets] :
# Create a bbox using the around technique
bbox = tuple((f"around:{self.radius}", str(self.location[0]), str(self.location[1])))
toilets_list = []
query = overpassQueryBuilder(
bbox = bbox,
elementType = ['node', 'way', 'relation'],
# selector can in principle be a list already,
# but it generates the intersection of the queries
# we want the union
selector = ['"amenity"="toilets"'],
includeCenter = True,
out = 'center'
)
self.logger.debug(f"Query: {query}")
try:
result = self.overpass.query(query)
except Exception as e:
self.logger.error(f"Error fetching landmarks: {e}")
return None
for elem in result.elements():
location = (elem.centerLat(), elem.centerLon())
# handle unprecise and no-name locations
if location[0] is None:
location = (elem.lat(), elem.lon())
else :
continue
toilets = Toilets(location=location)
if 'wheelchair' in elem.tags().keys() and elem.tag('wheelchair') == 'yes':
toilets.wheelchair = True
if 'changing_table' in elem.tags().keys() and elem.tag('changing_table') == 'yes':
toilets.changing_table = True
if 'fee' in elem.tags().keys() and elem.tag('fee') == 'yes':
toilets.fee = True
if 'opening_hours' in elem.tags().keys() :
toilets.opening_hours = elem.tag('opening_hours')
toilets_list.append(toilets)
return toilets_list

View File

@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
revision: "09de023485e95e6d1225c2baa44b8feb85e0d45f"
revision: "54e66469a933b60ddf175f858f82eaeb97e48c8d"
channel: "stable"
project_type: app
@ -13,11 +13,26 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f
base_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f
create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
- platform: android
create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
- platform: ios
create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
- platform: linux
create_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f
base_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f
create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
- platform: macos
create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
- platform: web
create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
- platform: windows
create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d
# User provided section

View File

@ -0,0 +1,220 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.970.0)
aws-sdk-core (3.202.2)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.159.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.9.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.111.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.3.1)
fastlane (2.222.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.7.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.4.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.7)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.7.2)
jwt (2.8.2)
base64
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.4.1)
nanaimo (0.3.0)
naturally (2.2.1)
nkf (0.2.0)
optparse (0.5.0)
os (1.1.4)
plist (3.7.1)
public_suffix (6.0.1)
rake (13.2.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.3.6)
strscan
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.5)
signet (0.19.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
strscan (3.1.0)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.5.0)
word_wrap (1.0.0)
xcodeproj (1.25.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (>= 3.3.2, < 4.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
x86_64-linux
DEPENDENCIES
fastlane
BUNDLED WITH
2.5.18

View File

@ -77,7 +77,7 @@ android {
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
// // Placeholders of keys that are replaced by the build system.
manifestPlaceholders += ['MAPS_API_KEY': System.getenv('ANDROID_GOOGLE_MAPS_API_KEY')]
manifestPlaceholders += ['MAPS_API_KEY': System.getenv('GOOGLE_MAPS_API_KEY')]
}

View File

@ -3,7 +3,7 @@ default_platform(:android)
platform :android do
desc "Deploy a new version to closed testing (play store)"
lane :deploy_beta do
lane :deploy_testing do
build_name = ENV["BUILD_NAME"]
build_number = ENV["BUILD_NUMBER"]
@ -18,7 +18,6 @@ platform :android do
upload_to_play_store(
track: 'alpha',
# upload aab files intstead
skip_upload_apk: true,
skip_upload_changelogs: true,
aab: "../build/app/outputs/bundle/release/app-release.aab",

View File

@ -19,7 +19,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false
id "com.android.application" version "7.3.0" apply false
id "org.jetbrains.kotlin.android" version "2.0.20" apply false
}

View File

@ -1,2 +0,0 @@
## Vector assets
As per https://www.svgrepo.com/collection/pixellove-bordered-vectors/ these icons are licensed under CC0.

107
frontend/assets/cat.svg Normal file
View File

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.5.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 500 500" style="enable-background:new 0 0 500 500;" xml:space="preserve">
<g id="OBJECTS">
<g>
<path style="fill:#F2DBDE;" d="M381.005,363.01c-53.963,8.445-84.441,11.1-138.832,6.101
c-54.388-4.998-109.48-25.844-144.743-67.555c-23.468-27.759-36.728-62.943-43.732-98.613c-3.745-19.07-5.754-39.21,0.433-57.635
c7.513-22.378,26.565-39.569,48.136-49.156c21.572-9.589,45.552-12.365,69.151-12.944c47.753-1.172,95.706,6.26,140.863,21.831
c35.603,12.277,69.954,29.937,96.972,56.171c27.019,26.233,46.213,61.723,47.963,99.341
C458.967,298.17,438.434,354.022,381.005,363.01z"/>
<g>
<path style="fill:#F2BFC6;" d="M314.479,248.209c-22.398,36.41-29.246,81.831-19.597,123.401
c27.302-0.242,52.026-3.263,86.124-8.6c57.429-8.989,77.961-64.84,76.211-102.458c-1.503-32.308-15.881-63.041-37.024-87.694
C375.546,184.337,337.241,211.21,314.479,248.209z"/>
<path style="fill:#F2BFC6;" d="M60.074,229.111c2.232,7.566,4.802,15.029,7.749,22.32c40.138-5.931,78.066-26.379,104.834-56.907
c26.459-30.176,41.716-69.876,42.677-109.969c-14.6-1.246-29.267-1.705-43.916-1.345c-11.908,0.292-23.911,1.147-35.655,3.151
C136.569,142.478,107.155,198.423,60.074,229.111z"/>
<path style="fill:#F2BFC6;" d="M365.131,128.557c-16.748-9.529-34.631-17.233-52.85-23.516
c-6.45-2.224-12.962-4.262-19.517-6.153c-1.712,23.304-4.543,46.555-11.914,68.659c-9.236,27.692-26.464,53.808-52.01,67.931
c-22.973,12.7-50.376,14.689-74.443,25.169c-21.624,9.417-39.587,25.305-54.36,43.893c8.346,9.381,17.714,17.663,27.862,24.902
c16.736-21.461,41.874-37.166,67.161-48.559c35.578-16.03,74.129-26.682,105.739-49.566
C334.357,207.023,357.577,169.22,365.131,128.557z"/>
</g>
</g>
<ellipse style="opacity:0.15;fill:#2D3038;" cx="250.223" cy="394.224" rx="109.236" ry="18.917"/>
<g>
<path style="fill:#2D3038;" d="M305.132,388.442c-0.168,1.158-0.626,2.243-1.458,3.061c-1.863,1.832-4.823,1.724-7.427,1.538
c-17.939-1.285-36.017-0.625-53.815,1.965c-7.053,3.155-16.423,3.233-25.275,2.004c-8.853-1.231-17.514-3.684-26.397-4.661
c-8.885-0.976-21.867-0.33-26.499,2.758c0,0-7.266,3.996-12.907,12.021c-3.367,4.789-4.105,11.306-2.377,16.899
c2.452,7.945,10.312,13.334,18.475,14.912c8.163,1.579,16.603-0.053,24.6-2.327c22.82-6.49,43.805-18.134,66.018-26.468
c22.213-8.334,47.017-13.282,69.546-5.844c3.96,1.306,7.879,3.033,10.941,5.866c3.062,2.832,5.173,6.927,4.813,11.081
c-0.464,5.356-4.97,9.719-10.061,11.444c-5.092,1.726-10.658,1.275-15.953,0.346c-5.296-0.93-10.554-2.17-15.926-2.414
c-20.08-0.909-38.455,4.247-56.124,10.857c-17.669,6.608-35.096,14.21-53.56,18.085c-18.463,3.874-35.807,8.106-51.682-4.186
c-20.345-15.753-19.603-41.137-8.091-63.296c5.521-10.629,12.589-18.637,19.416-27.732c-1.72-12.542-6.898-24.945-9.467-37.525
c-4.135-20.25-1.309-41.854,7.666-61.314c5.614-15.439,11.257-30.942,19.093-45.38c7.835-14.438,18.007-27.88,31.297-37.536
c13.289-9.656,29.927-15.279,46.993-13.222c7.787-8.403,16.038-16.377,24.703-23.871c-1.319-7.29-1.183-14.637,0.584-20.961
c-4.077-8.872-8.2-17.907-9.54-27.579c-0.835-6.027-0.441-12.408,1.577-17.991c1.878-5.198,8.452-6.799,12.542-3.08
c6.673,6.07,12.683,12.869,17.891,20.235c18.398-4.802,38.164-4.231,56.264,1.583c6.473-8.017,14.398-14.861,23.286-20.075
c2.366-1.388,5.533-2.613,7.657-0.875c1.683,1.377,1.736,3.89,1.592,6.059c-0.815,12.217-3.418,24.313-8.016,36.577
c4.862,15.779,0.82,33.862-9.812,46.412c-2.168,11.956,1.193,24.438,2.504,36.665c2.294,21.385-1.98,43.411-12.271,62.744
c-2.4,4.508-5.754,8.444-9.863,11.477c-1.71,1.263-3.38,2.581-5.006,3.951c-5.172,20.881-10.139,41.311-15.351,62.281
c2.061,7.78,4.487,15.496,7.272,23.126c3.209-0.899,6.478-1.696,9.816-1.809c3.896-0.132,7.942,0.744,11.024,3.131
c2.308,1.785,3.979,4.375,4.658,7.212c0.484,2.028,0.445,4.26-0.563,6.086c-0.663,1.203-1.81,2.171-3.102,2.583
c0.454,1.78,0.565,3.616,0.106,5.385c-0.778,3.004-3.622,5.6-6.675,5.375c-0.047,0.112-0.097,0.223-0.151,0.333
c-0.979,1.985-3.08,3.228-5.239,3.714c-2.063,0.464-4.207,0.333-6.319,0.174c-0.138,0.225-0.3,0.437-0.489,0.633
c-1.556,1.603-4.16,1.338-6.346,0.87c-3.015-0.645-6.04-1.471-8.688-3.051c-2.647-1.583-4.906-4.013-5.707-6.991
c-1.237-4.607,2.111-10.097,0.151-14.313c-3.538-7.609-7.733-14.893-12.004-22.126c-8.712,7.077-18.162,13.242-28.147,18.367
c6.95-0.974,14.248-1.345,21.476-0.293c3.273,0.475,6.596,1.283,9.285,3.208c2.689,1.924,4.631,5.173,4.214,8.453
c-0.34,2.664-2.596,5.054-5.156,5.449"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M151.465,379.089
c0.578-3.877,0.614-7.729,0.28-11.566"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M296.431,98.602
c1.739,2.591,3.381,5.247,4.918,7.962"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M273.736,153.553
c-0.645-1.929-1.188-3.891-1.625-5.865"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M295.23,106.811
c-4.87-7.904-10.55-15.309-16.923-22.061c-1.834-1.943-4.156-3.987-6.799-3.598c-2.928,0.431-4.574,3.626-5.147,6.53
c-1.629,8.254,1.474,16.627,4.521,24.47"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M352.846,98.327
c1.084,0.372,2.162,0.763,3.232,1.174"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M363.545,168.179
c-1.077,1.107-2.211,2.161-3.399,3.155"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M295.583,130.136
c3.86-4.907,10.772-7.181,16.791-5.521c6.019,1.659,10.791,7.151,11.446,13.054c-4.594,3.601-11.6,3.717-16.311,0.268
c-3.162-2.315-5.105-6.101-5.423-9.993"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M363.109,126.785
c-1.79-2.631-5.159-4.002-8.321-3.646c-3.162,0.356-6.042,2.317-7.787,4.979c-1.743,2.662-2.395,5.96-1.828,9.854
c4.738,1.952,10.727,0.164,13.621-4.066c1.462-2.137,2.057-4.785,1.832-7.36"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M350.957,171.048
c-4.278,4.378-10.749,6.497-16.787,5.499"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M338.68,282.717
c-5.42,4.867-10.31,10.327-14.541,16.258"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M333.834,368.351
c0.757,2.017,1.54,4.028,2.348,6.032c2.26-0.589,4.541-1.183,6.876-1.268c2.333-0.084,4.757,0.381,6.656,1.74
c1.559,1.116,2.664,2.753,3.552,4.452c0.261,0.499,0.505,1.013,0.727,1.536"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M317.138,283.315
c0.476,18.805,3.038,37.553,7.633,55.961"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M337.823,376.837
c2.877-0.595,5.878,0.99,7.67,3.316c1.791,2.327,2.567,5.273,3.025,8.174c0.191,1.214,0.327,2.48,0.209,3.695"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M327.236,380.633
c3.086-0.38,6.102,1.606,7.733,4.252c1.632,2.645,2.112,5.835,2.285,8.939c0.04,0.721,0.054,1.476-0.027,2.204"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M305.059,385.808
c-0.036-0.193-0.079-0.385-0.128-0.573c-1.058-4.111-4.728-7.422-8.927-8.052"/>
<g>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M250.442,264.812
c-1.67-3.125-3.183-6.325-4.488-9.622c-5.098-12.883-6.92-27.047-5.248-40.801"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M302.266,351.248
c-7.667-12.944-15.022-25.405-19.496-39.762"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M272.435,372.065
c-3.368,0.554-6.637,1.226-9.757,1.918c10.852-22.715,21.971-46.794,19.913-71.883c-0.826-10.055-4.036-20.316-11.156-27.463
c-8.522-8.553-21.576-11.406-33.547-9.827c-22.022,2.903-41.327,20.57-46.167,42.248"/>
</g>
<g>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M328.579,152.076
c1.379-0.341,2.796,0.501,3.736,1.565c0.942,1.065,1.588,2.366,2.551,3.41c0.963,1.044,2.43,1.826,3.784,1.398
c1.002-0.317,1.702-1.217,2.207-2.139c0.504-0.921,0.888-1.923,1.572-2.721c1.237-1.447,3.432-1.978,5.192-1.258"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M360.735,158.173
c-2.16,5.007-7.325,8.57-12.773,8.812c-1.946,0.086-3.967-0.245-5.593-1.317c-1.872-1.234-2.979-3.253-3.85-5.361
c-0.089,1.146-0.496,2.29-1.133,3.25c-1.229,1.854-3.175,3.116-5.189,4.059c-3.3,1.546-7.007,2.373-10.616,1.879
c-3.611-0.495-7.099-2.413-9.07-5.477"/>
<path style="fill:none;stroke:#FFFFFF;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="M338.276,158.534
c0,0,0.176,1.073,0.244,1.773"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@ -1,79 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>cel-snow-globe</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="General" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="SLICES-64px" transform="translate(-450.000000, 0.000000)">
</g>
<g id="ICONS" transform="translate(-445.000000, 5.000000)">
<g id="cel-snow-globe" transform="translate(450.000000, 2.000000)">
<path d="M46,44 C48.209,44 50,45.791 50,48 L50,52 L2,52 L2,48 C2,45.791 3.791,44 6,44 L46,44 Z" id="Fill-1055" fill="#EEC261">
</path>
<path d="M7.2402,44.002 C2.7562,39.33 0.0002,32.987 0.0002,26 C0.0002,11.641 11.6402,0 26.0002,0 C40.3592,0 52.0002,11.641 52.0002,26 C52.0002,32.986 49.2442,39.33 44.7602,44.001 L7.2402,44.002 Z" id="Fill-1056" fill="#B6E0F2">
</path>
<path d="M38,37 C38,33.134 34.866,30 31,30 C27.134,30 24,33.134 24,37 C24,40.866 27.134,44 31,44 C34.866,44 38,40.866 38,37" id="Fill-1057" fill="#E9EFFA">
</path>
<path d="M26,25 C26,22.238 28.239,20 31,20 C33.761,20 36,22.238 36,25 C36,27.762 33.761,30 31,30 C28.239,30 26,27.762 26,25" id="Fill-1058" fill="#E9EFFA">
</path>
<path d="M38,37 C38,33.134 34.866,30 31,30 C27.134,30 24,33.134 24,37 C24,40.866 27.134,44 31,44 C34.866,44 38,40.866 38,37 Z" id="Stroke-1059" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M26,25 C26,22.238 28.239,20 31,20 C33.761,20 36,22.238 36,25 C36,27.762 33.761,30 31,30 C28.239,30 26,27.762 26,25 Z" id="Stroke-1060" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M46,44 C48.209,44 50,45.791 50,48 L50,52 L2,52 L2,48 C2,45.791 3.791,44 6,44 L46,44 Z" id="Stroke-1061" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M7.2402,44.002 C2.7562,39.33 0.0002,32.987 0.0002,26 C0.0002,11.641 11.6402,0 26.0002,0 C40.3592,0 52.0002,11.641 52.0002,26 C52.0002,32.986 49.2442,39.33 44.7602,44.001" id="Stroke-1062" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M8,24 C8,14.059 16.059,6 26,6" id="Stroke-1063" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M20,28 L26.061,32.04" id="Stroke-1064" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M42,28 L35.939,32.04" id="Stroke-1065" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M42,25 L42,28" id="Stroke-1066" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M45,28 L42,28" id="Stroke-1067" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M20,25 L20,28" id="Stroke-1068" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M17,28 L20,28" id="Stroke-1069" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M10.02,31.0098 C10.02,31.5688 9.568,32.0208 9.01,32.0208 C8.452,32.0208 8,31.5688 8,31.0098 C8,30.4518 8.452,29.9998 9.01,29.9998 C9.568,29.9998 10.02,30.4518 10.02,31.0098 Z" id="Stroke-1070" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M22.02,15.0098 C22.02,15.5688 21.568,16.0208 21.01,16.0208 C20.452,16.0208 20,15.5688 20,15.0098 C20,14.4518 20.452,13.9998 21.01,13.9998 C21.568,13.9998 22.02,14.4518 22.02,15.0098 Z" id="Stroke-1071" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M44.02,17.0098 C44.02,17.5688 43.568,18.0208 43.01,18.0208 C42.452,18.0208 42,17.5688 42,17.0098 C42,16.4518 42.452,15.9998 43.01,15.9998 C43.568,15.9998 44.02,16.4518 44.02,17.0098 Z" id="Stroke-1072" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M18.02,37.0098 C18.02,37.5688 17.568,38.0208 17.01,38.0208 C16.452,38.0208 16,37.5688 16,37.0098 C16,36.4518 16.452,35.9998 17.01,35.9998 C17.568,35.9998 18.02,36.4518 18.02,37.0098 Z" id="Stroke-1073" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M36.02,9.0098 C36.02,9.5688 35.568,10.0208 35.01,10.0208 C34.452,10.0208 34,9.5688 34,9.0098 C34,8.4518 34.452,7.9998 35.01,7.9998 C35.568,7.9998 36.02,8.4518 36.02,9.0098 Z" id="Stroke-1074" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

273
frontend/assets/city.svg Normal file
View File

@ -0,0 +1,273 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.5.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1000 700" style="enable-background:new 0 0 1000 700;" xml:space="preserve">
<g id="Shadow">
<g style="opacity:0.1;">
<path style="fill:#38415C;" d="M186.919,556.734c0,0.331,0.541,0.599,1.208,0.599c0.667,0,1.208-0.268,1.208-0.599
c0-0.331-0.541-0.599-1.208-0.599C187.46,556.135,186.919,556.403,186.919,556.734z"/>
<path style="fill:#38415C;" d="M957.699,446.328h-12.196h-37.267h-8.131h-4.525h-22.106h-29.729h-8.01h-8.921h-7.462H777.69h-5.38
h-8.517h-13.921h-24.898h-0.367h-35.201h-33.29h-13.405h-8.642h-18.387h-20.084H584.19h-2.542h-5.421h-37.944h-7.453h-2.757
h-2.757h-1.428h-8.748h-5.514h-10.175h-0.905h-4.609h-10.175h-5.514h-10.175h-2.757h-2.757h-27.938h-8.05h-29.96h-6.713h-18.964
h-11.234h-48.644h-12.099h-10.229h-20.764h-12.382h-3.512h-23.242h-5.943h-13.266h-10.795h-35.413h-16.467h-4.656h-8.696h-25.877
H89.054h-4.763H72.026h-7.508H53.821H42.302v9.376h11.519v6.41h10.696v6.835h19.774v2.177h20.658v9.405h17.084v-5.919h11.557
v10.475h3.789v11.69h11.18v9.823h-4.017v1.763h7.066v4.785h23.433v28.254h-1.845v1.897h4.028v2.429h4.636v0.913v2.777v2.41h5.594
v4.306h0.673l0,0h0.673v-4.306h3.015l1.823-2.41h12.206v-3.69h31.948V543.3h4.028v-1.897h-1.845V484.37h15.302v40.617h1.509v3.023
h2.811v10.012h1.016v-10.012h2.287v7.997h1.017v-7.997h4.828v-3.023h6.098v-10.569h11.445v-0.743h-1.492v-2.116h7.56v32.974
h-1.078v0.849h7.678v5.101h-0.992v0.817h7.047v2.933h-1.099v0.627h3.502v2.77h3.348v5.513h0.402h0.398h0.314v-5.513h1.354v9.889
h0.402h0.399h0.314v-9.889h0.451h2.897v-2.77h3.034v-0.627h-0.632v-2.933h7.047v-0.817h-0.992v-5.101h7.678v-0.849h-1.078v-43.096
h23.505v-5.982h8.399v31.88h-4.954v0.806h4.954v1.443h6.279v2.37h30.344v27.318h7.165v21.803h13.871V593h1.952v-11.927h4.298
v9.528h1.952v-9.528h21.941v-12.964h3.982v-8.839h11.148v-32.999h4.318v-7.629l6.342,0.769v1.742h5.514v-1.073l10.175,1.234v1.969
h5.514v-1.3l9.332,1.131v9.523h2.491v16.539h11.982v6.297h6.46v5.29h2.267v9.068h0.586v-9.068h2.267v-5.29h6.46v-6.297h12.075
v-16.539h2.399v-45.67l5.467,13.925h5.729v12.219h-2.645v0.527h31.278v6.75h-3.52v0.763h-1.791v2.08h8.284v2.313h-1.087v0.668
h18.198v-0.668h-1.087v-2.313h23.966c0.802,1.935,2.023,3.811,3.668,5.596l-3.992,0.913c-0.688-0.732-2.184-1.239-3.92-1.239
c-2.388,0-4.324,0.96-4.324,2.143c0,1.183,1.936,2.143,4.324,2.143c2.388,0,4.324-0.96,4.324-2.143
c0-0.239-0.08-0.468-0.225-0.683l4.015-0.919c2.595,2.749,6.165,5.281,10.623,7.491c0.352,0.174,0.709,0.346,1.069,0.515
l-3.154,1.668c-0.76-0.329-1.753-0.528-2.841-0.528c-2.388,0-4.324,0.959-4.324,2.143s1.936,2.143,4.324,2.143
c2.388,0,4.324-0.959,4.324-2.143c0-0.559-0.432-1.068-1.139-1.449l3.16-1.671c5.36,2.471,11.576,4.337,18.308,5.527l-1.744,2.453
c-0.378-0.054-0.777-0.083-1.19-0.083c-2.388,0-4.324,0.96-4.324,2.143c0,1.183,1.936,2.143,4.324,2.143
c2.388,0,4.324-0.96,4.324-2.143c0-0.895-1.107-1.662-2.68-1.982l1.743-2.451c5.551,0.953,11.445,1.449,17.493,1.449
c0.498,0,0.995-0.003,1.491-0.01l0.198,3.017c-2.096,0.148-3.707,1.041-3.707,2.121c0,1.183,1.936,2.143,4.324,2.143
c2.388,0,4.324-0.96,4.324-2.143c0-1.184-1.936-2.143-4.324-2.143c-0.046,0-0.091,0.001-0.137,0.002l-0.197-3.004
c2.456-0.044,4.881-0.173,7.265-0.378l-2.223,24.735l79.948-8.225v-43.336h13.883v22.309h24.985v8.902h1.355v-8.902h2.795v16.446
h1.355v-16.446h3.219v11.855h1.355v-11.855h4.235v-54.059h12.874V506.6h2.033v3.715h2.033v2.582h14.483v-2.582h2.033V506.6h7.369
v1.594h3.557V506.6h1.259v4.262h5.082V506.6h1.452l3.161-11.593h11.528v5.526h0.762v-5.526h4.32v6.746h0.762v-6.746h6.25v-1.567
h-1.592v-17.317h12.874l1.507-4.997h10.931v-9.012h11.954v-6.86h12.196V446.328z M653.829,518.335l-11.117,0.179v-7.937h2.055
v-0.76h-2.055v-0.593l13.295,2.426c-1.417,1.94-2.19,4.031-2.19,6.21C653.816,518.019,653.821,518.177,653.829,518.335z
M689.289,499.58c-4.354,0.083-8.516,0.542-12.36,1.312l-5.314-6.414v-0.42h5.082v4.786h5.082v-4.786h7.148L689.289,499.58z
M702.329,517.554l-8.713,0.14c-0.026-0.114-0.079-0.224-0.155-0.328l9.073-2.076L702.329,517.554z M666.025,494.058v0.401
c-0.325,0.085-0.657,0.163-0.979,0.251l-0.713-0.651H666.025z M666.025,495.263v0.341l-0.291-0.266
C665.83,495.311,665.929,495.289,666.025,495.263z M666.025,496.603v2.241h2.454l3.554,3.247c-2.98,0.871-5.693,1.943-8.062,3.179
l-10.904-5.064c0.33-0.173,0.666-0.344,1.007-0.513c3.276-1.624,6.914-3.003,10.823-4.12L666.025,496.603z M672.377,502.405
l15.07,13.768l-22.95-10.659C666.813,504.306,669.465,503.258,672.377,502.405z M669.572,498.844h2.043v-3.739l4.87,5.877
c-1.242,0.259-2.449,0.55-3.618,0.872L669.572,498.844z M691.664,494.058v4.786h12.332l-1.224,1.721
c-3.776-0.648-7.828-1.001-12.044-1.001c-0.32,0-0.64,0.002-0.959,0.006l-0.361-5.512H691.664z M703.939,499.641l-0.101,1.127
c-0.206-0.039-0.404-0.087-0.612-0.124L703.939,499.641z M702.896,511.25l-8.307,4.394l8.619-7.874L702.896,511.25z
M702.863,511.616l-0.306,3.407l-9.299,2.127c-0.053-0.046-0.11-0.09-0.172-0.133l0.598-0.547L702.863,511.616z M693.364,518.468
l8.74,1.595l-0.252,2.801l-8.846-4.108C693.147,518.667,693.268,518.571,693.364,518.468z M693.53,518.245
c0.056-0.1,0.091-0.205,0.102-0.312l8.676-0.14l-0.182,2.021L693.53,518.245z M656.116,486.551l-11.415-10.428h11.415V486.551z
M656.116,487.55v4.746h-1.779v1.763h8.903l0.969,0.885c-4.029,1.15-7.778,2.571-11.154,4.243
c-0.352,0.175-0.698,0.352-1.039,0.53l-3.311-1.538c0.63-0.372,1.01-0.852,1.01-1.376c0-1.184-1.936-2.143-4.324-2.143
c-1.018,0-1.941,0.182-2.68,0.473v-5.035h2.055v-0.76h-2.055v-9.479h2.055v-0.76h-2.055v-2.977h0.897L656.116,487.55z
M642.711,500.338h2.055v-0.76h-2.055v-1.104c0.739,0.292,1.662,0.473,2.68,0.473c1.158,0,2.209-0.226,2.985-0.593l3.31,1.537
c-3.677,1.959-6.684,4.15-8.975,6.503V500.338z M642.711,508.163c2.337-2.844,5.703-5.479,10.027-7.784l10.906,5.065
c-3.215,1.722-5.771,3.749-7.47,5.983l-13.463-2.457V508.163z M664.17,505.688l24.004,11.148l0.198,0.181
c-0.107,0.074-0.2,0.152-0.279,0.235l-31.242-5.701C658.515,509.362,661.02,507.375,664.17,505.688z M673.21,502.168
c1.146-0.316,2.33-0.602,3.548-0.855l12.647,15.264c-0.111,0.028-0.218,0.06-0.32,0.094L673.21,502.168z M677.203,501.222
c3.766-0.755,7.844-1.204,12.11-1.286l1.082,16.492c-0.187,0.011-0.369,0.03-0.544,0.057L677.203,501.222z M689.793,499.928
c0.311-0.004,0.623-0.006,0.935-0.006c4.131,0,8.103,0.346,11.804,0.981l-11.067,15.563c-0.19-0.025-0.388-0.04-0.591-0.045
L689.793,499.928z M702.985,500.982c0.278,0.05,0.543,0.112,0.818,0.165l-0.497,5.535l-10.935,9.99
c-0.142-0.048-0.294-0.09-0.453-0.126L702.985,500.982z M692.678,518.929l9.146,4.247l-0.629,7.002l-9.143-11.035
C692.279,519.085,692.49,519.013,692.678,518.929z M656.683,511.774l31.244,5.702c-0.056,0.1-0.091,0.204-0.102,0.312
l-33.276,0.536c-0.008-0.154-0.012-0.309-0.012-0.464C654.537,515.724,655.295,513.675,656.683,511.774z M687.841,518.026
c0.026,0.114,0.079,0.224,0.155,0.328l-30.225,6.916c-1.892-2.059-3.018-4.325-3.204-6.708L687.841,518.026z M688.199,518.57
c0.106,0.092,0.231,0.178,0.373,0.257l-22.753,12.035c-3.243-1.527-5.915-3.348-7.845-5.376L688.199,518.57z M688.923,518.989
c0.188,0.074,0.394,0.137,0.616,0.186l-11.067,15.563c-4.604-0.824-8.776-2.098-12.301-3.714L688.923,518.989z M689.992,519.254
c0.19,0.024,0.388,0.04,0.591,0.045l1.082,16.493c-0.311,0.004-0.623,0.006-0.936,0.006c-4.131,0-8.102-0.346-11.804-0.98
L689.992,519.254z M691.063,519.291c0.187-0.011,0.369-0.03,0.544-0.057l9.537,11.51l-0.39,4.342
c-2.753,0.394-5.632,0.641-8.61,0.697L691.063,519.291z M640.035,523.229h7.987v-2.08h-1.791v-0.763h-3.52v-1.635l11.136-0.179
c0.189,2.432,1.339,4.745,3.27,6.846l-13.578,3.106C641.982,526.835,640.816,525.059,640.035,523.229z M654.074,536.027
c-4.336-2.149-7.809-4.612-10.333-7.285l13.579-3.107c1.969,2.07,4.697,3.929,8.007,5.487l-10.218,5.404
C654.761,536.362,654.415,536.196,654.074,536.027z M655.459,536.689l10.219-5.405c3.597,1.65,7.855,2.95,12.553,3.791
l-4.97,6.989C666.716,540.907,660.672,539.092,655.459,536.689z M690.728,543.552c-5.882,0-11.614-0.483-17.013-1.409l4.97-6.989
c3.776,0.648,7.828,1.001,12.043,1.001c0.321,0,0.64-0.002,0.959-0.006l0.485,7.393
C691.692,543.549,691.211,543.552,690.728,543.552z M692.653,543.535l-0.485-7.395c2.956-0.057,5.813-0.299,8.553-0.681
l-0.69,7.679C697.611,543.354,695.148,543.49,692.653,543.535z"/>
</g>
</g>
<g id="Object">
<g style="opacity:0.3;">
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="207.5072" y1="393.376" x2="207.5072" y2="229.7061">
<stop offset="0" style="stop-color:#403E40"/>
<stop offset="0.1275" style="stop-color:#4E4D4E"/>
<stop offset="0.3124" style="stop-color:#5A5A5A"/>
<stop offset="0.5479" style="stop-color:#626262"/>
<stop offset="1" style="stop-color:#646464"/>
</linearGradient>
<polygon style="fill:url(#SVGID_1_);" points="175.04,393.376 239.974,393.376 239.974,259.43 241.819,259.43 241.819,255.601
237.792,255.601 237.792,250.701 205.844,250.701 205.844,243.255 193.638,243.255 191.815,238.393 188.799,238.393
188.799,229.706 188.126,229.706 187.454,229.706 187.454,238.393 181.859,238.393 181.859,243.255 181.859,248.859
181.859,250.701 177.223,250.701 177.223,255.601 173.195,255.601 173.195,259.43 175.04,259.43 "/>
<linearGradient id="SVGID_00000000931258187104496080000017865145222397382034_" gradientUnits="userSpaceOnUse" x1="188.1266" y1="229.7061" x2="188.1266" y2="227.2891">
<stop offset="0" style="stop-color:#403E40"/>
<stop offset="0.1275" style="stop-color:#4E4D4E"/>
<stop offset="0.3124" style="stop-color:#5A5A5A"/>
<stop offset="0.5479" style="stop-color:#626262"/>
<stop offset="1" style="stop-color:#646464"/>
</linearGradient>
<path style="fill:url(#SVGID_00000000931258187104496080000017865145222397382034_);" d="M189.335,228.498
c0-0.668-0.541-1.209-1.209-1.209c-0.667,0-1.208,0.541-1.208,1.209c0,0.667,0.541,1.208,1.208,1.208
C188.794,229.706,189.335,229.165,189.335,228.498z"/>
<linearGradient id="SVGID_00000036247364810958532620000001993945857249512106_" gradientUnits="userSpaceOnUse" x1="508.9194" y1="421.4165" x2="508.9194" y2="155.3276">
<stop offset="0" style="stop-color:#403E40"/>
<stop offset="0.1275" style="stop-color:#4E4D4E"/>
<stop offset="0.3124" style="stop-color:#5A5A5A"/>
<stop offset="0.5479" style="stop-color:#626262"/>
<stop offset="1" style="stop-color:#646464"/>
</linearGradient>
<polygon style="fill:url(#SVGID_00000036247364810958532620000001993945857249512106_);" points="777.689,401.218 777.689,221.14
697.741,204.545 706.501,401.218 471.904,401.218 471.904,289.958 467.586,289.958 467.586,223.38 456.438,223.38
456.438,205.547 452.456,205.547 452.456,179.391 430.514,179.391 430.514,160.168 428.562,160.168 428.562,179.391
424.264,179.391 424.264,155.328 422.311,155.328 422.311,179.391 408.44,179.391 408.44,223.38 401.275,223.38 401.275,289.958
395.133,289.958 395.133,401.218 332.748,401.218 332.748,253.114 333.825,253.114 333.825,251.402 326.147,251.402
326.147,241.111 327.14,241.111 327.14,239.463 320.092,239.463 320.092,233.547 320.725,233.547 320.725,232.282 317.69,232.282
317.69,226.693 314.794,226.693 314.343,226.693 314.343,206.742 314.029,206.742 313.63,206.742 313.228,206.742
313.228,226.693 311.874,226.693 311.874,215.571 311.56,215.571 311.161,215.571 310.759,215.571 310.759,226.693
307.411,226.693 307.411,232.282 303.909,232.282 303.909,233.547 305.009,233.547 305.009,239.463 297.962,239.463
297.962,241.111 298.954,241.111 298.954,251.402 291.276,251.402 291.276,253.114 292.354,253.114 292.354,401.218
84.29,401.218 84.29,421.417 933.548,421.417 933.548,401.218 "/>
</g>
<linearGradient id="SVGID_00000121963338060960119620000016097684000583641491_" gradientUnits="userSpaceOnUse" x1="499.6613" y1="451.2495" x2="499.6613" y2="202.0752">
<stop offset="0.0815" style="stop-color:#403E40"/>
<stop offset="0.4715" style="stop-color:#444244"/>
<stop offset="0.8768" style="stop-color:#504F50"/>
<stop offset="1" style="stop-color:#555455"/>
</linearGradient>
<path style="fill:url(#SVGID_00000121963338060960119620000016097684000583641491_);" d="M918.278,419.4v-18.183h-25.56
l-9.674-71.571h-1.452v-8.598h-5.082v8.598h-1.259v-3.216h-3.557v3.216h-7.369v-7.496h-2.033v-5.209h-14.483v5.209h-2.033v7.496
h-2.033v42.986h-12.874V263.564h-4.235v-23.92h-1.355v23.92h-3.219v-33.181h-1.355v33.181h-2.795v-17.961h-1.355v17.961h-24.985
v77.418h-27.78v50.154h-25.944l-20.601-37.972c3.473-2,6.738-4.405,9.735-7.193l4.225,4.508c-0.907,0.793-1.481,1.957-1.481,3.256
c0,2.388,1.936,4.324,4.324,4.324c2.388,0,4.324-1.936,4.324-4.324c0-2.388-1.936-4.324-4.324-4.324
c-0.916,0-1.764,0.285-2.463,0.771l-4.255-4.54c0.36-0.341,0.717-0.687,1.069-1.039c4.458-4.459,8.028-9.568,10.623-15.114
l4.705,2.172c-0.086,0.339-0.131,0.693-0.131,1.059c0,2.388,1.936,4.324,4.324,4.324c2.388,0,4.324-1.936,4.324-4.324
c0-2.388-1.936-4.324-4.324-4.324c-1.852,0-3.432,1.165-4.048,2.803l-4.648-2.146c2.866-6.273,4.489-13.092,4.744-20.153
l5.065,0.165c0.062,2.334,1.972,4.207,4.321,4.207c2.388,0,4.324-1.936,4.324-4.324c0-2.388-1.936-4.324-4.324-4.324
c-2.266,0-4.123,1.743-4.308,3.961l-5.064-0.165c0.013-0.496,0.021-0.993,0.021-1.491c0-6.303-1.088-12.438-3.173-18.192
l4.231-1.558c0.664,1.533,2.191,2.606,3.968,2.606c2.388,0,4.324-1.936,4.324-4.324c0-2.388-1.936-4.324-4.324-4.324
c-2.388,0-4.324,1.936-4.324,4.324c0,0.44,0.066,0.866,0.189,1.267l-4.229,1.557c-2.41-6.464-6.085-12.437-10.898-17.611
l3.31-3.102c0.776,0.741,1.827,1.197,2.985,1.197c2.388,0,4.324-1.935,4.324-4.324c0-2.388-1.936-4.324-4.324-4.324
c-2.388,0-4.324,1.936-4.324,4.324c0,1.057,0.38,2.025,1.01,2.776l-3.31,3.102c-0.341-0.36-0.687-0.717-1.039-1.069
c-5.012-5.013-10.848-8.903-17.201-11.546l1.821-4.434c0.413,0.131,0.853,0.203,1.309,0.203c2.388,0,4.324-1.936,4.324-4.324
c0-2.388-1.936-4.324-4.324-4.324c-2.388,0-4.324,1.936-4.324,4.324c0,1.762,1.054,3.276,2.566,3.95l-1.816,4.423
c-5.685-2.304-11.775-3.615-18.057-3.842l0.197-6.06c0.046,0.001,0.091,0.003,0.137,0.003c2.388,0,4.324-1.936,4.324-4.324
c0-2.388-1.936-4.324-4.324-4.324c-2.388,0-4.324,1.936-4.324,4.324c0,2.179,1.611,3.98,3.707,4.279l-0.198,6.086
c-0.496-0.014-0.993-0.021-1.491-0.021c-6.048,0-11.942,1.001-17.493,2.925l-1.743-4.946c1.573-0.647,2.68-2.193,2.68-4
c0-2.388-1.936-4.324-4.324-4.324c-2.388,0-4.324,1.936-4.324,4.324c0,2.388,1.936,4.324,4.324,4.324
c0.413,0,0.812-0.059,1.19-0.167l1.744,4.948c-6.732,2.401-12.948,6.166-18.308,11.152l-3.16-3.372
c0.707-0.77,1.139-1.796,1.139-2.923c0-2.388-1.936-4.324-4.324-4.324c-2.388,0-4.324,1.935-4.324,4.324
c0,2.388,1.936,4.324,4.324,4.324c1.088,0,2.081-0.402,2.841-1.065l3.154,3.366c-0.36,0.341-0.717,0.688-1.069,1.04
c-4.458,4.458-8.028,9.568-10.623,15.114l-4.015-1.854c0.146-0.433,0.225-0.896,0.225-1.377c0-2.388-1.936-4.324-4.324-4.324
c-2.388,0-4.324,1.936-4.324,4.324c0,2.388,1.936,4.324,4.324,4.324c1.736,0,3.232-1.023,3.92-2.5l3.992,1.843
c-2.865,6.273-4.489,13.093-4.744,20.153l-5.154-0.167c-0.064-2.333-1.973-4.204-4.321-4.204c-2.388,0-4.324,1.936-4.324,4.324
c0,2.388,1.936,4.324,4.324,4.324c2.267,0,4.125-1.744,4.308-3.963l5.153,0.167c-0.013,0.496-0.021,0.993-0.021,1.491
c0,6.302,1.088,12.438,3.173,18.191l-4.231,1.558c-0.664-1.533-2.191-2.606-3.969-2.606c-2.388,0-4.324,1.936-4.324,4.324
c0,2.388,1.936,4.324,4.324,4.324c2.388,0,4.324-1.936,4.324-4.324c0-0.44-0.066-0.866-0.189-1.266l4.229-1.557
c2.409,6.464,6.084,12.436,10.898,17.611l-3.31,3.102c-0.776-0.741-1.827-1.197-2.985-1.197c-2.388,0-4.324,1.935-4.324,4.324
c0,2.388,1.936,4.324,4.324,4.324c2.388,0,4.324-1.936,4.324-4.324c0-1.057-0.38-2.025-1.01-2.776l3.311-3.102
c0.341,0.36,0.687,0.717,1.039,1.069c3.376,3.375,7.125,6.242,11.154,8.561l-20.601,37.972h-16.685v-25.743h-6.801v-48.882h2.645
v-1.063H564.32v1.063h2.645v24.652h-5.729l-5.467,28.096v-92.143h-2.399v-33.37h-12.075v-12.705h-6.46V220.37h-2.267v-18.294
h-0.586v18.294h-2.267v10.672h-6.46v12.705h-11.982v33.37h-2.491V337.7h-18.579v53.436h-61.639V287.814h4.954v-1.626h-4.954v-2.911
h-6.279v-4.781h-51.354v4.781h-6.279v2.911h-4.954v1.626h4.954v64.319h-8.4v-12.069h-51.819v14.737h-11.298v27.442h-18.294V292.55
h-6.098v-6.099h-4.828v-16.134h-1.017v16.134h-2.287v-20.2h-1.016v20.2h-2.811v6.099h-1.509v89.693h-37.859v-52.597h1.779v-3.557
h-11.688v-9.655h-5.59v9.655h-5.082v-9.655h-5.082v9.655h-9.885v-9.655h-30.261v9.655h-7.066v3.557h4.017v19.819h-11.18v44.72
h-15.346v-11.942h-17.084v26.044H84.29V419.4H53.82v31.849h891.682V419.4H918.278z M716.559,351.896l-7.135-13.152
c2.287-1.349,4.417-2.938,6.354-4.731l10.219,10.906C723.091,347.622,719.925,349.955,716.559,351.896z M689.85,309.7
c0.175,0.055,0.357,0.094,0.544,0.116l-1.082,33.274c-4.266-0.165-8.344-1.071-12.11-2.594L689.85,309.7z M676.758,340.314
c-1.218-0.512-2.402-1.089-3.548-1.726l15.875-29.261c0.102,0.07,0.209,0.133,0.32,0.19L676.758,340.314z M690.874,309.832
c0.203-0.01,0.401-0.041,0.591-0.091l11.067,31.4c-3.701,1.281-7.673,1.978-11.804,1.978c-0.313,0-0.625-0.004-0.936-0.012
L690.874,309.832z M691.917,309.581c0.159-0.072,0.311-0.157,0.453-0.254l15.875,29.261c-1.677,0.932-3.435,1.734-5.261,2.394
L691.917,309.581z M694.589,311.399l20.697,22.088c-1.893,1.751-3.973,3.303-6.206,4.623L694.589,311.399z M693.683,309.731
l-0.598-1.103c0.062-0.086,0.119-0.176,0.172-0.268l30.224,13.952c-1.93,4.091-4.602,7.766-7.844,10.847L693.683,309.731z
M693.46,307.925c0.077-0.21,0.129-0.432,0.156-0.662l33.274,1.082c-0.186,4.809-1.313,9.38-3.204,13.534L693.46,307.925z
M693.631,306.783c-0.011-0.217-0.046-0.428-0.102-0.63l31.244-11.503c1.388,3.836,2.146,7.971,2.146,12.279
c0,0.313-0.004,0.624-0.012,0.936L693.631,306.783z M693.364,305.702c-0.097-0.207-0.217-0.401-0.358-0.579l24.281-22.752
c3.15,3.404,5.654,7.411,7.319,11.828L693.364,305.702z M692.678,304.772c-0.188-0.17-0.399-0.315-0.627-0.433l12.647-30.797
c4.661,1.958,8.83,4.864,12.261,8.477L692.678,304.772z M691.606,304.157c-0.175-0.055-0.357-0.094-0.544-0.116l1.082-33.273
c4.265,0.164,8.344,1.071,12.11,2.594L691.606,304.157z M690.582,304.025c-0.203,0.01-0.401,0.041-0.591,0.09l-11.067-31.4
c3.701-1.281,7.672-1.978,11.804-1.978c0.313,0,0.625,0.004,0.936,0.012L690.582,304.025z M689.539,304.276
c-0.221,0.099-0.428,0.226-0.616,0.375L666.17,280.37c3.525-3.261,7.697-5.832,12.301-7.494L689.539,304.276z M688.572,304.978
c-0.143,0.158-0.268,0.332-0.373,0.518l-30.224-13.953c1.929-4.091,4.602-7.766,7.845-10.847L688.572,304.978z M687.996,305.932
c-0.077,0.211-0.129,0.432-0.156,0.662l-33.274-1.082c0.186-4.809,1.313-9.38,3.204-13.534L687.996,305.932z M687.825,307.075
c0.011,0.217,0.046,0.427,0.102,0.629l-31.244,11.503c-1.388-3.836-2.146-7.97-2.146-12.279c0-0.313,0.004-0.625,0.012-0.936
L687.825,307.075z M688.092,308.155c0.078,0.168,0.171,0.326,0.279,0.474l-0.198,0.365l-24.004,22.492
c-3.15-3.404-5.654-7.411-7.319-11.828L688.092,308.155z M687.446,310.332l-15.07,27.777c-2.912-1.72-5.564-3.835-7.88-6.272
L687.446,310.332z M672.866,339.221c1.169,0.649,2.376,1.238,3.618,1.759l-5.681,13.833c-1.732-0.721-3.424-1.537-5.07-2.445
L672.866,339.221z M676.929,341.163c3.843,1.555,8.006,2.479,12.36,2.647l-0.485,14.92c-6.107-0.221-12.028-1.496-17.555-3.735
L676.929,341.163z M689.769,343.828c0.319,0.008,0.638,0.013,0.959,0.013c4.215,0,8.267-0.712,12.043-2.02l4.97,14.101
c-5.399,1.87-11.131,2.844-17.013,2.844c-0.482,0-0.964-0.007-1.444-0.021L689.769,343.828z M703.225,341.661
c1.862-0.672,3.655-1.49,5.365-2.44l7.133,13.148c-2.417,1.333-4.933,2.468-7.528,3.394L703.225,341.661z M727.382,343.582
c-0.341,0.341-0.686,0.676-1.035,1.007l-10.217-10.904c3.31-3.144,6.038-6.895,8.007-11.07l13.579,6.269
C735.191,334.277,731.718,339.247,727.382,343.582z M737.917,328.448l-13.578-6.268c1.931-4.239,3.081-8.904,3.27-13.813
l14.921,0.485C742.281,315.718,740.702,322.349,737.917,328.448z M742.565,306.929c0,0.482-0.007,0.963-0.02,1.444l-14.917-0.485
c0.008-0.319,0.012-0.639,0.012-0.959c0-4.397-0.774-8.615-2.19-12.528l14.03-5.165
C741.507,294.832,742.565,300.799,742.565,306.929z M728.718,271.66c4.68,5.032,8.253,10.839,10.596,17.124l-14.032,5.167
c-1.699-4.508-4.255-8.598-7.47-12.072L728.718,271.66z M727.382,270.274c0.341,0.341,0.676,0.686,1.007,1.035l-10.904,10.217
c-3.502-3.687-7.756-6.653-12.513-8.65l5.681-13.833C716.831,261.615,722.507,265.399,727.382,270.274z M692.652,255.126
c6.107,0.221,12.028,1.496,17.555,3.735l-5.681,13.833c-3.843-1.555-8.006-2.479-12.36-2.647L692.652,255.126z M690.728,255.092
c0.482,0,0.964,0.007,1.444,0.02l-0.485,14.917c-0.319-0.008-0.638-0.012-0.959-0.012c-4.215,0-8.267,0.712-12.043,2.019
l-4.97-14.101C679.114,256.065,684.846,255.092,690.728,255.092z M673.261,258.094l4.97,14.102
c-4.698,1.696-8.956,4.319-12.553,7.648l-10.219-10.906C660.671,264.091,666.716,260.43,673.261,258.094z M654.074,270.274
c0.341-0.341,0.687-0.676,1.035-1.007l10.218,10.904c-3.31,3.144-6.038,6.895-8.007,11.071l-13.579-6.269
C646.265,279.58,649.738,274.61,654.074,270.274z M643.539,285.409l13.578,6.268c-1.931,4.239-3.081,8.905-3.27,13.813
l-14.921-0.485C639.175,298.14,640.754,291.509,643.539,285.409z M638.891,306.929c0-0.482,0.007-0.964,0.02-1.444l14.917,0.485
c-0.008,0.318-0.012,0.638-0.012,0.959c0,4.396,0.774,8.614,2.19,12.528l-14.03,5.166
C639.949,319.025,638.891,313.058,638.891,306.929z M652.738,342.197c-4.68-5.032-8.253-10.839-10.597-17.124l14.032-5.167
c1.699,4.508,4.255,8.598,7.47,12.072L652.738,342.197z M654.074,343.582c-0.341-0.341-0.676-0.686-1.007-1.035l10.904-10.217
c2.368,2.494,5.082,4.656,8.062,6.414l-7.135,13.152C660.988,349.642,657.35,346.859,654.074,343.582z M665.046,353.636
c1.692,0.934,3.431,1.771,5.21,2.512l-1.821,4.434c-0.413-0.131-0.853-0.202-1.309-0.202c-2.388,0-4.324,1.936-4.324,4.324
c0,2.388,1.936,4.324,4.324,4.324c2.388,0,4.324-1.936,4.324-4.324c0-1.762-1.054-3.276-2.566-3.95l1.816-4.423
c5.685,2.304,11.775,3.615,18.057,3.842l-0.148,4.534c-2.341,0.053-4.224,1.967-4.224,4.321c0,2.388,1.936,4.324,4.324,4.324
c2.388,0,4.324-1.936,4.324-4.324c0-2.26-1.734-4.113-3.944-4.306l0.148-4.535c0.496,0.014,0.993,0.021,1.491,0.021
c6.048,0,11.942-1.001,17.493-2.925l1.554,4.408c-1.471,0.69-2.491,2.184-2.491,3.916c0,2.388,1.936,4.324,4.324,4.324
c2.388,0,4.324-1.936,4.324-4.324c0-2.388-1.936-4.324-4.324-4.324c-0.485,0-0.951,0.081-1.387,0.229l-1.547-4.388
c2.667-0.952,5.252-2.117,7.736-3.487l20.345,37.5h-92.055L665.046,353.636z"/>
<g>
<linearGradient id="SVGID_00000121273610027325662480000007068999652675512506_" gradientUnits="userSpaceOnUse" x1="815.83" y1="285.1626" x2="815.83" y2="287.5796">
<stop offset="0" style="stop-color:#403E40"/>
<stop offset="1" style="stop-color:#161F21"/>
</linearGradient>
<path style="fill:url(#SVGID_00000121273610027325662480000007068999652675512506_);" d="M816.844,286.371
c0-0.667-0.454-1.208-1.014-1.208c-0.56,0-1.014,0.541-1.014,1.208c0,0.668,0.454,1.208,1.014,1.208
C816.39,287.58,816.844,287.039,816.844,286.371z"/>
<linearGradient id="SVGID_00000011738580747612097720000010840228285618223286_" gradientUnits="userSpaceOnUse" x1="500" y1="287.5796" x2="500" y2="451.2495">
<stop offset="0" style="stop-color:#403E40"/>
<stop offset="1" style="stop-color:#161F21"/>
</linearGradient>
<polygon style="fill:url(#SVGID_00000011738580747612097720000010840228285618223286_);" points="927.404,433.241 921.11,391.136
908.236,391.136 908.236,356.197 909.828,356.197 909.828,353.036 903.578,353.036 903.578,339.427 902.815,339.427
902.815,353.036 898.496,353.036 898.496,341.887 897.734,341.887 897.734,353.036 871.695,353.036 871.695,356.197
873.474,356.197 873.474,427.088 843.745,427.088 843.745,395.334 826.815,395.334 826.815,317.303 828.363,317.303
828.363,313.475 824.982,313.475 824.982,308.574 821.091,308.574 821.091,306.732 821.091,301.129 821.091,296.267
816.395,296.267 816.395,287.58 815.83,287.58 815.265,287.58 815.265,296.267 812.734,296.267 811.204,301.129 800.958,301.129
800.958,308.574 774.141,308.574 774.141,313.475 770.76,313.475 770.76,317.303 772.309,317.303 772.309,368.073
749.871,368.073 749.871,417.957 724.973,406.925 724.973,358.507 728.991,358.507 728.991,354.95 721.924,354.95
721.924,345.294 691.664,345.294 691.664,354.95 681.778,354.95 681.778,345.294 676.696,345.294 676.696,354.95 671.615,354.95
671.615,345.294 666.025,345.294 666.025,354.95 654.337,354.95 654.337,358.507 656.115,358.507 656.115,425.617
644.765,425.617 644.765,424.913 642.711,424.913 642.711,405.789 644.765,405.789 644.765,404.255 642.711,404.255
642.711,385.13 644.765,385.13 644.765,383.597 642.711,383.597 642.711,364.472 644.765,364.472 644.765,362.939
642.711,362.939 642.711,343.814 644.765,343.814 644.765,342.28 642.711,342.28 642.711,323.156 644.765,323.156
644.765,321.622 642.711,321.622 642.711,301.83 646.231,301.83 646.231,300.291 648.021,300.291 648.021,296.095
614.595,296.095 614.595,291.429 615.682,291.429 615.682,290.081 597.484,290.081 597.484,291.429 598.571,291.429
598.571,296.095 590.287,296.095 590.287,300.291 592.078,300.291 592.078,301.83 595.598,301.83 595.598,321.622
593.543,321.622 593.543,323.156 595.598,323.156 595.598,342.28 593.543,342.28 593.543,343.814 595.598,343.814
595.598,362.939 593.543,362.939 593.543,364.472 595.598,364.472 595.598,383.597 593.543,383.597 593.543,385.13
595.598,385.13 595.598,404.255 593.543,404.255 593.543,405.789 595.598,405.789 595.598,424.913 593.543,424.913
593.543,426.447 595.598,426.447 595.598,442.075 584.189,442.075 584.189,381.685 538.283,381.685 538.283,425.617
530.83,425.617 530.83,288.763 525.315,288.763 525.315,292.286 515.14,294.775 515.14,292.487 509.625,292.487 509.625,296.124
499.45,298.612 499.45,295.989 493.936,295.989 493.936,299.961 483.76,302.45 483.76,300.286 478.246,300.286 478.246,303.799
468.071,306.288 468.071,304.423 462.557,304.423 462.557,438.816 454.799,438.816 454.799,367.168 426.065,367.168
426.065,411.227 396.608,411.227 396.608,373.655 392.979,373.655 392.979,361.316 395.133,361.316 395.133,360.077
385.601,360.077 385.601,356.197 384.584,356.197 384.584,360.077 381.535,360.077 381.535,340.162 380.773,340.162
380.773,360.077 376.802,360.077 376.802,361.316 379.138,361.316 379.138,373.655 359.697,373.655 359.697,402.316
340.771,402.316 330.886,438.816 311.053,438.816 311.053,319.642 284.794,319.642 284.794,315.373 286.286,315.373
286.286,313.875 266.397,313.875 266.397,315.373 267.961,315.373 267.961,319.642 267.961,326.508 267.961,408.127
255.579,408.127 255.579,377.289 259.98,377.289 259.98,374.497 228.825,374.497 228.825,355.867 222.882,355.867
222.882,352.039 209.616,352.039 209.616,355.867 198.821,355.867 198.821,422.389 163.408,422.389 163.408,373.052
133.589,373.052 133.589,412.021 107.712,412.021 107.712,436.954 89.054,436.954 89.054,405.609 64.517,405.609 64.517,432.333
42.301,432.333 42.301,451.25 64.517,451.25 72.025,451.25 84.29,451.25 89.054,451.25 107.712,451.25 133.589,451.25
142.285,451.25 146.941,451.25 163.408,451.25 198.821,451.25 209.616,451.25 222.882,451.25 228.825,451.25 252.067,451.25
255.579,451.25 267.961,451.25 288.725,451.25 298.954,451.25 311.053,451.25 359.697,451.25 370.931,451.25 389.895,451.25
396.608,451.25 426.568,451.25 434.618,451.25 462.557,451.25 465.314,451.25 468.071,451.25 478.246,451.25 483.76,451.25
493.936,451.25 498.545,451.25 499.45,451.25 509.625,451.25 515.14,451.25 523.887,451.25 525.315,451.25 528.072,451.25
530.83,451.25 538.283,451.25 576.227,451.25 581.647,451.25 584.189,451.25 595.598,451.25 615.682,451.25 634.069,451.25
642.711,451.25 656.115,451.25 689.405,451.25 724.606,451.25 724.973,451.25 749.871,451.25 763.792,451.25 772.309,451.25
777.689,451.25 819.353,451.25 826.815,451.25 835.736,451.25 843.745,451.25 873.474,451.25 895.58,451.25 900.105,451.25
908.236,451.25 957.698,451.25 957.698,433.241 "/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -1,64 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>cld-server</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="General" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="SLICES-64px" transform="translate(-810.000000, -200.000000)">
</g>
<g id="ICONS" transform="translate(-805.000000, -195.000000)">
<g id="cld-server" transform="translate(810.000000, 204.000000)">
<path d="M48,12 C51.313,12 54,9.313 54,6 C54,2.687 51.313,0 48,0 L6,0 C2.687,0 0,2.687 0,6 C0,9.313 2.687,12 6,12 L48,12 Z" id="Fill-424" fill="#969CE3">
</path>
<path d="M10,6 C10,7.104 9.104,8 8,8 C6.896,8 6,7.104 6,6 C6,4.896 6.896,4 8,4 C9.104,4 10,4.896 10,6" id="Fill-425" fill="#7BBDEC">
</path>
<path d="M48,30 C51.313,30 54,27.313 54,24 C54,20.687 51.313,18 48,18 L6,18 C2.687,18 0,20.687 0,24 C0,27.313 2.687,30 6,30 L48,30 Z" id="Fill-426" fill="#969CE3">
</path>
<path d="M10,24 C10,25.104 9.104,26 8,26 C6.896,26 6,25.104 6,24 C6,22.896 6.896,22 8,22 C9.104,22 10,22.896 10,24" id="Fill-427" fill="#7BBDEC">
</path>
<path d="M48,48 C51.313,48 54,45.313 54,42 C54,38.687 51.313,36 48,36 L6,36 C2.687,36 0,38.687 0,42 C0,45.313 2.687,48 6,48 L48,48 Z" id="Fill-428" fill="#969CE3">
</path>
<path d="M10,42 C10,43.104 9.104,44 8,44 C6.896,44 6,43.104 6,42 C6,40.896 6.896,40 8,40 C9.104,40 10,40.896 10,42" id="Fill-429" fill="#7BBDEC">
</path>
<path d="M48,12 C51.313,12 54,9.313 54,6 C54,2.687 51.313,0 48,0 L6,0 C2.687,0 0,2.687 0,6 C0,9.313 2.687,12 6,12 L48,12 Z" id="Stroke-430" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M10,6 C10,7.104 9.104,8 8,8 C6.896,8 6,7.104 6,6 C6,4.896 6.896,4 8,4 C9.104,4 10,4.896 10,6 Z" id="Stroke-431" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M48,6 L36,6" id="Stroke-432" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M48,30 C51.313,30 54,27.313 54,24 C54,20.687 51.313,18 48,18 L6,18 C2.687,18 0,20.687 0,24 C0,27.313 2.687,30 6,30 L48,30 Z" id="Stroke-433" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M10,24 C10,25.104 9.104,26 8,26 C6.896,26 6,25.104 6,24 C6,22.896 6.896,22 8,22 C9.104,22 10,22.896 10,24 Z" id="Stroke-434" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M48,24 L36,24" id="Stroke-435" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M48,48 C51.313,48 54,45.313 54,42 C54,38.687 51.313,36 48,36 L6,36 C2.687,36 0,38.687 0,42 C0,45.313 2.687,48 6,48 L48,48 Z" id="Stroke-436" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M10,42 C10,43.104 9.104,44 8,44 C6.896,44 6,43.104 6,42 C6,40.896 6.896,40 8,40 C9.104,40 10,40.896 10,42 Z" id="Stroke-437" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M48,42 L36,42" id="Stroke-438" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -1,64 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>con-drill</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="General" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="SLICES-64px" transform="translate(-450.000000, -300.000000)">
</g>
<g id="ICONS" transform="translate(-445.000000, -295.000000)">
<g id="con-drill" transform="translate(452.000000, 306.000000)">
<path d="M4,46 L20,46 C21.104,46 22,45.104 22,44 L22,36 C22,34.896 21.104,34 20,34 L13.375,34 L2,34 L2,44 C2,45.104 2.896,46 4,46" id="Fill-680" fill="#99A5B7">
</path>
<path d="M40,4 L34,4 L34,12 L40,12 C41.104,12 42,11.104 42,10 L42,6 C42,4.896 41.104,4 40,4" id="Fill-681" fill="#E9EFFA">
</path>
<path d="M30,16 C32.209,16 34,14.209 34,12 L34,4 C34,1.791 32.209,0 30,0 L4,0 C1.791,0 0,1.791 0,4 L0,12 C0,14.209 1.791,16 4,16 L30,16 Z" id="Fill-682" fill="#D3D873">
</path>
<path d="M12.71,22 L18,22 C16.354,20.354 17.87,17.918 19,16 L14,16 L12.71,22 Z" id="Fill-683" fill="#F16963">
</path>
<path d="M13.375,34 C11.926,34 10.75,32.824 10.75,31.375 C10.75,31.12 10.786,30.874 10.854,30.641 L14,16 L6,16 L2,34 L13.375,34 Z" id="Fill-684" fill="#AEC14A">
</path>
<path d="M12.71,22 L18,22 C16.354,20.354 17.87,17.918 19,16" id="Stroke-685" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M42,8 L54,8" id="Stroke-686" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M34,4 L40,4 C41.104,4 42,4.896 42,6 L42,10 C42,11.104 41.104,12 40,12 L34,12" id="Stroke-687" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M2,34 L6,16" id="Stroke-688" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M6,8 L14,8" id="Stroke-689" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M6,4 L14,4" id="Stroke-690" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M2,34 L2,44 C2,45.104 2.896,46 4,46 L20,46 C21.104,46 22,45.104 22,44 L22,36 C22,34.896 21.104,34 20,34 L13.375,34" id="Stroke-691" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M13.375,34 C11.926,34 10.75,32.824 10.75,31.375 C10.75,31.12 10.786,30.874 10.854,30.641 L14,16" id="Stroke-692" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M2,34 L14,34" id="Stroke-693" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M30,16 C32.209,16 34,14.209 34,12 L34,4 C34,1.791 32.209,0 30,0 L4,0 C1.791,0 0,1.791 0,4 L0,12 C0,14.209 1.791,16 4,16 L30,16 Z" id="Stroke-694" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>con-warning</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="General" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="SLICES-64px" transform="translate(-720.000000, -300.000000)">
</g>
<g id="ICONS" transform="translate(-715.000000, -295.000000)">
<g id="con-warning" transform="translate(718.000000, 302.000000)">
<path d="M50,46 C53.313,46 56,43.313 56,40 C56,38.751 55.358,37.299 55.358,37.299 L32.878,2.51 L32.88,2.509 C31.791,0.99 30.011,1.13686838e-13 28,1.13686838e-13 C25.989,1.13686838e-13 24.209,0.99 23.12,2.509 L23.122,2.51 L0.642,37.299 C0.642,37.299 0,38.751 0,40 C0,43.313 2.687,46 6,46 L50,46 Z" id="Fill-390" fill="#F3E777">
</path>
<path d="M26,36 C26,34.896 26.896,34 28,34 C29.104,34 30,34.896 30,36 C30,37.104 29.104,38 28,38 C26.896,38 26,37.104 26,36" id="Fill-391" fill="#F16963">
</path>
<path d="M32,16 C32,13.791 30.209,12 28,12 C25.791,12 24,13.791 24,16 L26,28 C26,29.104 26.896,30 28,30 C29.104,30 30,29.104 30,28 L32,16 Z" id="Fill-392" fill="#F16963">
</path>
<path d="M26,36 C26,34.896 26.896,34 28,34 C29.104,34 30,34.896 30,36 C30,37.104 29.104,38 28,38 C26.896,38 26,37.104 26,36 Z" id="Stroke-393" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M32,16 C32,13.791 30.209,12 28,12 C25.791,12 24,13.791 24,16 L26,28 C26,29.104 26.896,30 28,30 C29.104,30 30,29.104 30,28 L32,16 Z" id="Stroke-394" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M50,46 C53.313,46 56,43.313 56,40 C56,38.751 55.358,37.299 55.358,37.299 L32.878,2.51 L32.88,2.509 C31.791,0.99 30.011,1.13686838e-13 28,1.13686838e-13 C25.989,1.13686838e-13 24.209,0.99 23.12,2.509 L23.122,2.51 L0.642,37.299 C0.642,37.299 0,38.751 0,40 C0,43.313 2.687,46 6,46 L50,46 Z" id="Stroke-395" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1,76 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>gen-lifebelt</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="General" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="SLICES-64px">
</g>
<g id="ICONS" transform="translate(5.000000, 5.000000)">
<g id="gen-lifebelt" transform="translate(0.000000, 2.000000)">
<path d="M26.0001,40 C18.2681,40 12.0001,33.732 12.0001,26 C12.0001,18.267 18.2681,12 26.0001,12 C33.7321,12 40.0001,18.267 40.0001,26 C40.0001,33.732 33.7321,40 26.0001,40 M26.0001,0 C11.6411,0 0.0001,11.64 0.0001,26 C0.0001,40.359 11.6411,52 26.0001,52 C40.3591,52 52.0001,40.359 52.0001,26 C52.0001,11.64 40.3591,0 26.0001,0" id="Fill-464" fill="#F16963">
</path>
<path d="M3.0025,13.8716 L13.6385,19.4216 L13.6485,19.4116 C14.9905,16.9016 17.0765,14.8566 19.6105,13.5526 L19.6385,13.5256 L13.8725,3.0026 C9.2455,5.4476 5.4475,9.2456 3.0025,13.8716" id="Fill-465" fill="#F1F0E2">
</path>
<path d="M38.128,3.0022 L32.361,13.5252 L32.39,13.5532 C34.923,14.8562 37.01,16.9012 38.352,19.4122 L38.361,19.4212 L48.998,13.8712 C46.553,9.2452 42.754,5.4472 38.128,3.0022" id="Fill-466" fill="#F1F0E2">
</path>
<path d="M13.648,32.5872 L13.639,32.5782 L3.002,38.1282 C5.447,42.7542 9.246,46.5532 13.872,48.9972 L19.639,38.4742 L19.611,38.4472 C17.077,37.1442 14.99,35.0982 13.648,32.5872" id="Fill-467" fill="#F1F0E2">
</path>
<path d="M48.9976,38.1284 L38.3616,32.5774 L38.3516,32.5864 C37.0106,35.0974 34.9236,37.1434 32.3896,38.4474 L32.3616,38.4744 L38.1276,48.9974 C42.7546,46.5524 46.5526,42.7544 48.9976,38.1284" id="Fill-468" fill="#F1F0E2">
</path>
<path d="M2.9971,13.8689 C2.3621,12.7229 2.0001,11.4029 2.0001,9.9999 C2.0001,5.5819 5.5821,1.9999 10.0001,1.9999 C11.4031,1.9999 12.7231,2.3619 13.8691,2.9969" id="Stroke-469" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M49.003,13.8689 C49.638,12.7229 50,11.4029 50,9.9999 C50,5.5819 46.418,1.9999 42,1.9999 C40.597,1.9999 39.277,2.3619 38.131,2.9969" id="Stroke-470" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M2.9971,38.1311 C2.3621,39.2771 2.0001,40.5961 2.0001,42.0001 C2.0001,46.4171 5.5821,50.0001 10.0001,50.0001 C11.4031,50.0001 12.7231,49.6381 13.8691,49.0031" id="Stroke-471" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M49.003,38.1311 C49.638,39.2771 50,40.5961 50,42.0001 C50,46.4171 46.418,50.0001 42,50.0001 C40.597,50.0001 39.277,49.6381 38.131,49.0031" id="Stroke-472" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M52.0001,26 C52.0001,11.64 40.3591,0 26.0001,0 C11.6411,0 0.0001,11.64 0.0001,26 C0.0001,40.359 11.6411,52 26.0001,52 C40.3591,52 52.0001,40.359 52.0001,26 Z" id="Stroke-473" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M40.0001,26 C40.0001,33.732 33.7321,40 26.0001,40 C18.2681,40 12.0001,33.732 12.0001,26 C12.0001,18.267 18.2681,12 26.0001,12 C33.7321,12 40.0001,18.267 40.0001,26 Z" id="Stroke-474" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M13.8692,2.9968 L19.6392,13.5248" id="Stroke-475" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M13.6387,19.4216 L2.9967,13.8686" id="Stroke-476" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M38.1309,2.9968 L32.3609,13.5248" id="Stroke-477" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M38.3614,19.4216 L49.0034,13.8686" id="Stroke-478" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M13.8692,49.0027 L19.6392,38.4747" id="Stroke-479" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M13.6387,32.5779 L2.9967,38.1309" id="Stroke-480" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M38.1309,49.0027 L32.3609,38.4747" id="Stroke-481" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
<path d="M38.3614,32.5779 L49.0034,38.1309" id="Stroke-482" stroke="#414547" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</path>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

161
frontend/assets/plan.svg Normal file
View File

@ -0,0 +1,161 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.5.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 2200 2200" style="enable-background:new 0 0 2200 2200;" xml:space="preserve">
<g id="Objects">
<g>
<path style="fill:#788D8E;" d="M1202.178,2002.073c-5.328,0-9.648-4.319-9.649-9.647c-0.001-5.328,4.319-9.649,9.647-9.649
c9.63-0.001,19.271-0.006,28.918-0.014c0.003,0,0.006,0,0.009,0c5.325,0,9.643,4.314,9.647,9.639
c0.005,5.328-4.311,9.651-9.639,9.656C1221.458,2002.068,1211.813,2002.072,1202.178,2002.073z M1144.298,2002.03
c-0.006,0-0.01,0-0.016,0c-9.658-0.015-19.305-0.036-28.94-0.061c-5.328-0.014-9.636-4.345-9.622-9.673
c0.014-5.319,4.331-9.622,9.648-9.622c0.008,0,0.017,0,0.025,0c9.628,0.025,19.269,0.046,28.919,0.061
c5.328,0.009,9.641,4.335,9.632,9.663C1153.937,1997.721,1149.619,2002.03,1144.298,2002.03z M1288.979,2001.966
c-5.317,0-9.634-4.306-9.647-9.626c-0.012-5.328,4.298-9.657,9.626-9.669c9.634-0.022,19.274-0.047,28.923-0.075
c5.297,0.018,9.66,4.292,9.676,9.619c0.015,5.328-4.291,9.66-9.619,9.676c-9.652,0.028-19.299,0.054-28.936,0.075
C1288.994,2001.966,1288.986,2001.966,1288.979,2001.966z M1057.498,2001.759c-0.015,0-0.03,0-0.045,0
c-9.659-0.044-19.306-0.095-28.939-0.152c-5.328-0.031-9.622-4.376-9.591-9.704c0.031-5.309,4.344-9.591,9.646-9.591
c0.02,0,0.04,0,0.058,0c9.625,0.057,19.263,0.107,28.914,0.152c5.328,0.025,9.628,4.364,9.603,9.692
C1067.12,1997.468,1062.805,2001.759,1057.498,2001.759z M1375.787,2001.691c-5.31,0-9.625-4.293-9.647-9.609
c-0.022-5.328,4.281-9.665,9.609-9.686c9.636-0.039,19.28-0.079,28.928-0.123c0.015,0,0.029,0,0.044,0
c5.308,0,9.623,4.291,9.647,9.604c0.024,5.328-4.276,9.666-9.604,9.691c-9.651,0.043-19.297,0.084-28.937,0.122
C1375.813,2001.691,1375.8,2001.691,1375.787,2001.691z M1462.601,2001.29c-5.305,0-9.619-4.287-9.647-9.598
c-0.027-5.328,4.27-9.67,9.598-9.698c9.641-0.05,19.285-0.102,28.932-0.156c0.018,0,0.037,0,0.056,0
c5.302,0,9.616,4.284,9.646,9.594c0.029,5.328-4.266,9.671-9.594,9.701c-9.649,0.054-19.297,0.105-28.94,0.156
C1462.635,2001.29,1462.618,2001.29,1462.601,2001.29z M970.707,2001.198c-0.027,0-0.054,0-0.081,0
c-9.662-0.079-19.308-0.166-28.939-0.259c-5.328-0.052-9.605-4.413-9.554-9.741c0.052-5.296,4.361-9.554,9.646-9.554
c0.032,0,0.063,0,0.095,0.001c9.621,0.093,19.258,0.179,28.911,0.258c5.328,0.044,9.612,4.399,9.568,9.727
C980.31,1996.93,975.998,2001.198,970.707,2001.198z M1549.42,2000.802c-5.301,0-9.614-4.281-9.646-9.59
c-0.032-5.328,4.262-9.673,9.59-9.705l28.936-0.176c0.021,0,0.042,0,0.061,0c5.301,0,9.614,4.279,9.647,9.587
c0.033,5.328-4.259,9.674-9.587,9.708l-28.942,0.176C1549.459,2000.802,1549.439,2000.802,1549.42,2000.802z M883.923,2000.297
c-0.041,0-0.083,0-0.124-0.001c-9.663-0.122-19.308-0.25-28.935-0.385c-5.328-0.075-9.586-4.454-9.511-9.782
c0.075-5.328,4.464-9.604,9.782-9.511c9.617,0.136,19.254,0.264,28.907,0.385c5.328,0.067,9.592,4.44,9.525,9.768
C893.501,1996.057,889.195,2000.297,883.923,2000.297z M1636.244,2000.264c-5.3,0-9.612-4.279-9.646-9.586
c-0.034-5.328,4.258-9.675,9.586-9.709l28.943-0.184c0.021,0,0.042,0,0.062,0c5.3,0,9.613,4.279,9.647,9.586
c0.034,5.328-4.259,9.675-9.586,9.709l-28.943,0.184C1636.286,2000.264,1636.265,2000.264,1636.244,2000.264z M1723.075,1999.719
c-5.3,0-9.614-4.28-9.647-9.588c-0.033-5.328,4.26-9.674,9.588-9.707l28.946-0.177c0.02,0,0.04,0,0.059,0
c5.301,0,9.615,4.28,9.647,9.589c0.032,5.328-4.261,9.674-9.589,9.706l-28.945,0.177
C1723.115,1999.719,1723.095,1999.719,1723.075,1999.719z M1809.912,1999.201c-5.303,0-9.616-4.283-9.647-9.593
c-0.03-5.328,4.265-9.672,9.593-9.702l28.952-0.16c0.018,0,0.036,0,0.053,0c5.304,0,9.618,4.285,9.647,9.596
c0.028,5.328-4.268,9.67-9.596,9.699l-28.947,0.16C1809.949,1999.201,1809.931,1999.201,1809.912,1999.201z M797.15,1998.997
c-0.057,0-0.115-0.001-0.172-0.002c-9.665-0.169-19.31-0.346-28.935-0.531c-5.327-0.103-9.562-4.504-9.459-9.832
c0.104-5.327,4.48-9.533,9.832-9.459c9.612,0.186,19.245,0.363,28.899,0.531c5.327,0.092,9.57,4.487,9.477,9.814
C806.701,1994.788,802.399,1998.997,797.15,1998.997z M1896.756,1998.75c-5.307,0-9.621-4.29-9.647-9.602
c-0.025-5.328,4.274-9.667,9.602-9.693c9.658-0.045,19.31-0.089,28.958-0.129c0.014,0,0.027,0,0.041,0
c5.309,0,9.624,4.292,9.647,9.607c0.023,5.328-4.279,9.666-9.607,9.688c-9.644,0.041-19.294,0.084-28.948,0.129
C1896.787,1998.75,1896.771,1998.75,1896.756,1998.75z M710.388,1997.237c-0.076,0-0.152-0.001-0.228-0.003
c-9.667-0.223-19.311-0.457-28.932-0.7c-5.326-0.135-9.535-4.562-9.401-9.889c0.133-5.243,4.425-9.404,9.64-9.404
c0.083,0,0.166,0.001,0.249,0.003c9.607,0.243,19.237,0.477,28.891,0.7c5.327,0.122,9.545,4.541,9.421,9.868
C719.907,1993.064,715.612,1997.237,710.388,1997.237z M623.642,1994.948c-0.096,0-0.193-0.001-0.29-0.004
c-9.67-0.287-19.314-0.585-28.929-0.894c-5.326-0.172-9.503-4.628-9.333-9.953c0.171-5.325,4.65-9.465,9.953-9.333
c9.6,0.309,19.227,0.607,28.88,0.892c5.326,0.158,9.516,4.604,9.357,9.93C633.126,1990.815,628.838,1994.948,623.642,1994.948z
M536.937,1991.683c-0.226,0-0.453-0.007-0.682-0.024c-10.955-0.765-20.624-1.8-29.559-3.167
c-5.267-0.806-8.883-5.728-8.078-10.995c0.805-5.268,5.725-8.888,10.995-8.078c8.408,1.286,17.563,2.264,27.985,2.992
c5.316,0.371,9.323,4.981,8.952,10.296C546.196,1987.794,541.959,1991.683,536.937,1991.683z M452.936,1972.044
c-1.444,0-2.91-0.325-4.291-1.012c-5.129-2.553-10.134-5.398-14.875-8.458c-3.821-2.466-7.597-5.17-11.224-8.036
c-4.179-3.305-4.889-9.372-1.585-13.552c3.305-4.18,9.371-4.889,13.552-1.585c3.144,2.486,6.415,4.828,9.719,6.961
c4.142,2.673,8.519,5.162,13.01,7.397c4.77,2.373,6.713,8.165,4.34,12.935C459.893,1970.083,456.481,1972.044,452.936,1972.044z
M390.843,1913.094c-3.257,0-6.436-1.65-8.252-4.636c-5.088-8.366-9.753-17.406-13.866-26.869
c-2.125-4.886,0.115-10.57,5.002-12.693c4.884-2.125,10.569,0.114,12.694,5.002c3.765,8.661,8.023,16.915,12.657,24.534
c2.768,4.552,1.323,10.487-3.23,13.256C394.28,1912.639,392.55,1913.094,390.843,1913.094z M360.798,1832.103
c-4.545,0-8.593-3.227-9.469-7.857c-1.832-9.681-3.208-19.642-4.092-29.605c-0.471-5.307,3.45-9.991,8.757-10.463
c5.305-0.466,9.991,3.449,10.462,8.757c0.828,9.335,2.117,18.663,3.831,27.724c0.99,5.236-2.451,10.282-7.686,11.272
C361.996,1832.047,361.393,1832.103,360.798,1832.103z M357.3,1745.636c-0.337,0-0.677-0.018-1.02-0.054
c-5.299-0.557-9.143-5.304-8.586-10.603c1.051-10.004,2.612-19.95,4.636-29.561c1.098-5.214,6.217-8.55,11.429-7.451
c5.214,1.099,8.55,6.215,7.451,11.429c-1.889,8.965-3.345,18.252-4.327,27.6C366.362,1741.952,362.175,1745.636,357.3,1745.636z
M379.592,1662.099c-1.292,0-2.604-0.261-3.863-0.812c-4.881-2.136-7.107-7.824-4.97-12.706
c3.967-9.067,8.429-18.064,13.26-26.742c2.591-4.655,8.466-6.329,13.122-3.737c4.656,2.592,6.329,8.466,3.737,13.122
c-4.533,8.143-8.72,16.585-12.442,25.091C386.85,1659.939,383.308,1662.099,379.592,1662.099z M425.149,1588.527
c-2.174,0-4.36-0.73-6.162-2.228c-4.097-3.407-4.658-9.489-1.252-13.587c6.291-7.567,13.033-14.977,20.039-22.024
c3.756-3.78,9.865-3.796,13.644-0.039c3.778,3.756,3.796,9.865,0.039,13.643c-6.604,6.642-12.958,13.624-18.884,20.754
C430.666,1587.342,427.917,1588.527,425.149,1588.527z M488.757,1529.81c-3.021,0-5.994-1.414-7.875-4.063
c-3.085-4.345-2.062-10.367,2.282-13.452c8.036-5.705,16.424-11.148,24.935-16.182c4.587-2.711,10.502-1.194,13.215,3.393
c2.712,4.586,1.194,10.503-3.393,13.215c-8.051,4.762-15.987,9.912-23.588,15.308
C492.639,1529.232,490.688,1529.81,488.757,1529.81z M565.026,1488.849c-3.858,0-7.5-2.33-8.989-6.14
c-1.939-4.962,0.513-10.558,5.476-12.497c8.996-3.515,18.386-6.814,27.91-9.805c5.085-1.593,10.499,1.23,12.094,6.314
c1.597,5.083-1.229,10.498-6.313,12.095c-9.107,2.86-18.081,6.012-26.67,9.368
C567.381,1488.636,566.194,1488.849,565.026,1488.849z M648.419,1465.236c-4.529,0-8.569-3.204-9.462-7.816
c-1.012-5.232,2.408-10.293,7.639-11.306c9.243-1.788,18.981-3.446,28.945-4.926c5.268-0.786,10.177,2.855,10.961,8.125
c0.783,5.27-2.855,10.178-8.125,10.961c-9.686,1.439-19.145,3.049-28.114,4.784
C649.644,1465.178,649.027,1465.236,648.419,1465.236z M734.448,1453.776c-4.949,0-9.161-3.786-9.599-8.809
c-0.464-5.308,3.463-9.987,8.771-10.45c8.927-0.779,18.419-1.521,29.019-2.265c5.285-0.369,9.926,3.633,10.3,8.948
c0.373,5.315-3.633,9.926-8.948,10.299c-10.489,0.737-19.875,1.469-28.691,2.239
C735.013,1453.764,734.729,1453.776,734.448,1453.776z M821.074,1447.866c-5.059,0-9.308-3.941-9.621-9.059
c-0.325-5.318,3.722-9.893,9.041-10.219l28.889-1.767c5.341-0.318,9.893,3.723,10.219,9.041c0.325,5.318-3.722,9.893-9.041,10.219
l-28.889,1.766C821.471,1447.86,821.272,1447.866,821.074,1447.866z M907.74,1442.568c-5.059,0-9.308-3.941-9.621-9.059
c-0.325-5.318,3.722-9.893,9.041-10.219l28.889-1.766c5.344-0.326,9.893,3.723,10.219,9.041c0.325,5.318-3.722,9.893-9.041,10.218
l-28.889,1.767C908.137,1442.562,907.938,1442.568,907.74,1442.568z M994.406,1437.269c-5.059,0-9.307-3.941-9.62-9.059
c-0.325-5.318,3.722-9.893,9.041-10.219l28.889-1.766c5.345-0.309,9.893,3.722,10.219,9.041c0.325,5.318-3.722,9.893-9.041,10.219
l-28.889,1.767C994.805,1437.263,994.604,1437.269,994.406,1437.269z M1081.072,1431.969c-5.059,0-9.307-3.941-9.62-9.059
c-0.325-5.318,3.722-9.893,9.041-10.219l28.889-1.766c5.322-0.318,9.894,3.723,10.219,9.041c0.325,5.318-3.722,9.893-9.041,10.219
l-28.889,1.767C1081.47,1431.964,1081.27,1431.969,1081.072,1431.969z M1167.738,1426.671c-5.059,0-9.307-3.941-9.62-9.059
c-0.325-5.318,3.722-9.893,9.041-10.219l28.889-1.767c5.349-0.326,9.894,3.723,10.219,9.041c0.325,5.318-3.722,9.893-9.041,10.219
l-28.889,1.766C1168.135,1426.665,1167.936,1426.671,1167.738,1426.671z M1254.358,1420.693c-4.914,0-9.115-3.738-9.592-8.73
c-0.508-5.304,3.38-10.015,8.685-10.523c10.205-0.975,19.481-2.043,28.357-3.264c5.276-0.72,10.146,2.966,10.872,8.244
c0.725,5.279-2.966,10.146-8.244,10.872c-9.142,1.257-18.676,2.354-29.148,3.356
C1254.976,1420.678,1254.665,1420.693,1254.358,1420.693z M1339.835,1406.05c-4.233,0-8.115-2.807-9.295-7.086
c-1.416-5.136,1.6-10.448,6.735-11.864c9.242-2.549,18.223-5.456,26.694-8.639c4.994-1.875,10.551,0.65,12.425,5.636
c1.875,4.988-0.649,10.55-5.636,12.425c-9.014,3.388-18.553,6.476-28.353,9.178
C1341.548,1405.937,1340.684,1406.05,1339.835,1406.05z M1418.99,1371.212c-3.131,0-6.201-1.522-8.057-4.329
c-2.938-4.445-1.716-10.43,2.729-13.368c7.542-4.985,14.948-10.622,22.014-16.754c4.022-3.493,10.115-3.063,13.609,0.962
c3.493,4.024,3.062,10.117-0.962,13.609c-7.698,6.683-15.781,12.832-24.022,18.28
C1422.663,1370.694,1420.817,1371.212,1418.99,1371.212z M1480.743,1310.868c-1.931,0-3.882-0.578-5.577-1.782
c-4.344-3.086-5.366-9.108-2.281-13.452c5.366-7.558,10.318-15.487,14.721-23.567c2.549-4.679,8.41-6.406,13.087-3.856
c4.679,2.549,6.406,8.409,3.856,13.087c-4.765,8.747-10.126,17.328-15.932,25.506
C1486.737,1309.454,1483.762,1310.868,1480.743,1310.868z M1517.216,1232.602c-0.778,0-1.568-0.094-2.356-0.292
c-5.169-1.297-8.306-6.538-7.009-11.707c2.256-8.984,3.898-18.121,4.878-27.155c0.575-5.299,5.342-9.13,10.632-8.55
c5.298,0.575,9.126,5.335,8.55,10.633c-1.076,9.911-2.875,19.928-5.346,29.771
C1525.467,1229.681,1521.535,1232.602,1517.216,1232.602z M1520.169,1146.498c-4.554,0-8.606-3.239-9.472-7.879
c-1.672-8.957-4.032-17.905-7.015-26.593c-1.731-5.04,0.953-10.527,5.992-12.257c5.036-1.729,10.528,0.953,12.257,5.992
c3.287,9.575,5.889,19.438,7.733,29.318c0.978,5.237-2.475,10.276-7.713,11.254
C1521.352,1146.444,1520.757,1146.498,1520.169,1146.498z M1486.631,1067.128c-3.053,0-6.055-1.445-7.93-4.142
c-5.223-7.514-11.025-14.791-17.243-21.63c-3.585-3.942-3.295-10.044,0.647-13.629c3.942-3.584,10.044-3.295,13.628,0.648
c6.781,7.457,13.11,15.396,18.811,23.597c3.041,4.375,1.961,10.387-2.415,13.428
C1490.452,1066.568,1488.532,1067.128,1486.631,1067.128z M1424.593,1007.152c-1.794,0-3.608-0.499-5.227-1.546
c-7.728-4.995-15.75-9.441-23.845-13.217l-0.314-0.146c-4.827-2.257-6.911-7.999-4.653-12.826c2.257-4.826,8-6.91,12.825-4.653
l0.276,0.129c8.915,4.158,17.716,9.036,26.183,14.508c4.475,2.892,5.758,8.864,2.866,13.339
C1430.859,1005.596,1427.759,1007.152,1424.593,1007.152z M1344.533,974.803c-0.7,0-1.41-0.076-2.123-0.236
c-8.759-1.966-18.077-3.643-27.693-4.985c-5.277-0.736-8.958-5.611-8.222-10.888c0.737-5.277,5.615-8.957,10.888-8.222
c10.137,1.415,19.98,3.187,29.254,5.268c5.199,1.167,8.467,6.327,7.3,11.527C1352.93,971.754,1348.947,974.803,1344.533,974.803z
M274.324,966.773c-5.318,0-9.634-4.305-9.647-9.625c-0.012-5.328,4.297-9.657,9.625-9.67l28.943-0.066c0.008,0,0.016,0,0.023,0
c5.317,0,9.634,4.305,9.647,9.625c0.012,5.328-4.297,9.657-9.625,9.67l-28.943,0.066
C274.339,966.773,274.331,966.773,274.324,966.773z M361.152,966.573c-5.318,0-9.635-4.305-9.647-9.625
c-0.012-5.328,4.297-9.657,9.625-9.67l28.943-0.066c5.338-0.002,9.657,4.297,9.67,9.625s-4.297,9.657-9.625,9.67l-28.943,0.066
C361.167,966.573,361.16,966.573,361.152,966.573z M447.979,966.373c-5.318,0-9.634-4.305-9.647-9.625
c-0.012-5.328,4.297-9.657,9.625-9.67l28.942-0.066c0.008,0,0.015,0,0.023,0c5.318,0,9.635,4.305,9.648,9.625
c0.012,5.328-4.297,9.657-9.626,9.67l-28.942,0.066C447.995,966.373,447.987,966.373,447.979,966.373z M534.808,966.174
c-5.318,0-9.635-4.305-9.648-9.625c-0.012-5.328,4.297-9.657,9.625-9.67l28.943-0.066c0.008,0,0.015,0,0.023,0
c5.318,0,9.635,4.305,9.648,9.625c0.012,5.328-4.297,9.657-9.626,9.67l-28.943,0.066
C534.823,966.174,534.815,966.174,534.808,966.174z M621.636,965.973c-5.318,0-9.635-4.305-9.648-9.625
c-0.012-5.328,4.297-9.657,9.625-9.67l28.943-0.066c0.008,0,0.015,0,0.023,0c5.318,0,9.635,4.305,9.648,9.625
c0.012,5.328-4.297,9.657-9.626,9.67l-28.943,0.066C621.651,965.973,621.643,965.973,621.636,965.973z M708.463,965.774
c-5.318,0-9.635-4.305-9.648-9.625c-0.012-5.328,4.297-9.657,9.625-9.67l28.943-0.066c0.008,0,0.015,0,0.023,0
c5.318,0,9.635,4.305,9.648,9.625c0.012,5.328-4.297,9.657-9.626,9.67l-28.943,0.066
C708.478,965.774,708.471,965.774,708.463,965.774z M795.291,965.574c-5.318,0-9.635-4.305-9.648-9.625
c-0.012-5.328,4.297-9.657,9.625-9.67l28.943-0.066c5.292,0.018,9.658,4.297,9.67,9.625c0.012,5.328-4.297,9.657-9.626,9.67
l-28.943,0.066C795.306,965.574,795.299,965.574,795.291,965.574z M882.12,965.374c-5.318,0-9.635-4.305-9.648-9.625
c-0.012-5.328,4.297-9.657,9.625-9.67l28.943-0.066c0.008,0,0.016,0,0.024,0c5.317,0,9.634,4.305,9.647,9.625
c0.012,5.328-4.297,9.657-9.626,9.67l-28.943,0.066C882.135,965.374,882.127,965.374,882.12,965.374z M968.947,965.174
c-5.318,0-9.635-4.305-9.648-9.625c-0.012-5.328,4.297-9.657,9.625-9.67l28.943-0.066c0.008,0,0.015,0,0.023,0
c5.318,0,9.635,4.305,9.648,9.625c0.012,5.328-4.297,9.657-9.626,9.67l-28.943,0.066
C968.962,965.174,968.955,965.174,968.947,965.174z M1258.376,965.13c-0.103,0-0.204-0.001-0.307-0.005
c-8.629-0.27-17.745-0.424-28.69-0.483c-5.328-0.029-9.623-4.372-9.595-9.7c0.029-5.31,4.342-9.595,9.647-9.595
c0.018,0,0.036,0,0.054,0c11.114,0.06,20.389,0.216,29.188,0.492c5.326,0.167,9.508,4.619,9.341,9.945
C1267.849,961.007,1263.564,965.13,1258.376,965.13z M1055.775,964.974c-5.318,0-9.635-4.305-9.648-9.625
c-0.012-5.328,4.297-9.657,9.625-9.67l28.943-0.066c0.008,0,0.016,0,0.024,0c5.318,0,9.634,4.305,9.647,9.625
c0.012,5.328-4.297,9.657-9.626,9.67l-28.943,0.066C1055.79,964.974,1055.783,964.974,1055.775,964.974z M1142.603,964.775
c-5.318,0-9.635-4.305-9.648-9.625c-0.012-5.328,4.297-9.657,9.625-9.67l28.943-0.066c0.007,0,0.015,0,0.023,0
c5.318,0,9.635,4.305,9.648,9.625c0.012,5.328-4.297,9.657-9.626,9.67l-28.943,0.066
C1142.619,964.775,1142.611,964.775,1142.603,964.775z"/>
<path style="fill:#D13737;" d="M462.452,197.927c-156.647,0-283.638,126.991-283.638,283.638
c0,251.801,283.638,464.048,283.638,464.048S746.09,733.366,746.09,481.565C746.09,324.918,619.099,197.927,462.452,197.927z
M462.316,679.485c-109.374,0-198.045-88.671-198.045-198.055c0-109.374,88.671-198.045,198.045-198.045
c109.384,0,198.055,88.671,198.055,198.045C660.371,590.814,571.701,679.485,462.316,679.485z"/>
<path style="fill:#18ACB7;" d="M1737.548,1228.212c-156.647,0-283.638,126.991-283.638,283.638
c0,251.801,283.638,464.048,283.638,464.048s283.638-212.246,283.638-464.048
C2021.187,1355.203,1894.196,1228.212,1737.548,1228.212z M1737.413,1709.77c-109.374,0-198.045-88.671-198.045-198.055
c0-109.374,88.671-198.045,198.045-198.045c109.384,0,198.055,88.671,198.055,198.045
C1935.468,1621.1,1846.797,1709.77,1737.413,1709.77z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,126 +0,0 @@
# Terms and Conditions
> Last updated: January 09, 2025
>
> Also see: https://anydev.info/terms-and-conditions/
Please read these terms and conditions carefully before using our Service.
## Interpretation and Definitions
### Interpretation
The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural.
### Definitions
For the purposes of these Terms and Conditions:
- **Application** means the software program provided by the Company downloaded by You on any electronic device, named AnyWay
- **Application Store** means the digital distribution service operated and developed by Apple Inc. (Apple App Store) or Google Inc. (Google Play Store) in which the Application has been downloaded.
- **Affiliate** means an entity that controls, is controlled by or is under common control with a party, where "control" means ownership of 50% or more of the shares, equity interest or other securities entitled to vote for election of directors or other managing authority.
- **Country** refers to: Switzerland
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to AnyDev.
- **Device** means any device that can access the Service such as a computer, a cellphone or a digital tablet.
- **Service** refers to the Application.
- **Terms and Conditions** (also referred as "Terms") mean these Terms and Conditions that form the entire agreement between You and the Company regarding the use of the Service. This Terms and Conditions agreement has been created with the help of the Terms and Conditions Generator.
- **Third-party Social Media Service** means any services or content (including data, information, products or services) provided by a third-party that may be displayed, included or made available by the Service.
- **You** means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.
## Acknowledgment
These are the Terms and Conditions governing the use of this Service and the agreement that operates between You and the Company. These Terms and Conditions set out the rights and obligations of all users regarding the use of the Service.
Your access to and use of the Service is conditioned on Your acceptance of and compliance with these Terms and Conditions. These Terms and Conditions apply to all visitors, users and others who access or use the Service.
By accessing or using the Service You agree to be bound by these Terms and Conditions. If You disagree with any part of these Terms and Conditions then You may not access the Service.
You represent that you are over the age of 18. The Company does not permit those under 18 to use the Service.
Your access to and use of the Service is also conditioned on Your acceptance of and compliance with the Privacy Policy of the Company. Our Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your personal information when You use the Application or the Website and tells You about Your privacy rights and how the law protects You. Please read Our Privacy Policy carefully before using Our Service.
## Links to Other Websites
Our Service may contain links to third-party web sites or services that are not owned or controlled by the Company.
The Company has no control over, and assumes no responsibility for, the content, privacy policies, or practices of any third party web sites or services. You further acknowledge and agree that the Company shall not be responsible or liable, directly or indirectly, for any damage or loss caused or alleged to be caused by or in connection with the use of or reliance on any such content, goods or services available on or through any such web sites or services.
We strongly advise You to read the terms and conditions and privacy policies of any third-party web sites or services that You visit.
## Termination
We may terminate or suspend Your access immediately, without prior notice or liability, for any reason whatsoever, including without limitation if You breach these Terms and Conditions.
Upon termination, Your right to use the Service will cease immediately.
## Limitation of Liability
Notwithstanding any damages that You might incur, the entire liability of the Company and any of its suppliers under any provision of this Terms and Your exclusive remedy for all of the foregoing shall be limited to the amount actually paid by You through the Service or 100 USD if You haven't purchased anything through the Service.
To the maximum extent permitted by applicable law, in no event shall the Company or its suppliers be liable for any special, incidental, indirect, punitive, or consequential damages whatsoever (including, but not limited to, damages for loss of profits, loss of data, loss of use, loss of goodwill, business interruption, personal injury, loss of privacy, or any other pecuniary or non-pecuniary loss or damage) arising out of or in any way related to the use of or inability to use the Service, third-party software and/or third-party hardware used with the Service, or otherwise in connection with any provision of this Terms, even if the Company or any supplier has been advised of the possibility of such damages and even if the remedy fails of its essential purpose.
In particular, the Company and its suppliers are not liable for any damages or losses that may arise from:
- Your reliance on any content provided through the Service;
- Errors, mistakes, or inaccuracies of content;
- Any unauthorized access to or use of our servers and/or any personal information stored therein;
- Any interruption or cessation of transmission to or from the Service;
- Any bugs, viruses, trojan horses, or the like which may be transmitted to or through the Service by any third party;
- Any errors or omissions in any content or for any loss or damage of any kind incurred as a result of Your use of any content posted, emailed, transmitted, or otherwise made available via the Service.
The Company shall not be liable for any loss or damage resulting from failure to meet any of Your expectations related to the use or performance of the Service, including but not limited to inaccuracies in GPS location services, suggested itineraries, or other location-based services.
Some jurisdictions do not allow the exclusion or limitation of certain types of liability, such as incidental or consequential damages or implied warranties. Therefore, the above limitations or exclusions may not apply to You. In such jurisdictions, each party's liability will be limited to the greatest extent permitted by law.
To the extent permitted by applicable law, the Company and its suppliers aggregate liability to You for any claims arising from or related to the use of the Service shall in no event exceed the greater of (a) the amount You paid, if any, for accessing the Service during the twelve (12) month period preceding the claim or (b) one hundred (100) USD.
You agree that the limitations of liability set forth in this section will survive any termination or expiration of these Terms and apply even if any limited remedy specified in these Terms is found to have failed its essential purpose.
"AS IS" and "AS AVAILABLE" Disclaimer
The Service is provided to You "AS IS" and "AS AVAILABLE" and with all faults and defects without warranty of any kind. To the maximum extent permitted under applicable law, the Company, on its own behalf and on behalf of its Affiliates and its and their respective licensors and service providers, expressly disclaims all warranties, whether express, implied, statutory or otherwise, with respect to the Service, including all implied warranties of merchantability, fitness for a particular purpose, title and non-infringement, and warranties that may arise out of course of dealing, course of performance, usage or trade practice. Without limitation to the foregoing, the Company provides no warranty or undertaking, and makes no representation of any kind that the Service will meet Your requirements, achieve any intended results, be compatible or work with any other software, applications, systems or services, operate without interruption, meet any performance or reliability standards or be error free or that any errors or defects can or will be corrected.
Without limiting the foregoing, neither the Company nor any of the company's provider makes any representation or warranty of any kind, express or implied: (i) as to the operation or availability of the Service, or the information, content, and materials or products included thereon; (ii) that the Service will be uninterrupted or error-free; (iii) as to the accuracy, reliability, or currency of any information or content provided through the Service; or (iv) that the Service, its servers, the content, or e-mails sent from or on behalf of the Company are free of viruses, scripts, trojan horses, worms, malware, timebombs or other harmful components.
Some jurisdictions do not allow the exclusion of certain types of warranties or limitations on applicable statutory rights of a consumer, so some or all of the above exclusions and limitations may not apply to You. But in such a case the exclusions and limitations set forth in this section shall be applied to the greatest extent enforceable under applicable law.
## Governing Law
The laws of the Country, excluding its conflicts of law rules, shall govern this Terms and Your use of the Service. Your use of the Application may also be subject to other local, state, national, or international laws.
## Disputes Resolution
If You have any concern or dispute about the Service, You agree to first try to resolve the dispute informally by contacting the Company.
For European Union (EU) Users
If You are a European Union consumer, you will benefit from any mandatory provisions of the law of the country in which You are resident.
## United States Legal Compliance
You represent and warrant that (i) You are not located in a country that is subject to the United States government embargo, or that has been designated by the United States government as a "terrorist supporting" country, and (ii) You are not listed on any United States government list of prohibited or restricted parties.
## Severability and Waiver
### Severability
If any provision of these Terms is held to be unenforceable or invalid, such provision will be changed and interpreted to accomplish the objectives of such provision to the greatest extent possible under applicable law and the remaining provisions will continue in full force and effect.
### Waiver
Except as provided herein, the failure to exercise a right or to require performance of an obligation under these Terms shall not affect a party's ability to exercise such right or require such performance at any time thereafter nor shall the waiver of a breach constitute a waiver of any subsequent breach.
Translation Interpretation
These Terms and Conditions may have been translated if We have made them available to You on our Service. You agree that the original English text shall prevail in the case of a dispute.
Changes to These Terms and Conditions
We reserve the right, at Our sole discretion, to modify or replace these Terms at any time. If a revision is material We will make reasonable efforts to provide at least 30 days' notice prior to any new terms taking effect. What constitutes a material change will be determined at Our sole discretion.
By continuing to access or use Our Service after those revisions become effective, You agree to be bound by the revised terms. If You do not agree to the new terms, in whole or in part, please stop using the website and the Service.
## Contact Us
If you have any questions about these Terms and Conditions, You can contact us:
- By visiting this page on our website: https://anydev.info

288
frontend/ios/Gemfile.lock Normal file
View File

@ -0,0 +1,288 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
activesupport (5.2.8.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
tzinfo (~> 1.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.1004.0)
aws-sdk-core (3.212.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.95.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.170.1)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
claide (1.1.0)
cocoapods (1.10.2)
addressable (~> 2.6)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.10.2)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 1.4.0, < 2.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.4.0, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
fourflusher (>= 2.3.0, < 3.0)
gh_inspector (~> 1.0)
molinillo (~> 0.6.6)
nap (~> 1.0)
ruby-macho (~> 1.4)
xcodeproj (>= 1.19.0, < 2.0)
cocoapods-core (1.10.2)
activesupport (> 5.0, < 6)
addressable (~> 2.6)
algoliasearch (~> 1.0)
concurrent-ruby (~> 1.1)
fuzzy_match (~> 2.0.4)
nap (~> 1.0)
netrc (~> 0.11)
public_suffix
typhoeus (~> 1.0)
cocoapods-deintegrate (1.0.5)
cocoapods-downloader (1.6.3)
cocoapods-plugins (1.0.0)
nap
cocoapods-search (1.0.1)
cocoapods-trunk (1.6.0)
nap (>= 0.8, < 2.0)
netrc (~> 0.11)
cocoapods-try (1.2.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
concurrent-ruby (1.3.4)
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
escape (0.0.4)
ethon (0.16.0)
ffi (>= 1.15.0)
excon (0.112.0)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.3.1)
fastlane (2.225.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
ffi (1.17.0)
ffi (1.17.0-x86_64-darwin)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.7.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.4.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.7)
domain_name (~> 0.5)
httpclient (2.8.3)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.8.1)
jwt (2.9.3)
base64
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.25.1)
molinillo (0.6.6)
multi_json (1.15.0)
multipart-post (2.4.1)
nanaimo (0.4.0)
nap (1.1.0)
naturally (2.2.1)
netrc (0.11.0)
nkf (0.2.0)
optparse (0.6.0)
os (1.1.4)
plist (3.7.1)
public_suffix (6.0.1)
rake (13.2.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.3.9)
rouge (2.0.7)
ruby-macho (1.4.0)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.5)
signet (0.19.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
thread_safe (0.3.6)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
typhoeus (1.4.1)
ethon (>= 0.9.0)
tzinfo (1.2.11)
thread_safe (~> 0.1)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
x86_64-darwin-23
DEPENDENCIES
cocoapods
fastlane
BUNDLED WITH
2.5.23

View File

@ -15,7 +15,7 @@ platform :ios do
desc "Deploy a new version to closed testing (testflight)"
lane :deploy_beta do
lane :deploy_testing do
build_name = ENV["BUILD_NAME"]
build_number = ENV["BUILD_NUMBER"]
@ -28,22 +28,12 @@ platform :ios do
readonly: true,
)
# replace secrets by real values, the stupid way
sh(
"sed",
"-i",
"",
"s/IOS_GOOGLE_MAPS_API_KEY/#{ENV["IOS_GOOGLE_MAPS_API_KEY"]}/g",
"../Runner/AppDelegate.swift"
)
sh(
"flutter",
"build",
"ipa",
"--release",
"--debug",
"--build-name=#{build_name}",
"--build-number=#{build_number}",
)
@ -81,6 +71,7 @@ platform :ios do
"",
"s/IOS_GOOGLE_MAPS_API_KEY/#{ENV["IOS_GOOGLE_MAPS_API_KEY"]}/g",
"../Runner/AppDelegate.swift"
)
sh(
@ -97,12 +88,12 @@ platform :ios do
skip_build_archive: true,
archive_path: "../build/ios/archive/Runner.xcarchive"
)
upload_to_app_store(
skip_screenshots: true,
skip_metadata: true,
precheck_include_in_app_purchases: false,
submit_for_review: true,
automatic_release: true,
# automatically release the app after review

165
frontend/lib/layout.dart Normal file
View File

@ -0,0 +1,165 @@
import 'package:anyway/pages/settings.dart';
import 'package:flutter/material.dart';
import 'package:anyway/constants.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/modules/trips_saved_list.dart';
import 'package:anyway/utils/load_trips.dart';
import 'package:anyway/pages/new_trip_location.dart';
import 'package:anyway/pages/current_trip.dart';
import 'package:anyway/pages/onboarding.dart';
// BasePage is the scaffold that holds all other pages
// A side drawer is used to switch between pages
class BasePage extends StatefulWidget {
final String mainScreen;
final Trip? trip;
const BasePage({
super.key,
required this.mainScreen,
this.trip,
});
@override
State<BasePage> createState() => _BasePageState();
}
class _BasePageState extends State<BasePage> {
@override
Widget build(BuildContext context) {
Widget currentView = const Text("loading...");
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(
body: Center(
child: Text("Wow, so empty!"),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const NewTripPage()
)
);
},
label: Text("Plan a trip"),
),
);
}
} else {
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(
child: Column(
children: [
Container(
decoration: BoxDecoration(
gradient: APP_GRADIENT,
),
height: 150,
child: Center(
child: Text(
APP_NAME,
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
),
ListTile(
title: const Text('Your Trips'),
leading: const Icon(Icons.map),
selected: widget.mainScreen == "map",
onTap: () {},
trailing: ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const NewTripPage()
)
);
},
child: const Text('New'),
),
),
// Adds a ListView to the drawer. This ensures the user can scroll
// through the options in the drawer if there isn't enough vertical
// space to fit everything.
Expanded(
child: TripsOverview(trips: trips),
),
ElevatedButton(
onPressed: () async {
removeAllTripsFromPrefs();
},
child: const Text('Clear trips'),
),
const Divider(indent: 10, endIndent: 10),
ListTile(
title: const Text('How to use'),
leading: Icon(Icons.help),
selected: widget.mainScreen == "tutorial",
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BasePage(mainScreen: "tutorial")
)
);
},
),
// settings in the bottom of the drawer
ListTile(
title: const Text('Settings'),
leading: const Icon(Icons.settings),
selected: widget.mainScreen == "settings",
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BasePage(mainScreen: "settings")
)
);
},
),
],
),
),
);
}
}

View File

@ -1,121 +0,0 @@
import 'package:flutter/material.dart';
import 'package:anyway/constants.dart';
import 'package:anyway/main.dart';
import 'package:anyway/modules/help_dialog.dart';
import 'package:anyway/modules/trips_saved_list.dart';
import 'package:anyway/pages/onboarding.dart';
import 'package:anyway/pages/current_trip.dart';
import 'package:anyway/pages/settings.dart';
import 'package:anyway/pages/new_trip_location.dart';
mixin ScaffoldLayout<T extends StatefulWidget> on State<T> {
Widget mainScaffold(
BuildContext context,
{
Widget child = const Text("emptiness"),
Widget title = const Text(APP_NAME),
List<String> helpTexts = const []
}
) {
return Scaffold(
appBar: AppBar(
title: title,
actions: [
IconButton(
icon: const Icon(Icons.help),
tooltip: 'Help',
onPressed: () {
if (helpTexts.isNotEmpty) {
helpDialog(context, helpTexts[0], helpTexts[1]);
}
}
),
],
),
body: Center(child: child),
drawer: Drawer(
child: Column(
children: [
Container(
decoration: const BoxDecoration(
gradient: APP_GRADIENT,
),
height: 150,
child: const Center(
child: Text(
APP_NAME,
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
),
ListTile(
title: const Text('Your Trips'),
leading: const Icon(Icons.map),
selected: widget is TripPage,
onTap: () {},
trailing: ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const NewTripPage()
)
);
},
child: const Text('New'),
),
),
// Adds a ListView to the drawer. This ensures the user can scroll
// through the options in the drawer if there isn't enough vertical
// space to fit everything.
Expanded(
child: TripsOverview(trips: savedTrips),
),
ElevatedButton(
onPressed: () async {
savedTrips.clearTrips();
},
child: const Text('Clear trips'),
),
const Divider(indent: 10, endIndent: 10),
ListTile(
title: const Text('How to use'),
leading: const Icon(Icons.help),
selected: widget is OnboardingPage,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const OnboardingPage()
)
);
},
),
// settings in the bottom of the drawer
ListTile(
title: const Text('Settings'),
leading: const Icon(Icons.settings),
selected: widget is SettingsPage,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => SettingsPage()
)
);
},
),
],
),
),
);
}
}

View File

@ -1,26 +1,22 @@
import 'package:flutter/material.dart';
import 'package:anyway/constants.dart';
import 'package:anyway/utils/get_first_page.dart';
import 'package:anyway/utils/load_trips.dart';
import 'package:anyway/layout.dart';
void main() => runApp(const App());
// Some global variables
final GlobalKey<ScaffoldMessengerState> rootScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
final SavedTrips savedTrips = SavedTrips();
// the list of saved trips is then populated implicitly by getFirstPage()
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: APP_NAME,
home: getFirstPage(),
theme: APP_THEME,
scaffoldMessengerKey: rootScaffoldMessengerKey
);
Widget build(BuildContext context) {
return MaterialApp(
title: APP_NAME,
home: BasePage(mainScreen: "map"),
theme: APP_THEME,
scaffoldMessengerKey: rootScaffoldMessengerKey
);
}
}

View File

@ -16,27 +16,22 @@ class CurrentTripErrorMessage extends StatefulWidget {
class _CurrentTripErrorMessageState extends State<CurrentTripErrorMessage> {
@override
Widget build(BuildContext context) => Center(
child: Column(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"😢",
style: TextStyle(
fontSize: 40,
),
const Icon(
Icons.error_outline,
color: Colors.red,
size: 50,
),
const Padding(
padding: EdgeInsets.only(left: 10),
),
const SizedBox(height: 10),
AutoSizeText(
// at this point the trip is guaranteed to have an error message
widget.trip.errorDescription!,
maxLines: 30,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
'Error: ${widget.trip.errorDescription}',
maxLines: 3,
),
],
),
)
);
}

View File

@ -1,18 +1,21 @@
import 'dart:developer';
import 'package:anyway/modules/step_between_landmarks.dart';
import 'package:flutter/material.dart';
import 'package:anyway/modules/landmark_card.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/modules/step_between_landmarks.dart';
import 'package:anyway/modules/landmark_card.dart';
import 'package:anyway/main.dart';
// Returns a list of widgets that represent the landmarks matching the given selector
List<Widget> landmarksList(Trip trip, {required bool Function(Landmark) selector}) {
List<Widget> landmarksList(Trip trip) {
log("Trip ${trip.uuid} ${trip.landmarks.length} landmarks");
List<Widget> children = [];
log("Trip ${trip.uuid} ${trip.landmarks.length} landmarks");
if (trip.landmarks.isEmpty || trip.landmarks.length <= 1 && trip.landmarks.first.type == typeStart ) {
children.add(
const Text("No landmarks in this trip"),
@ -21,24 +24,40 @@ List<Widget> landmarksList(Trip trip, {required bool Function(Landmark) selector
}
for (Landmark landmark in trip.landmarks) {
if (selector(landmark)) {
children.add(
LandmarkCard(landmark, trip),
);
children.add(
Dismissible(
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);
if (!landmark.visited) {
Landmark? nextLandmark = landmark.next;
while (nextLandmark != null && nextLandmark.visited) {
nextLandmark = nextLandmark.next;
}
if (nextLandmark != null) {
children.add(
StepBetweenLandmarks(current: landmark, next: nextLandmark!)
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) {
children.add(
StepBetweenLandmarks(current: landmark, next: landmark.next!)
);
}
}
return children;
}

View File

@ -1,21 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:anyway/structs/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 {
final Trip trip;
const CurrentTripLoadingIndicator({
@ -27,123 +15,46 @@ class CurrentTripLoadingIndicator extends StatefulWidget {
State<CurrentTripLoadingIndicator> createState() => _CurrentTripLoadingIndicatorState();
}
class _CurrentTripLoadingIndicatorState extends State<CurrentTripLoadingIndicator> {
@override
Widget build(BuildContext context) => Stack(
fit: StackFit.expand,
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
const Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(bottom: 12),
child: StatusText(),
)
)
],
);
}
Widget build(BuildContext context) => Center(
child: FutureBuilder(
future: widget.trip.cityName,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
Widget greeter;
Widget loadingIndicator = const Padding(
padding: EdgeInsets.only(top: 10),
child: CircularProgressIndicator()
);
// 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(const 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) {
Widget greeter;
if (snapshot.hasData) {
greeter = AnimatedDotsText(
baseText: 'Creating your trip to ${snapshot.data}',
style: greeterStyle,
);
} else if (snapshot.hasError) {
// the exact error is shown in the central part of the trip overview. No need to show it here
greeter = Text(
'Error while loading trip.',
style: greeterStyle,
);
} else {
greeter = AnimatedDotsText(
baseText: 'Creating your trip',
style: greeterStyle,
);
}
return greeter;
}
);
class AnimatedDotsText extends StatefulWidget {
final String baseText;
final TextStyle style;
const AnimatedDotsText({
Key? key,
required this.baseText,
required this.style,
}) : super(key: key);
@override
_AnimatedDotsTextState createState() => _AnimatedDotsTextState();
}
class _AnimatedDotsTextState extends State<AnimatedDotsText> {
int dotCount = 0;
@override
void initState() {
super.initState();
Timer.periodic(const Duration(seconds: 1), (timer) {
if (mounted) {
setState(() {
dotCount = (dotCount + 1) % 4;
// show up to 3 dots
});
} else {
timer.cancel();
if (snapshot.hasData) {
greeter = AutoSizeText(
maxLines: 1,
'Generating your trip to ${snapshot.data}...',
style: greeterStyle,
);
} else if (snapshot.hasError) {
// the exact error is shown in the central part of the trip overview. No need to show it here
greeter = AutoSizeText(
maxLines: 1,
'Error while loading trip.',
style: greeterStyle,
);
} else {
greeter = AutoSizeText(
maxLines: 1,
'Generating your trip...',
style: greeterStyle,
);
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
greeter,
loadingIndicator,
],
);
}
});
}
@override
Widget build(BuildContext context) {
String dots = '.' * dotCount;
return AutoSizeText(
'${widget.baseText}$dots',
style: widget.style,
maxLines: 2,
);
}
}
)
);
}

View File

@ -1,19 +1,22 @@
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:widget_to_marker/widget_to_marker.dart';
import 'package:anyway/constants.dart';
import 'package:anyway/modules/landmark_map_marker.dart';
import 'package:flutter/material.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/modules/landmark_map_marker.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:widget_to_marker/widget_to_marker.dart';
class CurrentTripMap extends StatefulWidget {
final Trip? trip;
CurrentTripMap({this.trip});
CurrentTripMap({
this.trip
});
@override
State<CurrentTripMap> createState() => _CurrentTripMapState();
@ -27,23 +30,7 @@ class _CurrentTripMapState extends State<CurrentTripMap> {
zoom: 11.0,
);
Set<Marker> mapMarkers = <Marker>{};
Set<Polyline> mapPolylines = <Polyline>{};
@override
void initState() {
super.initState();
widget.trip?.addListener(setMapMarkers);
widget.trip?.addListener(setMapRoute);
}
@override
void dispose() {
widget.trip?.removeListener(setMapMarkers);
widget.trip?.removeListener(setMapRoute);
super.dispose();
}
void _onMapCreated(GoogleMapController controller) async {
mapController = controller;
@ -53,70 +40,38 @@ class _CurrentTripMapState extends State<CurrentTripMap> {
controller.moveCamera(update);
}
setMapMarkers();
setMapRoute();
}
void _onCameraIdle() {
// print(mapController.getLatLng(ScreenCoordinate(x: 0, y: 0)));
}
void setMapMarkers() async {
Iterator<(int, Landmark)> it = (widget.trip?.landmarks.toList() ?? []).indexed.iterator;
while (it.moveNext()) {
int i = it.current.$1;
Landmark landmark = it.current.$2;
MarkerId markerId = MarkerId("${landmark.uuid} - ${landmark.visited}");
List<double> location = landmark.location;
// only create a new marker, if there is no marker for this landmark
if (!mapMarkers.any((Marker marker) => marker.markerId == markerId)) {
Marker marker = Marker(
markerId: markerId,
position: LatLng(location[0], location[1]),
icon: await ThemedMarker(landmark: landmark, position: i).toBitmapDescriptor(
logicalSize: const Size(150, 150),
imageSize: const Size(150, 150),
)
);
setState(() {
mapMarkers.add(marker);
});
}
}
}
void setMapRoute() async {
List<Landmark> landmarks = widget.trip?.landmarks.toList() ?? [];
Set<Polyline> polyLines = <Polyline>{};
if (landmarks.length < 2) {
return;
Set<Marker> newMarkers = <Marker>{};
for (int i = 0; i < landmarks.length; i++) {
Landmark landmark = landmarks[i];
List<double> location = landmark.location;
Marker marker = Marker(
markerId: MarkerId(landmark.uuid),
position: LatLng(location[0], location[1]),
icon: await ThemedMarker(landmark: landmark, position: i).toBitmapDescriptor(
logicalSize: const Size(150, 150),
imageSize: const Size(150, 150)
),
);
newMarkers.add(marker);
}
for (Landmark landmark in landmarks) {
if (landmark.next != null) {
List<LatLng> step = [
LatLng(landmark.location[0], landmark.location[1]),
LatLng(landmark.next!.location[0], landmark.next!.location[1])
];
Polyline stepLine = Polyline(
polylineId: PolylineId('step-${landmark.uuid}'),
points: step,
color: landmark.visited || (landmark.next?.visited ?? false) ? Colors.grey : PRIMARY_COLOR,
width: 5
);
polyLines.add(stepLine);
}
}
setState(() {
mapPolylines = polyLines;
mapMarkers = newMarkers;
});
}
@override
Widget build(BuildContext context) {
widget.trip?.addListener(setMapMarkers);
Future<SharedPreferences> preferences = SharedPreferences.getInstance();
return FutureBuilder(
@ -129,7 +84,7 @@ class _CurrentTripMapState extends State<CurrentTripMap> {
} else {
return const CircularProgressIndicator();
}
},
}
);
}
@ -138,8 +93,8 @@ class _CurrentTripMapState extends State<CurrentTripMap> {
onMapCreated: _onMapCreated,
initialCameraPosition: _cameraPosition,
onCameraIdle: _onCameraIdle,
// onLongPress: ,
markers: mapMarkers,
polylines: mapPolylines,
cloudMapId: MAP_ID,
mapToolbarEnabled: false,
zoomControlsEnabled: false,
@ -147,4 +102,5 @@ class _CurrentTripMapState extends State<CurrentTripMap> {
myLocationButtonEnabled: false,
);
}
}

View File

@ -1,14 +1,9 @@
import 'package:anyway/pages/current_trip.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:anyway/constants.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/modules/current_trip_error_message.dart';
import 'package:anyway/modules/current_trip_loading_indicator.dart';
import 'package:flutter/material.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/modules/current_trip_summary.dart';
import 'package:anyway/modules/current_trip_save_button.dart';
import 'package:anyway/modules/current_trip_landmarks_list.dart';
@ -36,93 +31,52 @@ class _CurrentTripPanelState extends State<CurrentTripPanel> {
listenable: widget.trip,
builder: (context, child) {
if (widget.trip.uuid == 'error') {
return ListView(
controller: widget.controller,
padding: const EdgeInsets.only(top: 10, left: 10, right: 10, bottom: 30),
children: [
SizedBox(
return Align(
alignment: Alignment.topCenter,
child: SizedBox(
// reuse the exact same height as the panel has when collapsed
// this way the greeter will be centered when the panel is collapsed
// note that we need to account for the padding above
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 10,
child: Center(child:
AutoSizeText(
maxLines: 1,
'Error',
style: greeterStyle
)
),
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20,
child: CurrentTripErrorMessage(trip: widget.trip)
),
CurrentTripErrorMessage(trip: widget.trip),
],
);
);
} else if (widget.trip.uuid == 'pending') {
return Align(
alignment: Alignment.topCenter,
child: SizedBox(
// reuse the exact same height as the panel has when collapsed
// this way the greeter will be centered when the panel is collapsed
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT,
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20,
child: CurrentTripLoadingIndicator(trip: widget.trip),
),
);
} else {
return ListView(
controller: widget.controller,
padding: const EdgeInsets.only(top: 10, left: 10, right: 10, bottom: 30),
padding: const EdgeInsets.only(bottom: 30),
children: [
SizedBox(
// reuse the exact same height as the panel has when collapsed
// this way the greeter will be centered when the panel is collapsed
// note that we need to account for the padding above
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 10,
height: MediaQuery.of(context).size.height * TRIP_PANEL_MIN_HEIGHT - 20,
child: CurrentTripGreeter(trip: widget.trip),
),
Padding(
padding: EdgeInsets.all(10),
child: Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
CurrentTripSummary(trip: widget.trip),
if (widget.trip.landmarks.where((Landmark landmark) => landmark.visited).isNotEmpty)
ExpansionTile(
leading: const Icon(Icons.location_on),
title: const Text('Visited Landmarks (tap to expand)'),
children: [
...landmarksList(widget.trip, selector: (Landmark landmark) => landmark.visited),
],
visualDensity: VisualDensity.compact,
collapsedShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
],
),
),
),
const Padding(padding: EdgeInsets.only(top: 10)),
// CurrentTripSummary(trip: widget.trip),
// const Divider(),
...landmarksList(widget.trip),
const Padding(padding: EdgeInsets.only(top: 10)),
// upcoming landmarks
...landmarksList(widget.trip, selector: (Landmark landmark) => landmark.visited == false),
const Padding(padding: EdgeInsets.only(top: 10)),
Center(child: saveButton(trip: widget.trip)),
Center(child: saveButton(widget.trip)),
],
);
}
}
);
}
}
}

View File

@ -1,52 +1,41 @@
import 'package:flutter/material.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:anyway/main.dart';
import 'package:anyway/structs/trip.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
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 {
savedTrips.addTrip(widget.trip);
rootScaffoldMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text('Trip saved'),
duration: Duration(seconds: 2),
dismissDirection: DismissDirection.horizontal
)
);
},
child: SizedBox(
width: 100,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.save,
),
Expanded(
child: Padding(
padding: EdgeInsets.only(left: 10, top: 5, bottom: 5, right: 5),
child: AutoSizeText(
'Save trip',
maxLines: 2,
),
),
),
],
),
Widget saveButton(Trip trip) => ElevatedButton(
onPressed: () async {
SharedPreferences prefs = await SharedPreferences.getInstance();
trip.toPrefs(prefs);
rootScaffoldMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text('Trip saved'),
duration: Duration(seconds: 2),
dismissDirection: DismissDirection.horizontal
)
);
}
}
},
child: SizedBox(
width: 100,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.save,
),
Expanded(
child: Padding(
padding: EdgeInsets.only(left: 10, top: 5, bottom: 5, right: 5),
child: AutoSizeText(
'Save trip',
maxLines: 2,
),
),
),
],
),
)
);

View File

@ -1,7 +1,6 @@
import 'package:anyway/structs/trip.dart';
import 'package:flutter/material.dart';
class CurrentTripSummary extends StatefulWidget {
final Trip trip;
const CurrentTripSummary({
@ -15,29 +14,18 @@ class CurrentTripSummary extends StatefulWidget {
class _CurrentTripSummaryState extends State<CurrentTripSummary> {
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: widget.trip,
builder: (context, child) => Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.flag, size: 20),
const Padding(padding: EdgeInsets.only(right: 10)),
Text('${widget.trip.landmarks.length} stops', style: Theme.of(context).textTheme.bodyLarge),
]
),
Row(
children: [
const Icon(Icons.hourglass_bottom_rounded, size: 20),
const Padding(padding: EdgeInsets.only(right: 10)),
Text('${widget.trip.totalTime.inHours}h ${widget.trip.totalTime.inMinutes.remainder(60)}min', style: Theme.of(context).textTheme.bodyLarge),
]
),
],
)
)
);
}
Widget build(BuildContext context) {
return Column(
children: [
Text('Summary'),
// Text('Start: ${widget.trip.start}'),
// Text('End: ${widget.trip.end}'),
Text('Total duration: ${widget.trip.totalTime}'),
Text('Total distance: ${widget.trip.totalTime}'),
// Text('Fuel: ${widget.trip.fuel}'),
// Text('Cost: ${widget.trip.cost}'),
],
);
}
}

View File

@ -1,25 +0,0 @@
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();
},
),
],
);
},
);
}

View File

@ -1,23 +1,13 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:anyway/constants.dart';
import 'package:anyway/main.dart';
import 'package:anyway/structs/trip.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:anyway/structs/landmark.dart';
class LandmarkCard extends StatefulWidget {
final Landmark landmark;
final Trip parentTrip;
LandmarkCard(
this.landmark,
this.parentTrip,
);
LandmarkCard(this.landmark);
@override
_LandmarkCardState createState() => _LandmarkCardState();
@ -26,184 +16,112 @@ class LandmarkCard extends StatefulWidget {
class _LandmarkCardState extends State<LandmarkCard> {
@override
Widget build(BuildContext context) {
Widget build(BuildContext context) {
ThemeData theme = Theme.of(context);
return Container(
constraints: BoxConstraints(
// express the max height in terms text lines
maxHeight: 7 * (Theme.of(context).textTheme.titleMedium!.fontSize! + 10),
),
height: 160,
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
),
elevation: 5,
clipBehavior: Clip.antiAliasWithSaveLayer,
// if the image is available, display it on the left side of the card, otherwise only display the text
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image and landmark "type" on the left
AspectRatio(
aspectRatio: 3 / 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.landmark.imageURL != null && widget.landmark.imageURL!.isNotEmpty)
Expanded(
child: CachedNetworkImage(
imageUrl: widget.landmark.imageURL!,
placeholder: (context, url) => const Center(child: CircularProgressIndicator()),
errorWidget: (context, url, error) => imagePlaceholder(widget.landmark),
fit: BoxFit.cover
)
)
else
imagePlaceholder(widget.landmark),
if (widget.landmark.type != typeStart && widget.landmark.type != typeFinish)
Container(
color: PRIMARY_COLOR,
child: Center(
child: Padding(
padding: EdgeInsets.all(5),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.timer_outlined, size: 16),
Text("${widget.landmark.duration?.inMinutes} minutes"),
],
)
)
),
)
],
)
Container( // the image on the left
// inherit the height of the parent container
height: double.infinity,
// force a fixed width
width: 160,
child: CachedNetworkImage(
imageUrl: widget.landmark.imageURL ?? '',
placeholder: (context, url) => Center(child: CircularProgressIndicator()),
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,
),
),
// Main information, useful buttons on the right
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Flexible(
child: Padding(
padding: EdgeInsets.all(10),
child: Column(
children: [
Row(
children: [
Text(
widget.landmark.name,
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
if (widget.landmark.nameEN != null)
Text(
widget.landmark.nameEN!,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
Flexible(
child: Text(
widget.landmark.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
maxLines: 2,
),
]
),
),
// fill the vspace
const Spacer(),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.only(left: 5, right: 5, bottom: 10),
// the scroll view should be flush once the buttons are scrolled to the left
// but initially there should be some padding
child: Wrap(
spacing: 10,
// show the type, the website, and the wikipedia link as buttons/labels in a row
children: [
doneToggleButton(),
if (widget.landmark.websiteURL != null)
websiteButton(),
optionsButton()
)
],
),
),
],
)
)
if (widget.landmark.nameEN != null)
Row(
children: [
Flexible(
child: Text(
widget.landmark.nameEN!,
style: const TextStyle(
fontSize: 16,
),
maxLines: 1,
),
)
],
),
SingleChildScrollView(
// allows the buttons to be scrolled
scrollDirection: Axis.horizontal,
child: Wrap(
spacing: 10,
// show the type, the website, and the wikipedia link as buttons/labels in a row
children: [
TextButton.icon(
onPressed: () {},
icon: widget.landmark.type.icon,
label: Text(widget.landmark.type.name),
),
if (widget.landmark.duration != null && widget.landmark.duration!.inMinutes > 0)
TextButton.icon(
onPressed: () {},
icon: Icon(Icons.hourglass_bottom),
label: Text('${widget.landmark.duration!.inMinutes} minutes'),
),
if (widget.landmark.websiteURL != null)
TextButton.icon(
onPressed: () async {
// open a browser with the website link
await launchUrl(Uri.parse(widget.landmark.websiteURL!));
},
icon: Icon(Icons.link),
label: Text('Website'),
),
if (widget.landmark.wikipediaURL != null)
TextButton.icon(
onPressed: () async {
// open a browser with the wikipedia link
await launchUrl(Uri.parse(widget.landmark.wikipediaURL!));
},
icon: Icon(Icons.book),
label: Text('Wikipedia'),
),
],
),
),
],
),
),
),
],
)
)
);
}
Widget doneToggleButton() {
return TextButton.icon(
onPressed: () async {
widget.landmark.visited = !widget.landmark.visited;
widget.parentTrip.notifyUpdate();
},
icon: Icon(widget.landmark.visited ? Icons.undo_sharp : Icons.check),
label: Text(widget.landmark.visited ? "Add to overview" : "Done!"),
);
}
Widget websiteButton () => TextButton.icon(
onPressed: () async {
// open a browser with the website link
await launchUrl(Uri.parse(widget.landmark.websiteURL!));
},
icon: const Icon(Icons.link),
label: const Text('Website'),
);
Widget optionsButton () => PopupMenuButton(
icon: const Icon(Icons.settings),
style: TextButtonTheme.of(context).style,
itemBuilder: (context) => [
PopupMenuItem(
child: ListTile(
leading: const Icon(Icons.delete),
title: const Text('Delete'),
onTap: () async {
widget.parentTrip.removeLandmark(widget.landmark);
rootScaffoldMessengerKey.currentState!.showSnackBar(
SnackBar(content: Text("${widget.landmark.name} won't be shown again"))
);
},
),
),
PopupMenuItem(
child: ListTile(
leading: const Icon(Icons.star),
title: const Text('Favorite'),
onTap: () async {
rootScaffoldMessengerKey.currentState!.showSnackBar(
SnackBar(content: Text("Not implemented yet"))
);
},
),
),
],
);
);
}
}
Widget imagePlaceholder (Landmark landmark) => Expanded(
child:
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [GRADIENT_START, GRADIENT_END],
),
),
child: Center(
child: Icon(landmark.type.icon.icon, size: 50),
),
),
);

View File

@ -40,7 +40,7 @@ class ThemedMarker extends StatelessWidget {
children: [
Container(
decoration: BoxDecoration(
gradient: landmark.visited ? LinearGradient(colors: [Colors.grey, Colors.white]) : APP_GRADIENT,
gradient: APP_GRADIENT,
shape: BoxShape.circle,
border: Border.all(color: Colors.black, width: 5),
),

View File

@ -1,5 +1,5 @@
import 'package:anyway/layout.dart';
import 'package:anyway/main.dart';
import 'package:anyway/pages/current_trip.dart';
import 'package:anyway/structs/preferences.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/utils/fetch_trip.dart';
@ -46,20 +46,21 @@ class _NewTripButtonState extends State<NewTripButton> {
UserPreferences preferences = widget.preferences;
if (preferences.nature.value == 0 && preferences.shopping.value == 0 && preferences.sightseeing.value == 0){
rootScaffoldMessengerKey.currentState!.showSnackBar(
const SnackBar(content: Text("Please specify at least one preference"))
SnackBar(content: Text("Please specify at least one preference"))
);
} else if (preferences.maxTime.value == 0){
rootScaffoldMessengerKey.currentState!.showSnackBar(
const SnackBar(content: Text("Please choose a longer duration"))
SnackBar(content: Text("Please choose a longer duration"))
);
} else {
Trip trip = widget.trip;
fetchTrip(trip, widget.preferences);
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TripPage(trip: trip)
builder: (context) => BasePage(mainScreen: "map", trip: trip)
)
);
}
}
}

View File

@ -9,15 +9,6 @@ import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.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 {
Future<SharedPreferences> prefs = SharedPreferences.getInstance();
Trip trip;
@ -36,35 +27,26 @@ class _NewTripLocationSearchState extends State<NewTripLocationSearch> {
setTripLocation (String query) async {
List<Location> locations = [];
Location startLocation;
log('Searching for: $query');
if (GeocodingPlatform.instance != null) {
locations.addAll(await locationFromAddress(query));
try{
locations = await locationFromAddress(query);
} catch (e) {
log('No results found for: $query : $e');
}
if (locations.isNotEmpty) {
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(),
Location location = locations.first;
widget.trip.landmarks.clear();
widget.trip.addLandmark(
Landmark(
uuid: 'pending',
name: query,
location: [location.latitude, location.longitude],
type: typeStart
)
);
}
widget.trip.landmarks.clear();
widget.trip.addLandmark(
Landmark(
uuid: 'pending',
name: query,
location: [startLocation.latitude, startLocation.longitude],
type: typeStart
)
);
}
late Widget locationSearchBar = SearchBar(

View File

@ -1,14 +1,14 @@
// A map that allows the user to select a location for a new trip.
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:widget_to_marker/widget_to_marker.dart';
import 'dart:developer';
import 'package:anyway/constants.dart';
import 'package:anyway/structs/trip.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:anyway/modules/landmark_map_marker.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:anyway/structs/trip.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:widget_to_marker/widget_to_marker.dart';
class NewTripMap extends StatefulWidget {
@ -26,10 +26,11 @@ class _NewTripMapState extends State<NewTripMap> {
target: LatLng(48.8566, 2.3522),
zoom: 11.0,
);
GoogleMapController? _mapController;
late GoogleMapController _mapController;
final Set<Marker> _markers = <Marker>{};
_onLongPress(LatLng location) {
log('Long press: $location');
widget.trip.landmarks.clear();
widget.trip.addLandmark(
Landmark(
@ -55,15 +56,11 @@ class _NewTripMapState extends State<NewTripMap> {
),
)
);
// check if the controller is ready
if (_mapController != null) {
_mapController!.animateCamera(
CameraUpdate.newLatLng(
LatLng(landmark.location[0], landmark.location[1])
)
);
}
_mapController.moveCamera(
CameraUpdate.newLatLng(
LatLng(landmark.location[0], landmark.location[1])
)
);
setState(() {});
}
}
@ -78,7 +75,6 @@ class _NewTripMapState extends State<NewTripMap> {
widget.trip.addListener(updateTripDetails);
Future<SharedPreferences> preferences = SharedPreferences.getInstance();
return FutureBuilder(
future: preferences,
builder: (context, snapshot) {

View File

@ -1,118 +0,0 @@
import 'package:anyway/structs/agreement.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:anyway/modules/onboarding_card.dart';
class OnboardingAgreementCard extends StatefulWidget {
final String title;
final String description;
final String imagePath;
final String agreementTextPath;
final ValueChanged<bool> onAgreementChanged;
OnboardingAgreementCard({
super.key,
required this.title,
required this.description,
required this.imagePath,
required this.agreementTextPath,
required this.onAgreementChanged
});
@override
State<OnboardingAgreementCard> createState() => _OnboardingAgreementCardState();
}
class _OnboardingAgreementCardState extends State<OnboardingAgreementCard> {
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OnboardingCard(title: widget.title, description: widget.description, imagePath: widget.imagePath),
Padding(padding: EdgeInsets.only(top: 20)),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// The checkbox of the agreement
FutureBuilder(
future: getAgreement(),
builder: (context, snapshot) {
bool agreed = false;
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasData) {
Agreement agreement = snapshot.data!;
agreed = agreement.agreed;
} else {
agreed = false;
}
} else {
agreed = false;
}
return Checkbox(
value: agreed,
onChanged: (value) {
setState(() {
widget.onAgreementChanged(value!);
});
saveAgreement(value!);
},
);
},
),
// The text of the agreement
Text(
"I agree to the ",
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Colors.white,
),
),
// The clickable text of the agreement that shows the agreement text
GestureDetector(
onTap: () {
// show a dialog with the agreement text
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
scrollable: true,
content: FutureBuilder(
future: DefaultAssetBundle.of(context).loadString(widget.agreementTextPath),
builder: (context, snapshot) {
if (snapshot.hasData) {
return MarkdownBody(
data: snapshot.data.toString(),
);
} else {
return CircularProgressIndicator();
}
},
)
);
}
);
},
child: Text(
"Terms of Service (click to view)",
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
);
}
}

View File

@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class OnboardingCard extends StatelessWidget {
final String title;
final String description;
final String imagePath;
int index;
String title;
String description;
String imagePath;
const OnboardingCard({
OnboardingCard({
required this.index,
required this.title,
required this.description,
required this.imagePath,
@ -14,32 +16,41 @@ class OnboardingCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineLarge!.copyWith(
color: Colors.white,
Color baseColor = Theme.of(context).colorScheme.secondary;
// have a different color for each card, incrementing the hue
Color currentColor = baseColor.withAlpha(baseColor.alpha - index * 30);
return Container(
color: currentColor,
alignment: Alignment.center,
child: Padding(
padding: EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
Padding(padding: EdgeInsets.only(top: 20)),
SvgPicture.asset(
imagePath,
height: 200,
),
Padding(padding: EdgeInsets.only(top: 20)),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Colors.white,
Padding(padding: EdgeInsets.only(top: 20)),
SvgPicture.asset(
imagePath,
height: 200,
),
),
]
),
Padding(padding: EdgeInsets.only(top: 20)),
Text(
description,
style: TextStyle(
fontSize: 16,
),
),
]
),
)
);
}
}
}

View File

@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:anyway/structs/landmark.dart';
import 'package:flutter/material.dart';
import 'package:anyway/modules/map_chooser.dart';
class StepBetweenLandmarks extends StatefulWidget {
final Landmark current;
final Landmark next;
@ -21,23 +19,12 @@ class StepBetweenLandmarks extends StatefulWidget {
class _StepBetweenLandmarksState extends State<StepBetweenLandmarks> {
@override
Widget build(BuildContext context) {
int? time = widget.current.tripTime?.inMinutes;
// since landmarks might have been marked as visited, the next landmark might not be the immediate next in the list
// => the precomputed trip time is not valid anymore
if (widget.current.next != widget.next) {
time = null;
}
// round 0 travel time to 1 minute
if (time != null && time < 1) {
time = 1;
}
int timeRounded = 5 * ((widget.current.tripTime?.inMinutes ?? 0) ~/ 5);
// ~/ is integer division (rounding)
return Container(
margin: const EdgeInsets.all(10),
padding: const EdgeInsets.all(10),
decoration: const BoxDecoration(
margin: EdgeInsets.all(10),
padding: EdgeInsets.all(10),
decoration: BoxDecoration(
border: Border(
left: BorderSide(width: 3.0, color: Colors.black),
),
@ -46,22 +33,21 @@ class _StepBetweenLandmarksState extends State<StepBetweenLandmarks> {
children: [
Column(
children: [
const Icon(Icons.directions_walk),
Text(
time == null ? "" : "$time min",
style: const TextStyle(fontSize: 10)
),
Icon(Icons.directions_walk),
Text("~$timeRounded min", style: TextStyle(fontSize: 10)),
],
),
const Spacer(),
ElevatedButton.icon(
Spacer(),
ElevatedButton(
onPressed: () async {
showMapChooser(context, widget.current, widget.next);
},
icon: const Icon(Icons.directions),
label: const Text("Directions"),
child: Row(
children: [
Icon(Icons.directions),
Text("Directions"),
],
),
)
],
),

View File

@ -1,12 +1,11 @@
import 'package:anyway/pages/current_trip.dart';
import 'package:anyway/utils/load_trips.dart';
import 'package:flutter/material.dart';
import 'package:anyway/layout.dart';
import 'package:anyway/structs/trip.dart';
class TripsOverview extends StatefulWidget {
final SavedTrips trips;
final Future<List<Trip>> trips;
const TripsOverview({
super.key,
required this.trips,
@ -17,34 +16,50 @@ class TripsOverview extends StatefulWidget {
}
class _TripsOverviewState extends State<TripsOverview> {
Widget listBuild (BuildContext context, SavedTrips trips) {
Widget listBuild (BuildContext context, AsyncSnapshot<List<Trip>> snapshot) {
List<Widget> children;
List<Trip> items = trips.trips;
children = List<Widget>.generate(items.length, (index) {
Trip trip = items[index];
return ListTile(
title: FutureBuilder(
future: trip.cityName,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
if (snapshot.hasData) {
return Text("Trip to ${snapshot.data}");
} else if (snapshot.hasError) {
return Text("Error: ${snapshot.error}");
} else {
return const Text("Trip to ...");
}
if (snapshot.hasData) {
children = List<Widget>.generate(snapshot.data!.length, (index) {
Trip trip = snapshot.data![index];
return ListTile(
title: FutureBuilder(
future: trip.cityName,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
if (snapshot.hasData) {
return Text("Trip to ${snapshot.data}");
} else if (snapshot.hasError) {
return Text("Error: ${snapshot.error}");
} else {
return const Text("Trip to ...");
}
},
),
leading: Icon(Icons.pin_drop),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BasePage(mainScreen: "map", trip: trip)
)
);
},
);
});
} else if (snapshot.hasError) {
children = [
const Icon(
Icons.error_outline,
color: Colors.red,
size: 60,
),
leading: Icon(Icons.pin_drop),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TripPage(trip: trip)
)
);
},
);
});
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text('Error: ${snapshot.error}'),
),
];
} else {
children = [Center(child: CircularProgressIndicator())];
}
return ListView(
children: children,
@ -54,11 +69,9 @@ class _TripsOverviewState extends State<TripsOverview> {
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.trips,
builder: (BuildContext context, Widget? child) {
return listBuild(context, widget.trips);
}
return FutureBuilder(
future: widget.trips,
builder: listBuild,
);
}
}
}

View File

@ -1,5 +1,4 @@
import 'package:anyway/constants.dart';
import 'package:anyway/layouts/scaffold.dart';
import 'package:flutter/material.dart';
import 'package:sliding_up_panel/sliding_up_panel.dart';
@ -11,7 +10,7 @@ final Shader textGradient = APP_GRADIENT.createShader(Rect.fromLTWH(0.0, 0.0, 20
TextStyle greeterStyle = TextStyle(
foreground: Paint()..shader = textGradient,
fontWeight: FontWeight.bold,
fontSize: 25
fontSize: 26
);
@ -28,13 +27,11 @@ class TripPage extends StatefulWidget {
class _TripPageState extends State<TripPage> with ScaffoldLayout{
class _TripPageState extends State<TripPage> {
@override
Widget build(BuildContext context) {
return mainScaffold(
context,
child: SlidingUpPanel(
return SlidingUpPanel(
// use panelBuilder instead of panel so that we can reuse the scrollcontroller for the listview
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
@ -44,7 +41,7 @@ class _TripPageState extends State<TripPage> with ScaffoldLayout{
maxHeight: MediaQuery.of(context).size.height * TRIP_PANEL_MAX_HEIGHT,
// 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
// 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
// panelSnapping: false
borderRadius: const BorderRadius.only(topLeft: Radius.circular(25), topRight: Radius.circular(25)),
@ -55,17 +52,6 @@ class _TripPageState extends State<TripPage> with ScaffoldLayout{
color: Colors.black,
)
],
),
title: FutureBuilder(
future: widget.trip.cityName,
builder: (context, snapshot) => Text(
'Trip to ${snapshot.hasData ? snapshot.data! : "..."}',
)
),
helpTexts: [
'Current trip',
'You can see and edit your current trip here. Swipe up from the bottom to see a detailed view of the recommendations.'
],
);
}
}

View File

@ -1,4 +1,4 @@
import 'package:anyway/layouts/scaffold.dart';
import 'package:anyway/modules/new_trip_button.dart';
import 'package:anyway/modules/new_trip_options_button.dart';
import 'package:flutter/material.dart';
@ -14,34 +14,28 @@ class NewTripPage extends StatefulWidget {
_NewTripPageState createState() => _NewTripPageState();
}
class _NewTripPageState extends State<NewTripPage> with ScaffoldLayout {
class _NewTripPageState extends State<NewTripPage> {
final TextEditingController latController = TextEditingController();
final TextEditingController lonController = TextEditingController();
Trip trip = Trip();
@override
Widget build(BuildContext context) {
// floating search bar and map as a background
return mainScaffold(
context,
child: Scaffold(
body: Stack(
children: [
NewTripMap(trip),
Padding(
padding: EdgeInsets.all(15),
child: NewTripLocationSearch(trip),
),
],
),
floatingActionButton: NewTripOptionsButton(trip: trip),
return Scaffold(
appBar: AppBar(
title: const Text('New 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."
],
body: Stack(
children: [
NewTripMap(trip),
Padding(
padding: EdgeInsets.all(15),
child: NewTripLocationSearch(trip),
),
],
),
floatingActionButton: NewTripOptionsButton(trip: trip),
);
}
}

Some files were not shown because too many files have changed in this diff Show More