Compare commits
134 Commits
feature/tr
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| b995123a36 | |||
|
|
0ed58d6fa1 | ||
|
|
29666babbc | ||
|
|
2c16bbf3e7 | ||
|
|
8231127099 | ||
|
|
531f133875 | ||
|
|
4fa241ad60 | ||
|
|
3cccdab368 | ||
|
|
ebb1304eeb | ||
|
|
8c4f67b106 | ||
|
|
26c4954e37 | ||
|
|
91e84f5cf1 | ||
| 8161f62ce0 | |||
| f2c7aec2d3 | |||
|
|
67ea0587db | ||
|
|
eaaee3c14a | ||
|
|
28b25da1ad | ||
|
|
c3c0ad74a7 | ||
|
|
aa4c994069 | ||
|
|
ad1c728876 | ||
| 2b0c030980 | |||
|
|
1cda6bfe70 | ||
|
|
c1198c98db | ||
|
|
cc64fb1c32 | ||
|
|
f6dd580894 | ||
|
|
8c95857207 | ||
|
|
509ccea760 | ||
|
|
7a6be53e0b | ||
|
|
85efd83485 | ||
|
|
d67c0ef625 | ||
| 6145c40654 | |||
|
|
2060eea09f | ||
|
|
218b508f43 | ||
|
|
44b09a22e8 | ||
| 6be202a0d2 | |||
|
|
cea47548bc | ||
|
|
24ad736091 | ||
| 2077aeebf8 | |||
|
|
7b76f2f696 | ||
|
|
08a904da7e | ||
|
|
5fa9d982fb | ||
|
|
d6af174904 | ||
|
|
d2f9d992f5 | ||
|
|
e194019eae | ||
|
|
86c4bc5cd2 | ||
|
|
384992634d | ||
|
|
93a51ec922 | ||
|
|
43953757e7 | ||
|
|
2c1d09297a | ||
| bbe74b83bb | |||
| e0f8f5490e | |||
|
|
cd9f68234f | ||
|
|
91ab7ae971 | ||
|
|
3ae3aca9a5 | ||
|
|
8c296e9397 | ||
|
|
6cd226bf56 | ||
|
|
b5bcae6ada | ||
|
|
72c1386fb8 | ||
|
|
ae9998f640 | ||
|
|
2fb2e9a76f | ||
| 373429b870 | |||
|
|
20d99f2145 | ||
|
|
df083fd78e | ||
|
|
3dece30145 | ||
|
|
0e65d6e919 | ||
|
|
8ae20d96aa | ||
|
|
56b8c06b84 | ||
|
|
846410e6bf | ||
|
|
1fb6f607a2 | ||
| 5dfb31e91c | |||
|
|
f9157e8e61 | ||
|
|
e948243417 | ||
|
|
5b4ef3c794 | ||
|
|
5d05625658 | ||
|
|
8c1daa308c | ||
| faa7a7e5b1 | |||
|
|
5d2f64a688 | ||
| 125c6a3f00 | |||
|
|
fa38932010 | ||
|
|
8d2b40f0cd | ||
| 826dd64f1a | |||
| c347b79f02 | |||
| cdda87f3c6 | |||
| 6180d2f097 | |||
|
|
d953046539 | ||
|
|
24f47b5f39 | ||
|
|
fa73c0a1e0 | ||
|
|
dc6da042f9 | ||
|
|
88ce05c848 | ||
|
|
64a8fd6011 | ||
|
|
940d5426e8 | ||
|
|
18463c3ca2 | ||
|
|
bf0a71c63c | ||
|
|
4c3cc012aa | ||
|
|
f68a2599e9 | ||
|
|
ad180b00cc | ||
|
|
d82ce649eb | ||
|
|
49d207473b | ||
|
|
47c664894c | ||
| c42a55af12 | |||
|
|
a5f102f99d | ||
|
|
71bbb1a3f1 | ||
|
|
ecd6f93dcb | ||
|
|
9ce8bd49ed | ||
|
|
a2a9a3a373 | ||
|
|
e17bbbfe3f | ||
|
|
d4b543caff | ||
|
|
8c495d5c20 | ||
|
|
cc6e6e3146 | ||
|
|
458a9d0013 | ||
|
|
9409cbb981 | ||
|
|
8e0b21fb1c | ||
|
|
0109f50cc9 | ||
|
|
1ba9c8c032 | ||
|
|
bf596e7d97 | ||
|
|
112c37f7b5 | ||
|
|
51d5283917 | ||
|
|
de443bb5de | ||
|
|
9c3f70d36b | ||
|
|
587e231e05 | ||
|
|
615727e09d | ||
|
|
cf611ce8c4 | ||
|
|
cc07e11f8f | ||
| c602e75f33 | |||
|
|
e54232842b | ||
| 7f68ff57f7 | |||
| a9c6873137 | |||
| 5ee6cc7a82 | |||
| d92c164127 | |||
|
|
76d9036a61 | ||
| 1cb0a68bb6 | |||
|
|
afb4234ce4 | ||
|
|
c8c0b7f900 | ||
|
|
96c7a5575a |
BIN
KapitelGeschaftsidee_GruppeData.pdf
Normal file
BIN
KapitelGeschaftsidee_GruppeData.pdf
Normal file
Binary file not shown.
2304
Scripts/setup.sql
2304
Scripts/setup.sql
File diff suppressed because it is too large
Load Diff
BIN
Travopti_Docs.pdf
Normal file
BIN
Travopti_Docs.pdf
Normal file
Binary file not shown.
@ -1,7 +1,10 @@
|
||||
PORT=3000
|
||||
METEOSTAT_API_KEY=LMlDskju
|
||||
METEOSTAT_API_KEY_V2=O9X1xxKjheNwF1vfLcdRMmQ9JlobOugL
|
||||
DB_HOST=lhinderberger.dev
|
||||
DB_USER=root
|
||||
DB_PASSWORD=devtest
|
||||
DB_PORT=3306
|
||||
DATABASE=travopti
|
||||
DATABASE=travopti
|
||||
GOOGLE_CLOUD_APIS=AIzaSyCeMBLfpqTp0IVB7Xipx6ekRQFUBjPacQc
|
||||
SHOW_MATCH_VALUE=1
|
||||
275
backend/app.js
275
backend/app.js
@ -1,275 +0,0 @@
|
||||
const express = require('express')
|
||||
const moment = require('moment')
|
||||
const _ = require('lodash')
|
||||
const score = require('./util/score')
|
||||
const transformer = require('./util/transformer')
|
||||
const base = require('./util/base64')
|
||||
|
||||
const app = express()
|
||||
|
||||
const port = 3000
|
||||
//const multiplier_temp = 5
|
||||
const multiplier = {
|
||||
temperature_mean_max: 5,
|
||||
precipitation: 3.5,
|
||||
raindays: 3,
|
||||
sunhours: 2.5,
|
||||
}
|
||||
|
||||
const samplePresets = [
|
||||
{
|
||||
id: 29837,
|
||||
parameter: "temperature",
|
||||
label: "warm",
|
||||
values: [22, 25]
|
||||
}
|
||||
]
|
||||
|
||||
app.get('/', (req, res) => res.send('Hello Timo!'))
|
||||
app.get('/v1/regions', (req, res) => getAllRegions().then(x => res.json({ data: x })))
|
||||
app.get('/v1/presets', (req, res) => res.json({ data: samplePresets }))
|
||||
app.get('/v1/search', searchHandler)
|
||||
app.get('/v1/climate/update', climateUpdateHandler)
|
||||
|
||||
app.listen(port, () => console.log(`Travopti backend listening at http://localhost:${port}`))
|
||||
|
||||
function climateUpdateHandler(req, res) {
|
||||
let parameter = []
|
||||
if (req.query.startDate) parameter.push(req.query.startDate)
|
||||
if (req.query.endDate) parameter.push(req.query.endDate)
|
||||
climate.update(...parameter).then(x => {
|
||||
res.send(x)
|
||||
}).catch(e => {
|
||||
let result = {
|
||||
message: 'error during update process. check backend logs.',
|
||||
error: e
|
||||
}
|
||||
res.send(result)
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
function searchHandler(req, res) {
|
||||
let response = {}
|
||||
|
||||
response.meta = {
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
headers: req.headers
|
||||
}
|
||||
|
||||
let q = req.query.q ? base.base64ToObj(req.query.q) : req.query
|
||||
console.log('Q:', q)
|
||||
|
||||
let queryObj = {}
|
||||
if (q.temperature) queryObj['temperature_mean_max'] = q.temperature
|
||||
if (q.precipitation) queryObj['precipitation'] = q.precipitation
|
||||
if (q.raindays) queryObj['raindays'] = q.raindays
|
||||
if (q.sunhours) queryObj['sunhours'] = q.sunhours
|
||||
|
||||
scoreAndSearch(q.from, q.to, queryObj).then(searchResults => {
|
||||
response.data = searchResults
|
||||
res.json(response)
|
||||
}).catch(e => {
|
||||
console.log(e)
|
||||
res.json(e.message)
|
||||
})
|
||||
}
|
||||
|
||||
async function scoreAndSearch(from, to, queries) {
|
||||
// TODO break funtion into parts when implementing non-climate queries and modularize (new file)
|
||||
|
||||
console.log('search')
|
||||
|
||||
// get Min and Max values for each Parameter
|
||||
const minMax = await getClimateMinMax()
|
||||
|
||||
// randomize if empty queries
|
||||
if (_.isEmpty(queries)) {
|
||||
let t = _.round(_.random(minMax.min.temperature_mean_max, minMax.max.temperature_mean_max - 5), 0)
|
||||
let p = _.round(_.random(minMax.min.precipitation, minMax.max.precipitation - 50), 0)
|
||||
let r = _.round(_.random(minMax.min.raindays, minMax.max.raindays - 5), 0)
|
||||
let s = _.round(_.random(minMax.min.sunhours, minMax.max.sunhours - 50), 0)
|
||||
queries.temperature_mean_max = `${t},${t + 5}`
|
||||
queries.precipitation = `${p},${p + 50}`
|
||||
queries.raindays = `${r},${r + 5}`
|
||||
queries.sunhours = `${s},${s + 50}`
|
||||
}
|
||||
queries = oldToNewQuerySyntax(queries)
|
||||
console.log(queries)
|
||||
|
||||
// TODO simplify and remove support for old query syntaax
|
||||
let monthFrom = 0
|
||||
let monthTo = 0
|
||||
let dayFrom = 0
|
||||
let dayTo = 0
|
||||
|
||||
if (_.isNumber(from) && _.isNumber(to)) {
|
||||
let dateFrom = moment(from).toDate()
|
||||
let dateTo = moment(to).toDate()
|
||||
monthFrom = dateFrom.getMonth()
|
||||
monthTo = dateTo.getMonth()
|
||||
dayFrom = dateFrom.getDay()
|
||||
dayTo = dateTo.getDay()
|
||||
if (moment(dateFrom).add(23, 'hours').isAfter(moment(dateTo))) throw new Error("ERR: 'to' must be at least one day after 'from'.")
|
||||
} else {
|
||||
// to still support old query syntax
|
||||
let re = /([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))/i;
|
||||
monthFrom = Number(from.split("-")[1])
|
||||
monthTo = Number(to.split("-")[1])
|
||||
dayFrom = Number(from.split("-")[2])
|
||||
dayTo = Number(to.split("-")[2])
|
||||
if (!from.match(re) || !to.match(re)) throw new Error("ERR: invalid parameter:", from, to)
|
||||
if (moment(from, 'YYYY-MM-DD').add(23, 'hours').isAfter(moment(to, 'YYYY-MM-DD'))) throw new Error("ERR: 'to' must be at least one day after 'from'.")
|
||||
}
|
||||
|
||||
// -- Prepare search --
|
||||
// to calculate average if traveldates are in more than one month
|
||||
let travelPeriods = []
|
||||
if (monthFrom === monthTo) {
|
||||
let element = {
|
||||
month: monthFrom,
|
||||
days: dayTo - dayFrom
|
||||
}
|
||||
travelPeriods.push(element)
|
||||
} else {
|
||||
for (let index = monthFrom; index <= monthTo; index++) {
|
||||
let element = {}
|
||||
if (index === monthFrom) {
|
||||
element = {
|
||||
month: index,
|
||||
days: 32 - dayFrom
|
||||
}
|
||||
} else if (index === monthTo) {
|
||||
element = {
|
||||
month: index,
|
||||
days: dayTo
|
||||
}
|
||||
} else {
|
||||
element = {
|
||||
month: index,
|
||||
days: 30
|
||||
}
|
||||
}
|
||||
travelPeriods.push(element)
|
||||
}
|
||||
}
|
||||
|
||||
// calculate detail scores
|
||||
let detailScores = await Promise.all(travelPeriods.map(async period => {
|
||||
period.climate = await getAllRegionsWithClimatePerMonth(period.month)
|
||||
period.scores = {}
|
||||
Object.entries(queries).forEach(([key, value]) => {
|
||||
// console.log('key',key)
|
||||
// console.log('val', value)
|
||||
period.scores[key] = calculateScores(key, period.climate, value[0], value[1], minMax)
|
||||
});
|
||||
return period
|
||||
}));
|
||||
|
||||
|
||||
// calculate ratio and transform into target object
|
||||
return {
|
||||
results: transformer.transform(detailScores),
|
||||
debug: {
|
||||
detailScores: detailScores,
|
||||
minMax: minMax
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calculateScores(type, regionDataRows, searchLowParam, searchMaxParam, minMax) {
|
||||
console.log('calculateScores for', type)
|
||||
let result = regionDataRows.map(x => {
|
||||
const sc = Math.round(score.calculateScoreRange(minMax.min[type], minMax.max[type], multiplier[type], x[type], searchLowParam, searchMaxParam) * 100) / 100
|
||||
|
||||
return {
|
||||
region_id: x.region_id,
|
||||
type: type,
|
||||
value: x[type],
|
||||
score: x[type] === null ? null : sc
|
||||
}
|
||||
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
async function getClimateMinMax() {
|
||||
console.log('getClimateMinMax')
|
||||
const sqlMin = `SELECT
|
||||
MIN(temperature_mean) AS temperature_mean,
|
||||
MIN(temperature_mean_min) AS temperature_mean_min,
|
||||
MIN(temperature_mean_max) AS temperature_mean_max,
|
||||
MIN(precipitation) AS precipitation,
|
||||
MIN(raindays) AS raindays,
|
||||
MIN(sunshine) AS sunhours
|
||||
FROM region_climate`
|
||||
const sqlMax = `SELECT
|
||||
MAX(temperature_mean) AS temperature_mean,
|
||||
MAX(temperature_mean_min) AS temperature_mean_min,
|
||||
MAX(temperature_mean_max) AS temperature_mean_max,
|
||||
MAX(precipitation) AS precipitation,
|
||||
MAX(raindays) AS raindays,
|
||||
MAX(sunshine) AS sunhours
|
||||
FROM region_climate`
|
||||
const [qResMin, qResMax] = await Promise.all([getQueryRows(sqlMin), getQueryRows(sqlMax)])
|
||||
//console.log(qResMin)
|
||||
return { min: qResMin[0], max: qResMax[0] }
|
||||
}
|
||||
|
||||
async function getQueryRows(sql) {
|
||||
//console.log('getQueryRows')
|
||||
const [rows, fields] = await db.execute(sql)
|
||||
return rows
|
||||
}
|
||||
|
||||
function getAllRegions() {
|
||||
const sql = `SELECT
|
||||
regions.id AS region_id,
|
||||
regions.region AS name,
|
||||
regions.country_id AS country_id,
|
||||
countries.country AS country,
|
||||
regions.meteostat_id AS meteostat_id
|
||||
FROM regions
|
||||
JOIN countries
|
||||
ON regions.country_id = countries.id`
|
||||
return getQueryRows(sql)
|
||||
}
|
||||
|
||||
function getClimatePerRegionAndMonth(regionId, month) {
|
||||
console.log('getClimatePerRegionAndMonth')
|
||||
const sql = `SELECT region_id, AVG(temperature_mean), AVG(temperature_mean_min), AVG(temperature_mean_max), AVG(precipitation), AVG(sunshine) FROM region_climate WHERE month = ${month} AND region_id = ${regionId}`
|
||||
return getQueryRows(sql)
|
||||
}
|
||||
|
||||
function getAllRegionsWithClimatePerMonth(month) {
|
||||
console.log('getAllRegionsWithClimatePerMonth')
|
||||
const sql = `SELECT
|
||||
region_climate.region_id AS region_id,
|
||||
regions.country_id AS country_id,
|
||||
regions.region AS name,
|
||||
ROUND(AVG(region_climate.temperature_mean), 1) AS temperature_mean,
|
||||
ROUND(AVG(region_climate.temperature_mean_min), 1) AS temperature_mean_min,
|
||||
ROUND(AVG(region_climate.temperature_mean_max), 1) AS temperature_mean_max,
|
||||
ROUND(AVG(region_climate.precipitation), 1) AS precipitation,
|
||||
ROUND(AVG(region_climate.raindays), 1) AS raindays,
|
||||
ROUND(AVG(region_climate.sunshine), 1) AS sunhours
|
||||
FROM region_climate JOIN regions ON region_climate.region_id = regions.id WHERE region_climate.month = ${month} GROUP BY region_id`
|
||||
return getQueryRows(sql)
|
||||
}
|
||||
|
||||
function oldToNewQuerySyntax(queries) {
|
||||
let res = {}
|
||||
try {
|
||||
if (queries.temperature_mean_max) res.temperature_mean_max = [queries.temperature_mean_max.split(',')[0], queries.temperature_mean_max.split(',')[1]]
|
||||
if (queries.precipitation) res.precipitation = [queries.precipitation.split(',')[0], queries.precipitation.split(',')[1]]
|
||||
if (queries.raindays) res.raindays = [queries.raindays.split(',')[0], queries.raindays.split(',')[1]]
|
||||
if (queries.sunhours) res.sunhours = [queries.sunhours.split(',')[0], queries.sunhours.split(',')[1]]
|
||||
console.log('queries successfully transformed');
|
||||
} catch (error) {
|
||||
console.log('queries are ok');
|
||||
return queries
|
||||
}
|
||||
return res
|
||||
}
|
||||
@ -14,12 +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
|
||||
@ -27,12 +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
|
||||
@ -48,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);
|
||||
}
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"temperature_mean_max": 5,
|
||||
"precipitation": 3.5,
|
||||
"raindays": 3,
|
||||
"sunhours": 2.5
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"id": 29837,
|
||||
"parameter": "temperature",
|
||||
"label": "warm",
|
||||
"values": [22, 25]
|
||||
}
|
||||
19
backend/models/getPlace.js
Normal file
19
backend/models/getPlace.js
Normal file
@ -0,0 +1,19 @@
|
||||
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=${fields}&input=${q}&key=${process.env.GOOGLE_CLOUD_APIS}`)
|
||||
|
||||
// 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) {
|
||||
photo.url = await getPlacePhoto(photo.photo_reference)
|
||||
}
|
||||
}
|
||||
*/
|
||||
return res.data
|
||||
}
|
||||
20
backend/models/getPlaceNearby.js
Normal file
20
backend/models/getPlaceNearby.js
Normal file
@ -0,0 +1,20 @@
|
||||
const axios = require("axios")
|
||||
const getPlacePhoto = require("./getPlacePhoto.js")
|
||||
|
||||
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}&type=${types}&rankby=${rankby}&key=${process.env.GOOGLE_CLOUD_APIS}`
|
||||
);
|
||||
// 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) {
|
||||
photo.url = await getPlacePhoto(photo.photo_reference)
|
||||
}
|
||||
}*/
|
||||
return res.data
|
||||
}
|
||||
10
backend/models/getPlacePhoto.js
Normal file
10
backend/models/getPlacePhoto.js
Normal file
@ -0,0 +1,10 @@
|
||||
const axios = require("axios")
|
||||
|
||||
module.exports = async (photoref) => {
|
||||
const res = await axios.get(
|
||||
`https://maps.googleapis.com/maps/api/place/photo?photoreference=${photoref}&maxwidth=1600&key=${process.env.GOOGLE_CLOUD_APIS}`
|
||||
);
|
||||
|
||||
const url = res.request.res.responseUrl
|
||||
return url
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
21
backend/models/getRegionNearbyById.js
Normal file
21
backend/models/getRegionNearbyById.js
Normal 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;
|
||||
};
|
||||
|
||||
@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@ -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
22
backend/models/getTags.js
Normal 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;
|
||||
};
|
||||
@ -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
|
||||
|
||||
110
backend/models/handleClimateUpdateV2.js
Normal file
110
backend/models/handleClimateUpdateV2.js
Normal 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
|
||||
*/
|
||||
34
backend/models/handleRegionLonLat.js
Normal file
34
backend/models/handleRegionLonLat.js
Normal 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
|
||||
}
|
||||
48
backend/models/handleUpdateRegionNearby.js
Normal file
48
backend/models/handleUpdateRegionNearby.js
Normal 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
|
||||
}
|
||||
51
backend/models/handleUpdateRegionNearbyById.js
Normal file
51
backend/models/handleUpdateRegionNearbyById.js
Normal 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
|
||||
}
|
||||
32
backend/models/handleUpdateRegionNearbyImgUrl.js
Normal file
32
backend/models/handleUpdateRegionNearbyImgUrl.js
Normal 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
|
||||
}
|
||||
33
backend/models/handleUpdateRegionNearbyImgUrlById.js
Normal file
33
backend/models/handleUpdateRegionNearbyImgUrlById.js
Normal 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
|
||||
}
|
||||
206
backend/package-lock.json
generated
206
backend/package-lock.json
generated
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "cc-data-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "app.js",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "nodemon ./index.js"
|
||||
},
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
|
||||
95
backend/routes/place.js
Normal file
95
backend/routes/place.js
Normal file
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @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 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 photoref = req.query.photoref
|
||||
const photo = await getPlacePhoto(photoref)
|
||||
res.json(photo)
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@ -1,24 +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) => {
|
||||
res.json(await getRegions(dbConn));
|
||||
});
|
||||
router.get('/api/v1/regions/:id/image', (req, res) => {
|
||||
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`))
|
||||
const data = await getRegions(dbConn)
|
||||
if (req.query.randomize) {
|
||||
const randomize = sqlSanitzer(req.query.randomize)
|
||||
res.json(_.sampleSize(data, randomize))
|
||||
} else {
|
||||
res.sendFile(path.join(__dirname, `../data/regions/images/x.png`))
|
||||
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`))) {
|
||||
console.log("EXISTS")
|
||||
res.status(200).sendFile(path.join(__dirname, `../data/regions/images/${req.params.id}.jpg`))
|
||||
} else {
|
||||
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;
|
||||
};
|
||||
|
||||
@ -1,18 +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 { filter } = require("lodash");
|
||||
/**
|
||||
* @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 => {
|
||||
@ -24,8 +86,7 @@ function presetHandler(dbConn) {
|
||||
}
|
||||
|
||||
function searchHandler(dbConn) {
|
||||
const scoreAndSearch = sas(dbConn)
|
||||
return function (req, res) {
|
||||
return async function (req, res) {
|
||||
let response = {}
|
||||
|
||||
response.meta = {
|
||||
@ -34,50 +95,53 @@ function searchHandler(dbConn) {
|
||||
headers: req.headers
|
||||
}
|
||||
|
||||
// SWITCH TO SUPPORT OLD AND NEW BASE64 BASED QUERY SYNTAX
|
||||
let q = req.query.q ? base64.base64ToObj(req.query.q) : req.query
|
||||
console.log('Q:', q)
|
||||
|
||||
let scoreQueryObj = {}
|
||||
if (q.temperature) scoreQueryObj['temperature_mean_max'] = q.temperature
|
||||
if (q.temperature_mean_max) scoreQueryObj['temperature_mean_max'] = q.temperature_mean_max
|
||||
if (q.precipitation) scoreQueryObj['precipitation'] = q.precipitation
|
||||
if (q.rain_days) scoreQueryObj['rain_days'] = q.rain_days
|
||||
if (q.sun_hours) scoreQueryObj['sun_hours'] = q.sun_hours
|
||||
if (q.accommodation_costs) scoreQueryObj['accommodation_costs'] = q.accommodation_costs
|
||||
if (q.food_costs) scoreQueryObj['food_costs'] = q.food_costs
|
||||
if (q.alcohol_costs) scoreQueryObj['alcohol_costs'] = q.alcohol_costs
|
||||
if (q.water_costs) scoreQueryObj['water_costs'] = q.water_costs
|
||||
if (q.public_transportation_costs) scoreQueryObj['public_transportation_costs'] = q.public_transportation_costs
|
||||
if (q.entertainment_costs) scoreQueryObj['entertainment_costs'] = q.entertainment_costs
|
||||
if (q.average_per_day_costs) scoreQueryObj['average_per_day_costs'] = q.average_per_day_costs
|
||||
|
||||
//console.log(scoreQueryObj)
|
||||
|
||||
if (_.isEmpty(scoreQueryObj)) {
|
||||
res.status(400).send('provide at least one search parameter.');
|
||||
// transform syntax and seperate climate queries from price queries
|
||||
if (!req.query.q) {
|
||||
q = oldToNewQuerySyntax(q)
|
||||
}
|
||||
scoreAndSearch(q.from, q.to, scoreQueryObj).then(searchResults => {
|
||||
// CHOOSE PARAMS WHICH SHALL BE PASSED TO SCORE AND SEARCH
|
||||
let scoreQueryObj = prepareQueries(q)
|
||||
|
||||
//response.data = searchResults
|
||||
// FILTER if query contains filterString
|
||||
if (q.textfilter) {
|
||||
response = filterByString(searchResults, q.textfilter, q.fulltext)
|
||||
} else {
|
||||
response = searchResults
|
||||
let [regions] = await Promise.all([getRegions(dbConn)])
|
||||
let data = {
|
||||
regions: regions,
|
||||
}
|
||||
|
||||
// FILTER if query contains filterString
|
||||
if (q.textfilter) {
|
||||
data.regions = filterByString(data.regions, q.textfilter, q.fulltext)
|
||||
}
|
||||
|
||||
scoreAndSearch(data, q.from, q.to, scoreQueryObj).then(searchResults => {
|
||||
|
||||
// only dev:
|
||||
if (process.env.SHOW_MATCH_VALUE === '1') searchResults.forEach(reg => reg.name = `${reg.name} (${_.round(reg.score * 10, 1)}% match)`)
|
||||
|
||||
// FILTER NULLSCORES
|
||||
if (!_.get(q, 'showRegionsWithNullScore', false)) {
|
||||
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))
|
||||
}
|
||||
res.json(response)
|
||||
// SEND RESPONSE
|
||||
res.json(searchResults)
|
||||
|
||||
}).catch(e => {
|
||||
// TODO error handling
|
||||
console.log(e)
|
||||
res.json(e.message)
|
||||
res.status(400).send(e.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function filterByString(searchResults, filterString, boolFulltext) {
|
||||
return _.filter(searchResults, region => {
|
||||
console.log("filtering: filterString, boolFulltext");
|
||||
console.log(filterString, boolFulltext);
|
||||
// console.log("filtering: filterString, boolFulltext");
|
||||
// console.log(filterString, boolFulltext);
|
||||
filterString = filterString.toLowerCase()
|
||||
let name = region.name.toLowerCase()
|
||||
let country = region.country.toLowerCase()
|
||||
@ -87,4 +151,32 @@ function filterByString(searchResults, filterString, boolFulltext) {
|
||||
}
|
||||
return name.includes(filterString) || country.includes(filterString)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function prepareQueries(queries) {
|
||||
let q = {
|
||||
climate: {},
|
||||
costs: {},
|
||||
others: {}
|
||||
}
|
||||
// climate
|
||||
if (queries.temperature_mean_max) q.climate.temperature_mean_max = queries.temperature_mean_max
|
||||
if (queries.precipitation) q.climate.precipitation = queries.precipitation
|
||||
if (queries.rain_days) q.climate.rain_days = queries.rain_days
|
||||
if (queries.sun_hours) q.climate.sun_hours = queries.sun_hours
|
||||
|
||||
// costs
|
||||
if (queries.accommodation_costs) q.costs.accommodation_costs = queries.accommodation_costs
|
||||
if (queries.food_costs) q.costs.food_costs = queries.food_costs
|
||||
if (queries.alcohol_costs) q.costs.alcohol_costs = queries.alcohol_costs
|
||||
if (queries.water_costs) q.costs.water_costs = queries.water_costs
|
||||
if (queries.local_transportation_costs) q.costs.local_transportation_costs = queries.local_transportation_costs
|
||||
if (queries.entertainment_costs) q.costs.entertainment_costs = queries.entertainment_costs
|
||||
if (queries.average_per_day_costs) q.costs.average_per_day_costs = queries.average_per_day_costs
|
||||
|
||||
// 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
135
backend/routes/update.js
Normal 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
62
backend/settings.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
module.exports = function (dbConn) {
|
||||
return async function getAllRegionsWithClimatePerMonth(month) {
|
||||
console.log('getAllRegionsWithClimatePerMonth')
|
||||
|
||||
@ -1,22 +1,38 @@
|
||||
exports.oldToNewQuerySyntax = function (queries) {
|
||||
let res = {}
|
||||
try {
|
||||
if (queries.temperature_mean_max) res.temperature_mean_max = [Number(queries.temperature_mean_max.split(',')[0]), Number(queries.temperature_mean_max.split(',')[1])]
|
||||
if (queries.precipitation) res.precipitation = [Number(queries.precipitation.split(',')[0]), Number(queries.precipitation.split(',')[1])]
|
||||
if (queries.rain_days) res.rain_days = [Number(queries.rain_days.split(',')[0]), Number(queries.rain_days.split(',')[1])]
|
||||
if (queries.sun_hours) res.sun_hours = [Number(queries.sun_hours.split(',')[0]), Number(queries.sun_hours.split(',')[1])]
|
||||
const _ = require('lodash')
|
||||
|
||||
if (queries.accommodation_costs) res.accommodation_costs = [Number(queries.accommodation_costs.split(',')[0]), Number(queries.accommodation_costs.split(',')[1])]
|
||||
if (queries.food_costs) res.food_costs = [Number(queries.food_costs.split(',')[0]), Number(queries.food_costs.split(',')[1])]
|
||||
if (queries.alcohol_costs) res.alcohol_costs = [Number(queries.alcohol_costs.split(',')[0]), Number(queries.alcohol_costs.split(',')[1])]
|
||||
if (queries.water_costs) res.water_costs = [Number(queries.water_costs.split(',')[0]), Number(queries.water_costs.split(',')[1])]
|
||||
if (queries.public_transportation_costs) res.public_transportation_costs = [Number(queries.public_transportation_costs.split(',')[0]), Number(queries.public_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])]
|
||||
console.log('queries successfully transformed');
|
||||
} catch (error) {
|
||||
console.log('oldToNewQuerySyntax error');
|
||||
return queries
|
||||
module.exports = function (queries) {
|
||||
let res = _.clone(queries)
|
||||
console.log(res);
|
||||
|
||||
// try {
|
||||
if (queries.temperature_mean_max) res.temperature_mean_max = [Number(queries.temperature_mean_max.split(',')[0]), Number(queries.temperature_mean_max.split(',')[1])]
|
||||
if (queries.precipitation) res.precipitation = [Number(queries.precipitation.split(',')[0]), Number(queries.precipitation.split(',')[1])]
|
||||
if (queries.rain_days) res.rain_days = [Number(queries.rain_days.split(',')[0]), Number(queries.rain_days.split(',')[1])]
|
||||
if (queries.sun_hours) res.sun_hours = [Number(queries.sun_hours.split(',')[0]), Number(queries.sun_hours.split(',')[1])]
|
||||
|
||||
if (queries.accommodation_costs) res.accommodation_costs = [Number(queries.accommodation_costs.split(',')[0]), Number(queries.accommodation_costs.split(',')[1])]
|
||||
if (queries.food_costs) res.food_costs = [Number(queries.food_costs.split(',')[0]), Number(queries.food_costs.split(',')[1])]
|
||||
if (queries.alcohol_costs) res.alcohol_costs = [Number(queries.alcohol_costs.split(',')[0]), Number(queries.alcohol_costs.split(',')[1])]
|
||||
if (queries.water_costs) res.water_costs = [Number(queries.water_costs.split(',')[0]), Number(queries.water_costs.split(',')[1])]
|
||||
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');
|
||||
// return queries
|
||||
// }
|
||||
return res
|
||||
}
|
||||
@ -4,16 +4,63 @@ exports.calculateAvgScore = (...scores) => {
|
||||
return avgScore = scores.reduce((total, score) => total += score) / scores.length;
|
||||
}
|
||||
|
||||
exports.calculateScoreRange = (min, max, multiplier, regionVal, sLowVal, sHighVal) => {
|
||||
console.log('scores.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);
|
||||
return score <= 0 ? 0 : score * 10;
|
||||
exports.calculateScore = (transitionRange, regionVal, searchVal) => {
|
||||
let score = 1 - (Math.abs(searchVal - regionVal) / transitionRange);
|
||||
return (score) * 10;
|
||||
//return score <= 0 ? 0 : score * 10;
|
||||
}
|
||||
|
||||
exports.linear = function (x, exponent) {
|
||||
if (x < 0) return 0
|
||||
if (x > 10) return 10
|
||||
return x
|
||||
}
|
||||
|
||||
exports.easeOut = function (x, exponent) {
|
||||
if (x < 0) return 0
|
||||
if (x > 10) return 10
|
||||
return (1 - Math.pow(1 - (x / 10), exponent)) * 10
|
||||
}
|
||||
|
||||
exports.easeInOut = function (sc, exponent) {
|
||||
const x = (sc ) / 10
|
||||
// console.log(sc, x);
|
||||
if (x<0) return 0
|
||||
if (x>1) return 10
|
||||
return x < 0.5 ? Math.pow(2, exponent-1) * Math.pow(x,exponent) * 10 : (1 - Math.pow(-2 * x + 2, exponent)/2) * 10
|
||||
}
|
||||
|
||||
exports.easeInOutAsymmetric = function (sc, exponent) {
|
||||
const x = (sc ) / 10
|
||||
// console.log(sc, x);
|
||||
if (x<0) return 0
|
||||
if (x>1) return 10
|
||||
return x < 0.5 ? (2 * x) - 0.5 * 10 : (1 - Math.pow(-2 * x + 2, exponent)/2) * 10
|
||||
}
|
||||
|
||||
exports.sigmoid = function (x, exponent) {
|
||||
// const sigm = (1 / (1 + Math.pow(Math.E, 5 * -x))) * 10 + 5
|
||||
// const sigm = 10 / (1 + Math.pow(Math.E, 1.2 * -x + 6))
|
||||
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
|
||||
}
|
||||
@ -1,251 +1,306 @@
|
||||
const _ = require('lodash')
|
||||
const moment = require("moment")
|
||||
const getClimateMinMax = require("./getClimateMinMax.js")
|
||||
const oldToNewQuerySyntax = require("./oldToNewQuerySyntax.js")
|
||||
const getAllRegionsWithClimatePerMonth = require('./getAllRegionsWithClimatePerMonth')
|
||||
const score = require('./score')
|
||||
const getRegions = require('../models/getRegions.js')
|
||||
const scorer = require('./score')
|
||||
const SETTINGS = require('../settings').scoring
|
||||
|
||||
const SHOW_ALL_SCOREOBJECTS = false
|
||||
const MULTIPLIER = {
|
||||
temperature_mean_max: 5,
|
||||
precipitation: 3.5,
|
||||
rain_days: 3,
|
||||
sun_hours: 2.5,
|
||||
accommodation_costs: 5,
|
||||
food_costs: 5,
|
||||
alcohol_costs: 5,
|
||||
water_costs: 5,
|
||||
public_transportation_costs: 5,
|
||||
entertainment_costs: 5,
|
||||
average_per_day_costs: 5
|
||||
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 (from, to, queries) {
|
||||
console.log('search')
|
||||
// PREPARE SEARCH
|
||||
// validate dates
|
||||
const dates = validateDates(from, to)
|
||||
function sumForRangeAvg(from, to, avg) {
|
||||
let duration = moment(to).diff(moment(from), 'days')
|
||||
return duration * avg
|
||||
}
|
||||
|
||||
// transform syntax and seperate climate queries from price queries
|
||||
queries = oldToNewQuerySyntax.oldToNewQuerySyntax(queries)
|
||||
console.log(queries)
|
||||
const q = prepareQueries(queries)
|
||||
console.log('q', q)
|
||||
|
||||
// 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 boundaryCosts = {
|
||||
max: {
|
||||
accommodation_costs: 500,
|
||||
food_costs: 100,
|
||||
alcohol_costs: 100,
|
||||
water_costs: 100,
|
||||
public_transportation_costs: 100,
|
||||
entertainment_costs: 100,
|
||||
average_per_day_costs: 1000
|
||||
|
||||
},
|
||||
min: {
|
||||
accommodation_costs: 0,
|
||||
food_costs: 0,
|
||||
alcohol_costs: 0,
|
||||
water_costs: 0,
|
||||
public_transportation_costs: 0,
|
||||
entertainment_costs: 0,
|
||||
average_per_day_costs: 0
|
||||
}
|
||||
}
|
||||
|
||||
// little tweak to show score object without request
|
||||
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]
|
||||
}
|
||||
// CALCULATE SCORES FOR CLIMATE PROPS
|
||||
regions.forEach(reg => {
|
||||
Object.entries(q.climate).forEach(([key, value]) => {
|
||||
let finalScoreObj = getScoreAndAverageFromClimate(key, travelPeriods, reg, value[0], value[1], boundaryClimate)
|
||||
reg.scores.push(finalScoreObj)
|
||||
});
|
||||
|
||||
// CALCULATE SCORES FOR PRICE PROPS
|
||||
Object.entries(q.costs).forEach(([key, value]) => {
|
||||
let finalScoreObj = getScoreFromCosts(key, reg, value[0], value[1], boundaryCosts)
|
||||
|
||||
reg.scores.push(finalScoreObj)
|
||||
});
|
||||
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);
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
// CALCULATE AVERAGE SCORE
|
||||
reg.score = calculateAverage(reg.scores)
|
||||
})
|
||||
return _.orderBy(regions, ({score}) => score || 0, 'desc') //.filter(el => !_.isNaN(el.score))
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
return _.round(sum / cnt, 2)
|
||||
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)
|
||||
}
|
||||
|
||||
function prepareQueries(queries) {
|
||||
let q = {
|
||||
climate: {},
|
||||
costs: {}
|
||||
}
|
||||
// climate
|
||||
if (queries.temperature_mean_max) q.climate.temperature_mean_max = queries.temperature_mean_max
|
||||
if (queries.precipitation) q.climate.precipitation = queries.precipitation
|
||||
if (queries.rain_days) q.climate.rain_days = queries.rain_days
|
||||
if (queries.sun_hours) q.climate.sun_hours = queries.sun_hours
|
||||
|
||||
// costs
|
||||
if (queries.accommodation_costs) q.costs.accommodation_costs = queries.accommodation_costs
|
||||
if (queries.food_costs) q.costs.food_costs = queries.food_costs
|
||||
if (queries.alcohol_costs) q.costs.alcohol_costs = queries.alcohol_costs
|
||||
if (queries.water_costs) q.costs.water_costs = queries.water_costs
|
||||
if (queries.public_transportation_costs) q.costs.public_transportation_costs = queries.public_transportation_costs
|
||||
if (queries.entertainment_costs) q.costs.entertainment_costs = queries.entertainment_costs
|
||||
if (queries.average_per_day_costs) q.costs.average_per_day_costs = queries.average_per_day_costs
|
||||
|
||||
return q
|
||||
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()
|
||||
}
|
||||
|
||||
function travelPeriodsFromDates(dates) {
|
||||
//console.log(dates);
|
||||
|
||||
let travelPeriods = []
|
||||
if (dates.from.month === dates.to.month) {
|
||||
let period = {
|
||||
month: dates.from.month,
|
||||
days: dates.to.day - dates.from.day
|
||||
}
|
||||
travelPeriods.push(period)
|
||||
} else {
|
||||
for (let i = dates.from.month; i <= dates.to.month; i++) {
|
||||
let period = {}
|
||||
if (i === dates.from.month) {
|
||||
period = {
|
||||
month: i,
|
||||
days: 32 - dates.from.day
|
||||
}
|
||||
} else if (i === dates.to.month) {
|
||||
period = {
|
||||
month: i,
|
||||
days: dates.to.day
|
||||
}
|
||||
} else {
|
||||
period = {
|
||||
month: i,
|
||||
days: 30
|
||||
}
|
||||
}
|
||||
travelPeriods.push(period)
|
||||
}
|
||||
}
|
||||
return travelPeriods
|
||||
travelPeriods.push(period)
|
||||
|
||||
} else {
|
||||
for (var m = moment(start); m.isSameOrBefore(moment(end).endOf("month")); m.add(1, 'months')) {
|
||||
console.log(m);
|
||||
|
||||
travelPeriods.push(createPeriod(start, end, m.month()+1, m.year()))
|
||||
}
|
||||
}
|
||||
return travelPeriods
|
||||
}
|
||||
|
||||
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
|
||||
let dateTo = new Date(to)
|
||||
fromAndTo.to.day = dateTo.getDate()
|
||||
fromAndTo.to.month = dateTo.getMonth() + 1
|
||||
if (moment(dateFrom).add(23, 'hours').isAfter(moment(dateTo))) throw new Error("ERR: 'to' must be at least one day after 'from'.")
|
||||
} 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.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(from, 'YYYY-MM-DD').add(23, 'hours').isAfter(moment(to, 'YYYY-MM-DD'))) throw new Error("ERR: 'to' must be at least one day after 'from'.")
|
||||
}
|
||||
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 = 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
|
||||
}
|
||||
|
||||
return fromAndTo
|
||||
function createPeriod(start, end, currentMonth, currentYear) {
|
||||
let period = {}
|
||||
console.log(start, end, currentMonth, currentYear);
|
||||
|
||||
if (currentMonth === start.month() + 1 && currentYear === start.year()) {
|
||||
console.log('first month')
|
||||
period = {
|
||||
month: currentMonth,
|
||||
days: 32 - start.date()
|
||||
}
|
||||
|
||||
function getScoreAndAverageFromClimate(type, travelPeriods, region, searchLowParam, searchMaxParam, minMax) {
|
||||
console.log('getScoreAndAverageFromClimate for', region.name, type)
|
||||
|
||||
const singleScores = travelPeriods.map(period => {
|
||||
const sc = _.round(score.calculateScoreRange(minMax.min[type], minMax.max[type], MULTIPLIER[type], region[type][period.month - 1], searchLowParam, searchMaxParam), 2)
|
||||
let res = {
|
||||
//region_id: x.region_id,
|
||||
type: type,
|
||||
value: region[type][period.month - 1],
|
||||
score: (region[type][period.month - 1] === null || searchLowParam === null) ? null : sc,
|
||||
days: period.days
|
||||
}
|
||||
|
||||
return res
|
||||
})
|
||||
|
||||
let averagedScore = {
|
||||
type: type,
|
||||
value: 0,
|
||||
score: 0,
|
||||
days: 0
|
||||
}
|
||||
singleScores.forEach(el => {
|
||||
if (el.value !== null) {
|
||||
averagedScore.value += (el.value * el.days)
|
||||
averagedScore.score += (el.score * el.days)
|
||||
averagedScore.days += (el.days)
|
||||
} else {
|
||||
console.log('skip averaging')
|
||||
console.log(el)
|
||||
|
||||
}
|
||||
})
|
||||
averagedScore.value = _.round(averagedScore.value / averagedScore.days, 1)
|
||||
averagedScore.score = _.round(averagedScore.score / averagedScore.days, 1)
|
||||
if (searchLowParam === null) averagedScore.score = null
|
||||
delete averagedScore.days
|
||||
|
||||
return averagedScore
|
||||
} else if (currentMonth === end.month() + 1) {
|
||||
console.log('end month')
|
||||
period = {
|
||||
month: currentMonth,
|
||||
days: end.date()
|
||||
}
|
||||
|
||||
function getScoreFromCosts(type, region, searchLowParam, searchMaxParam, minMax) {
|
||||
console.log('getScoreFromCosts for', region.name, type)
|
||||
const sc = _.round(score.calculateScoreRange(minMax.min[type], minMax.max[type], MULTIPLIER[type], region[type], searchLowParam, searchMaxParam), 2)
|
||||
|
||||
let finScore = {
|
||||
type: type,
|
||||
value: region[type],
|
||||
score: sc,
|
||||
}
|
||||
finScore.value = _.round(finScore.value, 1)
|
||||
finScore.score = _.round(finScore.score, 1)
|
||||
if (searchLowParam === null) finScore.score = null
|
||||
|
||||
return finScore
|
||||
} else {
|
||||
console.log('middle month')
|
||||
period = {
|
||||
month: currentMonth,
|
||||
days: 30
|
||||
}
|
||||
}
|
||||
return period
|
||||
}
|
||||
|
||||
|
||||
//end
|
||||
function calculateScoreForPeriod(type, travelPeriods, region, searchLowParam, searchMaxParam) {
|
||||
// console.log('getScoreAndAverageFromClimate for', region.name, type)
|
||||
|
||||
const singleScores = travelPeriods. map(period => {
|
||||
let res = {
|
||||
type: type,
|
||||
value: region[type] !== null ? region[type][period.month - 1] : null,
|
||||
days: period.days
|
||||
}
|
||||
|
||||
return res
|
||||
})
|
||||
|
||||
let averagedScore = {
|
||||
type: type,
|
||||
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, 3)
|
||||
delete averagedScore.days
|
||||
|
||||
let transitionRange = SETTINGS[type][0]
|
||||
// special for precipitation
|
||||
if (type === 'precipitation') {
|
||||
transitionRange = scorer.increaseTransitionForHighValues(SETTINGS[type][0], searchLowParam)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
14
backend/util/sqlstring_sanitizer.js
Normal file
14
backend/util/sqlstring_sanitizer.js
Normal 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)
|
||||
}
|
||||
};
|
||||
@ -25,7 +25,8 @@
|
||||
"aot": false,
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
"src/assets",
|
||||
"src/robots.txt"
|
||||
],
|
||||
"styles": [
|
||||
"./node_modules/@angular/material/prebuilt-themes/indigo-pink.css",
|
||||
|
||||
6
frontend/bundle-frontend.sh
Normal file
6
frontend/bundle-frontend.sh
Normal file
@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
[[ -e "dist/*" ]] && rm -r dist/*
|
||||
npm run build && \
|
||||
(cd dist/frontend && tar czf ../cc-data-bundle.tar.gz ./) && \
|
||||
echo "Done!"
|
||||
195
frontend/package-lock.json
generated
195
frontend/package-lock.json
generated
@ -1071,20 +1071,20 @@
|
||||
}
|
||||
},
|
||||
"@babel/compat-data": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.9.0.tgz",
|
||||
"integrity": "sha512-zeFQrr+284Ekvd9e7KAX954LkapWiOmQtsfHirhxqfdlX6MEC32iRE+pqUGlYIBchdevaCwvzxWGSy/YBNI85g==",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.8.0.tgz",
|
||||
"integrity": "sha512-ixPUWJpnd9hHvRkyIE3mJ6PY5DEWmR08UkcpdqI5kV5g/d6knT8Wth1LE5v5sVTIJkm9dGpQsXnhwxcf2/PjAg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"browserslist": "^4.9.1",
|
||||
"browserslist": "^4.8.2",
|
||||
"invariant": "^2.2.4",
|
||||
"semver": "^5.5.0"
|
||||
"semver": "^7.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
|
||||
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz",
|
||||
"integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
@ -1187,6 +1187,89 @@
|
||||
"semver": "^5.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/compat-data": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.10.3.tgz",
|
||||
"integrity": "sha512-BDIfJ9uNZuI0LajPfoYV28lX8kyCPMHY6uY4WH1lJdcicmAfxCK5ASzaeV0D/wsUaRH/cLk+amuxtC37sZ8TUg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"browserslist": "^4.12.0",
|
||||
"invariant": "^2.2.4",
|
||||
"semver": "^5.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"browserslist": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.12.0.tgz",
|
||||
"integrity": "sha512-UH2GkcEDSI0k/lRkuDSzFl9ZZ87skSy9w2XAn1MsZnL+4c4rqbBd3e82UWHbYDpztABrPBhZsTEeuxVfHppqDg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"caniuse-lite": "^1.0.30001043",
|
||||
"electron-to-chromium": "^1.3.413",
|
||||
"node-releases": "^1.1.53",
|
||||
"pkg-up": "^2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"caniuse-lite": {
|
||||
"version": "1.0.30001087",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001087.tgz",
|
||||
"integrity": "sha512-KAQRGtt+eGCQBSp2iZTQibdCf9oe6cNTi5lmpsW38NnxP4WMYzfU6HCRmh4kJyh6LrTM9/uyElK4xcO93kafpg==",
|
||||
"dev": true
|
||||
},
|
||||
"find-up": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
|
||||
"integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"locate-path": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"locate-path": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
|
||||
"integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"p-locate": "^2.0.0",
|
||||
"path-exists": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"p-limit": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz",
|
||||
"integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"p-try": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"p-locate": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
|
||||
"integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"p-limit": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"p-try": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
|
||||
"integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=",
|
||||
"dev": true
|
||||
},
|
||||
"pkg-up": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-2.0.0.tgz",
|
||||
"integrity": "sha1-yBmscoBZpGHKscOImivjxJoATX8=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"find-up": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"semver": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
|
||||
@ -1928,6 +2011,89 @@
|
||||
"semver": "^5.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/compat-data": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.10.3.tgz",
|
||||
"integrity": "sha512-BDIfJ9uNZuI0LajPfoYV28lX8kyCPMHY6uY4WH1lJdcicmAfxCK5ASzaeV0D/wsUaRH/cLk+amuxtC37sZ8TUg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"browserslist": "^4.12.0",
|
||||
"invariant": "^2.2.4",
|
||||
"semver": "^5.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"browserslist": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.12.0.tgz",
|
||||
"integrity": "sha512-UH2GkcEDSI0k/lRkuDSzFl9ZZ87skSy9w2XAn1MsZnL+4c4rqbBd3e82UWHbYDpztABrPBhZsTEeuxVfHppqDg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"caniuse-lite": "^1.0.30001043",
|
||||
"electron-to-chromium": "^1.3.413",
|
||||
"node-releases": "^1.1.53",
|
||||
"pkg-up": "^2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"caniuse-lite": {
|
||||
"version": "1.0.30001087",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001087.tgz",
|
||||
"integrity": "sha512-KAQRGtt+eGCQBSp2iZTQibdCf9oe6cNTi5lmpsW38NnxP4WMYzfU6HCRmh4kJyh6LrTM9/uyElK4xcO93kafpg==",
|
||||
"dev": true
|
||||
},
|
||||
"find-up": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
|
||||
"integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"locate-path": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"locate-path": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
|
||||
"integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"p-locate": "^2.0.0",
|
||||
"path-exists": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"p-limit": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz",
|
||||
"integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"p-try": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"p-locate": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
|
||||
"integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"p-limit": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"p-try": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
|
||||
"integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=",
|
||||
"dev": true
|
||||
},
|
||||
"pkg-up": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-2.0.0.tgz",
|
||||
"integrity": "sha1-yBmscoBZpGHKscOImivjxJoATX8=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"find-up": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"semver": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
|
||||
@ -5338,6 +5504,11 @@
|
||||
"integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==",
|
||||
"dev": true
|
||||
},
|
||||
"hammerjs": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
|
||||
"integrity": "sha1-BO93hiz/K7edMPdpIJWTAiK/YPE="
|
||||
},
|
||||
"handle-thing": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
|
||||
@ -7900,6 +8071,14 @@
|
||||
"integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==",
|
||||
"dev": true
|
||||
},
|
||||
"ngx-device-detector": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/ngx-device-detector/-/ngx-device-detector-1.4.5.tgz",
|
||||
"integrity": "sha512-e3OlUKPrg+hoichpn/wx+C/YicUfdR6SIFo6848Nv5JbpLaMDvEgqsJsQjSGP2phKSnFIsOsDKHBb8iGfZfDLw==",
|
||||
"requires": {
|
||||
"tslib": "^1.9.0"
|
||||
}
|
||||
},
|
||||
"nice-try": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
|
||||
|
||||
@ -22,6 +22,8 @@
|
||||
"@angular/platform-browser-dynamic": "~8.2.14",
|
||||
"@angular/router": "~8.2.14",
|
||||
"@ngx-translate/core": "^12.1.2",
|
||||
"hammerjs": "^2.0.8",
|
||||
"ngx-device-detector": "^1.4.5",
|
||||
"rxjs": "~6.4.0",
|
||||
"tslib": "^1.10.0",
|
||||
"uuid": "^8.1.0",
|
||||
@ -32,6 +34,7 @@
|
||||
"@angular/cli": "~8.3.19",
|
||||
"@angular/compiler-cli": "~8.2.14",
|
||||
"@angular/language-service": "~8.2.14",
|
||||
"@babel/compat-data": "^7.8.0",
|
||||
"@types/canvasjs": "^1.9.6",
|
||||
"@types/jasmine": "~3.3.8",
|
||||
"@types/jasminewd2": "~2.0.3",
|
||||
|
||||
@ -4,12 +4,16 @@ import {HomeComponent} from './containers/home/home.component';
|
||||
import {NotfoundComponent} from './containers/notfound/notfound.component';
|
||||
import {SearchComponent} from './containers/search/search.component';
|
||||
import {RegionDetailsComponent} from './containers/region-details/region-details.component';
|
||||
import {BookmarkListComponent} from './containers/bookmark-list/bookmark-list.component';
|
||||
import {TeamComponent} from './containers/team/team.component';
|
||||
|
||||
|
||||
const routes: Routes = [
|
||||
{path: 'home', component: HomeComponent},
|
||||
{path: 'search', component: SearchComponent},
|
||||
{path: 'region/:id', component: RegionDetailsComponent},
|
||||
{path: 'bookmark', component: BookmarkListComponent},
|
||||
{path: 'team', component: TeamComponent},
|
||||
{path: '', redirectTo: 'home', pathMatch: 'full'},
|
||||
{path: '**', component: NotfoundComponent}
|
||||
];
|
||||
|
||||
@ -1,26 +1,32 @@
|
||||
<mat-toolbar color="primary" class="toolbar">
|
||||
<button mat-icon-button class="menu-btn" (click)="drawer.toggle()">
|
||||
<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 class="content">
|
||||
<mat-drawer #drawer class="drawer">
|
||||
<div class="nav">
|
||||
<a mat-button routerLink="home" (click)="drawer.close()">
|
||||
<mat-icon>home</mat-icon>
|
||||
<span>Home</span></a>
|
||||
<a mat-button routerLink="impressum" (click)="drawer.close()">
|
||||
<mat-icon>subject</mat-icon>
|
||||
<span>Impressum</span></a>
|
||||
<mat-drawer-container autosize class="drawer-container">
|
||||
<mat-drawer #drawer [mode]="isMobile?'over':'side'" [opened]="!isMobile">
|
||||
<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>
|
||||
<div class="routed-component-wrapper">
|
||||
<div class="routed-component">
|
||||
<mat-drawer-content class="content">
|
||||
<div class="router-outlet">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
</mat-drawer-content>
|
||||
</mat-drawer-container>
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,60 +1,91 @@
|
||||
:host {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
height: 4rem;
|
||||
|
||||
.menu-btn {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1 1 auto
|
||||
flex: 0 1 auto;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer {
|
||||
padding: 1rem;
|
||||
.drawer-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nav {
|
||||
.drawer {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.side-nav {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 12.5vw;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem 0;
|
||||
|
||||
>a {
|
||||
a {
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: start;
|
||||
|
||||
>span {
|
||||
padding-left: 2rem;
|
||||
mat-icon {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
span {
|
||||
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #00a0d2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feedback {
|
||||
margin: 1rem;
|
||||
text-align: center;
|
||||
color: gray;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1 1 auto;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.routed-component-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
|
||||
.routed-component {
|
||||
flex: 0 1 auto;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem;
|
||||
.router-outlet {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-drawer-container {
|
||||
overflow-y: visible;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {DeviceDetectorService} from 'ngx-device-detector';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@ -7,7 +8,10 @@ import {Component, OnInit} from '@angular/core';
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
|
||||
constructor() {
|
||||
isMobile: boolean;
|
||||
|
||||
constructor(private ds: DeviceDetectorService) {
|
||||
this.isMobile = ds.isMobile();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
@ -21,12 +21,36 @@ import {TranslateModule, TranslateService} from '@ngx-translate/core';
|
||||
// @ts-ignore
|
||||
import * as enLang from '../assets/i18n/en.json';
|
||||
import {HttpClientModule} from '@angular/common/http';
|
||||
import {MatButtonToggleModule, MatCheckboxModule, MatDividerModule} from '@angular/material';
|
||||
import {
|
||||
MatBadgeModule,
|
||||
MatButtonToggleModule,
|
||||
MatCheckboxModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
MatDividerModule,
|
||||
MatListModule,
|
||||
MatRadioModule,
|
||||
MatSliderModule,
|
||||
MatSlideToggleModule,
|
||||
MatStepperModule,
|
||||
MatTabsModule,
|
||||
MatTooltipModule
|
||||
} from '@angular/material';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {RegionComponent} from './components/region/region.component';
|
||||
import {ResultComponent} from './components/result/result.component';
|
||||
import {RegionDetailsComponent} from './containers/region-details/region-details.component';
|
||||
import {GraphComponent} from './components/graph/graph.component';
|
||||
import {RegionStatsComponent} from './components/region-stats/region-stats.component';
|
||||
import {BookmarkButtonComponent} from './components/bookmark-button/bookmark-button.component';
|
||||
import {BookmarkListComponent} from './containers/bookmark-list/bookmark-list.component';
|
||||
import {ShareButtonComponent} from './components/share-button/share-button.component';
|
||||
import {ShareDialogComponent} from './dialogs/share-dialog/share-dialog.component';
|
||||
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({
|
||||
@ -39,7 +63,16 @@ import {GraphComponent} from './components/graph/graph.component';
|
||||
RegionComponent,
|
||||
ResultComponent,
|
||||
RegionDetailsComponent,
|
||||
GraphComponent
|
||||
GraphComponent,
|
||||
RegionStatsComponent,
|
||||
BookmarkButtonComponent,
|
||||
BookmarkListComponent,
|
||||
ShareButtonComponent,
|
||||
ShareDialogComponent,
|
||||
TeamComponent,
|
||||
ToggleSliderComponent,
|
||||
PlaceComponent,
|
||||
MultiTagSelectComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
@ -59,10 +92,24 @@ import {GraphComponent} from './components/graph/graph.component';
|
||||
MatCheckboxModule,
|
||||
FormsModule,
|
||||
MatButtonToggleModule,
|
||||
MatDividerModule
|
||||
MatDividerModule,
|
||||
MatTooltipModule,
|
||||
MatDialogModule,
|
||||
DeviceDetectorModule,
|
||||
MatTabsModule,
|
||||
MatBadgeModule,
|
||||
MatStepperModule,
|
||||
MatRadioModule,
|
||||
MatSlideToggleModule,
|
||||
MatSliderModule,
|
||||
MatChipsModule,
|
||||
MatListModule
|
||||
],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent]
|
||||
bootstrap: [AppComponent],
|
||||
entryComponents: [
|
||||
ShareDialogComponent
|
||||
]
|
||||
})
|
||||
export class AppModule {
|
||||
constructor(translate: TranslateService) {
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
<button (click)="onToggle($event)"
|
||||
[color]="isBookmarked ? 'accent' : 'primary'"
|
||||
mat-icon-button
|
||||
matTooltip="{{isBookmarked ? 'Remove from bookmarks' : 'Add to bookmarks'}}"
|
||||
>
|
||||
<mat-icon>{{isBookmarked ? 'bookmark' : 'bookmark_border'}}</mat-icon>
|
||||
</button>
|
||||
@ -0,0 +1,25 @@
|
||||
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {BookmarkButtonComponent} from './bookmark-button.component';
|
||||
|
||||
describe('BookmarkButtonComponent', () => {
|
||||
let component: BookmarkButtonComponent;
|
||||
let fixture: ComponentFixture<BookmarkButtonComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [BookmarkButtonComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BookmarkButtonComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,40 @@
|
||||
import {Component, Input, OnDestroy, OnInit} from '@angular/core';
|
||||
import {Region} from '../../interfaces/region.interface';
|
||||
import {BookmarkService} from '../../services/bookmark.service';
|
||||
import {Subject} from 'rxjs';
|
||||
import {takeUntil} from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmark-button',
|
||||
templateUrl: './bookmark-button.component.html',
|
||||
styleUrls: ['./bookmark-button.component.scss']
|
||||
})
|
||||
export class BookmarkButtonComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input()
|
||||
region: Region;
|
||||
|
||||
isBookmarked = false;
|
||||
|
||||
private destroyed$ = new Subject<void>();
|
||||
|
||||
constructor(private bs: BookmarkService) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.bs.isMarked(this.region.region_id).pipe(
|
||||
takeUntil(this.destroyed$)
|
||||
).subscribe(val => this.isBookmarked = val);
|
||||
}
|
||||
|
||||
onToggle(event: Event) {
|
||||
event.stopPropagation();
|
||||
this.bs.toggleRegion(this.region.region_id);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyed$.next();
|
||||
this.destroyed$.complete();
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import {AfterViewInit, Component, Input} from '@angular/core';
|
||||
import * as CanvasJS from './canvasjs.min';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
import {ChartDataSeriesOptions} from 'canvasjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph',
|
||||
@ -10,7 +11,17 @@ import {v4 as uuidv4} from 'uuid';
|
||||
export class GraphComponent implements AfterViewInit {
|
||||
|
||||
@Input()
|
||||
monthlyData: number[] = [];
|
||||
monthlyDatas: number[][] = [];
|
||||
@Input()
|
||||
labels: string[] = [];
|
||||
@Input()
|
||||
colors: string[] = [];
|
||||
@Input()
|
||||
formatSting: string;
|
||||
@Input()
|
||||
graphType = 'line';
|
||||
@Input()
|
||||
minMax: number[];
|
||||
|
||||
readonly randomId = uuidv4();
|
||||
|
||||
@ -18,27 +29,59 @@ export class GraphComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
const data: ChartDataSeriesOptions[] = [];
|
||||
for (const monthlyData of this.monthlyDatas) {
|
||||
data.push({
|
||||
type: this.graphType,
|
||||
color: 'black',
|
||||
showInLegend: this.labels.length > 0,
|
||||
yValueFormatString: this.formatSting,
|
||||
dataPoints: [
|
||||
{y: monthlyData[0], x: 1, label: 'January'},
|
||||
{y: monthlyData[1], x: 2, label: 'February'},
|
||||
{y: monthlyData[2], x: 3, label: 'March'},
|
||||
{y: monthlyData[3], x: 4, label: 'April'},
|
||||
{y: monthlyData[4], x: 5, label: 'May'},
|
||||
{y: monthlyData[5], x: 6, label: 'June'},
|
||||
{y: monthlyData[6], x: 7, label: 'July'},
|
||||
{y: monthlyData[7], x: 8, label: 'August'},
|
||||
{y: monthlyData[8], x: 9, label: 'September'},
|
||||
{y: monthlyData[9], x: 10, label: 'October'},
|
||||
{y: monthlyData[10], x: 11, label: 'November'},
|
||||
{y: monthlyData[11], x: 12, label: 'December'}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.labels.length; i++) {
|
||||
data[i].name = this.labels[i];
|
||||
}
|
||||
|
||||
|
||||
for (let i = 0; i < this.colors.length; i++) {
|
||||
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,
|
||||
exportEnabled: false,
|
||||
data: [{
|
||||
type: 'line',
|
||||
color: 'green',
|
||||
dataPoints: [
|
||||
{y: this.monthlyData[0], label: 'January'},
|
||||
{y: this.monthlyData[1], label: 'February'},
|
||||
{y: this.monthlyData[2], label: 'March'},
|
||||
{y: this.monthlyData[3], label: 'April'},
|
||||
{y: this.monthlyData[4], label: 'May'},
|
||||
{y: this.monthlyData[5], label: 'June'},
|
||||
{y: this.monthlyData[6], label: 'July'},
|
||||
{y: this.monthlyData[7], label: 'August'},
|
||||
{y: this.monthlyData[8], label: 'September'},
|
||||
{y: this.monthlyData[9], label: 'October'},
|
||||
{y: this.monthlyData[10], label: 'November'},
|
||||
{y: this.monthlyData[11], label: 'December'}
|
||||
]
|
||||
}]
|
||||
backgroundColor: 'transparent',
|
||||
legend: {
|
||||
verticalAlign: 'bottom',
|
||||
horizontalAlign: 'left',
|
||||
dockInsidePlotArea: true
|
||||
},
|
||||
axisY,
|
||||
data
|
||||
});
|
||||
|
||||
chart.render();
|
||||
|
||||
@ -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>
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
6
frontend/src/app/components/place/place.component.html
Normal file
6
frontend/src/app/components/place/place.component.html
Normal 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>
|
||||
28
frontend/src/app/components/place/place.component.scss
Normal file
28
frontend/src/app/components/place/place.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
25
frontend/src/app/components/place/place.component.spec.ts
Normal file
25
frontend/src/app/components/place/place.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
24
frontend/src/app/components/place/place.component.ts
Normal file
24
frontend/src/app/components/place/place.component.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
<table>
|
||||
<tr *ngFor="let prop of shownKeys">
|
||||
<td>
|
||||
<div class="cell space">
|
||||
<mat-icon>{{PROPERTY_VIS_DEF[prop].icon}}</mat-icon>
|
||||
<span>{{prop|translate}}:</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="cell right">
|
||||
<span>{{region[prop] ? (region[prop]|number:'1.2-2') : 'N/A'}}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="cell">
|
||||
<span>{{PROPERTY_VIS_DEF[prop].unit}}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -0,0 +1,18 @@
|
||||
.cell {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
|
||||
&.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&.space {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
> mat-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {RegionStatsComponent} from './region-stats.component';
|
||||
|
||||
describe('RegionStatsComponent', () => {
|
||||
let component: RegionStatsComponent;
|
||||
let fixture: ComponentFixture<RegionStatsComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [RegionStatsComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(RegionStatsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,27 @@
|
||||
import {Component, Input, OnInit} from '@angular/core';
|
||||
import {Region} from '../../interfaces/region.interface';
|
||||
import {SearchParameter} from '../../interfaces/search-request.interface';
|
||||
import {REGION_PARAM_VIS} from '../../services/data.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-region-stats',
|
||||
templateUrl: './region-stats.component.html',
|
||||
styleUrls: ['./region-stats.component.scss']
|
||||
})
|
||||
export class RegionStatsComponent implements OnInit {
|
||||
|
||||
@Input()
|
||||
region: Region;
|
||||
@Input()
|
||||
shownKeys: SearchParameter[];
|
||||
|
||||
/** Contains the visual definitions */
|
||||
readonly PROPERTY_VIS_DEF = REGION_PARAM_VIS;
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,15 +1,12 @@
|
||||
<div class="region-mat-card">
|
||||
<img class="region-img" src="https://travopti.de/api/v1/regions/{{region.region_id}}/image">
|
||||
<img alt="Picture of {{region.name}}" class="region-img"
|
||||
src="https://travopti.de/api/v1/regions/{{region.region_id}}/image">
|
||||
<div class="region-footer">
|
||||
<div class="region-title">
|
||||
<span class="region-name">{{region.name}}</span>
|
||||
<span class="region-country">| {{region.country}}</span>
|
||||
</div>
|
||||
<button mat-icon-button>
|
||||
<mat-icon>bookmark</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button>
|
||||
<mat-icon>share</mat-icon>
|
||||
</button>
|
||||
<app-bookmark-button [region]="region"></app-bookmark-button>
|
||||
<app-share-button [region]="region"></app-share-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
|
||||
> .region-img {
|
||||
flex: 0 0 auto;
|
||||
@ -29,7 +30,7 @@
|
||||
|
||||
> .region-name {
|
||||
font-weight: bold;
|
||||
font-size: large;
|
||||
font-size: larger;
|
||||
align-self: center;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
@ -1,15 +1,63 @@
|
||||
<div class="result-mat-card">
|
||||
<img class="result-img" src="https://travopti.de/api/v1/regions/{{result.region_id}}/image">
|
||||
<div class="result-footer">
|
||||
<div class="result-title">
|
||||
<span class="result-name">{{result.name}}</span>
|
||||
<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-header">
|
||||
<span class="result-name">{{result.name}}<span *ngIf="debug"> ({{result.score}})</span></span>
|
||||
<span class="result-country">| {{result.country}}</span>
|
||||
</div>
|
||||
<button mat-icon-button>
|
||||
<mat-icon>bookmark</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button>
|
||||
<mat-icon>share</mat-icon>
|
||||
</button>
|
||||
<app-bookmark-button [region]="result"></app-bookmark-button>
|
||||
<app-share-button [region]="result"></app-share-button>
|
||||
</div>
|
||||
<div class="result-details">
|
||||
<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>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
|
||||
> .result-img {
|
||||
flex: 0 0 auto;
|
||||
@ -10,31 +11,95 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
> .result-footer {
|
||||
> .result-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
> .result-title {
|
||||
> .result-header {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 0.25rem 0;
|
||||
align-items: center;
|
||||
|
||||
> .result-name {
|
||||
font-weight: bold;
|
||||
font-size: larger;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
> .result-country {
|
||||
text-transform: uppercase;
|
||||
font-size: small;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
> .result-name {
|
||||
font-weight: bold;
|
||||
font-size: large;
|
||||
align-self: center;
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
|
||||
&.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&.space {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
> mat-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,20 +1,57 @@
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-result',
|
||||
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;
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,36 +1,137 @@
|
||||
<mat-card class="search-container">
|
||||
<section class="group">
|
||||
<h2>When is your trip?</h2>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Start</mat-label>
|
||||
<input [(ngModel)]="from" matInput required type="date">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>End</mat-label>
|
||||
<input [(ngModel)]="to" matInput required type="date">
|
||||
</mat-form-field>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What would you prefer?</h2>
|
||||
<div *ngFor="let key of multiPresetsKeys" class="sub-group">
|
||||
<span class="label">{{key|translate}}:</span><br>
|
||||
<mat-button-toggle-group [(ngModel)]="multiPresetSelection[key]" [value]="undefined">
|
||||
<mat-button-toggle *ngFor="let preset of multiPresets.get(key)"
|
||||
[value]="preset.preset_id">{{preset.tag_label|translate}}</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
</section>
|
||||
<span matCardTitle>Search</span>
|
||||
|
||||
<section>
|
||||
<h2>Whats most important to you?</h2>
|
||||
<div class="vertical">
|
||||
<mat-checkbox *ngFor="let preset of singlePresets"
|
||||
[(ngModel)]="singlePresetSelection[preset.preset_id]">{{preset.tag_label|translate}}</mat-checkbox>
|
||||
</div>
|
||||
</section>
|
||||
<mat-tab-group #tabGroup [animationDuration]="'0'" [selectedIndex]="selectedTab">
|
||||
|
||||
<button (click)="onSearch()" class="search-btn" color="primary" mat-flat-button>Search
|
||||
<!-- Guided Search Tab -->
|
||||
<mat-tab label="Guided">
|
||||
|
||||
<mat-vertical-stepper>
|
||||
<!-- Date input -->
|
||||
<mat-step>
|
||||
<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>
|
||||
<input (change)="checkDates()" [(ngModel)]="from" [min]="today" matInput required type="date">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>End</mat-label>
|
||||
<input (change)="checkDates()" [(ngModel)]="to" [min]="from" matInput required type="date">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-step>
|
||||
<!-- Multi presets -->
|
||||
<mat-step>
|
||||
<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">
|
||||
<mat-checkbox *ngFor="let preset of singlePresets"
|
||||
[(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>
|
||||
|
||||
<!-- Advanced Search Tab -->
|
||||
<mat-tab label="Advanced">
|
||||
<!-- Date -->
|
||||
<section class="group">
|
||||
<span class="title">Date</span>
|
||||
<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">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>End</mat-label>
|
||||
<input (change)="checkDates()" [(ngModel)]="to" [min]="from" matInput required type="date">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Text Filter -->
|
||||
<section class="group">
|
||||
<span class="title">Text</span>
|
||||
<div class="content vertical-wrap">
|
||||
<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 center-desc">
|
||||
<mat-slide-toggle [(ngModel)]="fullText"></mat-slide-toggle>
|
||||
<span>Search in description </span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Climate Params -->
|
||||
<section class="group">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
</mat-tab-group>
|
||||
|
||||
<button (click)="onSearch(tabGroup.selectedIndex === 1)" [disabled]="!from || !to" class="search-btn" color="primary"
|
||||
mat-flat-button>Search
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</button>
|
||||
|
||||
</mat-card>
|
||||
|
||||
@ -2,27 +2,119 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .group {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
> .search-btn {
|
||||
>.search-btn {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sub-group {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .label {
|
||||
.label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
mat-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
mat-radio-button {
|
||||
margin: 0 1rem 0.5rem 0;
|
||||
width: 40%;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 1rem 0;
|
||||
overflow: hidden;
|
||||
|
||||
.title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
&.space {
|
||||
>* {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.center {
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
&.center-desc {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
.vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vertical-wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ import {objToBase64} from '../../utils/base64conversion';
|
||||
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',
|
||||
@ -13,17 +15,39 @@ import {formatDate} from '@angular/common';
|
||||
})
|
||||
export class SearchInputComponent implements OnInit {
|
||||
|
||||
|
||||
selectedTab = 0;
|
||||
|
||||
presets: Preset[];
|
||||
singlePresets: Preset[];
|
||||
multiPresets: Map<string, Preset[]>;
|
||||
multiPresetsKeys: string[];
|
||||
selectedTags: string[] = [];
|
||||
|
||||
tags: string[];
|
||||
|
||||
from: string;
|
||||
to: string;
|
||||
|
||||
// Guided Search
|
||||
singlePresetSelection = {};
|
||||
multiPresetSelection = {};
|
||||
|
||||
constructor(private router: Router, private ps: PresetService) {
|
||||
// Advanced Search
|
||||
textFilter = '';
|
||||
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');
|
||||
|
||||
constructor(private router: Router, private ps: PresetService, private ss: SearchService) {
|
||||
const from = new Date();
|
||||
const to = new Date();
|
||||
to.setDate(from.getDate() + 7);
|
||||
@ -40,15 +64,51 @@ export class SearchInputComponent implements OnInit {
|
||||
this.multiPresets = this.ps.multiPresets;
|
||||
this.multiPresetsKeys = [...this.multiPresets.keys()];
|
||||
|
||||
for (const preset of this.singlePresets) {
|
||||
this.singlePresetSelection[preset.preset_id] = false;
|
||||
this.tags = await this.ss.getAvailableTags();
|
||||
|
||||
this.loadSearch();
|
||||
}
|
||||
|
||||
async onSearch(isAdvanced: boolean) {
|
||||
this.saveSearch(isAdvanced);
|
||||
|
||||
const query = isAdvanced ? this.getQueryFromAdvanced() : this.getQueryFromGuided();
|
||||
console.log(query);
|
||||
|
||||
await this.router.navigate(['/search'], {queryParams: {q: objToBase64(query)}});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a (multi) preset click.
|
||||
* @param preset The clicked preset
|
||||
* @return If the button is selected
|
||||
*/
|
||||
onMultiPresetSelect(preset: Preset) {
|
||||
if (this.multiPresetSelection[preset.parameter] === preset.preset_id) {
|
||||
this.multiPresetSelection[preset.parameter] = undefined;
|
||||
return false;
|
||||
} else {
|
||||
this.multiPresetSelection[preset.parameter] = preset.preset_id;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async onSearch() {
|
||||
checkDates() {
|
||||
const fromDate = new Date(this.from);
|
||||
const toDate = new Date(this.to);
|
||||
|
||||
if (toDate <= fromDate) {
|
||||
const newToDate = new Date(this.from);
|
||||
newToDate.setDate(fromDate.getDate() + 1);
|
||||
this.to = formatDate(newToDate, 'yyyy-MM-dd', 'en-GB');
|
||||
}
|
||||
}
|
||||
|
||||
private getQueryFromGuided(): Query {
|
||||
const query: Query = {
|
||||
from: new Date(this.from).getTime(),
|
||||
to: new Date(this.to).getTime(),
|
||||
tags: this.selectedTags
|
||||
};
|
||||
|
||||
for (const preset of this.singlePresets) {
|
||||
@ -63,6 +123,75 @@ export class SearchInputComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
await this.router.navigate(['/search'], {queryParams: {q: objToBase64(query)}});
|
||||
return query;
|
||||
}
|
||||
|
||||
private getQueryFromAdvanced(): Query {
|
||||
const query: Query = {
|
||||
from: new Date(this.from).getTime(),
|
||||
to: new Date(this.to).getTime(),
|
||||
tags: this.selectedTags
|
||||
};
|
||||
|
||||
if (this.textFilter.length > 0) {
|
||||
query.fulltext = this.fullText;
|
||||
query.textfilter = this.textFilter;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private saveSearch(isAdvanced: boolean) {
|
||||
this.ss.saveSearchInput({
|
||||
wasAdvanced: isAdvanced,
|
||||
from: this.from,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
private loadSearch() {
|
||||
const prevInput = this.ss.loadSearchInput();
|
||||
|
||||
if (prevInput) {
|
||||
this.from = prevInput.from;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
<button (click)="onShare($event)" mat-icon-button matTooltip="Share the link">
|
||||
<mat-icon>share</mat-icon>
|
||||
</button>
|
||||
@ -0,0 +1,25 @@
|
||||
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {ShareButtonComponent} from './share-button.component';
|
||||
|
||||
describe('ShareButtonComponent', () => {
|
||||
let component: ShareButtonComponent;
|
||||
let fixture: ComponentFixture<ShareButtonComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ShareButtonComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ShareButtonComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,54 @@
|
||||
import {Component, Input, OnInit} from '@angular/core';
|
||||
import {Region} from '../../interfaces/region.interface';
|
||||
import {ShareDialogComponent} from '../../dialogs/share-dialog/share-dialog.component';
|
||||
import {MatDialog} from '@angular/material';
|
||||
|
||||
@Component({
|
||||
selector: 'app-share-button',
|
||||
templateUrl: './share-button.component.html',
|
||||
styleUrls: ['./share-button.component.scss']
|
||||
})
|
||||
export class ShareButtonComponent implements OnInit {
|
||||
|
||||
@Input()
|
||||
region: Region;
|
||||
|
||||
constructor(public dialog: MatDialog) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
async onShare(event: Event) {
|
||||
event.stopPropagation();
|
||||
|
||||
// @ts-ignore
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await this.executeMobileShareMenu();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
} else {
|
||||
this.executeShareDialog();
|
||||
}
|
||||
}
|
||||
|
||||
async executeMobileShareMenu() {
|
||||
const shareData = {
|
||||
title: 'Travopti',
|
||||
text: `Check out the region "${this.region.name}"`,
|
||||
url: `https://travopti.de/region/${this.region.region_id}`
|
||||
};
|
||||
// @ts-ignore
|
||||
await navigator.share(shareData);
|
||||
}
|
||||
|
||||
executeShareDialog() {
|
||||
this.dialog.open(ShareDialogComponent, {
|
||||
width: '350px',
|
||||
data: this.region
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
<span>{{label}}</span>
|
||||
<mat-slide-toggle (change)="onSlideToggleChange($event)" [(ngModel)]="enabled"></mat-slide-toggle>
|
||||
<mat-slider
|
||||
(click)="enabled=true"
|
||||
[(value)]="value"
|
||||
[disabled]="!enabled"
|
||||
[max]="max"
|
||||
[min]="min"
|
||||
[step]="step"
|
||||
[thumbLabel]="true"
|
||||
></mat-slider>
|
||||
@ -0,0 +1,24 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
span {
|
||||
width: 33%;
|
||||
min-width: 8rem;
|
||||
margin-right: 0.5rem;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
}
|
||||
|
||||
mat-slide-toggle {
|
||||
flex: 0 1 auto;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
mat-slider {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {ToggleSliderComponent} from './toggle-slider.component';
|
||||
|
||||
describe('ToggleSliderComponent', () => {
|
||||
let component: ToggleSliderComponent;
|
||||
let fixture: ComponentFixture<ToggleSliderComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ToggleSliderComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ToggleSliderComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,42 @@
|
||||
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
|
||||
import {MatSlideToggleChange} from '@angular/material';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toggle-slider',
|
||||
templateUrl: './toggle-slider.component.html',
|
||||
styleUrls: ['./toggle-slider.component.scss']
|
||||
})
|
||||
export class ToggleSliderComponent implements OnInit {
|
||||
|
||||
enabled = false;
|
||||
rawValue: number;
|
||||
@Output() modelChange: EventEmitter<number> = new EventEmitter<number>();
|
||||
@Input() min = 0;
|
||||
@Input() max = 100;
|
||||
@Input() step = 1;
|
||||
@Input() label: string;
|
||||
|
||||
get value(): number {
|
||||
return this.rawValue;
|
||||
}
|
||||
|
||||
set value(value: number) {
|
||||
this.rawValue = value;
|
||||
this.modelChange.emit(value);
|
||||
}
|
||||
|
||||
@Input()
|
||||
set model(value: number) {
|
||||
this.rawValue = value;
|
||||
this.enabled = value !== undefined;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
onSlideToggleChange(event: MatSlideToggleChange) {
|
||||
if (event.checked === false) {
|
||||
this.modelChange.emit(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
<h2>Your bookmarks</h2>
|
||||
<div *ngIf="!isLoading" class="bookmarks-contianer">
|
||||
<app-region (click)="onBookmarkClick(bookmark)" *ngFor="let bookmark of bookmarks" [region]="bookmark"></app-region>
|
||||
<span *ngIf="bookmarks.length === 0">You have no bookmarks :(</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isLoading && !message" class="spinner">
|
||||
<mat-spinner></mat-spinner>
|
||||
</div>
|
||||
|
||||
<div *ngIf="message" class="spinner">
|
||||
<span>{{message}}</span>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bookmarks-contianer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
> * {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {BookmarkListComponent} from './bookmark-list.component';
|
||||
|
||||
describe('BookmarkListComponent', () => {
|
||||
let component: BookmarkListComponent;
|
||||
let fixture: ComponentFixture<BookmarkListComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [BookmarkListComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BookmarkListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,53 @@
|
||||
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 {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',
|
||||
templateUrl: './bookmark-list.component.html',
|
||||
styleUrls: ['./bookmark-list.component.scss']
|
||||
})
|
||||
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) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.bs.getRegionIds().pipe(
|
||||
takeUntil(this.destroyed$),
|
||||
tap(() => this.isLoading = true),
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
onBookmarkClick(region: Region) {
|
||||
this.router.navigate(['/region', region.region_id]).catch(console.log);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyed$.next();
|
||||
this.destroyed$.complete();
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -17,3 +17,8 @@ app-search-input {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.central {
|
||||
align-self: center;
|
||||
margin: 4rem 0;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
@ -10,13 +11,24 @@ import {Router} from '@angular/router';
|
||||
})
|
||||
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();
|
||||
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) {
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
<div *ngIf="region">
|
||||
<img class="region-img" src="https://travopti.de/api/v1/regions/{{region.region_id}}/image">
|
||||
<div #container *ngIf="region">
|
||||
<img alt="Picture of {{region.name}}" class="region-img"
|
||||
src="https://travopti.de/api/v1/regions/{{region.region_id}}/image">
|
||||
<div class="region-details-header">
|
||||
<div class="region-title">
|
||||
<span class="region-country">{{region.country}}</span>
|
||||
<span class="region-name">{{region.name}}</span>
|
||||
</div>
|
||||
<button mat-icon-button>
|
||||
<mat-icon>bookmark</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button>
|
||||
<mat-icon>share</mat-icon>
|
||||
</button>
|
||||
<app-bookmark-button [region]="region"></app-bookmark-button>
|
||||
<a href="https://www.google.com/flights?q=flight+to+{{uriRegionName}}" mat-icon-button matTooltip="Search flights"
|
||||
rel="noopener" target="_blank">
|
||||
<mat-icon>flight_takeoff</mat-icon>
|
||||
</a>
|
||||
<app-share-button [region]="region"></app-share-button>
|
||||
</div>
|
||||
<p *ngIf="region.description" class="region-decsription">
|
||||
<span>{{region.description.substr(0, DESC_CUT_POINT)}}</span>
|
||||
@ -19,38 +20,54 @@
|
||||
<span *ngIf="isDescExtended">{{region.description.substr(DESC_CUT_POINT)}}</span>
|
||||
</p>
|
||||
<div class="region-stats-group">
|
||||
<div>
|
||||
<table>
|
||||
<tr *ngFor="let prop of SHOWN_PROPS">
|
||||
<td>
|
||||
<div class="cell">
|
||||
<mat-icon>{{prop.icon}}</mat-icon>
|
||||
<span>{{prop.property|translate}}:</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="cell right">
|
||||
<span>{{region[prop.property].toFixed(2)}}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="cell">
|
||||
<span>{{prop.unit}}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<app-region-stats [region]="region" [shownKeys]="SHOWN_PROPS"></app-region-stats>
|
||||
</div>
|
||||
<div class="region-stats-group">
|
||||
<span class="group-title">Monthly Data</span>
|
||||
<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="##0.##'%'"
|
||||
graphType="column">
|
||||
</app-graph>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
<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"
|
||||
[minMax]="[-25, 50]" formatSting="##,##°C">
|
||||
</app-graph>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
<mat-tab *ngIf="region.precipitation" label="Precipitation">
|
||||
<ng-template matTabContent>
|
||||
<app-graph [minMax]="[0, 1200]" [monthlyDatas]="[region.precipitation]" class="graph" formatSting="####'mm'">
|
||||
</app-graph>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
<mat-tab *ngIf="region.rain_days" label="Rain days">
|
||||
<ng-template matTabContent>
|
||||
<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 class="region-stats-group">
|
||||
<span class="group-title">Max Temperatures [°C]</span>
|
||||
<app-graph [monthlyData]="region.temperature_mean_max" class="graph"></app-graph>
|
||||
</div>
|
||||
<div class="region-stats-group">
|
||||
<span class="group-title">Precipitation [mm]</span>
|
||||
<app-graph [monthlyData]="region.precipitation" class="graph"></app-graph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!region" class="spinner">
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
object-fit: cover;
|
||||
height: 10rem;
|
||||
margin-bottom: 1.5rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.region-details-header {
|
||||
@ -41,36 +42,50 @@
|
||||
|
||||
> .more-btn {
|
||||
color: #8f8f8f;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.region-stats-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 1rem 0;
|
||||
margin: 2rem 0 1rem 0;
|
||||
|
||||
> .group-title {
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
> .graph {
|
||||
mat-tab-body {
|
||||
min-height: 30vh !important;
|
||||
}
|
||||
|
||||
.graph {
|
||||
height: 30vh;
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.cell {
|
||||
.places-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
flex-direction: column;
|
||||
|
||||
&.right {
|
||||
justify-content: flex-end;
|
||||
> .group-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
> mat-icon {
|
||||
margin-right: 0.5rem;
|
||||
> .places {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-evenly;
|
||||
|
||||
app-place {
|
||||
margin: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,65 +1,60 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {AfterViewInit, Component, ElementRef, ViewChild} from '@angular/core';
|
||||
import {Region} from '../../interfaces/region.interface';
|
||||
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';
|
||||
|
||||
interface VisualRegionPropDef {
|
||||
property: string;
|
||||
icon: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-region-details',
|
||||
templateUrl: './region-details.component.html',
|
||||
styleUrls: ['./region-details.component.scss']
|
||||
})
|
||||
export class RegionDetailsComponent implements OnInit {
|
||||
export class RegionDetailsComponent implements AfterViewInit {
|
||||
|
||||
@ViewChild('container', {static: false})
|
||||
container: ElementRef;
|
||||
|
||||
/** Cut descriptions after x chars */
|
||||
readonly DESC_CUT_POINT = 300;
|
||||
/** Region property to show in view */
|
||||
readonly SHOWN_PROPS: VisualRegionPropDef[] = [
|
||||
{
|
||||
property: 'average_per_day_costs',
|
||||
icon: 'euro',
|
||||
unit: '€/day',
|
||||
},
|
||||
{
|
||||
property: 'food_costs',
|
||||
icon: 'local_dining',
|
||||
unit: '€/day',
|
||||
},
|
||||
{
|
||||
property: 'alcohol_costs',
|
||||
icon: 'local_bar',
|
||||
unit: '€/day',
|
||||
},
|
||||
{
|
||||
property: 'local_transportation_costs',
|
||||
icon: 'commute',
|
||||
unit: '€/day',
|
||||
},
|
||||
{
|
||||
property: 'entertainment_costs',
|
||||
icon: 'local_activity',
|
||||
unit: '€/day',
|
||||
}
|
||||
readonly SHOWN_PROPS: SearchParameter[] = [
|
||||
SearchParameter.AVERAGE_PER_DAY_COSTS,
|
||||
SearchParameter.ACCOMMODATION_COSTS,
|
||||
SearchParameter.FOOD_COSTS,
|
||||
SearchParameter.WATER_COSTS,
|
||||
SearchParameter.ALCOHOL_COSTS,
|
||||
SearchParameter.LOCAL_TRANSPORTATION_COSTS,
|
||||
SearchParameter.ENTERTAINMENT_COSTS,
|
||||
];
|
||||
|
||||
/** Current region */
|
||||
region: Region;
|
||||
/** URI encoded region name */
|
||||
uriRegionName: string;
|
||||
/** Extend the description text */
|
||||
isDescExtended = false;
|
||||
|
||||
places: Place[] = [];
|
||||
|
||||
constructor(private route: ActivatedRoute, private ds: DataService) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
ngAfterViewInit(): void {
|
||||
this.route.paramMap.pipe(
|
||||
switchMap((params: ParamMap) => this.ds.getRegion(parseInt(params.get('id'), 10)))
|
||||
).subscribe((region: Region) => this.region = region);
|
||||
).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();
|
||||
}
|
||||
}, 10);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -3,12 +3,32 @@
|
||||
<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>
|
||||
|
||||
<div *ngIf="results && results.length === 0" class="note">
|
||||
<mat-icon>error</mat-icon>
|
||||
<span>No match found!</span>
|
||||
</div>
|
||||
<div *ngIf="!results" class="spinner">
|
||||
<mat-spinner></mat-spinner>
|
||||
</div>
|
||||
|
||||
@ -8,12 +8,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
> app-result {
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.note {
|
||||
flex: 1 1 auto;
|
||||
align-self: center;
|
||||
font-size: 1.2rem;
|
||||
margin: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
import {Component, ElementRef, OnInit, ViewChild} from '@angular/core';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
import {Result} from '../../interfaces/result.interface';
|
||||
import {DataService} from '../../services/data.service';
|
||||
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,18 +19,32 @@ import {DataService} from '../../services/data.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;
|
||||
|
||||
constructor(private route: ActivatedRoute, private ds: DataService, private router: Router) {
|
||||
constructor(private route: ActivatedRoute, private ss: SearchService, private router: Router) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -29,12 +52,66 @@ export class SearchComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.results = await this.ds.searchRegions(this.queryString);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
frontend/src/app/containers/team/team.component.html
Normal file
21
frontend/src/app/containers/team/team.component.html
Normal file
@ -0,0 +1,21 @@
|
||||
<h1>Team</h1>
|
||||
<div class="team-member-container">
|
||||
<mat-card *ngFor="let member of team" class="member">
|
||||
<img [alt]="member.imageUrl ? 'Image of ' + member.name : 'Placeholder'"
|
||||
[src]="member.imageUrl ||'assets/placeholder.jpg'" class="image">
|
||||
<div class="name-pos-container">
|
||||
<span class="position">{{member.position}}</span>
|
||||
<span class="name">{{member.name}}</span>
|
||||
</div>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<td><span class="property">Course:</span></td>
|
||||
<td>{{member.course}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="property">Semester:</span></td>
|
||||
<td>{{member.semester}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</mat-card>
|
||||
</div>
|
||||
49
frontend/src/app/containers/team/team.component.scss
Normal file
49
frontend/src/app/containers/team/team.component.scss
Normal file
@ -0,0 +1,49 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.team-member-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-around;
|
||||
|
||||
.member {
|
||||
|
||||
margin: 1rem 0;
|
||||
|
||||
.image {
|
||||
width: 15rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.name-pos-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.position {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.table {
|
||||
.property {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
25
frontend/src/app/containers/team/team.component.spec.ts
Normal file
25
frontend/src/app/containers/team/team.component.spec.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {TeamComponent} from './team.component';
|
||||
|
||||
describe('TeamComponent', () => {
|
||||
let component: TeamComponent;
|
||||
let fixture: ComponentFixture<TeamComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [TeamComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(TeamComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
69
frontend/src/app/containers/team/team.component.ts
Normal file
69
frontend/src/app/containers/team/team.component.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
|
||||
export interface TeamMember {
|
||||
name: string;
|
||||
position: string;
|
||||
course: string;
|
||||
semester: number;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-team',
|
||||
templateUrl: './team.component.html',
|
||||
styleUrls: ['./team.component.scss']
|
||||
})
|
||||
export class TeamComponent implements OnInit {
|
||||
|
||||
readonly team: TeamMember[] = [
|
||||
{
|
||||
name: 'Patrick Gebhardt',
|
||||
position: 'Frontend Developer',
|
||||
course: 'Software Engineering (SEB)',
|
||||
semester: 6
|
||||
},
|
||||
{
|
||||
name: 'Lucas Hinderberger',
|
||||
position: 'Operations / Backend Developer',
|
||||
course: 'Software Engineering (SEB)',
|
||||
semester: 6
|
||||
},
|
||||
{
|
||||
name: 'Timo John',
|
||||
position: 'Database / Backend Developer',
|
||||
course: 'Software Engineering (SEB)',
|
||||
semester: 6
|
||||
},
|
||||
{
|
||||
name: 'Timo Volkmann',
|
||||
position: 'Project Lead / Backend Developer',
|
||||
course: 'Software Engineering (SEB)',
|
||||
semester: 6
|
||||
},
|
||||
{
|
||||
name: 'Yannick von Hofen',
|
||||
position: 'Marketing / Sales',
|
||||
course: 'Transport und Logistik (MTL)',
|
||||
semester: 2
|
||||
},
|
||||
{
|
||||
name: 'Thomas Schapper',
|
||||
position: 'Management Lead',
|
||||
course: 'Transport und Logistik (MTL)',
|
||||
semester: 2
|
||||
},
|
||||
{
|
||||
name: 'Nicolas Karon',
|
||||
position: 'Controlling',
|
||||
course: 'Transport und Logistik (MTL)',
|
||||
semester: 2
|
||||
}
|
||||
].sort((a, b) => a.name > b.name ? 1 : -1);
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
<div class="dialog-header">
|
||||
<span mat-dialog-title>Share the link</span>
|
||||
<button [mat-dialog-close]="undefined" mat-icon-button>
|
||||
<mat-icon>clear</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<input #url [disabled]="isCopied" class="url" value="http://{{host}}/region/{{data.region_id}}">
|
||||
<button (click)="onCopy()" [disabled]="isCopied" color="primary" mat-flat-button>
|
||||
<mat-icon>{{isCopied ? 'done' : 'content_copy'}}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user