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