sdelay-sayt-proekt-lifemap-k/server.js

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);
});