Compare commits

...

77 Commits

Author SHA1 Message Date
b995123a36 added docs 2021-04-21 11:43:20 +02:00
Timo John
0ed58d6fa1 Add more comments 2020-07-10 15:35:38 +02:00
Timo John
29666babbc Add comments 2020-07-10 15:16:04 +02:00
Timo John
2c16bbf3e7 Merge branch 'feature/api-doc' into 'develop'
Feature/api doc

See merge request tjohn/cc-data!36
2020-07-10 11:21:25 +02:00
Timo John
8231127099 Small changes to Swagger API doc 2020-07-10 11:20:24 +02:00
Timo John
531f133875 Changed to better JSDoc Syntax 2020-07-10 11:20:24 +02:00
Timo John
4fa241ad60 Changed place and country docs so query parameters work 2020-07-10 11:20:24 +02:00
Timo John
3cccdab368 Changed regions doc so it works with query parameters 2020-07-10 11:20:24 +02:00
Timo John
ebb1304eeb Edited title and description 2020-07-10 11:20:24 +02:00
Timo John
8c4f67b106 Implemented basic Swagger. Reachable by ./api/v1/doc 2020-07-10 11:20:24 +02:00
Patrick Gebhardt
26c4954e37 Fix hovering cursor over place 2020-06-26 11:35:32 +02:00
Timo Volkmann
91e84f5cf1 Merge branch 'tv/ui-improvements' into 'develop'
ui improvements

See merge request tjohn/cc-data!35
2020-06-25 22:48:30 +02:00
8161f62ce0 further improvements for search ui 2020-06-25 22:45:53 +02:00
f2c7aec2d3 better spacing advanced search 2020-06-25 22:45:53 +02:00
Patrick Gebhardt
67ea0587db Fix advanced search 2020-06-25 22:18:21 +02:00
Patrick Gebhardt
eaaee3c14a Add fixed graph scales 2020-06-25 21:41:28 +02:00
Patrick Gebhardt
28b25da1ad Fix format string on numbers 2020-06-25 21:12:08 +02:00
Patrick Gebhardt
c3c0ad74a7 Add more parameters to advanced search 2020-06-25 20:54:50 +02:00
Patrick Gebhardt
aa4c994069 Fix minor search issues
- Reset sorting for a new search
- Descending sort is default for tags
2020-06-25 20:16:11 +02:00
Timo Volkmann
ad1c728876 Merge branch 'bugfix/timecalc' into 'develop'
some timetravels

See merge request tjohn/cc-data!34
2020-06-25 18:28:49 +02:00
2b0c030980 some timetravels 2020-06-25 18:27:52 +02:00
Patrick Gebhardt
1cda6bfe70 Fix app visual 2020-06-25 16:05:48 +02:00
Timo John
c1198c98db Added method for tags by region id 2020-06-25 15:49:14 +02:00
Timo John
cc64fb1c32 hotfix: this time for real 2020-06-25 15:42:27 +02:00
Patrick Gebhardt
f6dd580894 Improve advanced search visuals 2020-06-25 15:34:34 +02:00
Patrick Gebhardt
8c95857207 Add sun hours to monthly chart 2020-06-25 15:34:34 +02:00
Patrick Gebhardt
509ccea760 Fix diagrams not shown for january null 2020-06-25 15:34:34 +02:00
Timo John
7a6be53e0b hotfix: search handles null values instead of arrays full of nulls 2020-06-25 15:15:04 +02:00
Timo John
85efd83485 If no value is there in a region, null is returned 2020-06-25 15:04:44 +02:00
Timo Volkmann
d67c0ef625 Merge branch 'refactor/scorer-cleanup' into 'develop'
cleaned up and reduced scorer overhead

See merge request tjohn/cc-data!33
2020-06-25 15:02:47 +02:00
6145c40654 cleaned up and reduced scorer overhead 2020-06-25 15:01:21 +02:00
Patrick Gebhardt
2060eea09f Add share button to side-nav 2020-06-25 13:08:24 +02:00
Patrick Gebhardt
218b508f43 Merge branch 'feature/total-price-details' into 'develop'
Add total costs to results

See merge request tjohn/cc-data!32
2020-06-25 12:28:16 +02:00
Patrick Gebhardt
44b09a22e8 Add total costs to results 2020-06-25 12:27:10 +02:00
6be202a0d2 changed result layout 2020-06-25 11:58:59 +02:00
Patrick Gebhardt
cea47548bc Merge branch 'frontend/wording' into 'develop'
Frontend/wording

See merge request tjohn/cc-data!31
2020-06-25 00:18:00 +02:00
Patrick Gebhardt
24ad736091 Improve wording 2020-06-25 00:16:43 +02:00
2077aeebf8 removed unnecessary package from backend 2020-06-24 23:28:13 +02:00
Timo John
7b76f2f696 Newest Version of Setup.sql 2020-06-24 22:32:24 +02:00
Timo John
08a904da7e Merge branch 'feature/weather' into 'develop'
Feature/weather

See merge request tjohn/cc-data!30
2020-06-24 17:24:29 +02:00
Timo John
5fa9d982fb Update update.js 2020-06-24 17:24:12 +02:00
Timo John
d6af174904 Implemented Daily weather API so every region has weather data 2020-06-24 17:24:12 +02:00
Patrick Gebhardt
d2f9d992f5 Change theme color 2020-06-24 17:21:01 +02:00
Patrick Gebhardt
e194019eae Add favicon 2020-06-24 17:05:20 +02:00
Patrick Gebhardt
86c4bc5cd2 Add travopti logo to header 2020-06-24 16:59:25 +02:00
Patrick Gebhardt
384992634d Merge branch 'feature/search-tag-ui' into 'develop'
Add tag search UI

See merge request tjohn/cc-data!29
2020-06-24 15:36:31 +02:00
Patrick Gebhardt
93a51ec922 Add tag search UI 2020-06-24 15:35:53 +02:00
Patrick Gebhardt
43953757e7 Update team page information 2020-06-24 14:11:14 +02:00
Timo Volkmann
2c1d09297a Merge branch 'feature/tags-search-backend' into 'develop'
Feature/tags search backend

See merge request tjohn/cc-data!28
2020-06-24 14:08:02 +02:00
bbe74b83bb implemented search for tags feature backend 2020-06-24 14:06:47 +02:00
e0f8f5490e some tag improvements 2020-06-24 14:06:47 +02:00
Patrick Gebhardt
cd9f68234f Fix explore the world layout for FireFox 2020-06-24 13:38:12 +02:00
Timo John
91ab7ae971 Merge branch 'feature/weather' into 'develop'
Feature/weather

See merge request tjohn/cc-data!27
2020-06-24 13:23:48 +02:00
Timo John
3ae3aca9a5 Hotfix: sql sanitzer now checks if parameter is a number 2020-06-24 13:23:48 +02:00
Patrick Gebhardt
8c296e9397 Add http error messages 2020-06-24 12:57:04 +02:00
Timo John
6cd226bf56 Merge branch 'develop' of https://git.it.hs-heilbronn.de/tjohn/cc-data into develop 2020-06-24 11:51:07 +02:00
Timo John
b5bcae6ada Updated setup.sql to newest version of database 2020-06-24 11:50:49 +02:00
Patrick Gebhardt
72c1386fb8 Fix search layout 2020-06-24 11:48:42 +02:00
Timo John
ae9998f640 Added protection against SQL Injection 2020-06-24 11:47:15 +02:00
Timo Volkmann
2fb2e9a76f Merge branch 'feature/tags-backend' into 'develop'
implemented tags endpoint

See merge request tjohn/cc-data!26
2020-06-24 10:46:09 +02:00
373429b870 implemented tags endpoint 2020-06-24 10:44:32 +02:00
Timo John
20d99f2145 Remoddeled /update API endpoint 2020-06-24 09:14:22 +02:00
Timo John
df083fd78e Add Endpoint for Reloading Imgurl for speicific place 2020-06-24 08:55:53 +02:00
Patrick Gebhardt
3dece30145 Add google maps search to places 2020-06-24 00:01:18 +02:00
Patrick Gebhardt
0e65d6e919 Fix search layout 2020-06-23 23:33:22 +02:00
Patrick Gebhardt
8ae20d96aa Merge branch 'feature/nearby-places' into 'develop'
Add nearby places

See merge request tjohn/cc-data!25
2020-06-23 23:08:51 +02:00
Patrick Gebhardt
56b8c06b84 Add nearby places 2020-06-23 23:02:41 +02:00
Patrick Gebhardt
846410e6bf Add accommodation to permanent sort options 2020-06-23 21:36:16 +02:00
Patrick Gebhardt
1fb6f607a2 Add total accommodation costs to results 2020-06-23 21:25:04 +02:00
5dfb31e91c fixed empty response, code 204 -> 200 2020-06-23 18:54:35 +02:00
Patrick Gebhardt
f9157e8e61 Add search result sorting 2020-06-23 16:42:59 +02:00
Timo John
e948243417 Changed endpoint to /api/v1/regions/:id/nearby 2020-06-23 16:33:58 +02:00
Timo John
5b4ef3c794 Merge branch 'feature/place' into 'develop'
Feature/place

See merge request tjohn/cc-data!24
2020-06-23 16:18:41 +02:00
Timo John
5d05625658 Feature/place 2020-06-23 16:18:41 +02:00
Timo Volkmann
8c1daa308c Merge branch 'feature/accomodation-accumulated-backend' into 'develop'
calculate accommodation total

See merge request tjohn/cc-data!23
2020-06-23 15:06:12 +02:00
faa7a7e5b1 removed incorrectly committed files 2020-06-23 15:04:50 +02:00
Timo Volkmann
5d2f64a688 Merge branch 'bugfix/showscore' into 'develop'
show_match_value now setted through env

See merge request tjohn/cc-data!22
2020-06-23 14:14:07 +02:00
81 changed files with 4651 additions and 709 deletions

Binary file not shown.

File diff suppressed because it is too large Load Diff

BIN
Travopti_Docs.pdf Normal file

Binary file not shown.

View File

@ -1,5 +1,6 @@
PORT=3000
METEOSTAT_API_KEY=LMlDskju
METEOSTAT_API_KEY_V2=O9X1xxKjheNwF1vfLcdRMmQ9JlobOugL
DB_HOST=lhinderberger.dev
DB_USER=root
DB_PASSWORD=devtest

View File

