156 lines
6.5 KiB
JavaScript
156 lines
6.5 KiB
JavaScript
const express = require("express");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const SEED = require("./seed/places.js");
|
|
|
|
const app = express();
|
|
app.use(express.json({ limit: "2mb" }));
|
|
|
|
const DB = path.join(__dirname, "data.json");
|
|
const USER = { lat: 43.238, lng: 76.879, label: "Алматы · центр" };
|
|
|
|
function readPlaces() {
|
|
if (!fs.existsSync(DB)) return null;
|
|
try { const d = JSON.parse(fs.readFileSync(DB, "utf8")); return Array.isArray(d) ? d : d.places || null; }
|
|
catch (_) { return null; }
|
|
}
|
|
function save(list) { fs.writeFileSync(DB, JSON.stringify(list, null, 2)); }
|
|
|
|
let PLACES = readPlaces();
|
|
if (!PLACES || !PLACES.length) {
|
|
PLACES = SEED();
|
|
save(PLACES);
|
|
console.log("data.json создан из seed, мест: " + PLACES.length);
|
|
}
|
|
if (Array.isArray(PLACES)) {
|
|
for (const p of PLACES) {
|
|
if (p.distanceToUser == null) p.distanceToUser = Number(hav(USER, { lat: p.lat, lng: p.lng }).toFixed(1));
|
|
}
|
|
save(PLACES);
|
|
}
|
|
console.log("Загружено мест: " + PLACES.length);
|
|
|
|
app.use(express.static(path.join(__dirname, "dist")));
|
|
app.use("/design-system", express.static(path.join(__dirname, "public", "design-system")));
|
|
|
|
app.get("/api/meta", function (_req, res) { res.json({ city: "Алматы", user: USER }); });
|
|
app.get("/api/places", function (_req, res) {
|
|
res.json(PLACES.map((p) => Object.assign({}, p, { _distanceToUser: p.distanceToUser })));
|
|
});
|
|
app.get("/api/places/:id", function (req, res) {
|
|
const p = PLACES.find((x) => x.id === req.params.id || x.slug === req.params.id);
|
|
if (!p) { res.status(404).json({ error: "Место не найдено" }); return; }
|
|
res.json(p);
|
|
});
|
|
|
|
function hav(user, p) {
|
|
const R = 6371, r = (d) => (d * Math.PI) / 180;
|
|
const dLat = r(p.lat - user.lat), dLng = r(p.lng - user.lng);
|
|
const s = Math.sin(dLat / 2) ** 2 + Math.cos(r(user.lat)) * Math.cos(r(p.lat)) * Math.sin(dLng / 2) ** 2;
|
|
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
|
|
}
|
|
|
|
const WIN = { now: [9, 22], day: [9, 17], evening: [17, 23], night: [21, 30], tomorrow: [9, 23] };
|
|
const BMAX = { low: 3000, mid: 7000, high: 15000, top: Infinity };
|
|
|
|
app.post("/api/recommendations", function (req, res) {
|
|
const q = req.body || {};
|
|
const its = Array.isArray(q.interests) ? q.interests : [];
|
|
const bMax = q.customBudget && Number(q.customBudget) > 0 ? Number(q.customBudget) : (BMAX[q.budget] || Infinity);
|
|
const per = q.company === "friends" || q.company === "family" ? 0.8 : 1;
|
|
|
|
const scored = [];
|
|
for (const p of PLACES) {
|
|
if (p.active === false) continue;
|
|
if (p.avg == null) continue;
|
|
const dist = p.distanceToUser != null ? p.distanceToUser : hav(USER, { lat: p.lat, lng: p.lng });
|
|
if (q.maxKm && q.maxKm > 0 && dist > q.maxKm) continue;
|
|
if (bMax !== Infinity && p.avg * per > bMax * 1.4) continue;
|
|
if (its.length && !p.tags.some((t) => its.includes(t))) continue;
|
|
|
|
let budgetScore = bMax === Infinity ? 0.7 : Math.max(0, 1 - Math.abs(p.avg * per - bMax * 0.7) / (bMax * 0.7 + 1));
|
|
const matches = p.tags.filter((t) => its.includes(t)).length;
|
|
let interestScore = its.length
|
|
? (matches >= 2 ? 1 : matches >= 1 ? 0.55 : 0.2)
|
|
: 0.5;
|
|
let distanceScore = !q.maxKm || q.maxKm === 0 ? 0.6 : Math.max(0, 1 - dist / q.maxKm);
|
|
let timeScore = 0.75;
|
|
if (p.hours && q.time && WIN[q.time] && p.hours.open < WIN[q.time][1]) timeScore = 1;
|
|
let ratingScore = (p.rating || 3.5) / 5;
|
|
let transportScore = q.transport && p.parking && q.transport !== "walk" ? 1 : 0.4;
|
|
|
|
let s = budgetScore * 30 + interestScore * 25 + distanceScore * 15 + timeScore * 15 + ratingScore * 10 + transportScore * 5;
|
|
if (q.company === "date" && p.tags.includes("романтика")) s += 6;
|
|
if ((q.company === "friends" || q.company === "date") && p.tags.includes("компания")) s += 4;
|
|
if (p.tags.includes("бесплатно")) s += 4;
|
|
|
|
scored.push({ p: p, dist: dist, score: s });
|
|
}
|
|
|
|
scored.sort(function (a, b) { return b.score - a.score; });
|
|
const top = scored.slice(0, 18).map((x) => x.p);
|
|
|
|
const plans = [];
|
|
const seen = new Set();
|
|
for (let i = 0; i < top.length && plans.length < 6; i++) {
|
|
const a = top[i];
|
|
const rest = top.filter((x) => x.id !== a.id);
|
|
const b = rest.find((x) => x.type !== a.type) || rest[0];
|
|
const c = rest.find((x) => x.id !== b.id && (x.type !== a.type && x.type !== b.type)) || rest.find((x) => x.id !== b.id);
|
|
const stops = [a, b, c].filter(Boolean);
|
|
if (stops.length < 2) continue;
|
|
const perStops = stops.filter((x) => x.avg != null);
|
|
const avg = perStops.length ? Math.round(perStops.reduce((sum, x) => sum + x.avg, 0) / perStops.length) : null;
|
|
const people = q.company === "date" ? 2 : (q.company === "friends" || q.company === "family") ? 4 : 1;
|
|
const dists = stops.map((x) => x.distanceToUser).filter((d) => d != null).map((d) => Number(d.toFixed(1)));
|
|
const key = stops.map((x) => x.id).join("|");
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
plans.push(buildPlan(stops, q, its, avg, people, dists));
|
|
}
|
|
|
|
res.json({
|
|
ok: true,
|
|
city: "Алматы",
|
|
user: USER,
|
|
plans: plans,
|
|
places: scored.map((x) => {
|
|
const o = Object.assign({}, x.p);
|
|
o._score = Math.round(x.score);
|
|
o._distance = Number(x.dist.toFixed(1));
|
|
o._budgetFit = bMax === Infinity ? "fit" : (x.p.avg * per <= bMax ? "fit" : "over");
|
|
return o;
|
|
})
|
|
});
|
|
|
|
function buildPlan(stops, q, its2, avg, people, dists) {
|
|
const titleBits = stops
|
|
.map((s) => s.tags.find((t) => its2.includes(t)) || s.type)
|
|
.filter(Boolean)
|
|
.filter((v, i, arr) => arr.indexOf(v) === i)
|
|
.slice(0, 3);
|
|
return {
|
|
id: "plan-" + stops.map((s) => s.id).join("-"),
|
|
title: titleBits.join(" + ") || stops[0].name,
|
|
company: q.company,
|
|
duration: 2 + stops.length,
|
|
perPerson: avg,
|
|
total: avg != null ? avg * people : null,
|
|
people: people,
|
|
maxDistance: dists.length ? Math.max.apply(null, dists) : null,
|
|
tags: stops.reduce((arr, s) => arr.concat(s.tags), []).filter((t, i, arr) => arr.indexOf(t) === i).slice(0, 6),
|
|
stops: stops.map((s, i) => ({
|
|
order: i + 1, id: s.id, name: s.name, type: s.type,
|
|
avg: s.avg, distance: s.distanceToUser, tags: s.tags.slice(0, 4),
|
|
lat: s.lat, lng: s.lng
|
|
}))
|
|
};
|
|
}
|
|
});
|
|
|
|
app.get("*", function (_req, res) { res.sendFile(path.join(__dirname, "dist", "index.html")); });
|
|
|
|
app.listen(process.env.PORT || 3000, function () {
|
|
console.log("LifeMap на порту " + (process.env.PORT || 3000) + ", мест: " + PLACES.length);
|
|
});
|