@ -14,13 +14,50 @@ const port = process.env.PORT
const search = require("./routes/search");
const regions = require("./routes/regions");
const countries = require("./routes/countries");
const climate = require("./routes/climate");
const places = require("./routes/place");
const update = require("./routes/update");
const app = express();
// Swagger API doc set up
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');
const swaggerOptions = {
swaggerDefinition: {
openapi: "3.0.0",
info: {
title: "TravOpti API",
version: "1.0.0",
description:
"Enable intrest controlled region searching with this API\n" +
"No API Key required.",
license: {
name: "Licensing Pending",
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=youtu.be"
},
contact: {
name: "travOpti",
url: "https://travopti.de/home",
email: "feedback@travopti.de"
}
},
servers: [
{
url: "https://travopti.de/api/v1"
}
]
},
apis: [
"./Routes/*.js",
"./Models/handleClimateUpdate.js",
"./Models/handleClimateUpdateV2.js",
]
};
const swaggerDocs = swaggerJsdoc(swaggerOptions);
(async () => {
try {
// Connect to MariaDB
const dbConn = await dbConnection();
// Express middleware
@ -28,13 +65,14 @@ const app = express();
app.use(express.static(path.join(__dirname, "../../dist")));
app.use(bodyParser.json());
app.use(cors());
app.use('/api/v1/doc', swaggerUi.serve, swaggerUi.setup(swaggerDocs, {explorer: false, docExpansion: "list"}));
// Express routes
app.use(search(dbConn));
app.use(regions(dbConn));
app.use(countries(dbConn));
app.use(climate(dbConn));
app.use(places(dbConn));
app.use(update(dbConn))
app.use((err, req, res, next) => {
// 500
@ -50,7 +88,6 @@ const app = express();
console.log(`Travopti backend listening at http://localhost:${port}`)
});
} catch (error) {
// TODO: logging
console.error("Failed to start the webserver");
console.error(error);
}

View File

@ -1,6 +0,0 @@
{
"temperature_mean_max": 5,
"precipitation": 3.5,
"raindays": 3,
"sunhours": 2.5
}

View File

@ -1,6 +0,0 @@
{
"id": 29837,
"parameter": "temperature",
"label": "warm",
"values": [22, 25]
}

View File

@ -1,12 +1,13 @@
const axios = require("axios")
const getPlacePhoto = require("./getPlacePhoto.js")
const fields = "photos,place_id,name,rating,geometry" // Parameters for Google Place API
module.exports = async (q) => {
const res = await axios.get(
`https://maps.googleapis.com/maps/api/place/findplacefromtext/json?inputtype=textquery&fields=photos,formatted_address,name,rating,opening_hours,geometry&input=${q}&key=${process.env.GOOGLE_CLOUD_APIS}`)
console.log(res.data)
`https://maps.googleapis.com/maps/api/place/findplacefromtext/json?inputtype=textquery&fields=${fields}&input=${q}&key=${process.env.GOOGLE_CLOUD_APIS}`)
// Photo url is not returned since it overuses Google Place API
// Photo url is not returned by default since it overuses Google Place API
/*
for (let candidate of res.data.candidates) {
for (let photo of candidate.photos) {

View File

@ -1,14 +1,15 @@
const axios = require("axios")
const getPlacePhoto = require("./getPlacePhoto.js")
const radius = 20000
const rankby = "prominence"
const radius = 20000 // Search radius in meters
const rankby = "prominence" // Sorting of results
const types = "tourist_attraction" // Category which shall be searched
module.exports = async (lat, lng) => {
const res = await axios.get(
`https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=${lat},${lng}&radius=${radius}&rankby=${rankby}&key=${process.env.GOOGLE_CLOUD_APIS}`
`https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=${lat},${lng}&radius=${radius}&type=${types}&rankby=${rankby}&key=${process.env.GOOGLE_CLOUD_APIS}`
);
// Photo url is not returned since it overuses Google Place API
// Photo url is not returned by default since it overuses Google Place API
/*
for (let result of res.data.results) {
for (let photo of result.photos) {

View File

@ -1,45 +0,0 @@
exports.getBYTdataByRegion = async (dbConn, id, travelstyle = 1) => {
const res = await dbConn.query(
`SELECT
region_id,
travelstyle,
average_per_day AS average_per_day_costs,
accomodation AS accommodation_costs,
food AS food_costs,
water AS water_costs,
local_transportation AS local_transportation_costs,
entertainment AS entertainment_costs
FROM regions_byt
WHERE region_id = ? AND travelstyle = ?`,
[id, travelstyle]
);
return res;
};
exports.getAllBYTdata = async (dbConn, travelstyle = 1) => {
const res = await dbConn.query(
`SELECT
region_id,
travelstyle,
average_per_day AS average_per_day_costs,
accomodation AS accommodation_costs,
food AS food_costs,
water AS water_costs,
local_transportation AS local_transportation_costs,
entertainment AS entertainment_costs
FROM regions_byt
WHERE travelstyle = ?`,
[travelstyle]
);
return res;
};
exports.getTrivagoData = async (dbConn, id) => {
const region = await dbConn.query(
`...`,
[id]
);
return region;
};

View File

@ -6,6 +6,8 @@ module.exports = async (dbConn, id) => {
regions.region AS name,
countries.country AS country,
regions.description AS description,
regions.lon AS lon,
regions.lat AS lat,
rcma.temperature_mean AS temperature_mean,
rcma.temperature_mean_min AS temperature_mean_min,
rcma.temperature_mean_max AS temperature_mean_max,
@ -24,18 +26,18 @@ module.exports = async (dbConn, id) => {
FROM regions
LEFT JOIN countries ON regions.country_id = countries.id
LEFT JOIN (SELECT rcma.region_id,
GROUP_CONCAT(IFNULL(rcma.temperature_mean,"") ORDER BY rcma.month SEPARATOR ', ') AS temperature_mean,
GROUP_CONCAT(IFNULL(rcma.temperature_mean_min, "") ORDER BY rcma.month SEPARATOR ', ') AS temperature_mean_min,
GROUP_CONCAT(IFNULL(rcma.temperature_mean_max, "") ORDER BY rcma.month SEPARATOR ', ') AS temperature_mean_max,
GROUP_CONCAT(IFNULL(rcma.precipitation, "") ORDER BY rcma.month SEPARATOR ', ') AS precipitation,
GROUP_CONCAT(IFNULL(rcma.rain_days, "") ORDER BY rcma.month SEPARATOR ', ') AS rain_days,
GROUP_CONCAT(IFNULL(rcma.sun_hours, "") ORDER BY rcma.month SEPARATOR ', ') AS sun_hours,
GROUP_CONCAT(IFNULL(rcma.humidity, "") ORDER BY rcma.month SEPARATOR ', ') AS humidity
GROUP_CONCAT(rcma.temperature_mean ORDER BY rcma.month SEPARATOR ', ') AS temperature_mean,
GROUP_CONCAT(rcma.temperature_mean_min ORDER BY rcma.month SEPARATOR ', ') AS temperature_mean_min,
GROUP_CONCAT(rcma.temperature_mean_max ORDER BY rcma.month SEPARATOR ', ') AS temperature_mean_max,
GROUP_CONCAT(rcma.precipitation ORDER BY rcma.month SEPARATOR ', ') AS precipitation,
GROUP_CONCAT(rcma.rain_days ORDER BY rcma.month SEPARATOR ', ') AS rain_days,
GROUP_CONCAT(rcma.sun_hours ORDER BY rcma.month SEPARATOR ', ') AS sun_hours,
GROUP_CONCAT(rcma.humidity ORDER BY rcma.month SEPARATOR ', ') AS humidity
FROM region_climate_monthly_avg AS rcma
GROUP BY rcma.region_id) rcma ON rcma.region_id = regions.id
LEFT JOIN regions_byt ON regions.id = regions_byt.region_id
LEFT JOIN (SELECT rtma.region_id,
GROUP_CONCAT(IFNULL(rtma.avg_price_relative,"") ORDER BY rtma.month SEPARATOR ', ') AS avg_price_relative
GROUP_CONCAT(rtma.avg_price_relative ORDER BY rtma.month SEPARATOR ', ') AS avg_price_relative
FROM regions_trivago_monthly_avg AS rtma
GROUP BY rtma.region_id) rtma
ON regions.id = rtma.region_id
@ -54,17 +56,6 @@ module.exports = async (dbConn, id) => {
region.sun_hours = arrayFormatting(region.sun_hours);
region.humidity = arrayFormatting(region.humidity);
const emptyArr = Array.from({length: 12}, () => null)
if (region.avg_price_relative === null) region.avg_price_relative = emptyArr
if (region.temperature_mean === null) region.temperature_mean = emptyArr
if (region.temperature_mean_min === null) region.temperature_mean_min = emptyArr
if (region.temperature_mean_max === null) region.temperature_mean_max = emptyArr
if (region.temperature_mean_max === null) region.temperature_mean_max = emptyArr
if (region.precipitation === null) region.precipitation = emptyArr
if (region.rain_days === null) region.rain_days = emptyArr
if (region.sun_hours === null) region.sun_hours = emptyArr
if (region.humidity === null) region.humidity = emptyArr
return region;
};

View File

@ -0,0 +1,21 @@
const arrayFormatting = require("../util/databaseArrayFormatting.js")
module.exports = async (dbConn, id) => {
const region_nearby = await dbConn.query(
`SELECT id as place_id,
region_id as region_id,
name as place_name,
lon as lon,
lat as lat,
rating as rating,
vicinity as vicinity,
photo_reference as photo_reference,
img_url as img_url
FROM regions_nearby
WHERE region_id = ?`,
[id]
);
return region_nearby;
};

View File

@ -1,12 +1,13 @@
const arrayFormatting = require("../util/databaseArrayFormatting.js");
const { takeRightWhile } = require("lodash");
const { allTagsWithValues } = require("./getTags.js");
module.exports = async (dbConn) => {
const regions = await dbConn.query(
`SELECT regions.id AS region_id,
const sqlRegions = `SELECT regions.id AS region_id,
regions.region AS name,
countries.country AS country,
regions.description AS description,
regions.lon AS lon,
regions.lat AS lat,
rcma.temperature_mean AS temperature_mean,
rcma.temperature_mean_min AS temperature_mean_min,
rcma.temperature_mean_max AS temperature_mean_max,
@ -25,23 +26,24 @@ module.exports = async (dbConn) => {
FROM regions
LEFT JOIN countries ON regions.country_id = countries.id
LEFT JOIN (SELECT rcma.region_id,
GROUP_CONCAT(IFNULL(rcma.temperature_mean,"") ORDER BY rcma.month SEPARATOR ', ') AS temperature_mean,
GROUP_CONCAT(IFNULL(rcma.temperature_mean_min, "") ORDER BY rcma.month SEPARATOR ', ') AS temperature_mean_min,
GROUP_CONCAT(IFNULL(rcma.temperature_mean_max, "") ORDER BY rcma.month SEPARATOR ', ') AS temperature_mean_max,
GROUP_CONCAT(IFNULL(rcma.precipitation, "") ORDER BY rcma.month SEPARATOR ', ') AS precipitation,
GROUP_CONCAT(IFNULL(rcma.rain_days, "") ORDER BY rcma.month SEPARATOR ', ') AS rain_days,
GROUP_CONCAT(IFNULL(rcma.sun_hours, "") ORDER BY rcma.month SEPARATOR ', ') AS sun_hours,
GROUP_CONCAT(IFNULL(rcma.humidity, "") ORDER BY rcma.month SEPARATOR ', ') AS humidity
GROUP_CONCAT(rcma.temperature_mean ORDER BY rcma.month SEPARATOR ', ') AS temperature_mean,
GROUP_CONCAT(rcma.temperature_mean_min ORDER BY rcma.month SEPARATOR ', ') AS temperature_mean_min,
GROUP_CONCAT(rcma.temperature_mean_max ORDER BY rcma.month SEPARATOR ', ') AS temperature_mean_max,
GROUP_CONCAT(rcma.precipitation ORDER BY rcma.month SEPARATOR ', ') AS precipitation,
GROUP_CONCAT(rcma.rain_days ORDER BY rcma.month SEPARATOR ', ') AS rain_days,
GROUP_CONCAT(rcma.sun_hours ORDER BY rcma.month SEPARATOR ', ') AS sun_hours,
GROUP_CONCAT(rcma.humidity ORDER BY rcma.month SEPARATOR ', ') AS humidity
FROM region_climate_monthly_avg AS rcma
GROUP BY rcma.region_id) rcma ON rcma.region_id = regions.id
LEFT JOIN regions_byt ON regions.id = regions_byt.region_id
LEFT JOIN (SELECT rtma.region_id,
GROUP_CONCAT(IFNULL(rtma.avg_price_relative,"") ORDER BY rtma.month SEPARATOR ', ') AS avg_price_relative
GROUP_CONCAT(rtma.avg_price_relative ORDER BY rtma.month SEPARATOR ', ') AS avg_price_relative
FROM regions_trivago_monthly_avg AS rtma
GROUP BY rtma.region_id) rtma
ON regions.id = rtma.region_id
WHERE regions_byt.travelstyle = 1`
);
const [regions, tags] = await Promise.all([dbConn.query(sqlRegions), allTagsWithValues(dbConn)])
for (k = 0; k < regions.length; k++) {
regions[k].avg_price_relative = arrayFormatting(regions[k].avg_price_relative);
@ -52,19 +54,15 @@ module.exports = async (dbConn) => {
regions[k].rain_days = arrayFormatting(regions[k].rain_days);
regions[k].sun_hours = arrayFormatting(regions[k].sun_hours);
regions[k].humidity = arrayFormatting(regions[k].humidity);
}
//console.log(regions.filter(region => region.rain_days === null))
return regions.map(region => {
const emptyArr = Array.from({ length: 12 }, () => null)
if (region.avg_price_relative === null) region.avg_price_relative = emptyArr
if (region.temperature_mean === null) region.temperature_mean = emptyArr
if (region.temperature_mean_min === null) region.temperature_mean_min = emptyArr
if (region.temperature_mean_max === null) region.temperature_mean_max = emptyArr
if (region.precipitation === null) region.precipitation = emptyArr
if (region.rain_days === null) region.rain_days = emptyArr
if (region.sun_hours === null) region.sun_hours = emptyArr
if (region.humidity === null) region.humidity = emptyArr
region.tags = tags.filter(tag => tag.region_id === region.region_id).map(tag => {
delete tag.region_id
return tag
})
return region
});
};

View File

@ -11,15 +11,11 @@ module.exports = async (dbConn) => {
);
for (k = 0; k < presets.length; k++) {
//if (presets[k].values.toString().includes("|")) {
const value = presets[k].value
presets[k].value = value.split("|");
for (i = 0; i < presets[k].value.length; i++) {
presets[k].value[i] = parseFloat(presets[k].value[i])
}
//} else {
// presets[k].values = parseInt(presets[k].values)
//}
}
return presets;
};

22
backend/models/getTags.js Normal file
View File

@ -0,0 +1,22 @@
exports.getUniqueTags = async (dbConn) => {
let tags = await dbConn.query(
`SELECT DISTINCT name FROM region_feedback;`
);
return tags;
};
exports.allTagsWithValues = async (dbConn) => {
let tags = await dbConn.query(
`SELECT region_id, name, value FROM region_feedback;`
);
return tags;
};
exports.getTagsByRegionId = async (dbConn, id) => {
let tags = await dbConn.query(
`SELECT region_id, name, value
FROM region_feedback
WHERE region_id = ${id};`
);
return tags;
};

View File

@ -1,11 +1,12 @@
const axios = require('axios')
const _ = require('lodash')
// TODO: Automatically retrieve dates via aviable Data and get rid of random dates
// Constants
// TODO: Automatically retrieve dates via aviable Data from database and get rid of "random" dates
const rangeStartDate = '2019-01' // If no date is given, this date will be used as startDate
const rangeEndDate = '2020-05'// If no date is given, this date will be used as endDate
// TODO: call method periodically, not over API
// TODO: call method periodically, not over API (fine for prototyping, tho)
module.exports = async (dbConn, startDate = rangeStartDate, endDate = rangeEndDate) => {
console.log('update climate with:', startDate, endDate);
@ -54,7 +55,6 @@ async function createClimateObjectFrom(src, startDate, endDate) {
sun_hours: element.sunshine,
humidity: element.humidity ? element.humidity : null
}
//console.log(result)
return result
})
return retVal
@ -62,7 +62,6 @@ async function createClimateObjectFrom(src, startDate, endDate) {
async function writeToDatabase(dbConn, climateObjArr) {
for (const element of climateObjArr) {
//console.log(element)
try {
await dbConn.query(`
INSERT INTO region_climate

View File

@ -0,0 +1,110 @@
const axios = require('axios')
const _ = require('lodash')
// Constants
// TODO: Automatically retrieve dates via aviable Data from database and get rid of "random" dates
const rangeStartDate = '2019-01-01' // If no date is given, this date will be used as startDate
const rangeEndDate = '2019-12-31'// If no date is given, this date will be used as endDate
// TODO: call method periodically, not over API (fine for prototyping, tho)
module.exports = async (dbConn, startDate = rangeStartDate, endDate = rangeEndDate) => {
console.log('update climate with:', startDate, endDate);
const result = await dbConn.query(`SELECT id, region, lon, lat FROM regions`)
const climateObject = await Promise.all(result.map(src => {
return createClimateObjectFrom(src, startDate, endDate)
}))
const climateObjectArr = climateObject.reduce((total, element) => total.concat(element), [])
await writeToDatabase(dbConn, climateObjectArr)
const res = `region_climate update v2 complete. see backend logs for info.`
return climateObjectArr
}
async function createClimateObjectFrom(src, startDate, endDate) {
let res
try {
res = await axios.get(
`https://api.meteostat.net/v2/point/daily?lat=${src.lat}&lon=${src.lon}&start=${startDate}&end=${endDate}`,
{
headers: {
"x-api-key": process.env.METEOSTAT_API_KEY_V2
}//,
//httpsAgent: agent
})
} catch (error) {
console.log("error while getting data from meteostat: couldn't find results for following region: ")
console.log(src.region,"with coords:",src.lon,src.lat)
console.log(error)
return []
}
if (!res.data.data) {
console.log("skipping: no data for station with meteostat_id " + src.meteostat_id + " (" + src.region + ")")
return []
}
const retVal = res.data.data.map(element => {
let result = {
region: src.region,
region_id: src.id,
year: element.date.split("-")[0],
month: element.date.split("-")[1],
day: element.date.split("-")[2],
temperature_mean: element.tavg,
temperature_mean_min: element.tmin,
temperature_mean_max: element.tmax,
precipitation: element.prcp,
rain_days: element.prcp > 2 ? 1:0, // More than 2mm => rainday
sun_hours: element.tsun/60
}
//console.log(result)
return result
})
return retVal
}
async function writeToDatabase(dbConn, climateObjArr) {
for (const element of climateObjArr) {
//console.log(element)
try {
await dbConn.query(`
INSERT INTO region_climate_day
(region_id, year, month, day, temperature_mean, temperature_mean_min, temperature_mean_max, precipitation, sun_hours, rain_days)
VALUES (${element.region_id}, ${element.year}, ${element.month}, ${element.day}, ${element.temperature_mean}, ${element.temperature_mean_min}, ${element.temperature_mean_max}, ${element.precipitation}, ${element.sun_hours}, ${element.rain_days})
ON DUPLICATE KEY UPDATE
temperature_mean = ${element.temperature_mean},
temperature_mean_min = ${element.temperature_mean_min},
temperature_mean_max = ${element.temperature_mean_max},
precipitation = ${element.precipitation},
sun_hours = ${element.sun_hours},
rain_days = ${element.rain_days};`)
} catch (error) {
if (error.code !== 'ER_DUP_ENTRY') {
console.log("element which causes problems: ")
console.log(element)
console.log("query which causes problems: ")
console.log(error)
} else {
console.log(element.region + ": " + error.sqlMessage)
}
}
}
};
/*
INSERT INTO region_climate
(region_id, YEAR, MONTH, temperature_mean, temperature_mean_min, temperature_mean_max, precipitation, rain_days, sun_hours)
SELECT
region_id,
YEAR,
MONTH,
ROUND(AVG(temperature_mean),2) AS temperature_mean,
MIN(temperature_mean_min) AS temperature_mean_min,
MAX(temperature_mean_max) AS temperature_mean_max,
ROUND(SUM(precipitation),2) AS precipitation,
SUM(rain_days) AS rain_days,
SUM(sun_hours) AS sun_hours
FROM region_climate_day
GROUP BY region_id, YEAR, month
*/

View File

@ -0,0 +1,34 @@
const axios = require("axios")
const getRegions = require("../models/getRegions.js")
const fields = "geometry" // Parameters for Google Places API
module.exports = async (dbConn) => {
const regions = await getRegions(dbConn)
for (let region of regions) {
try {
const q = region.name
const place = await axios.get(
`https://maps.googleapis.com/maps/api/place/findplacefromtext/json?inputtype=textquery&fields=geometry&input=${q}&key=${process.env.GOOGLE_CLOUD_APIS}`)
const region_id = region.region_id
const lon = parseFloat(place.data.candidates[0].geometry.location.lng)
const lat = parseFloat(place.data.candidates[0].geometry.location.lat)
await dbConn.query(
`UPDATE regions
SET lon=${lon}, lat=${lat}
WHERE id = ${region_id}`
);
console.log("Updating coordinates for region: ", region_id, q, " ", "lon:", lon, "lat", lat)
} catch (e) {
console.log(e)
}
}
const res = "lon lat update finished"
return res
}

View File

@ -0,0 +1,48 @@
const axios = require("axios")
const getRegions = require("../models/getRegions.js")
const getPlaceNearby = require("../models/getPlaceNearby.js")
module.exports = async (dbConn) => {
const regions = await getRegions(dbConn)
for (let region of regions) {
try {
const region_id = region.region_id
const region_lon = region.lon
const region_lat = region.lat
console.log("Updating nearby for region: ", region_id, region.name)
const places = await getPlaceNearby(region_lat, region_lon)
for (let result of places.results) {
const name = result.name
const rating = result.rating === undefined ? null : result.rating
const lon = result.geometry.location.lng
const lat = result.geometry.location.lat
const photo_ref = result.photos[0].photo_reference
const vicinity = result.vicinity
console.log("# New tourist attraction:", region_id, region.name, name)
await dbConn.query(
`INSERT INTO regions_nearby
(region_id,name,lon,lat,rating,vicinity,photo_reference)
VALUES
(${region_id},"${name}",${lon},${lat},${rating},"${vicinity}","${photo_ref}")
ON DUPLICATE KEY UPDATE
lon = ${lon},
lat = ${lat},
rating = ${rating},
vicinity = "${vicinity}",
photo_reference = "${photo_ref}"`
);
}
} catch (e) {
console.log(e)
}
}
const res = "region nearby update finished"
return res
}

View File

@ -0,0 +1,51 @@
const axios = require("axios")
const getRegionById = require("../models/getRegionById.js")
const getPlaceNearby = require("../models/getPlaceNearby.js")
module.exports = async (dbConn, id) => {
const region = await getRegionById(dbConn, id)
try {
const region_id = region.region_id
const region_lon = region.lon
const region_lat = region.lat
console.log("Updating nearby for region: ", region_id, region.name)
const places = await getPlaceNearby(region_lat, region_lon)
for (let result of places.results) {
try {
const name = result.name
const rating = result.rating === undefined ? null : result.rating
const lon = result.geometry.location.lng
const lat = result.geometry.location.lat
const photo_ref = result.photos[0].photo_reference
const vicinity = result.vicinity
console.log("# New tourist attraction:", region_id, region.name, name)
await dbConn.query(
`INSERT INTO regions_nearby
(region_id,name,lon,lat,rating,vicinity,photo_reference)
VALUES
(${region_id},"${name}",${lon},${lat},${rating},"${vicinity}","${photo_ref}")
ON DUPLICATE KEY UPDATE
lon = ${lon},
lat = ${lat},
rating = ${rating},
vicinity = "${vicinity}",
photo_reference = "${photo_ref}"`
);
} catch (e) {
console.log(e)
}
}
} catch (e) {
console.log(e)
}
const res = "region nearby by id update finished"
return res
}

View File

@ -0,0 +1,32 @@
const getRegionNearbyById = require("../models/getRegionNearbyById.js")
const getPlacePhoto = require("../models/getPlacePhoto.js")
module.exports = async (dbConn) => {
try {
const region_ids = await dbConn.query(`
SELECT distinct region_id
FROM regions_nearby
ORDER BY region_id`)
for (let region_id of region_ids) {
const nearby = await getRegionNearbyById(dbConn, region_id.region_id)
for (let place of nearby) {
const url = await getPlacePhoto(place.photo_reference)
console.log("# Setting image Url:", region_id, place.place_name, url)
await dbConn.query(`
UPDATE regions_nearby
SET img_url = "${url}"
WHERE id = ${place.place_id}`)
}
}
} catch (e) {
console.log(e)
}
const res = "region nearby img url update finished"
return res
}

View File

@ -0,0 +1,33 @@
const getRegionNearbyById = require("../models/getRegionNearbyById.js")
const getPlacePhoto = require("../models/getPlacePhoto.js")
module.exports = async (dbConn,id) => {
try {
const region_ids = await dbConn.query(`
SELECT distinct region_id
FROM regions_nearby
WHERE region_id = ?`,
[id])
for (let region_id of region_ids) {
const nearby = await getRegionNearbyById(dbConn, region_id.region_id)
for (let place of nearby) {
const url = await getPlacePhoto(place.photo_reference)
console.log("# Setting image Url:", region_id, place.place_name, url)
await dbConn.query(`
UPDATE regions_nearby
SET img_url = "${url}"
WHERE id = ${place.place_id}`)
}
}
} catch (e) {
console.log(e)
}
const res = "region nearby img url update finished"
return res
}

View File

@ -4,6 +4,45 @@
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@apidevtools/json-schema-ref-parser": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-8.0.0.tgz",
"integrity": "sha512-n4YBtwQhdpLto1BaUCyAeflizmIbaloGShsPyRtFf5qdFJxfssj+GgLavczgKJFa3Bq+3St2CKcpRJdjtB4EBw==",
"requires": {
"@jsdevtools/ono": "^7.1.0",
"call-me-maybe": "^1.0.1",
"js-yaml": "^3.13.1"
}
},
"@apidevtools/openapi-schemas": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.0.3.tgz",
"integrity": "sha512-QoPaxGXfgqgGpK1p21FJ400z56hV681a8DOcZt3J5z0WIHgFeaIZ4+6bX5ATqmOoCpRCsH4ITEwKaOyFMz7wOA=="
},
"@apidevtools/swagger-methods": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.1.tgz",
"integrity": "sha512-1Vlm18XYW6Yg7uHunroXeunWz5FShPFAdxBbPy8H6niB2Elz9QQsCoYHMbcc11EL1pTxaIr9HXz2An/mHXlX1Q=="
},
"@apidevtools/swagger-parser": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-9.0.1.tgz",
"integrity": "sha512-Irqybg4dQrcHhZcxJc/UM4vO7Ksoj1Id5e+K94XUOzllqX1n47HEA50EKiXTCQbykxuJ4cYGIivjx/MRSTC5OA==",
"requires": {
"@apidevtools/json-schema-ref-parser": "^8.0.0",
"@apidevtools/openapi-schemas": "^2.0.2",
"@apidevtools/swagger-methods": "^3.0.0",
"@jsdevtools/ono": "^7.1.0",
"call-me-maybe": "^1.0.1",
"openapi-types": "^1.3.5",
"z-schema": "^4.2.2"
}
},
"@jsdevtools/ono": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.2.tgz",
"integrity": "sha512-qS/a24RA5FEoiJS9wiv6Pwg2c/kiUo3IVUQcfeM9JvsR6pM8Yx+yl/6xWYLckZCT5jpLNhslgjiA8p/XcGyMRQ=="
},
"@sindresorhus/is": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz",
@ -103,6 +142,14 @@
"picomatch": "^2.0.4"
}
},
"argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"requires": {
"sprintf-js": "~1.0.2"
}
},
"array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -119,8 +166,7 @@
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"dev": true
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
},
"basic-auth": {
"version": "2.0.1",
@ -173,7 +219,6 @@
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -225,6 +270,11 @@
}
}
},
"call-me-maybe": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz",
"integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms="
},
"camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
@ -319,11 +369,15 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"commander": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-5.0.0.tgz",
"integrity": "sha512-JrDGPAKjMGSP1G0DUoaceEJ3DZgAfr/q6X7FVk4+U5KxUSKviYGM2k6zWkfyyBHy5rAtzgYJFa1ro2O9PtoxwQ=="
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
"configstore": {
"version": "5.0.1",
@ -421,6 +475,14 @@
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
"integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
},
"doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"requires": {
"esutils": "^2.0.2"
}
},
"dot-prop": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz",
@ -482,6 +544,11 @@
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
},
"esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
},
"etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@ -575,6 +642,11 @@
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac="
},
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"fsevents": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
@ -599,6 +671,19 @@
"pump": "^3.0.0"
}
},
"glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"glob-parent": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",
@ -703,6 +788,15 @@
"integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
"dev": true
},
"inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"requires": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
@ -809,6 +903,15 @@
"integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==",
"dev": true
},
"js-yaml": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz",
"integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==",
"requires": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
}
},
"json-buffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz",
@ -838,6 +941,16 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
},
"lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
},
"lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA="
},
"long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
@ -941,7 +1054,6 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -1122,11 +1234,15 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"dev": true,
"requires": {
"wrappy": "1"
}
},
"openapi-types": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-1.3.5.tgz",
"integrity": "sha512-11oi4zYorsgvg5yBarZplAqbpev5HkuVNPlZaPTknPDzAynq+lnJdXAmruGWP0s+dNYZS7bjM+xrTpJw7184Fg=="
},
"p-cancelable": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz",
@ -1167,6 +1283,11 @@
"util": "^0.10.3"
}
},
"path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
},
"path-to-regexp": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
@ -1405,10 +1526,15 @@
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==",
"dev": true
},
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"sqlstring": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz",
"integrity": "sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A="
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.2.tgz",
"integrity": "sha512-vF4ZbYdKS8OnoJAWBmMxCQDkiEBkGQYU7UZPtL8flbDRSNkhaXvRJ279ZtI6M+zDaQovVU4tuRgzK5fVhvFAhg=="
},
"statuses": {
"version": "1.5.0",
@ -1479,6 +1605,39 @@
"has-flag": "^3.0.0"
}
},
"swagger-jsdoc": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-4.0.0.tgz",
"integrity": "sha512-wHrmRvE/OQa3d387YIrRNPvsPwxkJc0tAYeCVa359gUIKPjC4ReduFhqq/+4erLUS79kY1T5Fv0hE0SV/PgBig==",
"requires": {
"commander": "5.0.0",
"doctrine": "3.0.0",
"glob": "7.1.6",
"js-yaml": "3.13.1",
"swagger-parser": "9.0.1"
}
},
"swagger-parser": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-9.0.1.tgz",
"integrity": "sha512-oxOHUaeNetO9ChhTJm2fD+48DbGbLD09ZEOwPOWEqcW8J6zmjWxutXtSuOiXsoRgDWvORYlImbwM21Pn+EiuvQ==",
"requires": {
"@apidevtools/swagger-parser": "9.0.1"
}
},
"swagger-ui-dist": {
"version": "3.28.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-3.28.0.tgz",
"integrity": "sha512-aPkfTzPv9djSiZI1NUkWr5HynCUsH+jaJ0WSx+/t19wq7MMGg9clHm9nGoIpAtqml1G51ofI+I75Ym72pukzFg=="
},
"swagger-ui-express": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.1.4.tgz",
"integrity": "sha512-Ea96ecpC+Iq9GUqkeD/LFR32xSs8gYqmTW1gXCuKg81c26WV6ZC2FsBSPVExQP6WkyUuz5HEiR0sEv/HCC343g==",
"requires": {
"swagger-ui-dist": "^3.18.1"
}
},
"term-size": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.0.tgz",
@ -1604,6 +1763,11 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
},
"validator": {
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-12.2.0.tgz",
"integrity": "sha512-jJfE/DW6tIK1Ek8nCfNFqt8Wb3nzMoAbocBF6/Icgg1ZFSBpObdnwVY2jQj6qUqzhx5jc71fpvBWyLGO7Xl+nQ=="
},
"vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -1621,8 +1785,7 @@
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
},
"write-file-atomic": {
"version": "3.0.3",
@ -1646,6 +1809,25 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
},
"z-schema": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-4.2.3.tgz",
"integrity": "sha512-zkvK/9TC6p38IwcrbnT3ul9in1UX4cm1y/VZSs4GHKIiDCrlafc+YQBgQBUdDXLAoZHf2qvQ7gJJOo6yT1LH6A==",
"requires": {
"commander": "^2.7.1",
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"validator": "^12.0.0"
},
"dependencies": {
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"optional": true
}
}
}
}
}

View File

@ -20,7 +20,10 @@
"moment": "^2.26.0",
"morgan": "^1.10.0",
"mysql2": "^2.1.0",
"path": "^0.12.7"
"path": "^0.12.7",
"sqlstring": "^2.3.2",
"swagger-jsdoc": "^4.0.0",
"swagger-ui-express": "^4.1.4"
},
"devDependencies": {
"nodemon": "^2.0.4"

View File

@ -1,11 +0,0 @@
const router = require("express").Router()
const handleClimateUpdate = require("../models/handleClimateUpdate.js")
module.exports = dbConn => {
router.put("/api/v1/climate/update", async (req, res) => {
const update = await handleClimateUpdate(dbConn)
res.json(update)
});
return router;
};

View File

@ -1,14 +1,53 @@
/**
* @swagger
* tags:
* name: Countries
* description: Access country data.
*/
const router = require("express").Router();
// Models
const getCountries = require("../models/getCountries.js");
const getCountryById = require("../models/getCountryById.js");
// Utils
const sqlSanitzer = require("../util/sqlstring_sanitizer.js")
module.exports = dbConn => {
/**
* @swagger
* /countries:
* get:
* summary: Get all countries
* tags: [Countries]
* responses:
* "200":
* description: Returns aviable data for all countries
*/
router.get("/api/v1/countries", async (req, res) => {
res.json(await getCountries(dbConn));
});
/**
* @swagger
* /countries/{id}:
* get:
* summary: Get a specific country by id
* tags: [Countries]
* parameters:
* - name: "id"
* in: "path"
* required: true
* type: int
* example: 23
* responses:
* "200":
* description: Returns aviable data for the country
* example: test
*/
router.get("/api/v1/countries/:id", async (req, res) => {
const id = req.params.id;
const id = sqlSanitzer(req.params.id);
res.json(await getCountryById(dbConn, id))
});
return router;

View File

@ -1,21 +1,93 @@
/**
* @swagger
* tags:
* name: Places
* description: Access to the Google Place API via the Key used in backend. Only for manual use in the prototype application!
*/
const router = require("express").Router()
// Models
const getPlace = require("../models/getPlace.js")
const getPlaceNearby = require("../models/getPlaceNearby.js")
const getPlacePhoto = require("../models/getPlacePhoto.js")
// Utils
const sqlSanitzer = require("../util/sqlstring_sanitizer.js")
module.exports = dbConn => {
/**
* @swagger
* /place:
* get:
* summary: Get a specific place
* tags: [Places]
* parameters:
* - name: "q"
* in: "query"
* required: true
* type: int
* description: "Querystring, by which the place is searched"
* example: Berlin
* responses:
* "200":
* description: Returns a place from the google places API.
*/
router.get("/api/v1/place", async (req, res) => {
const place = await getPlace(req.query.q)
res.json(place)
});
/**
* @swagger
* /place/nearby:
* get:
* summary: Get nearby touristic places
* tags: [Places]
* parameters:
* - name: "lat"
* in: "query"
* required: true
* type: float
* description: "Latitiude"
* example: 52.520365
* - name: "lng"
* in: "query"
* required: true
* type: float
* description: "Longitude"
* example: 13.403509
* responses:
* "200":
* description: Returns nearby places from the google places API.
*/
router.get("/api/v1/place/nearby", async (req, res) => {
const place = await getPlaceNearby(req.query.lat, req.query.lng)
const lat = req.query.lat
const lng = req.query.lng
const place = await getPlaceNearby(lat, lng)
res.json(place)
});
/**
* @swagger
* /place/photo:
* get:
* summary: Get a photo for a place
* tags: [Places]
* parameters:
* - name: "photoref"
* in: "query"
* required: true
* type: int
* description: "Photo_Reference which is returned for a place by Google Places API"
* example: CmRaAAAAbupojmH94negtiCnLGdfx2azxhVTEDI1rtTrYnQ7KclEI-Yy9_YGxN9h63AKrCzd22kk5z-UiK7fS4-zXnO5OqfNRZu2hrmfcp8b77rItediibAVovOOA5LnyJ9YYuofEhAAr0Im0zuiAtbDKPjbPUSBGhTFkSrH6FZxenbo1bCkdCXaUMhOug
* responses:
* "200":
* description: Returns the matching url to the photo.
*/
router.get("/api/v1/place/photo", async (req, res) => {
const photo = await getPlacePhoto(req.query.photoref)
const photoref = req.query.photoref
const photo = await getPlacePhoto(photoref)
res.json(photo)
});

View File

@ -1,29 +1,112 @@
/**
* @swagger
* tags:
* name: Regions
* description: Access region data.
*/
const router = require("express").Router();
// Models
const getRegions = require("../models/getRegions.js");
const getRegionById = require("../models/getRegionById.js");
const getRegionNearbyById = require("../models/getRegionNearbyById.js")
// Utils
const path = require("path");
const fs = require("fs");
const _ = require('lodash')
const sqlSanitzer = require("../util/sqlstring_sanitizer.js")
module.exports = dbConn => {
/**
* @swagger
* /regions:
* get:
* summary: Get all regions
* tags: [Regions]
* responses:
* "200":
* description: Returns available data for all regions
*/
router.get("/api/v1/regions", async (req, res) => {
const data = await getRegions(dbConn)
if (req.query.randomize) {
res.json(_.sampleSize(data, req.query.randomize))
const randomize = sqlSanitzer(req.query.randomize)
res.json(_.sampleSize(data, randomize))
} else {
res.json(data);
}
});
/**
* @swagger
* /regions/{id}:
* get:
* summary: Get a specific region by id
* tags: [Regions]
* parameters:
* - name: "id"
* in: "path"
* required: true
* type: int
* responses:
* "200":
* description: Returns available data for the region
*/
router.get("/api/v1/regions/:id", async (req, res) => {
console.log(typeof req.params.id)
const id = sqlSanitzer(req.params.id);
console.log(id)
res.json(await getRegionById(dbConn, id))
});
/**
* @swagger
* /regions/{id}/image:
* get:
* summary: Get image for specific region
* tags: [Regions]
* parameters:
* - name: "id"
* in: "path"
* required: true
* type: int
* responses:
* "200":
* description: Returns the image for a specific region
* "404":
* description: Returns a placeholder image for the region
*/
router.get('/api/v1/regions/:id/image', (req, res) => {
console.log("HERE")
if (fs.existsSync(path.join(__dirname, `../data/regions/images/${req.params.id}.jpg`))) {
res.sendFile(path.join(__dirname, `../data/regions/images/${req.params.id}.jpg`))
console.log("EXISTS")
res.status(200).sendFile(path.join(__dirname, `../data/regions/images/${req.params.id}.jpg`))
} else {
res.sendFile(path.join(__dirname, `../data/regions/images/x.png`))
console.log("NOT EXISTS")
res.status(404).sendFile(path.join(__dirname, `../data/regions/images/x.png`))
}
})
router.get("/api/v1/regions/:id", async (req, res) => {
const id = req.params.id;
res.json(await getRegionById(dbConn, id))
/**
* @swagger
* /regions/{id}/nearby:
* get:
* summary: Get nearby places of a specific region by id
* tags: [Regions]
* parameters:
* - name: "id"
* in: "path"
* required: true
* type: int
* responses:
* "200":
* description: Returns all nearby places for the region
*/
router.get("/api/v1/regions/:id/nearby", async (req,res) => {
const id = sqlSanitzer(req.params.id);
res.json(await getRegionNearbyById(dbConn,id))
});
return router;
};

View File

@ -1,19 +1,80 @@
const router = require("express").Router();
const _ = require('lodash')
const getSearchPresets = require("../models/getSearchPresets.js");
const base64 = require("../util/base64.js")
const sas = require("../util/scoreAndSearch.js");
const oldToNewQuerySyntax = require("../util/oldToNewQuerySyntax.js")
const getRegions = require('../models/getRegions.js')
/**
* @swagger
* tags:
* name: Search
* description: Access the search algorithm and the data provided for searching.
*/
const router = require("express").Router();
// Models
const getRegions = require('../models/getRegions.js');
const getSearchPresets = require("../models/getSearchPresets.js");
// Utils
const _ = require('lodash')
const base64 = require("../util/base64.js")
const scoreAndSearch = require("../util/scoreAndSearch.js");
const oldToNewQuerySyntax = require("../util/oldToNewQuerySyntax.js")
const { getUniqueTags } = require("../models/getTags.js");
module.exports = dbConn => {
/**
* @swagger
* /search:
* get:
* summary: Get Searchresults
* tags: [Search]
* parameters:
* - name: "q"
* in: "query"
* required: true
* type: int
* description: "Base64 encoded JS-Object with searchparameters"
* example: eyJmcm9tIjoxNTkzNjQ4MDAwMDAwLCJ0byI6MTU5NDI1MjgwMDAwMCwidGFncyI6W119
* responses:
* "200":
* description: Returns the region information and scores for the searchresults
*/
router.get("/api/v1/search", searchHandler(dbConn));
/**
* @swagger
* /search/presets:
* get:
* summary: Get the presets for the search parameters
* tags: [Search]
* responses:
* "200":
* description: Returns all presets for the search parameters
*/
router.get("/api/v1/search/presets", presetHandler(dbConn));
/**
* @swagger
* /search/tags:
* get:
* summary: Get the existing searchtags
* tags: [Search]
* responses:
* "200":
* description: Returns all existing searchtags
*/
router.get("/api/v1/search/tags", tagsHandler(dbConn));
return router;
};
function tagsHandler(dbConn) {
return function (req, res) {
getUniqueTags(dbConn).then(tags => {
res.json(tags.map(tag => tag.name))
}).catch(error => {
// TODO error handling
})
}
}
function presetHandler(dbConn) {
return function (req, res) {
getSearchPresets(dbConn).then(presets => {
@ -25,7 +86,6 @@ function presetHandler(dbConn) {
}
function searchHandler(dbConn) {
const scoreAndSearch = sas(dbConn)
return async function (req, res) {
let response = {}
@ -46,18 +106,18 @@ function searchHandler(dbConn) {
// CHOOSE PARAMS WHICH SHALL BE PASSED TO SCORE AND SEARCH
let scoreQueryObj = prepareQueries(q)
let data = await getRegions(dbConn)
let [regions] = await Promise.all([getRegions(dbConn)])
let data = {
regions: regions,
}
// FILTER if query contains filterString
if (q.textfilter) {
data = filterByString(data, q.textfilter, q.fulltext)
data.regions = filterByString(data.regions, q.textfilter, q.fulltext)
}
scoreAndSearch(data, q.from, q.to, scoreQueryObj).then(searchResults => {
//response.data = searchResults
const cutScores = !(_.isEmpty(scoreQueryObj.climate) && _.isEmpty(scoreQueryObj.costs) && _.isEmpty(scoreQueryObj.others))
// only dev:
if (process.env.SHOW_MATCH_VALUE === '1') searchResults.forEach(reg => reg.name = `${reg.name} (${_.round(reg.score * 10, 1)}% match)`)
@ -66,32 +126,8 @@ function searchHandler(dbConn) {
console.log('without null scores');
searchResults.forEach(el => console.log('region:', el.name, 'score:', el.score))
searchResults = searchResults.filter(el => !_.some(el.scores, score => _.isNaN(score.score) || _.isNil(score.score) || score.score <= 0))
// searchResults = searchResults.filter(el => !(_.isNil(el.score) || _.isNaN(el.score)) )
// searchResults = searchResults.filter(el => {
// let nullcnt = 0
// el.scores.forEach(sc => {
// if (_.isNaN(sc.score) || sc.score <= 0 || _.isNil(el.score)) {
// nullcnt++
// }
// })
// console.log(el.name, nullcnt)
// return nullcnt >= 2 ? false : true
// })
/*searchResults = searchResults.filter(el => {
console.log('scorrrrrr', el.score);
let keepIt = true
//if (_.some(el.scores, score => score.score <= 0) && el.score < 1) keepIt = false
return cutScores ? keepIt : true
})//.filter(el => !_.isNaN(el.score))*/
}
// SEND RESPONSE
if (_.isEmpty(searchResults)) {
res.status(204).json(searchResults)
return
}
// response.data = searchResults
// res.json(response)
res.json(searchResults)
}).catch(e => {
@ -140,6 +176,7 @@ function prepareQueries(queries) {
// others
if (queries.avg_price_relative) q.others.avg_price_relative = queries.avg_price_relative
if (queries.tags) q.others.tags = queries.tags
return q
}

135
backend/routes/update.js Normal file
View File

@ -0,0 +1,135 @@
/**
* @swagger
* tags:
* name: Update
* description: Endpoint for updating region specific data. Only for manual use in the prototype application!
*/
const router = require("express").Router();
// Models
const handleClimateUpdate = require("../models/handleClimateUpdate.js")
const handleClimateUpdateV2 = require("../models/handleClimateUpdateV2.js")
const handleUpdateRegionNearby = require("../models/handleUpdateRegionNearby.js")
const handleUpdateRegionNearbyById = require("../models/handleUpdateRegionNearbyById.js")
const handleUpdateRegionNearbyImgUrl = require("../models/handleUpdateRegionNearbyImgUrl.js")
const handleUpdateRegionNearbyImgUrlById = require("../models/handleUpdateRegionNearbyImgUrlById.js")
// Utils
const sqlSanitzer = require("../util/sqlstring_sanitizer.js")
module.exports = dbConn => {
/**
* @swagger
* /update/climate/v1:
* put:
* summary: Pull monthly data from meteostat API V1
* tags: [Update]
* responses:
* "200":
* description: Update information is logged in backend
*/
router.put("/api/v1/update/climate/v1", async (req, res) => {
const update = await handleClimateUpdate(dbConn)
res.json(update)
});
/**
* @swagger
* /update/climate/v2:
* put:
* summary: Pull daily data from meteostat API V2. Data is written to Travopti database and must be processed manually before it can be used.
* tags: [Update]
* responses:
* "200":
* description: Update information is logged in backend
*/
router.put("/api/v1/update/climate/v2", async (req, res) => {
const update = await handleClimateUpdateV2(dbConn)
res.json(update)
});
/**
* @swagger
* /update/regions/all/nearby:
* put:
* summary: Updates all nearby data for all regions
* tags: [Update]
* responses:
* "200":
* description: Updates all nearby data for all regions
*/
router.put("/api/v1/update/regions/all/nearby", async (req, res) => {
res.json(await handleUpdateRegionNearby(dbConn))
});
/**
* @swagger
* /update/regions/all/lonlat:
* put:
* summary: Updates all coordinate data for all regions
* tags: [Update]
* responses:
* "200":
* description: Updates all coordinate data for all regions
*/
router.put("/api/v1/update/regions/all/lonlat", async (req,res) => {
res.json(await handleRegionLonLat(dbConn))
});
/**
* @swagger
* /update/regions/all/nearby/image:
* put:
* summary: Updates the nearby image urls for all regions
* tags: [Update]
* responses:
* "200":
* description: Updates the nearby image urls for all regions
*/
router.put("/api/v1/update/regions/all/nearby/image", async (req, res) => {
res.json(await handleUpdateRegionNearbyImgUrl(dbConn))
});
/**
* @swagger
* /update/regions/{id}/nearby:
* put:
* summary: Updates the nearby data for a specific region
* tags: [Update]
* parameters:
* - name: "id"
* in: "path"
* required: true
* type: int
* responses:
* "200":
* description: Updates the nearby data for a specific region
*/
router.put("/api/v1/update/regions/:id/nearby", async (req, res) => {
const id = sqlSanitzer(req.params.id);
res.json(await handleUpdateRegionNearbyById(dbConn, id))
});
/**
* @swagger
* /update/regions/{id}/nearby/image:
* put:
* summary: Updates the nearby image urls for a specific region
* tags: [Update]
* parameters:
* - name: "id"
* in: "path"
* required: true
* type: int
* responses:
* "200":
* description: Updates the nearby image urls for a specific region
*/
router.put("/api/v1/update/regions/:id/nearby/image", async (req, res) => {
const id = sqlSanitzer(req.params.id);
res.json(await handleUpdateRegionNearbyImgUrlById(dbConn, id))
});
return router
}

62
backend/settings.js Normal file
View File

@ -0,0 +1,62 @@
module.exports = {
scoring: {
// parameter: [transition range, transition function, transistion slope exponent]
temperature_mean_max: [12, 'easeInOut', 2],
precipitation: [60, 'easeInOut', 2], // [170, 'easeInOut', 2],
rain_days: [5, 'easeInOut', 2],
sun_hours: [80, 'easeInOut', 2],
accommodation_costs: [30, 'linear', null],
food_costs: [25, 'linear', null],
alcohol_costs: [15, 'linear', null],
water_costs: [15, 'linear', null],
local_transportation_costs: [20, 'linear', null],
entertainment_costs: [20, 'easeInOut', 0.6],
average_per_day_costs: [100, 'linear', null],
avg_price_relative: [30, 'easeOut', 2],
},
boundaries: {
climate: {
min: {
temperature_mean: -9.6,
temperature_mean_min: -14.5,
temperature_mean_max: -4.7,
precipitation: 0,
rain_days: 0,
sun_hours: 3
},
max: {
temperature_mean: 38.3,
temperature_mean_min: 33.5,
temperature_mean_max: 43.7,
precipitation: 1145,
rain_days: 28,
sun_hours: 416
}
},
static: {
max: {
accommodation_costs: 500,
food_costs: 100,
alcohol_costs: 100,
water_costs: 100,
local_transportation_costs: 100,
entertainment_costs: 100,
average_per_day_costs: 1000,
avg_price_relative: 100
},
min: {
accommodation_costs: 0,
food_costs: 0,
alcohol_costs: 0,
water_costs: 0,
local_transportation_costs: 0,
entertainment_costs: 0,
average_per_day_costs: 0,
avg_price_relative: 0
}
}
}
}

View File

@ -1,9 +0,0 @@
const moment = require('moment')
let date = {
year: 2012,
month: 3,
day: 13
}
console.log(moment(date));

View File

@ -14,10 +14,20 @@ exports.base64ToObj = function(base64) {
return JSON.parse(atob(base64));
}
/**
* Decodes a base64 encoded object.
* @param base64 encoded object
* @returns {string} decoded object
*/
function atob(base64) {
return Buffer.from(base64, 'base64').toString('binary')
}
/**
* Encodes an object as base64 string.
* @param string The object to encode
* @returns {string} base64 encoded object
*/
function btoa(string) {
return Buffer.from(string).toString('base64')
}

View File

@ -1,3 +1,8 @@
/**
* Seperate Strings created via GROUP_CONCAT by database into an array
* @param array String with comma-seperated values
* @returns [float] array of float values
*/
module.exports = (array) => {
if (array !== null && array !== undefined) {
const value = array

View File

@ -1,5 +1,3 @@
module.exports = function (dbConn) {
return async function getAllRegionsWithClimatePerMonth(month) {
console.log('getAllRegionsWithClimatePerMonth')

View File

@ -17,7 +17,18 @@ module.exports = function (queries) {
if (queries.local_transportation_costs) res.local_transportation_costs = [Number(queries.local_transportation_costs.split(',')[0]), Number(queries.local_transportation_costs.split(',')[1])]
if (queries.entertainment_costs) res.entertainment_costs = [Number(queries.entertainment_costs.split(',')[0]), Number(queries.entertainment_costs.split(',')[1])]
if (queries.average_per_day_costs) res.average_per_day_costs = [Number(queries.average_per_day_costs.split(',')[0]), Number(queries.average_per_day_costs.split(',')[1])]
if (queries.avg_price_relative) res.avg_price_relative = [Number(queries.avg_price_relative.split(',')[0]), Number(queries.avg_price_relative.split(',')[1])]
if (queries.tags) {
res.tags = []
if (queries.tags.includes(',')) {
res.tags.push(...queries.tags.split(',').map(el => el.trim()))
} else {
res.tags.push(queries.tags)
}
}
// console.log(res);
// console.log('queries successfully transformed');
// } catch (error) {
// console.log('oldToNewQuerySyntax error');

View File

@ -4,17 +4,17 @@ exports.calculateAvgScore = (...scores) => {
return avgScore = scores.reduce((total, score) => total += score) / scores.length;
}
exports.calculateScoreRange = (min, max, multiplier, regionVal, sLowVal, sHighVal) => {
exports.calculateScoreRange = (transitionRange, regionVal, sLowVal, sHighVal) => {
//console.log('scores.calculateScoreRange:', min, max, multiplier, regionVal, sLowVal, sHighVal)
// return full score when in range
if (regionVal >= sLowVal && regionVal <= sHighVal) return 10;
// choose value with smallest distance
let sVal = Math.abs(regionVal - sLowVal) < Math.abs(regionVal - sHighVal) ? sLowVal : sHighVal;
return this.calculateScore(min, max, multiplier, regionVal, sVal);
return this.calculateScore(transitionRange, regionVal, sVal);
}
exports.calculateScore = (min, max, multiplier, regionVal, searchVal) => {
let score = 1 - (Math.abs(searchVal - regionVal) / (max - min) * multiplier);
exports.calculateScore = (transitionRange, regionVal, searchVal) => {
let score = 1 - (Math.abs(searchVal - regionVal) / transitionRange);
return (score) * 10;
//return score <= 0 ? 0 : score * 10;
}
@ -53,4 +53,14 @@ exports.sigmoid = function (x, exponent) {
const sigm = 10 / (1 + 8 * Math.pow(Math.E, 3/4 * -x))
console.log('sigmoid (IN/OUT):', _.round(x,3), _.round(sigm, 3))
return sigm
}
exports.increaseTransitionForHighValues = function (transitionRange, searchVal) {
//console.log(transitionRange);
// console.log(transitionRange);
// console.log(((Math.pow(searchVal / 20, 2) / 100) + 1));
// console.log(((Math.pow(searchVal / 20, 2) / 100) + 1) * transitionRange);
let transFactor = ((Math.pow(searchVal / 20, 2) / 100) + 1)
return transFactor >= 4 ? 4 * transitionRange : transFactor * transitionRange
}

View File

@ -1,284 +1,306 @@
const _ = require('lodash')
const moment = require("moment")
const getClimateMinMax = require("./getClimateMinMax.js")
const scorer = require('./score')
const getRegions = require('../models/getRegions.js')
const SETTINGS = require('../settings').scoring
const SHOW_ALL_SCOREOBJECTS = false
const SETTINGS = {
temperature_mean_max: [4.5, 'easeOut', 2],
precipitation: [4, 'easeInOut', 2],
rain_days: [2, 'easeInOut', 2],
sun_hours: [3.6, 'easeInOut', 2],
accommodation_costs: [17, 'linear', null],
food_costs: [4, 'linear', null],
alcohol_costs: [4, 'linear', null],
water_costs: [10, 'linear', null],
local_transportation_costs: [5, 'linear', null],
entertainment_costs: [5, 'easeInOut', 0.6],
average_per_day_costs: [5, 'linear', null],
avg_price_relative: [3, 'easeOut', 2],
module.exports = async function (data, from, to, q) {
console.log('search')
console.log(q)
if ((_.isNil(to) || _.isNil(from)) && !(_.isEmpty(q.climate) || _.isEmpty(q.costs) || _.isEmpty(q.others))) {
throw new Error('invalid query')
}
if (_.isNil(data) || _.isEmpty(data.regions)) {
throw new Error('database error')
}
let regionsArr = data.regions
// PREPARE SEARCH
// validate dates
const dates = validateDates(from, to)
// for calculating average if traveldates are in more than one month
const travelPeriods = travelPeriodsFromDates(dates)
// CALCULATE PROPERTIES FOR EACH REGION
regionsArr.forEach(reg => {
// CALCULATE SCORES FOR CLIMATE PROPS
reg.scores = []
Object.entries(q.climate).forEach(([key, value]) => {
let finalScoreObj = calculateScoreForPeriod(key, travelPeriods, reg, value[0], value[1])
reg.scores.push(finalScoreObj)
});
// CALCULATE SCORES FOR PRICE PROPS
Object.entries(q.costs).forEach(([key, value]) => {
let finalScoreObj = scoreFromSimpleRegionProperty(key, reg, value[0], value[1])
reg.scores.push(finalScoreObj)
});
// CALCULATE SCORE FOR OFFSEASON
if (_.has(q, 'others.avg_price_relative')) {
let offSeasonScoreObj = calculateScoreForPeriod('avg_price_relative', travelPeriods, reg, q.others.avg_price_relative[0], q.others.avg_price_relative[1], 'easeOut', 2)
reg.scores.push(offSeasonScoreObj)
}
// CALCULATE SCORE FOR TAGS
if (_.has(q, 'others.tags')) {
reg.scores.push(...scoresFromTags(reg.tags, q.others.tags))
}
// CALCULATE PRICE TENDENCY FOR TIMEFRAME
reg.price_tendency_relative = getAverageFromTrivago(travelPeriods, reg)
// CALCULATE SUM FOR ACCOMODATION FROM AVERAGE PRICES AND APPROX LIFESTYLE COSTS
reg.total_accommodation_costs = _.round(sumForRangeAvg(dates.from, dates.to, reg.accommodation_costs), 2)
reg.total_avg_lifestyle = _.round(sumForRangeAvg(dates.from, dates.to, reg.average_per_day_costs - reg.accommodation_costs), 2)
//reg.name = `${reg.name} ca. ${_.round(sumForRangeAvg(dates.from, dates.to, reg.accommodation_costs), 2)}€`
// CALCULATE TOTAL PRICE WITH TRANSPORTATION
// CALCULATE TOTAL PRICE WITH TRANSPORTATION AND ESTEEMED LIFE COSTS
// CALCULATE AVERAGE SCORE Stage 1
let scoreSubGroups = []
if (!_.isEmpty(q.climate)) scoreSubGroups.push(calculateAverage(reg.scores.filter(el => _.some(Object.keys(q.climate), entry => entry === el.type ) )) )
if (!_.isEmpty(q.costs)) scoreSubGroups.push(calculateAverage(reg.scores.filter(el => _.some(Object.keys(q.costs), entry => entry === el.type ))) )
if (!_.isEmpty(q.others.avg_price_relative)) scoreSubGroups.push(calculateAverage(reg.scores.filter(el => 'avg_price_relative' === el.type ) ))
if (!_.isEmpty(q.others.tags)) scoreSubGroups.push(calculateAverage(reg.scores.filter(el => _.some(q.others.tags, entry => entry === el.type ))) )
// CALCULATE AVERAGE SCORE Stage 2
// reg.score = calculateAverage(reg.scores)
reg.score = _.round(_.sum(scoreSubGroups) / scoreSubGroups.length, 3)
})
return _.orderBy(regionsArr, ({ score }) => score || 0, 'desc') //.filter(el => !_.isNaN(el.score))
}
module.exports = function (dbConn) {
return async function (regions, from, to, q) {
console.log('search')
if ((_.isNil(to) || _.isNil(from)) && !(_.isEmpty(q.climate) || _.isEmpty(q.costs) || _.isEmpty(q.others))) {
throw new Error('invalid query')
function sumForRangeAvg(from, to, avg) {
let duration = moment(to).diff(moment(from), 'days')
return duration * avg
}
function sumForRangeFromDailyValues(from, to, dailyValues) {
// NOT NEEDED YET
// for (var m = moment(from).subtract(1, 'months'); m.isSameOrBefore(moment(to).subtract(1, 'months')); m.add(1, 'day')) {
// console.log(m);
// }
}
function calculateAverage(scores) {
let sum = 0
let cnt = 0
scores.forEach(el => {
if (el.score !== null && el.score !== undefined && !_.isNaN(el.score)) {
cnt++
sum += el.score
}
// PREPARE SEARCH
// validate dates
const dates = validateDates(from, to)
// for calculating average if traveldates are in more than one month
const travelPeriods = travelPeriodsFromDates(dates)
// FETCH DATA FROM DB
const boundaryClimate = await getClimateMinMax.getClimateMinMax(dbConn)
// let regions = await getRegions(dbConn)
regions.forEach(reg => reg.scores = [])
const boundaryStatic = {
max: {
accommodation_costs: 500,
food_costs: 100,
alcohol_costs: 100,
water_costs: 100,
local_transportation_costs: 100,
entertainment_costs: 100,
average_per_day_costs: 1000,
avg_price_relative: 100
},
min: {
accommodation_costs: 0,
food_costs: 0,
alcohol_costs: 0,
water_costs: 0,
local_transportation_costs: 0,
entertainment_costs: 0,
average_per_day_costs: 0,
avg_price_relative: 0
}
if (el.score === null || el.score === undefined || _.isNaN(el.score)) {
cnt++
sum += -1
}
// little tweak to show score object without request DEPRECATED
if (SHOW_ALL_SCOREOBJECTS) {
if (!q.climate.temperature_mean_max) q.climate.temperature_mean_max = [null, null]
if (!q.climate.precipitation) q.climate.precipitation = [null, null]
if (!q.climate.rain_days) q.climate.rain_days = [null, null]
if (!q.climate.sun_hours) q.climate.sun_hours = [null, null]
if (!q.climate.accommodation_costs) q.climate.accommodation_costs = [null, null]
})
//if (sum === 0 && cnt === 0) return 0
return _.round(sum / cnt, 3)
}
function travelPeriodsFromDates(dates) {
let start = moment(`${dates.from.year}-${dates.from.month}-${dates.from.day}`, 'YYYY-MM-DD')
let end = moment(`${dates.to.year}-${dates.to.month}-${dates.to.day}`, 'YYYY-MM-DD')
console.log('start:', moment(start));
console.log('end:', moment(end));
// console.log('start:', moment(start).toISOString());
// console.log('end:', moment(end).toISOString());
// console.log('start:', moment(dates.from));
// console.log('end:', moment(dates.to));
console.log();
console.log();
console.log();
let travelPeriods = []
if (start.month() === end.month() && start.year() === end.year()) {
let period = {
month: start.month()+1,
days: end.date() - start.date()
}
// CALCULATE SCORES FOR CLIMATE PROPS
regions.forEach(reg => {
Object.entries(q.climate).forEach(([key, value]) => {
let finalScoreObj = calculateScoreForPeriod(key, travelPeriods, reg, value[0], value[1], boundaryClimate)
reg.scores.push(finalScoreObj)
});
travelPeriods.push(period)
} else {
for (var m = moment(start); m.isSameOrBefore(moment(end).endOf("month")); m.add(1, 'months')) {
console.log(m);
// CALCULATE SCORES FOR PRICE PROPS
Object.entries(q.costs).forEach(([key, value]) => {
let finalScoreObj = calculateSimpleScore(key, reg, value[0], value[1], boundaryStatic)
reg.scores.push(finalScoreObj)
});
// CALCULATE SCORE FOR OFFSEASON
if (_.has(q, 'others.avg_price_relative')) {
let offSeasonScoreObj = calculateScoreForPeriod('avg_price_relative', travelPeriods, reg, q.others.avg_price_relative[0], q.others.avg_price_relative[1], boundaryStatic, 'easeOut', 2)
reg.scores.push(offSeasonScoreObj)
}
reg.price_tendency_relative = getAverageFromTrivago(travelPeriods, reg)
// CALCULATE AVERAGE SCORE Stage 1
let scoreSubGroups = []
if (!_.isEmpty(q.climate)) scoreSubGroups.push(calculateAverage(reg.scores.filter(el => _.some(Object.keys(q.climate), entry => entry === el.type ) )) )
if (!_.isEmpty(q.costs)) scoreSubGroups.push(calculateAverage(reg.scores.filter(el => _.some(Object.keys(q.costs), entry => entry === el.type ))) )
if (!_.isEmpty(q.others)) scoreSubGroups.push(calculateAverage(reg.scores.filter(el => _.some(Object.keys(q.others), entry => entry === el.type ) )) )
// CALCULATE AVERAGE SCORE Stage 2
reg.score = calculateAverage(reg.scores)
// reg.score = _.sum(scoreSubGroups) / scoreSubGroups.length
})
return _.orderBy(regions, ({ score }) => score || 0, 'desc') //.filter(el => !_.isNaN(el.score))
travelPeriods.push(createPeriod(start, end, m.month()+1, m.year()))
}
}
return travelPeriods
}
function validateDates(from, to) {
let fromAndTo = {
from: {},
to: {}
}
function calculateAverage(scores) {
let sum = 0
let cnt = 0
scores.forEach(el => {
if (el.score !== null && el.score !== undefined && !_.isNaN(el.score)) {
cnt++
sum += el.score
}
if (el.score === null || el.score === undefined || _.isNaN(el.score)) {
cnt++
sum += -1
}
})
//if (sum === 0 && cnt === 0) return 0
return _.round(sum / cnt, 3)
if (_.isNumber(from) && _.isNumber(to)) {
let dateFrom = new Date(from)
fromAndTo.from.day = dateFrom.getDate()
fromAndTo.from.month = dateFrom.getMonth() + 1
fromAndTo.from.year = dateFrom.getFullYear()
let dateTo = new Date(to)
fromAndTo.to.day = dateTo.getDate()
fromAndTo.to.month = dateTo.getMonth() + 1
fromAndTo.to.year = dateTo.getFullYear()
} else {
// this block to still support old query syntax, validating from and to parameter
let re = /([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))/i;
fromAndTo.from.year = Number(from.split("-")[0])
fromAndTo.to.year = Number(to.split("-")[0])
fromAndTo.from.month = Number(from.split("-")[1])
fromAndTo.to.month = Number(to.split("-")[1])
fromAndTo.from.day = Number(from.split("-")[2])
fromAndTo.to.day = Number(to.split("-")[2])
if (!from.match(re) || !to.match(re)) throw new Error("ERR: invalid parameter:", from, to)
}
console.log(moment(`${fromAndTo.from.year}-${fromAndTo.from.month}-${fromAndTo.from.day}`, 'YYYY-MM-DD'))
console.log(moment(`${fromAndTo.to.year}-${fromAndTo.to.month}-${fromAndTo.to.day}`, 'YYYY-MM-DD'))
if (moment(`${fromAndTo.from.year}-${fromAndTo.from.month}-${fromAndTo.from.day}`, 'YYYY-MM-DD').add(23, 'hours').isAfter(moment(`${fromAndTo.to.year}-${fromAndTo.to.month}-${fromAndTo.to.day}`, 'YYYY-MM-DD'))) throw new Error("ERR: 'to' must be at least one day after 'from'.")
return fromAndTo
}
function createPeriod(start, end, currentMonth, currentYear) {
let period = {}
console.log(start, end, currentMonth, currentYear);
function travelPeriodsFromDates(dates) {
let travelPeriods = []
if (dates.from.month === dates.to.month && dates.from.year === dates.to.year) {
let period = {
month: dates.from.month,
days: dates.to.day - dates.from.day
}
travelPeriods.push(period)
} else {
for (var m = moment(dates.from).subtract(1, 'months'); m.isSameOrBefore(moment(dates.to).subtract(1, 'months').endOf("month")); m.add(1, 'months')) {
travelPeriods.push(createPeriod(dates.from, dates.to, m.month() + 1, m.year()))
}
if (currentMonth === start.month() + 1 && currentYear === start.year()) {
console.log('first month')
period = {
month: currentMonth,
days: 32 - start.date()
}
} else if (currentMonth === end.month() + 1) {
console.log('end month')
period = {
month: currentMonth,
days: end.date()
}
} else {
console.log('middle month')
period = {
month: currentMonth,
days: 30
}
return travelPeriods
}
return period
}
function calculateScoreForPeriod(type, travelPeriods, region, searchLowParam, searchMaxParam) {
// console.log('getScoreAndAverageFromClimate for', region.name, type)
function validateDates(from, to) {
let fromAndTo = {
from: {},
to: {}
}
if (_.isNumber(from) && _.isNumber(to)) {
let dateFrom = new Date(from)
fromAndTo.from.day = dateFrom.getDate()
fromAndTo.from.month = dateFrom.getMonth() + 1
fromAndTo.from.year = dateFrom.getFullYear()
let dateTo = new Date(to)
fromAndTo.to.day = dateTo.getDate()
fromAndTo.to.month = dateTo.getMonth() + 1
fromAndTo.to.year = dateFrom.getFullYear()
} else {
// this block to still support old query syntax, validating from and to parameter
let re = /([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))/i;
fromAndTo.from.year = Number(from.split("-")[0])
fromAndTo.to.year = Number(to.split("-")[0])
fromAndTo.from.month = Number(from.split("-")[1])
fromAndTo.to.month = Number(to.split("-")[1])
fromAndTo.from.day = Number(from.split("-")[2])
fromAndTo.to.day = Number(to.split("-")[2])
if (!from.match(re) || !to.match(re)) throw new Error("ERR: invalid parameter:", from, to)
}
if (moment(fromAndTo.from).add(23, 'hours').isAfter(moment(fromAndTo.to))) throw new Error("ERR: 'to' must be at least one day after 'from'.")
return fromAndTo
}
function createPeriod(from, to, currentMonth, currentYear) {
let period = {}
if (currentMonth === from.month && currentYear === from.year) {
period = {
month: currentMonth,
days: 32 - from.day
}
} else if (currentMonth === to.month) {
period = {
month: currentMonth,
days: to.day
}
} else {
period = {
month: currentMonth,
days: 30
}
}
return period
}
function calculateScoreForPeriod(type, travelPeriods, region, searchLowParam, searchMaxParam, minMax) {
// console.log('getScoreAndAverageFromClimate for', region.name, type)
const singleScores = travelPeriods.map(period => {
let res = {
type: type,
value: region[type][period.month - 1],
days: period.days
}
return res
})
let averagedScore = {
const singleScores = travelPeriods. map(period => {
let res = {
type: type,
value: 0,
days: 0
value: region[type] !== null ? region[type][period.month - 1] : null,
days: period.days
}
singleScores.forEach(el => {
if (el.value !== null && !_.isNaN(el.value)) {
averagedScore.value += (el.value * el.days)
averagedScore.days += (el.days)
} else {
// console.log('skip averaging')
// console.log(el)
}
})
averagedScore.value = _.round(averagedScore.value / averagedScore.days, 3)
delete averagedScore.days
let sc = scorer.calculateScoreRange(minMax.min[type], minMax.max[type], SETTINGS[type][0], averagedScore.value, searchLowParam, searchMaxParam)
averagedScore.score = _.round(scorer[SETTINGS[type][1]](sc, SETTINGS[type][2]), 3)
// console.log('score', averagedScore.score)
if (searchLowParam === null) averagedScore.score = null
return averagedScore
}
return res
})
function calculateSimpleScore(type, region, searchLowParam, searchMaxParam, minMax) {
// console.log('getScoreFromCosts for', region.name, type)
const sc = _.round(scorer.calculateScoreRange(minMax.min[type], minMax.max[type], SETTINGS[type][0], region[type], searchLowParam, searchMaxParam), 3)
let finScore = {
type: type,
value: region[type],
score: scorer[SETTINGS[type][1]](sc, SETTINGS[type][2]),
}
finScore.value = _.round(finScore.value, 1)
finScore.score = _.round(finScore.score, 3)
if (searchLowParam === null) finScore.score = null
return finScore
let averagedScore = {
type: type,
value: 0,
days: 0
}
function getAverageFromTrivago(travelPeriods, region) {
// console.log('getAverageFromTrivago for', region.name)
const singleScores = travelPeriods.map(period => {
let res = {
value: region.avg_price_relative[period.month - 1],
days: period.days
}
singleScores.forEach(el => {
if (el.value !== null && !_.isNaN(el.value)) {
averagedScore.value += (el.value * el.days)
averagedScore.days += (el.days)
} else {
// console.log('skip averaging')
// console.log(el)
return res
})
let averagedScore = {
value: 0,
days: 0
}
singleScores.forEach(el => {
if (el.value !== null && !_.isNaN(el.value)) {
averagedScore.value += (el.value * el.days)
averagedScore.days += (el.days)
} else {
// console.log('skip averaging')
// console.log(el)
}
})
averagedScore.value = _.round(averagedScore.value / averagedScore.days, 2)
return averagedScore.value
})
averagedScore.value = _.round(averagedScore.value / averagedScore.days, 3)
delete averagedScore.days
let transitionRange = SETTINGS[type][0]
// special for precipitation
if (type === 'precipitation') {
transitionRange = scorer.increaseTransitionForHighValues(SETTINGS[type][0], searchLowParam)
}
//end
let sc = scorer.calculateScoreRange(transitionRange, averagedScore.value, searchLowParam, searchMaxParam)
averagedScore.score = _.round(scorer[SETTINGS[type][1]](sc, SETTINGS[type][2]), 3)
// console.log('score', averagedScore.score)
if (searchLowParam === null) averagedScore.score = null
return averagedScore
}
function scoresFromTags(regionTags, tagStringsFromQueries) {
return tagStringsFromQueries.map(tagQuery => {
const tag = regionTags.find(tag => tagQuery === tag.name)
let retVal = {
type: tagQuery,
value: null,
score: null,
}
if (_.isNil(tag)) return retVal
retVal.value = tag.value
retVal.score = /* tag.value <= 0 ? 0 : */ _.round(scorer.calculateScoreRange(60, tag.value, 100, 100), 3)
console.log(retVal);
return retVal
})
}
function scoreFromSimpleRegionProperty(type, region, searchLowParam, searchMaxParam, minMax) {
// console.log('getScoreFromCosts for', region.name, type)
const sc = _.round(scorer.calculateScoreRange(SETTINGS[type][0], region[type], searchLowParam, searchMaxParam), 3)
let finScore = {
type: type,
value: region[type],
score: scorer[SETTINGS[type][1]](sc, SETTINGS[type][2]),
}
finScore.value = _.round(finScore.value, 1)
finScore.score = _.round(finScore.score, 3)
if (searchLowParam === null) finScore.score = null
return finScore
}
function getAverageFromTrivago(travelPeriods, region) {
// console.log('getAverageFromTrivago for', region.name)
const singleScores = travelPeriods.map(period => {
let res = {
value: region.avg_price_relative[period.month - 1],
days: period.days
}
return res
})
let averagedScore = {
value: 0,
days: 0
}
singleScores.forEach(el => {
if (el.value !== null && !_.isNaN(el.value)) {
averagedScore.value += (el.value * el.days)
averagedScore.days += (el.days)
} else {
// console.log('skip averaging')
// console.log(el)
}
})
averagedScore.value = _.round(averagedScore.value / averagedScore.days, 2)
return averagedScore.value
}

View File

@ -0,0 +1,14 @@
const sqlstring = require("sqlstring")
/**
* Sanitizes value if it isn't a numerical value
* @param val
* @returns string Sanitized String
*/
module.exports = (val) => {
if(!isNaN(val)) { // Checks if the value is a numerical value (in a string)
return val
} else {
return sqlstring.escape(val)
}
};

View File

@ -2,23 +2,26 @@
<button (click)="drawer.toggle()" *ngIf="isMobile" class="menu-btn" mat-icon-button>
<mat-icon>menu</mat-icon>
</button>
<h1 class="title">Travopti - Prototype</h1>
<img alt="Travopti logo" class="title" src="assets/logo.svg">
</mat-toolbar>
<mat-drawer-container autosize class="drawer">
<mat-drawer-container autosize class="drawer-container">
<mat-drawer #drawer [mode]="isMobile?'over':'side'" [opened]="!isMobile">
<div class="side-nav">
<a (click)="isMobile&&drawer.close()" mat-button routerLink="home" routerLinkActive="active">
<mat-icon>home</mat-icon>
<span>Home</span>
</a>
<a (click)="isMobile&&drawer.close()" mat-button routerLink="bookmark" routerLinkActive="active">
<mat-icon>bookmark</mat-icon>
<span>Your bookmarks</span>
</a>
<a (click)="isMobile&&drawer.close()" mat-button routerLink="team" routerLinkActive="active">
<mat-icon>group</mat-icon>
<span>About us</span>
</a>
<div class="drawer">
<div class="side-nav">
<a (click)="isMobile&&drawer.close()" mat-button routerLink="home" routerLinkActive="active">
<mat-icon>home</mat-icon>
<span>Home</span>
</a>
<a (click)="isMobile&&drawer.close()" mat-button routerLink="bookmark" routerLinkActive="active">
<mat-icon>bookmark</mat-icon>
<span>Your bookmarks</span>
</a>
<a (click)="isMobile&&drawer.close()" mat-button routerLink="team" routerLinkActive="active">
<mat-icon>group</mat-icon>
<span>About us</span>
</a>
</div>
<a class="feedback" href="mailto:feedback@travopti.de">Feedback</a>
</div>
</mat-drawer>
<mat-drawer-content class="content">
@ -27,6 +30,3 @@
</div>
</mat-drawer-content>
</mat-drawer-container>

View File

@ -10,24 +10,32 @@
display: flex;
flex-flow: row;
align-items: center;
height: 4rem;
.menu-btn {
margin-right: 1rem;
}
.title {
flex: 1 1 auto;
margin: 0;
flex: 0 1 auto;
height: 2.5rem;
}
}
.drawer-container {
height: 100%;
}
.drawer {
flex: 1 1 auto;
display: flex;
flex-direction: column;
height: 100%;
.side-nav {
flex: 1 1 auto;
display: flex;
flex-direction: column;
height: 100%;
min-width: 12.5vw;
box-sizing: border-box;
padding: 1rem 0;
@ -45,10 +53,16 @@
}
&.active {
color: #00ae00;
color: #00a0d2;
}
}
}
.feedback {
margin: 1rem;
text-align: center;
color: gray;
}
}
.content {

View File

@ -25,8 +25,10 @@ import {
MatBadgeModule,
MatButtonToggleModule,
MatCheckboxModule,
MatChipsModule,
MatDialogModule,
MatDividerModule,
MatListModule,
MatRadioModule,
MatSliderModule,
MatSlideToggleModule,
@ -47,6 +49,8 @@ import {ShareDialogComponent} from './dialogs/share-dialog/share-dialog.componen
import {TeamComponent} from './containers/team/team.component';
import {DeviceDetectorModule} from 'ngx-device-detector';
import {ToggleSliderComponent} from './components/toggle-slider/toggle-slider.component';
import {PlaceComponent} from './components/place/place.component';
import {MultiTagSelectComponent} from './components/multi-tag-select/multi-tag-select.component';
@NgModule({
@ -66,7 +70,9 @@ import {ToggleSliderComponent} from './components/toggle-slider/toggle-slider.co
ShareButtonComponent,
ShareDialogComponent,
TeamComponent,
ToggleSliderComponent
ToggleSliderComponent,
PlaceComponent,
MultiTagSelectComponent
],
imports: [
BrowserModule,
@ -95,7 +101,9 @@ import {ToggleSliderComponent} from './components/toggle-slider/toggle-slider.co
MatStepperModule,
MatRadioModule,
MatSlideToggleModule,
MatSliderModule
MatSliderModule,
MatChipsModule,
MatListModule
],
providers: [],
bootstrap: [AppComponent],

View File

@ -20,6 +20,8 @@ export class GraphComponent implements AfterViewInit {
formatSting: string;
@Input()
graphType = 'line';
@Input()
minMax: number[];
readonly randomId = uuidv4();
@ -60,6 +62,14 @@ export class GraphComponent implements AfterViewInit {
data[i].color = this.colors[i];
}
let axisY: CanvasJS.ChartOptions.ChartAxisY;
if (this.minMax) {
axisY = {
minimum: this.minMax[0],
maximum: this.minMax[1]
};
}
const chart = new CanvasJS.Chart(this.randomId, {
animationEnabled: true,
@ -70,6 +80,7 @@ export class GraphComponent implements AfterViewInit {
horizontalAlign: 'left',
dockInsidePlotArea: true
},
axisY,
data
});

View File

@ -0,0 +1,5 @@
<mat-chip-list>
<!--suppress AngularInvalidExpressionResultType -->
<mat-chip (click)="onChipClick(tag)" *ngFor="let tag of availableTags" [color]="isSelected(tag) ? 'accent' : 'none'"
selected>{{tag|translate}}</mat-chip>
</mat-chip-list>

View File

@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {MultiTagSelectComponent} from './multi-tag-select.component';
describe('MultiTagSelectComponent', () => {
let component: MultiTagSelectComponent;
let fixture: ComponentFixture<MultiTagSelectComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MultiTagSelectComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MultiTagSelectComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,59 @@
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
@Component({
selector: 'app-multi-tag-select',
templateUrl: './multi-tag-select.component.html',
styleUrls: ['./multi-tag-select.component.scss']
})
export class MultiTagSelectComponent implements OnInit {
@Input()
availableTags: string[] = [];
@Output()
selection = new EventEmitter<string[]>();
rawValue: string[] = [];
@Output() modelChange: EventEmitter<string[]> = new EventEmitter<string[]>();
constructor() {
}
get value(): string[] {
return this.rawValue;
}
set value(value: string[]) {
this.rawValue = value;
this.modelChange.emit(value);
}
@Input()
set model(value: string[]) {
this.rawValue = value;
}
ngOnInit() {
}
onChipClick(tag: string) {
if (this.isSelected(tag)) {
this.deselectTag(tag);
} else {
this.selectTag(tag);
}
}
isSelected(tag: string) {
return this.value && this.value.includes(tag);
}
private deselectTag(tag: string) {
this.value = this.value.filter(i => i !== tag);
}
private selectTag(tag: string) {
this.value = this.value.concat(tag);
}
}

View File

@ -0,0 +1,6 @@
<mat-card (click)="onClick($event)">
<img [src]="place.img_url" alt="Image of {{place.place_name}}">
<div class="footer">
<span class="name">{{place.place_name}}</span>
</div>
</mat-card>

View File

@ -0,0 +1,28 @@
mat-card {
padding: 0;
display: flex;
flex-direction: column;
}
img {
flex: 0 0 auto;
width: 20rem;
height: 11.25rem;
object-fit: cover;
}
.footer {
width: 20rem;
padding: 0.5rem;
box-sizing: border-box;
flex: 0 1 auto;
display: flex;
flex-direction: row;
.name {
flex: 0 1 auto;
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}

View File

@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {PlaceComponent} from './place.component';
describe('PlaceComponent', () => {
let component: PlaceComponent;
let fixture: ComponentFixture<PlaceComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [PlaceComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PlaceComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,24 @@
import {Component, Input, OnInit} from '@angular/core';
import {Place} from '../../interfaces/places.interface';
@Component({
selector: 'app-place',
templateUrl: './place.component.html',
styleUrls: ['./place.component.scss']
})
export class PlaceComponent implements OnInit {
@Input()
place: Place;
constructor() {
}
ngOnInit() {
}
onClick($event: MouseEvent) {
$event.stopPropagation();
window.open(`https://google.com/maps?q=${encodeURI(this.place.place_name)}`, '_blank');
}
}

View File

@ -2,38 +2,62 @@
<img alt="Picture of {{result.name}}" class="result-img"
src="https://travopti.de/api/v1/regions/{{result.region_id}}/image">
<div class="result-title">
<div class="result-name">
<span class="result-name">{{result.name}}</span>
<div class="result-header">
<span class="result-name">{{result.name}}<span *ngIf="debug"> ({{result.score}})</span></span>
<span class="result-country">| {{result.country}}</span>
</div>
<app-bookmark-button [region]="result"></app-bookmark-button>
<app-share-button [region]="result"></app-share-button>
</div>
<div class="result-details">
<table>
<tr *ngFor="let score of result.scores" [ngClass]="{'undefined': score.value == undefined}">
<td>
<div class="cell space">
<mat-icon>{{PROPERTY_VIS_DEF[score.type] ? PROPERTY_VIS_DEF[score.type].icon : 'bar_chart'}}</mat-icon>
<span>{{score.type|translate}}:</span>
</div>
</td>
<td>
<div class="cell right">
<span>{{score.value != undefined ? (score.value|number:'1.2-2') : 'N/A'}}</span>
</div>
</td>
<td>
<div class="cell">
<span>{{PROPERTY_VIS_DEF[score.type] ? PROPERTY_VIS_DEF[score.type].unit : ''}}</span>
</div>
</td>
<td>
<div class="cell">
<span>({{score.score}})</span>
</div>
</td>
</tr>
</table>
<mat-divider *ngIf="totalCosts"></mat-divider>
<div *ngIf="totalCosts" class="total-price-container">
<div matTooltip="Total">
<mat-icon>euro</mat-icon>
<span>{{totalCosts|number:'1.0-0'}}€</span>
</div>
<div matTooltip="Accommodation">
<mat-icon>hotel</mat-icon>
<span>{{totalAccommodation|number:'1.0-0'}}€</span>
</div>
<div matTooltip="Lifestyle">
<mat-icon>people</mat-icon>
<span>{{totalLifeStyle|number:'1.0-0'}}€</span>
</div>
<div (click)="onTravelCostRequest($event)" matTooltip="Travel (request)">
<mat-icon>commute</mat-icon>
<span>- - -</span>
</div>
</div>
<mat-divider *ngIf="result.scores.length > 0"></mat-divider>
<div *ngIf="result.scores.length > 0" class="searched-params">
<table>
<tr *ngFor="let score of result.scores" [ngClass]="{'undefined': score.value == undefined}">
<td>
<div class="cell space">
<mat-icon>{{PROPERTY_VIS_DEF[score.type] ? PROPERTY_VIS_DEF[score.type].icon : 'bar_chart'}}</mat-icon>
<span>{{score.type|translate}}:</span>
</div>
</td>
<td>
<div class="cell right">
<span>{{score.value != undefined ? (score.value|number:PROPERTY_VIS_DEF[score.type].decimals) : 'N/A'}}</span>
</div>
</td>
<td>
<div class="cell">
<span>{{PROPERTY_VIS_DEF[score.type] ? PROPERTY_VIS_DEF[score.type].unit : ''}}</span>
</div>
</td>
<td *ngIf="debug">
<div class="cell">
<span>({{score.score}})</span>
</div>
</td>
</tr>
</table>
</div>
<mat-divider></mat-divider>
<div class="result-desc">Estimated values for {{duration}} {{duration > 1 ? 'days' : 'day'}}</div>
</div>
</div>

View File

@ -15,33 +15,73 @@
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 0.5rem;
margin-bottom: 0.25rem;
> .result-name {
> .result-header {
flex: 1 1 auto;
display: flex;
flex-direction: row;
margin: 0.25rem 0;
> .result-country {
text-transform: uppercase;
font-size: small;
align-self: center;
}
align-items: center;
> .result-name {
font-weight: bold;
font-size: larger;
align-self: center;
margin-right: 0.25rem;
}
> .result-country {
text-transform: uppercase;
font-size: small;
margin-right: 0.25rem;
overflow-y: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
> .result-details {
display: flex;
flex-direction: column;
> .total-price-container {
display: flex;
flex-direction: row;
margin: 0.5rem 0;
> div {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 1rem;
> mat-icon {
margin-right: 0.25rem;
}
}
}
> .searched-params {
margin: 0.5rem 0
}
.undefined {
color: #8f8f8f;
}
.result-desc {
color: #b5b9be;
text-transform: uppercase;
font-size: small;
margin-right: 0.25rem;
margin-top: 0.5rem;
overflow-y: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}

View File

@ -1,4 +1,4 @@
import {Component, Input, OnInit} from '@angular/core';
import {Component, Input, OnChanges, OnInit, SimpleChanges} from '@angular/core';
import {Result} from '../../interfaces/result.interface';
import {REGION_PARAM_VIS} from '../../services/data.service';
@ -7,11 +7,23 @@ import {REGION_PARAM_VIS} from '../../services/data.service';
templateUrl: './result.component.html',
styleUrls: ['./result.component.scss']
})
export class ResultComponent implements OnInit {
export class ResultComponent implements OnInit, OnChanges {
@Input()
result: Result;
/** Date difference in days */
@Input()
duration: number;
@Input()
debug = false;
totalCosts: number;
totalAccommodation: number;
totalLifeStyle: number;
totalTravel: number;
/** Contains the visual definitions */
readonly PROPERTY_VIS_DEF = REGION_PARAM_VIS;
@ -21,4 +33,25 @@ export class ResultComponent implements OnInit {
ngOnInit() {
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.result || changes.duration) {
this.calculateTotalPrices();
}
}
onTravelCostRequest(event: MouseEvent) {
event.stopPropagation();
window.open(`https://www.google.com/flights?q=flight+to+${encodeURI(this.result.name)}`, '_blank');
}
private calculateTotalPrices() {
// Guard: undefined values
if (!this.result || !this.duration) {
return;
}
this.totalCosts = Math.round(this.result.average_per_day_costs * this.duration);
this.totalAccommodation = Math.round(this.result.accommodation_costs * this.duration);
this.totalLifeStyle = this.totalCosts - this.totalAccommodation;
}
}

View File

@ -8,9 +8,9 @@
<mat-tab label="Guided">
<mat-vertical-stepper>
<!-- Date input -->
<mat-step>
<ng-template matStepLabel>When is your trip?</ng-template>
<ng-template matStepLabel>When do you want to travel?</ng-template>
<div class="vertical-wrap">
<mat-form-field appearance="outline">
<mat-label>Start</mat-label>
@ -22,22 +22,24 @@
</mat-form-field>
</div>
</mat-step>
<!-- Multi presets -->
<mat-step>
<ng-template matStepLabel>Which climate would you prefer?</ng-template>
<div *ngFor="let key of multiPresetsKeys" class="sub-group">
<span class="label">{{key|translate}}:</span><br>
<mat-radio-group [ngModel]="multiPresetSelection[key]" [value]="undefined">
<mat-radio-button
#btn
(click)="btn.checked = onMultiPresetSelect(preset)"
*ngFor="let preset of multiPresets.get(key)"
[value]="preset.preset_id"
>{{preset.tag_label|translate}}</mat-radio-button>
</mat-radio-group>
<ng-template matStepLabel>Which climate do you prefer?</ng-template>
<div class="flexer">
<div *ngFor="let key of multiPresetsKeys" class="sub-group">
<span class="label">{{key|translate}}:</span><br>
<mat-radio-group [ngModel]="multiPresetSelection[key]" [value]="undefined">
<mat-radio-button
#btn
(click)="btn.checked = onMultiPresetSelect(preset)"
*ngFor="let preset of multiPresets.get(key)"
[value]="preset.preset_id"
>{{preset.tag_label|translate}}</mat-radio-button>
</mat-radio-group>
</div>
</div>
</mat-step>
<!-- Single presets -->
<mat-step>
<ng-template matStepLabel>What else is important to you?</ng-template>
<div class="vertical">
@ -45,6 +47,12 @@
[(ngModel)]="singlePresetSelection[preset.preset_id]">{{preset.tag_label|translate}}</mat-checkbox>
</div>
</mat-step>
<!-- Tag selection -->
<mat-step>
<ng-template matStepLabel>What are you interested in?</ng-template>
<app-multi-tag-select [(model)]="selectedTags" [availableTags]="tags"></app-multi-tag-select>
</mat-step>
</mat-vertical-stepper>
</mat-tab>
@ -53,7 +61,7 @@
<!-- Date -->
<section class="group">
<span class="title">Date</span>
<div class=" content vertical-wrap">
<div class="content vertical-wrap">
<mat-form-field appearance="outline">
<mat-label>Start</mat-label>
<input (change)="checkDates()" [(ngModel)]="from" [min]="today" matInput required type="date">
@ -68,30 +76,53 @@
<section class="group">
<span class="title">Text</span>
<div class="content vertical-wrap">
<mat-form-field class="text-input">
<mat-form-field appearance="outline" style="margin-bottom: -1.25em">
<mat-label>Text search</mat-label>
<input [(ngModel)]="textFilter" matInput>
<button (click)="textFilter = ''" *ngIf="textFilter.length" mat-icon-button matSuffix>
<mat-icon>clear</mat-icon>
</button>
</mat-form-field>
<div class="horizontal space">
<span>Search in description </span>
<div class="horizontal space center-desc">
<mat-slide-toggle [(ngModel)]="fullText"></mat-slide-toggle>
<span>Search in description </span>
</div>
</div>
</section>
<!-- Climate Params -->
<section class="group">
<span class="title">Climate</span>
<app-toggle-slider [(model)]="temperatureMeanMax" [label]="'Max Temp'" [max]="45" [min]="0"></app-toggle-slider>
<app-toggle-slider [(model)]="precipitation" [label]="'Precipitation'" [max]="500"
<div class="horizontal">
<span class="title">Climate</span>
<span class="desc">| sweetspot selection</span>
</div>
<app-toggle-slider [(model)]="temperatureMeanMax" [label]="'Temperature (°C)'" [max]="45"
[min]="0"></app-toggle-slider>
<app-toggle-slider [(model)]="precipitation" [label]="'Precipitation (mm)'" [max]="500"
[min]="0"></app-toggle-slider>
<app-toggle-slider [(model)]="sunHours" [label]="'Sun hours'" [max]="400"
[min]="0"></app-toggle-slider>
<app-toggle-slider [(model)]="rainDays" [label]="'Rain days'" [max]="31"
[min]="0"></app-toggle-slider>
</section>
<!-- Financial -->
<section class="group">
<span class="title">Fincancial</span>
<app-toggle-slider [(model)]="accommodation" [label]="'Accommodation'" [max]="60" [min]="0"></app-toggle-slider>
<div class="horizontal">
<span class="title">Financial</span>
<span class="desc">| sweetspot selection (€/day)</span>
</div>
<app-toggle-slider [(model)]="costPerDay" [label]="'Total cost'" [max]="400" [min]="0"></app-toggle-slider>
<app-toggle-slider [(model)]="accommodation" [label]="'Accommodation'" [max]="200"
[min]="0"></app-toggle-slider>
<app-toggle-slider [(model)]="localTransport" [label]="'Local transport'" [max]="20"
[min]="0"></app-toggle-slider>
</section>
<!-- Tags -->
<section class="group">
<div class="horizontal">
<span class="title">Tags</span>
<span class="desc">| Search by user ratings</span>
</div>
<app-multi-tag-select [(model)]="selectedTags" [availableTags]="tags"></app-multi-tag-select>
</section>
</mat-tab>

View File

@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
> .search-btn {
>.search-btn {
margin-top: 1rem;
}
}
@ -31,20 +31,33 @@
display: flex;
flex-direction: column;
margin: 1rem 0;
overflow: hidden;
> .title {
.title {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
> .content {
margin: 0 2.5rem;
.desc {
font-size: smaller;
margin-left: 0.25rem;
text-align: center;
text-transform: uppercase;
color: gray;
}
>.content {
// margin: 0 2.5rem;
margin-top: 0.5rem;
.text-input {
min-width: 14rem;
}
}
> app-multi-tag-select {
margin: 1rem;
}
}
.horizontal {
@ -53,12 +66,22 @@
align-items: center;
&.space {
> * {
margin-right: 1rem;
>* {
margin: 0.5rem;
}
}
&.center {
justify-content: space-around;
}
&.center-desc {
justify-content: center;
}
}
.vertical {
display: flex;
flex-direction: column;
@ -68,6 +91,30 @@
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-around;
justify-content: space-between;
align-items: center;
margin-left: -0.5rem;
margin-right: -0.5rem;
>* {
flex-grow: 1;
margin: 0 0.5rem;
}
}
.small-bottom-margin {
margin-bottom: 0.4rem !important;
}
.flexer {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
margin-right: -1rem;
> .sub-group {
flex-grow: 1;
margin-right: 1rem;
}
}

View File

@ -6,6 +6,7 @@ import {PresetService} from '../../services/preset.service';
import {Preset} from '../../interfaces/preset.interface';
import {formatDate} from '@angular/common';
import {SearchService} from '../../services/search.service';
import {toMinMaxArray} from '../../utils/toMinMaxArray';
@Component({
selector: 'app-search-input',
@ -21,6 +22,9 @@ export class SearchInputComponent implements OnInit {
singlePresets: Preset[];
multiPresets: Map<string, Preset[]>;
multiPresetsKeys: string[];
selectedTags: string[] = [];
tags: string[];
from: string;
to: string;
@ -34,7 +38,12 @@ export class SearchInputComponent implements OnInit {
fullText = false;
temperatureMeanMax: number;
precipitation: number;
rainDays: number;
sunHours: number;
accommodation: number;
costPerDay: number;
entertainment: number;
localTransport: number;
readonly today = this.from = formatDate(new Date(), 'yyyy-MM-dd', 'en-GB');
@ -55,6 +64,8 @@ export class SearchInputComponent implements OnInit {
this.multiPresets = this.ps.multiPresets;
this.multiPresetsKeys = [...this.multiPresets.keys()];
this.tags = await this.ss.getAvailableTags();
this.loadSearch();
}
@ -62,6 +73,7 @@ export class SearchInputComponent implements OnInit {
this.saveSearch(isAdvanced);
const query = isAdvanced ? this.getQueryFromAdvanced() : this.getQueryFromGuided();
console.log(query);
await this.router.navigate(['/search'], {queryParams: {q: objToBase64(query)}});
}
@ -96,6 +108,7 @@ export class SearchInputComponent implements OnInit {
const query: Query = {
from: new Date(this.from).getTime(),
to: new Date(this.to).getTime(),
tags: this.selectedTags
};
for (const preset of this.singlePresets) {
@ -117,6 +130,7 @@ export class SearchInputComponent implements OnInit {
const query: Query = {
from: new Date(this.from).getTime(),
to: new Date(this.to).getTime(),
tags: this.selectedTags
};
if (this.textFilter.length > 0) {
@ -124,9 +138,14 @@ export class SearchInputComponent implements OnInit {
query.textfilter = this.textFilter;
}
query.temperature_mean_max = this.temperatureMeanMax ? [this.temperatureMeanMax, this.temperatureMeanMax] : undefined;
query.precipitation = this.precipitation ? [this.precipitation, this.precipitation] : undefined;
query.accommodation_costs = this.accommodation ? [this.accommodation, this.accommodation] : undefined;
query.temperature_mean_max = toMinMaxArray(this.temperatureMeanMax);
query.precipitation = toMinMaxArray(this.precipitation);
query.sun_hours = toMinMaxArray(this.sunHours);
query.rain_days = toMinMaxArray(this.rainDays);
query.average_per_day_costs = toMinMaxArray(this.costPerDay);
query.accommodation_costs = toMinMaxArray(this.accommodation);
query.entertainment_costs = toMinMaxArray(this.entertainment);
query.local_transportation_costs = toMinMaxArray(this.localTransport);
return query;
}
@ -138,11 +157,17 @@ export class SearchInputComponent implements OnInit {
to: this.to,
singlePresetSelection: this.singlePresetSelection,
multiPresetSelection: this.multiPresetSelection,
tags: this.selectedTags,
fullText: this.fullText,
textFiler: this.textFilter,
tempMeanMax: this.temperatureMeanMax,
precipitation: this.precipitation,
rain_days: this.rainDays,
sun_hours: this.sunHours,
average_per_day_costs: this.costPerDay,
accommodation: this.accommodation,
entertainment_costs: this.entertainment,
local_transportation_costs: this.localTransport,
});
}
@ -154,12 +179,18 @@ export class SearchInputComponent implements OnInit {
this.to = prevInput.to;
this.singlePresetSelection = prevInput.singlePresetSelection;
this.multiPresetSelection = prevInput.multiPresetSelection;
this.selectedTags = prevInput.tags;
this.textFilter = prevInput.textFiler;
this.fullText = prevInput.fullText;
this.selectedTab = prevInput.wasAdvanced ? 1 : 0;
this.temperatureMeanMax = prevInput.tempMeanMax;
this.precipitation = prevInput.precipitation;
this.rainDays = prevInput.rain_days;
this.sunHours = prevInput.sun_hours;
this.costPerDay = prevInput.average_per_day_costs;
this.accommodation = prevInput.accommodation;
this.entertainment = prevInput.entertainment_costs;
this.localTransport = prevInput.local_transportation_costs;
}
}

View File

@ -4,7 +4,11 @@
<span *ngIf="bookmarks.length === 0">You have no bookmarks :(</span>
</div>
<div *ngIf="isLoading" class="spinner">
<div *ngIf="isLoading && !message" class="spinner">
<mat-spinner></mat-spinner>
</div>
<div *ngIf="message" class="spinner">
<span>{{message}}</span>
</div>

View File

@ -2,9 +2,10 @@ import {Component, OnDestroy, OnInit} from '@angular/core';
import {Region} from '../../interfaces/region.interface';
import {BookmarkService} from '../../services/bookmark.service';
import {DataService} from '../../services/data.service';
import {switchMap, takeUntil, tap} from 'rxjs/operators';
import {catchError, switchMap, takeUntil, tap} from 'rxjs/operators';
import {Router} from '@angular/router';
import {Subject} from 'rxjs';
import {HttpErrorResponse} from '@angular/common/http';
@Component({
selector: 'app-bookmark-list',
@ -15,6 +16,7 @@ export class BookmarkListComponent implements OnInit, OnDestroy {
bookmarks: Region[];
isLoading = true;
message: string;
private destroyed$ = new Subject<void>();
constructor(private bs: BookmarkService, private ds: DataService, private router: Router) {
@ -27,9 +29,14 @@ export class BookmarkListComponent implements OnInit, OnDestroy {
switchMap(ids => {
const regions: Promise<Region>[] = ids.map(id => this.ds.getRegion(id));
return Promise.all(regions);
}),
catchError((err, caught) => {
this.message = `${(err as HttpErrorResponse).status}: ${(err as HttpErrorResponse).statusText} :/`;
return caught;
})
).subscribe(regions => {
this.bookmarks = regions;
this.message = undefined;
this.isLoading = false;
});
}

View File

@ -1,6 +1,9 @@
<app-search-input></app-search-input>
<h2>Explore the world</h2>
<div class="region-container">
<div *ngIf="!loading && !message" class="region-container">
<span *ngIf="!regions || regions.length === 0">No regions to explore :/</span>
<app-region (click)="onRegionClick(region)" *ngFor="let region of regions" [region]="region"></app-region>
</div>
<mat-spinner *ngIf="loading" class="central"></mat-spinner>
<span *ngIf="message" class="central">{{message}}</span>

View File

@ -17,3 +17,8 @@ app-search-input {
margin-bottom: 2rem;
}
}
.central {
align-self: center;
margin: 4rem 0;
}

View File

@ -2,6 +2,7 @@ import {Component, OnInit} from '@angular/core';
import {Region} from '../../interfaces/region.interface';
import {DataService} from '../../services/data.service';
import {Router} from '@angular/router';
import {HttpErrorResponse} from '@angular/common/http';
@Component({
selector: 'app-home',
@ -13,12 +14,21 @@ export class HomeComponent implements OnInit {
private readonly MAX_REGIONS = 10;
regions: Region[];
message: string;
loading = true;
constructor(private ds: DataService, private router: Router) {
}
async ngOnInit() {
this.regions = await this.ds.getAllRegions(this.MAX_REGIONS);
try {
this.regions = await this.ds.getAllRegions(this.MAX_REGIONS);
} catch (e) {
this.message = `${(e as HttpErrorResponse).status}: ${(e as HttpErrorResponse).statusText} :/`;
} finally {
this.loading = false;
}
}
onRegionClick(region: Region) {

View File

@ -27,45 +27,47 @@
<mat-tab-group>
<mat-tab *ngIf="region.avg_price_relative" label="Price Deviation">
<ng-template matTabContent>
<app-graph
[monthlyDatas]="[region.avg_price_relative]"
class="graph"
formatSting="##,##%"
graphType="column">
<app-graph [monthlyDatas]="[region.avg_price_relative]" class="graph" formatSting="##0.##'%'"
graphType="column">
</app-graph>
</ng-template>
</mat-tab>
<mat-tab *ngIf="region.temperature_mean_max && region.temperature_mean_max[0]" label="Temperatures">
<mat-tab *ngIf="region.temperature_mean_max" label="Temperatures">
<ng-template matTabContent>
<app-graph [colors]="['blue', 'red']"
[labels]="['Min', 'Max']"
[monthlyDatas]="[region.temperature_mean_min, region.temperature_mean_max]"
class="graph"
formatSting="##,##°C">
<app-graph [colors]="['blue', 'red']" [labels]="['Min', 'Max']"
[monthlyDatas]="[region.temperature_mean_min, region.temperature_mean_max]" class="graph"
[minMax]="[-25, 50]" formatSting="##,##°C">
</app-graph>
</ng-template>
</mat-tab>
<mat-tab *ngIf="region.precipitation && region.precipitation[0]" label="Precipitation">
<mat-tab *ngIf="region.precipitation" label="Precipitation">
<ng-template matTabContent>
<app-graph
[monthlyDatas]="[region.precipitation]"
class="graph"
formatSting="####mm">
<app-graph [minMax]="[0, 1200]" [monthlyDatas]="[region.precipitation]" class="graph" formatSting="####'mm'">
</app-graph>
</ng-template>
</mat-tab>
<mat-tab *ngIf="region.rain_days && region.rain_days[0]" label="Rain days">
<mat-tab *ngIf="region.rain_days" label="Rain days">
<ng-template matTabContent>
<app-graph
[monthlyDatas]="[region.rain_days]"
class="graph"
formatSting="####"
graphType="column">
<app-graph [minMax]="[0, 31]" [monthlyDatas]="[region.rain_days]" class="graph" formatSting="####"
graphType="column">
</app-graph>
</ng-template>
</mat-tab>
<mat-tab *ngIf="region.sun_hours" label="Sun hours">
<ng-template matTabContent>
<app-graph [minMax]="[0, 450]" [monthlyDatas]="[region.sun_hours]" class="graph" formatSting="####"
graphType="column">
</app-graph>
</ng-template>
</mat-tab>
</mat-tab-group>
</div>
<div class="places-container">
<span class="group-title">Places</span>
<div class="places">
<app-place *ngFor="let place of places" [place]="place"></app-place>
</div>
</div>
</div>
<div *ngIf="!region" class="spinner">

View File

@ -42,6 +42,7 @@
> .more-btn {
color: #8f8f8f;
cursor: pointer;
}
}
@ -66,6 +67,28 @@
}
.places-container {
display: flex;
flex-direction: column;
> .group-title {
font-size: 1.5rem;
margin-bottom: 1rem;
}
> .places {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
app-place {
margin: 0.5rem;
cursor: pointer;
}
}
}
.spinner {
flex: 1 1 auto;
display: flex;

View File

@ -4,6 +4,7 @@ import {ActivatedRoute, ParamMap} from '@angular/router';
import {DataService} from '../../services/data.service';
import {switchMap} from 'rxjs/operators';
import {SearchParameter} from '../../interfaces/search-request.interface';
import {Place} from '../../interfaces/places.interface';
@Component({
@ -36,6 +37,8 @@ export class RegionDetailsComponent implements AfterViewInit {
/** Extend the description text */
isDescExtended = false;
places: Place[] = [];
constructor(private route: ActivatedRoute, private ds: DataService) {
}
@ -45,6 +48,7 @@ export class RegionDetailsComponent implements AfterViewInit {
).subscribe((region: Region) => {
this.region = region;
this.uriRegionName = encodeURI(this.region.name.toLowerCase());
this.ds.getPlacesByRegion(this.region.region_id).then(places => this.places = places);
setTimeout(() => {
if (this.container) {
this.container.nativeElement.scrollIntoView();

View File

@ -3,9 +3,25 @@
<span #result></span>
<div *ngIf="results && results.length > 0">
<h2>Results ({{results.length}}):</h2>
<div class="result-header">
<span class="title">Results ({{results.length}})</span>
<div class="sorting">
<button (click)="sortDes = !sortDes; onSortDirChange()" mat-icon-button>
<mat-icon>{{sortDes ? 'arrow_downwards' : 'arrow_upwards'}}</mat-icon>
</button>
<mat-form-field>
<mat-label>Sort by</mat-label>
<mat-select (selectionChange)="onSortChange()" [(ngModel)]="sortBy">
<mat-option *ngFor="let option of sortOptions" [value]="option.property">
{{option.name|translate}}
</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="result-container">
<app-result (click)="onResultClick(result)" *ngFor="let result of results" [result]="result"></app-result>
<app-result (click)="onResultClick(result)" *ngFor="let result of results" [debug]="debug"
[duration]="duration" [result]="result"></app-result>
</div>
</div>

View File

@ -8,6 +8,24 @@
}
}
.result-header {
display: flex;
align-items: center;
margin: 1rem 0;
.title {
flex: 1 1 auto;
font-size: 1.5rem;
font-weight: bold;
}
.sorting {
display: flex;
flex-direction: row;
align-items: center;
}
}
.result-container {
display: flex;
flex-direction: column;

View File

@ -2,6 +2,15 @@ import {Component, ElementRef, OnInit, ViewChild} from '@angular/core';
import {ActivatedRoute, Router} from '@angular/router';
import {Result} from '../../interfaces/result.interface';
import {SearchService} from '../../services/search.service';
import {base64ToObj} from '../../utils/base64conversion';
import {Query} from '../../interfaces/search-request.interface';
interface SortOption {
name: string;
property: string;
descending?: boolean;
isScore?: boolean;
}
@Component({
selector: 'app-search',
@ -10,9 +19,22 @@ import {SearchService} from '../../services/search.service';
})
export class SearchComponent implements OnInit {
/** Current base64 encoded query */
queryString: string;
/** Current results */
results: Result[];
/** The property to sort by */
sortBy = 'score';
/** Sort descending */
sortDes = true;
/** Available sort options */
sortOptions: SortOption[] = [];
/** The difference between from and to in days */
duration: number;
debug = false;
@ViewChild('result', {static: false})
resultDiv: ElementRef;
@ -22,6 +44,7 @@ export class SearchComponent implements OnInit {
async ngOnInit() {
this.route.queryParams.subscribe(async params => {
this.queryString = params.q;
this.debug = params.debug || false;
this.results = undefined;
if (!this.queryString || this.queryString.length === 0) {
@ -31,10 +54,64 @@ export class SearchComponent implements OnInit {
this.results = await this.ss.executeSearch(this.queryString);
this.resultDiv.nativeElement.scrollIntoView({behavior: 'smooth', block: 'start'});
this.sortOptions = [
{name: 'Relevance', property: 'score', descending: true},
{name: 'Region name', property: 'name'},
{name: 'Price', property: 'average_per_day_costs'}
];
this.sortBy = 'score';
this.sortDes = true;
if (this.results.length > 0) {
const tags = await this.ss.getAvailableTags();
for (const {type} of this.results[0].scores) {
if (!this.sortOptions.find(i => i.property === type)) {
this.sortOptions.push({name: type, property: type, isScore: true, descending: tags.includes(type)});
}
}
}
// Calculate duration
const {from, to} = base64ToObj(this.queryString) as Query;
const difference: number = (new Date(to)).getTime() - (new Date(from)).getTime();
this.duration = Math.round(difference / 1000 / 60 / 60 / 24);
});
}
onResultClick(result: Result) {
this.router.navigate(['/region', result.region_id]).catch(console.log);
}
onSortDirChange() {
const option = this.sortOptions.find(i => i.property === this.sortBy);
this.sortResults(option, this.sortDes);
}
onSortChange() {
const option = this.sortOptions.find(i => i.property === this.sortBy);
this.sortDes = option.descending;
this.onSortDirChange();
}
/**
* Sorts the results array by the given property.
* @param property The property to sort by
* @param isScore If the property is in scores
* @param descending Sort descending instead of ascending
*/
private sortResults({property, isScore}: SortOption, descending: boolean) {
this.results = this.results.sort(isScore ? sortByScore : sortByPorperty);
function sortByPorperty(a: Result, b: Result): number {
const result = a[property] > b[property] ? 1 : -1;
return descending ? result * -1 : result;
}
function sortByScore(a: Result, b: Result): number {
const result = a.scores.find(i => i.type === property).value > b.scores.find(i => i.type === property).value ? 1 : -1;
return descending ? result * -1 : result;
}
}
}

View File

@ -24,41 +24,41 @@ export class TeamComponent implements OnInit {
},
{
name: 'Lucas Hinderberger',
position: 'Operations',
position: 'Operations / Backend Developer',
course: 'Software Engineering (SEB)',
semester: 6
},
{
name: 'Timo John',
position: 'Database/Backend Developer',
position: 'Database / Backend Developer',
course: 'Software Engineering (SEB)',
semester: 6
},
{
name: 'Timo Volkmann',
position: 'Backend Developer',
position: 'Project Lead / Backend Developer',
course: 'Software Engineering (SEB)',
semester: 6
},
{
name: 'Yannick von Hofen',
position: 'Management',
position: 'Marketing / Sales',
course: 'Transport und Logistik (MTL)',
semester: 2
},
{
name: 'Thomas Schapper',
position: 'Management',
position: 'Management Lead',
course: 'Transport und Logistik (MTL)',
semester: 2
},
{
name: 'Nicolas Karon',
position: 'Management',
position: 'Controlling',
course: 'Transport und Logistik (MTL)',
semester: 2
}
];
].sort((a, b) => a.name > b.name ? 1 : -1);
constructor() {
}

View File

@ -0,0 +1,20 @@
export interface Place {
/** The place id */
place_id: number;
/** The id of the parent region */
region_id: number;
/** The name of the place in english */
place_name: string;
/** The longitude position */
lon: number;
/** The latitude position */
lat: number;
/** The user rating between 0 and 5 */
rating: number;
/** Nearby address */
vicinity: string;
/** Google photo reference */
photo_reference: string;
/** URL to the image */
img_url: string;
}

View File

@ -15,6 +15,7 @@ export interface Query {
fulltext?: boolean;
textfilter?: string;
showRegionsWithNullScore?: boolean;
tags?: string[];
}
export enum SearchParameter {

View File

@ -3,7 +3,11 @@ import {HttpClient, HttpParams} from '@angular/common/http';
import {Preset} from '../interfaces/preset.interface';
import {Result} from '../interfaces/result.interface';
import {Region} from '../interfaces/region.interface';
import {Place} from '../interfaces/places.interface';
/**
* The data service handles all API interactions.
*/
@Injectable({
providedIn: 'root'
})
@ -16,16 +20,27 @@ export class DataService {
constructor(private http: HttpClient) {
}
/**
* Get results for specific search query.
* @param query The search query
*/
public searchRegions(query: string): Promise<Result[]> {
const params = new HttpParams().set('q', query);
return this.http.get<Result[]>(this.API_URL + '/search', {params}).toPromise();
}
/**
* Get all search presets.
*/
public getAllPresets(): Promise<Preset[]> {
return this.http.get<Preset[]>(this.API_URL + '/search/presets').toPromise();
}
/**
* Gets all regions.
* @param max Limit the returned regions. Selected regions are random.
*/
public async getAllRegions(max?: number): Promise<Region[]> {
let params = new HttpParams();
if (max) {
@ -38,6 +53,10 @@ export class DataService {
return regions;
}
/**
* Gets one region by its id.
* @param id The region id
*/
public async getRegion(id: number): Promise<Region> {
if (this.regionCache.has(id)) {
return this.regionCache.get(id);
@ -48,65 +67,167 @@ export class DataService {
return region;
}
/**
* Returns POIs near the region.
* @param id The regions id
*/
public getPlacesByRegion(id: number): Promise<Place[]> {
return this.http.get<Place[]>(`${this.API_URL}/regions/${id}/nearby`).toPromise();
}
/**
* Returns all available search tags.
*/
public getAllTags(): Promise<string[]> {
return this.http.get<string[]>(`${this.API_URL}/search/tags`).toPromise();
}
}
/**
* Defines meta data for all region parameters.
*/
export const REGION_PARAM_VIS: RegionParamVisLookup = {
temperature_mean: {
icon: 'wb_sunny',
unit: '°C'
unit: '°C',
decimals: '1.0-0'
},
temperature_mean_min: {
icon: 'wb_sunny',
unit: '°C'
unit: '°C',
decimals: '1.0-0'
},
temperature_mean_max: {
icon: 'wb_sunny',
unit: '°C'
unit: '°C',
decimals: '1.0-0'
},
precipitation: {
icon: 'opacity',
unit: 'mm'
unit: 'mm',
decimals: '1.0-0'
},
humidity: {
icon: 'grain',
unit: '%'
unit: '%',
decimals: '1.0-0'
},
sun_hours: {
icon: 'flare',
unit: 'h'
unit: 'h',
decimals: '1.0-0'
},
rain_days: {
icon: 'date_range',
unit: ''
unit: '',
decimals: '1.0-0'
},
food_costs: {
icon: 'local_dining',
unit: '€/day'
unit: '€/day',
decimals: '1.0-0'
},
alcohol_costs: {
icon: 'local_bar',
unit: '€/day'
unit: '€/day',
decimals: '1.2-2'
},
water_costs: {
icon: 'local_cafe',
unit: '€/day'
unit: '€/day',
decimals: '1.2-2'
},
local_transportation_costs: {
icon: 'commute',
unit: '€/day'
unit: '€/day',
decimals: '1.2-2'
},
entertainment_costs: {
icon: 'local_activity',
unit: '€/day'
unit: '€/day',
decimals: '1.2-2'
},
accommodation_costs: {
icon: 'hotel',
unit: '€/day'
unit: '€/day',
decimals: '1.2-2'
},
average_per_day_costs: {
icon: 'euro',
unit: '€/day'
}
unit: '€/day',
decimals: '1.2-2'
},
avg_price_relative: {
icon: 'hotel',
unit: '%',
decimals: '1.1-1'
},
beach: {
icon: 'stars',
unit: '%',
decimals: '1.0-0'
},
history: {
icon: 'stars',
unit: '%',
decimals: '1.0-0'
},
nature: {
icon: 'stars',
unit: '%',
decimals: '1.0-0'
},
art: {
icon: 'stars',
unit: '%',
decimals: '1.0-0'
},
culture: {
icon: 'stars',
unit: '%',
decimals: '1.0-0'
},
mountains: {
icon: 'stars',
unit: '%',
decimals: '1.0-0'
},
architecture: {
icon: 'stars',
unit: '%',
decimals: '1.0-0'
},
rainforest: {
icon: 'stars',
unit: '%',
decimals: '1.0-0'
},
nightlife: {
icon: 'stars',
unit: '%',
decimals: '1.0-0'
},
desert: {
icon: 'stars',
unit: '%',
decimals: '1.0-0'
},
food: {
icon: 'stars',
unit: '%',
decimals: '1.0-0'
},
shopping: {
icon: 'stars',
unit: '%',
decimals: '1.0-0'
},
volcanoes: {
icon: 'stars',
unit: '%',
decimals: '1.0-0'
},
};
export interface RegionParamVisLookup {
@ -116,4 +237,5 @@ export interface RegionParamVisLookup {
export interface RegionParamVis {
icon: string;
unit: string;
decimals: string;
}

View File

@ -8,11 +8,17 @@ export interface SearchInput {
to: string;
singlePresetSelection: object;
multiPresetSelection: object;
tags: string[];
textFiler: string;
fullText: boolean;
tempMeanMax: number;
precipitation: number;
rain_days: number;
sun_hours: number;
average_per_day_costs: number;
accommodation: number;
entertainment_costs: number;
local_transportation_costs: number;
}
@Injectable({
@ -21,6 +27,7 @@ export interface SearchInput {
export class SearchService {
private searchInput: SearchInput;
private cachedTags: string[];
constructor(private ds: DataService) {
}
@ -36,4 +43,13 @@ export class SearchService {
public loadSearchInput(): SearchInput {
return this.searchInput;
}
public async getAvailableTags(): Promise<string[]> {
if (this.cachedTags) {
return this.cachedTags;
}
const tags: string[] = await this.ds.getAllTags();
this.cachedTags = tags;
return tags;
}
}

View File

@ -0,0 +1,7 @@
/**
* Transforms a value into a min max array.
* @param value The value
*/
export function toMinMaxArray(value: number): number[] {
return value ? [value, value] : undefined;
}

View File

@ -0,0 +1,15 @@
<svg height="144.036" viewBox="0 0 108.027 108.027" width="144.036" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>.a {
fill: #fff
}
.b {
fill: #1d1d1b
}</style>
</defs>
<path class="a"
d="M107.95 13.937c-.017-.172-.051-.34-.074-.51-.044-.331-.086-.662-.15-.986-.04-.201-.097-.396-.144-.595-.069-.284-.135-.57-.219-.848-.064-.212-.142-.416-.215-.624-.089-.255-.176-.51-.278-.76-.087-.214-.188-.421-.285-.63-.107-.231-.212-.462-.33-.686-.112-.214-.235-.42-.357-.628-.121-.208-.242-.414-.373-.615-.137-.21-.282-.413-.428-.616a15.53 15.53 0 0 0-.41-.547c-.16-.204-.328-.401-.5-.596a15.357 15.357 0 0 0-1.009-1.05q-.222-.21-.452-.412a15.47 15.47 0 0 0-.648-.538 15.574 15.574 0 0 0-.46-.344c-.238-.172-.479-.34-.726-.498-.15-.097-.303-.187-.457-.278-.264-.157-.53-.31-.804-.451-.146-.075-.296-.144-.445-.215a15.325 15.325 0 0 0-.884-.394c-.138-.055-.279-.102-.418-.153-.321-.118-.643-.233-.974-.33-.12-.035-.245-.061-.366-.094a15.299 15.299 0 0 0-1.079-.256c-.088-.017-.179-.025-.268-.04A15.51 15.51 0 0 0 92.551 0H15.477A15.477 15.477 0 0 0 0 15.477V92.55c0 .52.028 1.033.078 1.539.017.173.05.34.073.51.044.331.086.662.15.986.04.201.097.396.145.595.068.285.134.57.218.848.064.212.142.417.215.624.09.255.176.511.278.76.088.214.188.421.285.63.107.232.213.462.33.687.113.213.235.42.357.628.122.207.243.414.374.615.136.21.281.412.428.615.133.185.267.369.409.548.161.203.329.4.5.596a15.344 15.344 0 0 0 1.01 1.05q.221.21.452.411c.211.185.426.364.648.538.15.118.305.232.46.344.238.172.478.34.725.499.15.096.304.186.457.277.264.157.53.31.804.451.146.076.297.144.446.215.29.139.583.274.884.394.137.055.278.102.418.153.32.118.642.233.973.33.12.035.245.062.367.094a15.3 15.3 0 0 0 1.078.256c.088.017.18.026.268.041a15.51 15.51 0 0 0 2.646.242h77.075a15.477 15.477 0 0 0 15.476-15.476V15.477c0-.52-.027-1.033-.077-1.54z"/>
<path class="b"
d="M92.55 0H15.478A15.477 15.477 0 0 0 0 15.477V92.55a15.473 15.473 0 0 0 14.055 15.405l-.417-3.343a6.255 6.255 0 0 0 .69-2.26c0-.19-.063-.252-.251-.252 1.066-6.844 2.197-13.374 3.515-19.53l-.125-.564q6.877-30.424 9.231-41.13c-9.86.44-12.81-2.136-8.729-7.725 6.72.252 10.174 0 10.362-.69.25-.753 1.571-7.034 4.019-18.901a10.315 10.315 0 0 1 8.98-3.831c2.951 2.072 4.332 4.333 4.144 6.657a8.654 8.654 0 0 1 .063 1.757c-.25 0-.691 2.387-1.318 7.222h.187l-.502.941c0 1.697-.125 3.519-.314 5.464l7.284.564c4.081-.94 5.274.944 3.58 5.716-.817 1.883-5.086 2.7-12.936 2.385-1.004 5.212-1.947 9.672-2.888 13.376l.125.502c-.125.315-.315 1.006-.627 2.197h.25q-.47.754-3.39 15.826c-.19 0-.315.25-.502.753l.062 1.256c-.44-.062-1.76 6.907-4.019 20.786q-2.6 10.326-4.046 12.895H92.55a15.477 15.477 0 0 0 15.476-15.476V15.477A15.477 15.477 0 0 0 92.551 0zM80.766 53.435a7.127 7.127 0 0 1-6.782 3.453c0-.312-.19-.44-.502-.44H71.91a5.483 5.483 0 0 1-4.207-1.632h-.691q-.847.472-3.013 5.275a80.485 80.485 0 0 1-7.097 9.357v.188l.25.44-2.949 13.25.188.439c-1.697 9.105-2.888 13.689-3.58 13.689q-.094 1.413-3.58 2.261a2.046 2.046 0 0 1-.626-.188c-2.449.313-3.894.44-4.333.44-2.322-.44-3.455-1.006-3.392-1.822l.314-1.38a4.557 4.557 0 0 1-1.256-2.514l.315-.44.125-2.762.25-1.319a1.906 1.906 0 0 1-.188-.69 172.516 172.516 0 0 1 3.14-17.143l-.25-.44c2.826-11.177 4.27-17.018 4.27-17.52h-.25c1.694-6.216 2.698-9.67 3.013-10.299a14.49 14.49 0 0 1 4.647-1.568 7.397 7.397 0 0 1 7.284 5.023c.188 0 .314.816.375 2.511l-.312 1.13.187 1.383a17.633 17.633 0 0 0 5.59-12.12 4.424 4.424 0 0 1 2.762-2.01c4.96 0 7.724 2.197 8.352 6.594 2.387 4.334 3.58 7.033 3.517 8.226z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -1,33 +1,48 @@
{
"temperature_mean_max": "Max Temperature Average",
"temperature_mean_max": "Day temperature",
"temperature": "Temperature",
"rain_days": "Rainy days",
"sun_hours": "Sunny hours",
"rain_days": "Rain days",
"sun_hours": "Sunshine",
"precipitation": "Precipitation",
"humidity": "Humidity",
"alcohol_costs": "Alcohol",
"food_costs": "Food",
"water_costs": "Water",
"cheap_alcohol": "Cheap alcohol",
"cheap_alcohol": "cheap alcohol",
"local_transportation_costs": "Public transport",
"average_per_day_costs": "Total costs",
"entertainment_costs": "Entertainment",
"accommodation_costs": "Accommodation",
"cheap_food": "Cheap food",
"cheap_water": "Cheap water",
"cheap_transportations": "Cheap public transport",
"cheap_entertainment": "Cheap entertainment",
"cheap_accommodation": "Cheap accommodation",
"off_season": "Off-season",
"accommodation_costs": "Accommodation price",
"avg_price_relative": "Price tendency",
"cheap_food": "cheap food",
"cheap_water": "cheap water",
"cheap_transportations": "cheap public transport",
"cheap_entertainment": "cheap entertainment",
"cheap_accommodation": "cheap accommodation",
"off_season": "low season",
"warm": "warm",
"chilly": "cold",
"mild:": "mild",
"cold": "freezing",
"sunny": "sunny",
"dark": "dark",
"almost_no_rain": "almost none",
"little_rain": "little",
"floodlike_rain": "flooding",
"sunny": "all day long",
"dark": "the sun scares me",
"normal": "better not so much",
"almost_no_rain": "no rain",
"little_rain": "a little is good",
"floodlike_rain": "i like it pouring",
"few_raindays": "few",
"many_raindays": "many"
"many_raindays": "many",
"art": "Art",
"beach": "Beach",
"history": "History",
"nature": "Nature",
"culture": "Culture",
"mountains": "Mountains",
"architecture": "Architecture",
"rainforest": "Rainforest",
"desert": "Desert",
"food": "Food",
"shopping": "Shopping",
"volcanoes": "Volcanoes",
"nightlife": "Nightlife"
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 948 B

View File

@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="assets/favicon.svg" rel="shortcut icon" type="image/svg+xml">
</head>
<body>
<app-root></app-root>

View File

@ -39,21 +39,21 @@ $travopti-black: (
)
);
$travopti-green: (
50: #016500,
100: #006500,
200: #006500,
300: #005a00,
400: #008000,
500: #006000,
600: #005a00,
700: #005a00,
800: #2e7d32,
900: #1b5e20,
A100: #00ae00,
A200: #009000,
A400: #006400,
A700: #004d00,
$travopti-blue: (
50: #00a0d2,
100: #00a0d2,
200: #00a0d2,
300: #00a0d2,
400: #00a0d2,
500: #00a0d2,
600: #00a0d2,
700: #00a0d2,
800: #00a0d2,
900: #00a0d2,
A100: #00a0d2,
A200: #00a0d2,
A400: #00a0d2,
A700: #00a0d2,
contrast: (
50: $light-primary-text,
100: $light-primary-text,
@ -77,7 +77,7 @@ $travopti-green: (
// (imported above). For each palette, you can optionally specify a default, lighter, and darker
// hue. Available color palettes: https://material.io/design/color/
$travopti-app-primary: mat-palette($travopti-black);
$travopti-app-accent: mat-palette($travopti-green, A200, A100, A400);
$travopti-app-accent: mat-palette($travopti-blue, A200, A100, A400);
// The warn palette is optional (defaults to red).
$travopti-app-warn: mat-palette($mat-red);