vkusvill 0.0.0-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/README.ru.md +89 -0
- package/bin/vv.js +12 -0
- package/package.json +39 -0
- package/src/api.js +228 -0
- package/src/app.js +328 -0
- package/src/cli.js +159 -0
- package/src/image.js +144 -0
- package/src/main.js +113 -0
- package/src/product.js +135 -0
- package/src/safe.js +37 -0
- package/src/store.js +190 -0
- package/src/sys.js +36 -0
- package/src/term/input.js +131 -0
- package/src/term/screen.js +204 -0
- package/src/term/width.js +74 -0
- package/src/ui/draw.js +324 -0
- package/src/ui/product-ui.js +192 -0
- package/src/ui/theme.js +47 -0
- package/src/ui/widgets.js +236 -0
- package/src/views/browser.js +251 -0
- package/src/views/cart.js +306 -0
- package/src/views/catalog.js +150 -0
- package/src/views/overlays.js +324 -0
- package/src/views/product.js +308 -0
- package/src/views/profile.js +228 -0
- package/src/views/recipes.js +358 -0
- package/src/views/shops.js +396 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Shops: region, city, metro and feature filters, "open now" from the opening hours,
|
|
2
|
+
// and a Braille-dot map of all loaded shops with crosshairs on the selected one; z zooms in.
|
|
3
|
+
|
|
4
|
+
import { T, mix } from "../ui/theme.js";
|
|
5
|
+
import { ListView, Menu, TextInput } from "../ui/widgets.js";
|
|
6
|
+
import { Braille } from "../ui/draw.js";
|
|
7
|
+
import * as api from "../api.js";
|
|
8
|
+
import { plural } from "../product.js";
|
|
9
|
+
import { store } from "../store.js";
|
|
10
|
+
import { loader } from "./browser.js";
|
|
11
|
+
import { openUrl, copy } from "../sys.js";
|
|
12
|
+
import { strWidth } from "../term/width.js";
|
|
13
|
+
|
|
14
|
+
// Moscow outlines for the map backdrop: MKAD ring road, Garden Ring, Kremlin
|
|
15
|
+
const MKAD = [[55.911, 37.59], [55.896, 37.70], [55.86, 37.80], [55.81, 37.843], [55.75, 37.843], [55.69, 37.83], [55.64, 37.80], [55.60, 37.73], [55.574, 37.68], [55.58, 37.60], [55.60, 37.50], [55.64, 37.45], [55.68, 37.41], [55.73, 37.37], [55.79, 37.37], [55.84, 37.39], [55.88, 37.45], [55.905, 37.53], [55.911, 37.59]];
|
|
16
|
+
const CENTER = [55.752, 37.617];
|
|
17
|
+
const MOSCOW_BOX = { lat0: 55.52, lat1: 55.96, lon0: 37.25, lon1: 37.98 };
|
|
18
|
+
|
|
19
|
+
const FILTER_KEYS = { region: "region", city: "city", subway: "subway", feature: "feature" };
|
|
20
|
+
const FILTER_NAMES = { region: "Регион", city: "Город", subway: "Метро", feature: "Особенность" };
|
|
21
|
+
|
|
22
|
+
// Moscow time: opening hours are given in local time, and most shops are in the Moscow time zone
|
|
23
|
+
function mskMinutes() {
|
|
24
|
+
const d = new Date(Date.now() + 3 * 3600e3);
|
|
25
|
+
return d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function openState(schedule) {
|
|
29
|
+
if (!schedule) return null;
|
|
30
|
+
if (/круглосуточно/i.test(schedule)) return { open: true, text: "круглосуточно" };
|
|
31
|
+
const m = /(\d{1,2})[:.](\d{2})\s*(?:до|-|–|—)\s*(\d{1,2})[:.](\d{2})/.exec(schedule);
|
|
32
|
+
if (!m) return null;
|
|
33
|
+
const a = +m[1] * 60 + +m[2], b = +m[3] * 60 + +m[4];
|
|
34
|
+
const now = mskMinutes();
|
|
35
|
+
const open = b > a ? now >= a && now < b : now >= a || now < b;
|
|
36
|
+
const hh = (v) => `${String(Math.floor(v / 60) % 24).padStart(2, "0")}:${String(v % 60).padStart(2, "0")}`;
|
|
37
|
+
return { open, text: open ? `открыт до ${hh(b)}` : `закрыт · откроется в ${hh(a)}` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function shopsView(app) {
|
|
41
|
+
return new ShopsView(app);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Warehouse for online orders only: «Только для курьеров, …», «Закрыт для покупателей…»
|
|
45
|
+
export const isCourier = (s) => /только для курьер|закрыт для покупател/i.test(`${s.address} ${s.schedule ?? ""} ${s.description ?? ""}`);
|
|
46
|
+
const cleanAddress = (s) => s.address.replace(/^только для курьеров,?\s*/i, "");
|
|
47
|
+
|
|
48
|
+
export function shopState(s) {
|
|
49
|
+
if (isCourier(s)) return { open: false, courier: true, text: "склад онлайн-заказов, покупателей не пускают" };
|
|
50
|
+
return openState(s.schedule);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// City → region and metro → city links: the VkusVill reference lists don't provide them, so we collect
|
|
54
|
+
// them from the shops we load and keep them on disk; city and metro lists narrow down to the chosen region
|
|
55
|
+
const GEO_TTL = 90 * 24 * 3600e3;
|
|
56
|
+
const geo = { cities: {}, subways: {}, done: {} };
|
|
57
|
+
const geoReady = api.diskGet("geo", "v1", GEO_TTL).then((g) => { if (g) Object.assign(geo, g); });
|
|
58
|
+
let geoTimer = null;
|
|
59
|
+
function learn(items) {
|
|
60
|
+
for (const s of items) {
|
|
61
|
+
const r = s.region?.id;
|
|
62
|
+
if (s.city?.id) {
|
|
63
|
+
const c = (geo.cities[s.city.id] ??= { r, shops: {} });
|
|
64
|
+
c.r = r;
|
|
65
|
+
c.shops[s.id] = 1;
|
|
66
|
+
}
|
|
67
|
+
if (s.subway?.id) geo.subways[s.subway.id] = { r, c: s.city?.id, shops: { ...geo.subways[s.subway.id]?.shops, [s.id]: 1 } };
|
|
68
|
+
}
|
|
69
|
+
clearTimeout(geoTimer);
|
|
70
|
+
geoTimer = setTimeout(() => api.diskPut("geo", "v1", geo), 500);
|
|
71
|
+
}
|
|
72
|
+
const shopCount = (x) => Object.keys(x?.shops ?? {}).length;
|
|
73
|
+
|
|
74
|
+
class ShopsView {
|
|
75
|
+
constructor(app) {
|
|
76
|
+
this.app = app;
|
|
77
|
+
this.list = new ListView();
|
|
78
|
+
// Place filters survive restarts; Moscow region is the default
|
|
79
|
+
const saved = store.pref("shops", null);
|
|
80
|
+
const isId = (v) => Number.isInteger(v) && v >= 0;
|
|
81
|
+
if (saved && ["region", "city", "subway", "feature"].every((k) => isId(saved.f?.[k]))) {
|
|
82
|
+
this.f = { ...saved.f };
|
|
83
|
+
this.names = Object.fromEntries(Object.entries(saved.names ?? {}).filter(([, v]) => typeof v === "string"));
|
|
84
|
+
} else {
|
|
85
|
+
this.f = { region: 3872, city: 0, subway: 0, feature: 0 };
|
|
86
|
+
this.names = { region: "Москва и Московская обл" };
|
|
87
|
+
}
|
|
88
|
+
this.defs = [];
|
|
89
|
+
this.zoom = false;
|
|
90
|
+
this.search = new TextInput({ placeholder: "адрес, улица или метро", onChange: () => { this.list.sel = 0; this.list.top = 0; } });
|
|
91
|
+
this.focus = "list";
|
|
92
|
+
this.reset();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
reset() { this.regionHint = null; this.items = []; this.page = 0; this.pages = null; this.total = null; this.hasMore = true; this.loading = false; this.error = null; this.all = false; this.req = (this.req ?? 0) + 1; this.list.sel = 0; this.list.top = 0; }
|
|
96
|
+
busy() { return this.loading; }
|
|
97
|
+
onShow() { if (!this.page && !this.loading) geoReady.then(() => this.loadMore()); }
|
|
98
|
+
|
|
99
|
+
// Shops matching the search; courier-only warehouses go last
|
|
100
|
+
get visible() {
|
|
101
|
+
const q = this.search.value.trim().toLowerCase();
|
|
102
|
+
const v = q ? this.items.filter((s) => `${s.address} ${s.city?.name ?? ""} ${s.subway?.name ?? ""}`.toLowerCase().includes(q)) : this.items;
|
|
103
|
+
return [...v].sort((a, b) => isCourier(a) - isCourier(b));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
tick() {
|
|
107
|
+
if (this.hasMore && !this.loading && !this.error && this.page && this.list.sel >= this.visible.length - 5) this.loadMore();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async loadMore(prio = "hi") {
|
|
111
|
+
if (this.loading || !this.hasMore) return;
|
|
112
|
+
this.loading = true;
|
|
113
|
+
const req = this.req;
|
|
114
|
+
try {
|
|
115
|
+
const d = await api.shops({ page: this.page + 1, ...this.f }, { prio });
|
|
116
|
+
if (req !== this.req) return;
|
|
117
|
+
this.items.push(...(d.items ?? []));
|
|
118
|
+
learn(d.items ?? []);
|
|
119
|
+
this.page++;
|
|
120
|
+
this.pages = d.meta?.pages;
|
|
121
|
+
this.total = d.meta?.total;
|
|
122
|
+
this.hasMore = !!d.meta?.has_more;
|
|
123
|
+
if (d.meta?.filters?.length) this.defs = d.meta.filters;
|
|
124
|
+
// Every shop in the region has been seen, so its city list is complete
|
|
125
|
+
if (!this.hasMore && this.f.region && !this.f.city && !this.f.subway && !this.f.feature) geo.done[this.f.region] = true;
|
|
126
|
+
// No region is set, but all shops found are in one region, so show its name
|
|
127
|
+
const regions = new Set((d.items ?? []).map((s) => s.region?.name).filter(Boolean));
|
|
128
|
+
if (!this.f.region && regions.size === 1 && this.page === 1) this.regionHint = [...regions][0];
|
|
129
|
+
// Only a few pages: put them all on the map right away
|
|
130
|
+
if (this.page === 1 && this.pages && this.pages <= 6) this.all = true;
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if (req === this.req) { this.error = e.message; this.hasMore = false; }
|
|
133
|
+
} finally {
|
|
134
|
+
if (req === this.req) this.loading = false;
|
|
135
|
+
this.app.invalidate();
|
|
136
|
+
}
|
|
137
|
+
if (this.all && this.hasMore && req === this.req) this.loadMore("lo");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
loadAll() {
|
|
141
|
+
if (!this.hasMore) return this.app.toast("Все магазины уже на карте");
|
|
142
|
+
const left = (this.pages ?? 0) - this.page;
|
|
143
|
+
if (left > 20) this.app.toast(`Гружу ${left} страниц в фоне, около ${Math.max(1, Math.round(left / 50))} мин — потом из кэша`, T.orange);
|
|
144
|
+
this.all = true;
|
|
145
|
+
if (!this.loading) this.loadMore("lo");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
setFilter(kind, value, label) {
|
|
149
|
+
this.f[kind] = value;
|
|
150
|
+
this.names[kind] = value ? label : "";
|
|
151
|
+
if (value) {
|
|
152
|
+
// VkusVill reference lists cover the whole country: reset places that don't fit, keep those that do
|
|
153
|
+
if (kind === "region") {
|
|
154
|
+
if (this.f.city && geo.cities[this.f.city]?.r !== value) { this.f.city = 0; this.names.city = ""; }
|
|
155
|
+
if (this.f.subway && geo.subways[this.f.subway]?.r !== value) { this.f.subway = 0; this.names.subway = ""; }
|
|
156
|
+
}
|
|
157
|
+
if (kind === "city") {
|
|
158
|
+
if (this.f.region && geo.cities[value]?.r !== this.f.region) { this.f.region = 0; this.names.region = ""; }
|
|
159
|
+
if (this.f.subway && geo.subways[this.f.subway]?.c !== value) { this.f.subway = 0; this.names.subway = ""; }
|
|
160
|
+
}
|
|
161
|
+
if (kind === "subway") {
|
|
162
|
+
const sw = geo.subways[value];
|
|
163
|
+
if (this.f.region && sw?.r !== this.f.region) { this.f.region = 0; this.names.region = ""; }
|
|
164
|
+
if (this.f.city && sw?.c !== this.f.city) { this.f.city = 0; this.names.city = ""; }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
this.persist();
|
|
168
|
+
this.reset();
|
|
169
|
+
this.loadMore();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
persist() { store.setPref("shops", { f: this.f, names: this.names }); }
|
|
173
|
+
|
|
174
|
+
pick(kind) {
|
|
175
|
+
const def = this.defs.find((d) => d.param === FILTER_KEYS[kind]);
|
|
176
|
+
if (!def) return this.app.toast("Фильтры ещё загружаются", T.dim);
|
|
177
|
+
const regionName = (id) => this.defs.find((d) => d.param === "region")?.items.find((i) => i.id === id)?.name.replace(/ обл$| край$/, "");
|
|
178
|
+
const plural3 = (n) => `${n} ${plural(n, "магазин", "магазина", "магазинов")}`;
|
|
179
|
+
let options = def.items.map((i) => ({ label: i.name, value: i.id, checked: this.f[kind] === i.id }));
|
|
180
|
+
let title = FILTER_NAMES[kind];
|
|
181
|
+
const extra = [];
|
|
182
|
+
if (kind === "city" || kind === "subway") {
|
|
183
|
+
const map = kind === "city" ? geo.cities : geo.subways;
|
|
184
|
+
const inScope = (id) => {
|
|
185
|
+
const g = map[id];
|
|
186
|
+
if (kind === "subway" && this.f.city) return g?.c === this.f.city;
|
|
187
|
+
return this.f.region ? g?.r === this.f.region : true;
|
|
188
|
+
};
|
|
189
|
+
const scoped = this.f.region || (kind === "subway" && this.f.city);
|
|
190
|
+
if (scoped) {
|
|
191
|
+
options = options.filter((o) => inScope(o.value));
|
|
192
|
+
title += ` · ${kind === "subway" && this.f.city ? this.names.city : this.names.region}`;
|
|
193
|
+
if (!geo.done[this.f.region]) extra.push({ label: `⟳ найти остальные ${kind === "city" ? "города" : "станции"} региона`, value: "__all", hint: `${Math.max(0, (this.pages ?? 0) - this.page)} стр.`, color: T.lime });
|
|
194
|
+
else if (!options.length) extra.push({ label: kind === "city" ? "в регионе нет других городов" : "в регионе нет метро", value: "__none", color: T.dim });
|
|
195
|
+
}
|
|
196
|
+
for (const o of options) {
|
|
197
|
+
const n = shopCount(map[o.value]);
|
|
198
|
+
o.hint = scoped ? (n ? plural3(n) : "") : regionName(map[o.value]?.r) ?? "";
|
|
199
|
+
o.n = n;
|
|
200
|
+
}
|
|
201
|
+
if (scoped) options.sort((a, b) => b.n - a.n || a.label.localeCompare(b.label, "ru"));
|
|
202
|
+
}
|
|
203
|
+
this.app.overlay(new Menu({
|
|
204
|
+
title, filter: options.length > 10,
|
|
205
|
+
items: [{ label: "Любой", value: 0, checked: !this.f[kind] }, ...options, ...extra],
|
|
206
|
+
onPick: (it) => {
|
|
207
|
+
if (it.value === "__none") return;
|
|
208
|
+
if (it.value === "__all") {
|
|
209
|
+
// Walk all shops in the region so their cities and metro stations get into the lookup
|
|
210
|
+
for (const k of ["city", "subway", "feature"]) { this.f[k] = 0; this.names[k] = ""; }
|
|
211
|
+
this.persist();
|
|
212
|
+
this.reset();
|
|
213
|
+
this.all = true;
|
|
214
|
+
this.loadMore("lo");
|
|
215
|
+
this.app.toast("Собираю города региона — меню пополнится по ходу загрузки");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
this.setFilter(kind, it.value, it.label);
|
|
219
|
+
},
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
key(k) {
|
|
224
|
+
if (this.focus === "search") {
|
|
225
|
+
if (k.type === "paste") return this.search.key(k);
|
|
226
|
+
if (["down", "enter", "tab", "escape"].includes(k.name)) { this.focus = "list"; if (k.name === "escape") this.search.set(""); return true; }
|
|
227
|
+
return this.search.key(k);
|
|
228
|
+
}
|
|
229
|
+
if (k.type !== "key") return false;
|
|
230
|
+
const vis = this.visible;
|
|
231
|
+
if (this.list.key(k, vis.length)) return true;
|
|
232
|
+
const h = k.hot ?? k.name;
|
|
233
|
+
const s = vis[this.list.sel];
|
|
234
|
+
if (h === "/" || k.ch === ".") { this.focus = "search"; return true; }
|
|
235
|
+
if (k.name === "escape" && this.search.value) { this.search.set(""); return true; }
|
|
236
|
+
if (h === "r") { this.pick("region"); return true; }
|
|
237
|
+
if (h === "g") { this.pick("city"); return true; }
|
|
238
|
+
if (h === "m") { this.pick("subway"); return true; }
|
|
239
|
+
if (h === "f") { this.pick("feature"); return true; }
|
|
240
|
+
if (h === "L") { this.loadAll(); return true; }
|
|
241
|
+
if (h === "v") { this.mapOnly = !this.mapOnly; return true; }
|
|
242
|
+
if (h === "z" || k.name === "enter") { this.zoom = !this.zoom; return true; }
|
|
243
|
+
if (h === "o" && s) { openUrl(s.url); return true; }
|
|
244
|
+
if (h === "y" && s) { copy(["ВкусВилл", s.city?.name, cleanAddress(s)].filter(Boolean).join(", ")).then(() => this.app.toast("Адрес скопирован")); return true; }
|
|
245
|
+
if (h === "p" && s?.lat) { openUrl(`https://yandex.ru/maps/?pt=${s.lon},${s.lat}&z=17&l=map`); return true; }
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
hints() {
|
|
250
|
+
if (this.focus === "search") return [["⏎ ↓", "к списку"], ["esc", "сбросить поиск"]];
|
|
251
|
+
return [["↑↓", "магазин"], ["/", "поиск"], ...(this.app.g.w < 100 ? [["v", "карта"]] : []), ["z", "приблизить"], ["r g m f", "регион·город·метро·особ."], ["L", "все на карту"], ["p", "Я.Карты"], ["o", "на сайте"], ["y", "адрес"]];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
draw(g, r) {
|
|
255
|
+
const app = this.app;
|
|
256
|
+
g.fill(r.x, r.y, r.w, r.h, T.bg);
|
|
257
|
+
// Filters
|
|
258
|
+
let x = r.x + 1;
|
|
259
|
+
for (const [kind, key] of [["region", "r"], ["city", "g"], ["subway", "m"], ["feature", "f"]]) {
|
|
260
|
+
const val = this.f[kind] ? this.names[kind] : kind === "region" && this.regionHint ? this.regionHint : "любой";
|
|
261
|
+
const label = `${key} ${FILTER_NAMES[kind]}: ${val} ▾`;
|
|
262
|
+
const w = strWidth(label) + 2;
|
|
263
|
+
if (x + w > r.x + r.w - 30) break;
|
|
264
|
+
g.pill(x, r.y, label, { fg: this.f[kind] ? T.bg : T.sub, bg: this.f[kind] ? T.lime : T.raise, bold: !!this.f[kind] });
|
|
265
|
+
app.hit(x, r.y, w, 1, { click: () => this.pick(kind) });
|
|
266
|
+
x += w + 1;
|
|
267
|
+
}
|
|
268
|
+
// Loading progress
|
|
269
|
+
const progress = this.pages ? this.page / this.pages : 0;
|
|
270
|
+
const pw = 16;
|
|
271
|
+
const px = r.x + r.w - pw - 12;
|
|
272
|
+
g.gradBar(px, r.y, pw, progress, [T.greenD, T.lime]);
|
|
273
|
+
g.text(px + pw + 1, r.y, `${this.items.length}/${this.total ?? "…"}`, { fg: T.dim });
|
|
274
|
+
app.hit(px, r.y, pw + 10, 1, { click: () => this.loadAll() });
|
|
275
|
+
|
|
276
|
+
const top = r.y + 2, H = r.h - 2;
|
|
277
|
+
if (r.w < 100 && this.mapOnly) return this.drawMap(g, { x: r.x, y: top, w: r.w, h: H });
|
|
278
|
+
const lw = r.w >= 100 ? Math.max(44, Math.floor(r.w * 0.42)) : r.w;
|
|
279
|
+
const vis = this.visible;
|
|
280
|
+
// Search line and the "open now" summary
|
|
281
|
+
const sy = r.y + 1;
|
|
282
|
+
const searching = this.focus === "search";
|
|
283
|
+
g.fill(r.x, sy, lw, 1, searching ? T.raise : T.bg);
|
|
284
|
+
g.text(r.x + 1, sy, "⌕", { fg: searching || this.search.value ? T.lime : T.dim, bg: searching ? T.raise : T.bg });
|
|
285
|
+
this.search.draw(g, r.x + 3, sy, lw - 24, { focused: searching, bg: searching ? T.raise : T.bg, blink: app.frame % 10 < 7 });
|
|
286
|
+
app.hit(r.x, sy, lw - 20, 1, { click: () => { this.focus = "search"; } });
|
|
287
|
+
if (vis.length) {
|
|
288
|
+
const open = vis.filter((s) => shopState(s)?.open).length;
|
|
289
|
+
const shops = vis.filter((s) => !isCourier(s)).length;
|
|
290
|
+
g.textR(r.x + lw - 1, sy, `● ${open} из ${shops} открыто`, { fg: open ? T.lime : T.red, bg: searching ? T.raise : T.bg });
|
|
291
|
+
}
|
|
292
|
+
g.fill(r.x, top, lw, H, T.panel);
|
|
293
|
+
if (!vis.length) {
|
|
294
|
+
const msg = this.loading ? "Ищу магазины…" : this.error ? `⚠ ${this.error}` : this.items.length ? `По «${this.search.value}» ничего` : "Магазинов не нашлось";
|
|
295
|
+
g.textC(r.x, lw, top + Math.floor(H / 2) - 1, msg, { fg: this.error ? T.red : T.dim, bg: T.panel });
|
|
296
|
+
const many = ["region", "city", "subway", "feature"].filter((k) => this.f[k]).length > 1;
|
|
297
|
+
if (!this.loading && !this.error && many && !this.items.length) g.textC(r.x, lw, top + Math.floor(H / 2) + 1, "под все фильтры сразу ничего нет — ослабьте один из них", { fg: T.dim, bg: T.panel });
|
|
298
|
+
else if (this.search.value && this.hasMore && !this.loading) g.textC(r.x, lw, top + Math.floor(H / 2) + 1, "L — загрузить все магазины и искать по ним", { fg: T.dim, bg: T.panel });
|
|
299
|
+
if (this.loading) loader(g, r.x + Math.floor(lw / 2) - 6, top + Math.floor(H / 2) + 1, app.frame);
|
|
300
|
+
} else {
|
|
301
|
+
this.list.draw(g, app, r.x, top, lw - 1, H, vis, (s, i, y, sel) => {
|
|
302
|
+
const bg = sel ? T.selHi : T.panel;
|
|
303
|
+
g.fill(r.x, y, lw - 1, 2, bg);
|
|
304
|
+
if (sel) { g.text(r.x, y, "▌", { fg: T.lime, bg }); g.text(r.x, y + 1, "▌", { fg: T.lime, bg }); }
|
|
305
|
+
const st = shopState(s);
|
|
306
|
+
const courier = st?.courier;
|
|
307
|
+
g.text(r.x + 2, y, courier ? "◌" : st ? (st.open ? "●" : "○") : "·", { fg: courier ? T.mute : st?.open ? T.lime : T.red, bg });
|
|
308
|
+
let ax = r.x + 4;
|
|
309
|
+
if (courier) ax = g.text(ax, y, "склад ", { fg: T.violet, bg });
|
|
310
|
+
g.textW(ax, y, cleanAddress(s), r.x + lw - 10 - ax, { fg: courier ? T.dim : sel ? T.white : T.text, bold: sel && !courier, bg });
|
|
311
|
+
if (s.rating && !courier) g.textR(r.x + lw - 2, y, `★ ${String(s.rating).replace(".", ",")}`, { fg: T.yellow, bg });
|
|
312
|
+
const sub = [s.subway?.name ? `Ⓜ ${s.subway.name}` : s.city?.name, courier ? "только для курьеров" : st?.text ?? s.schedule].filter(Boolean).join(" · ");
|
|
313
|
+
g.textW(r.x + 4, y + 1, sub, lw - 6, { fg: courier ? T.mute : st && !st.open ? mix(T.red, T.dim, 0.5) : T.dim, bg });
|
|
314
|
+
}, { rowH: 2, onPick: () => { this.zoom = !this.zoom; } });
|
|
315
|
+
}
|
|
316
|
+
if (lw < r.w) this.drawMap(g, { x: r.x + lw + 1, y: top, w: r.w - lw - 1, h: H });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
drawMap(g, r) {
|
|
320
|
+
const app = this.app;
|
|
321
|
+
const sel = this.visible[this.list.sel];
|
|
322
|
+
const detailH = sel ? 9 : 0;
|
|
323
|
+
const mh = r.h - detailH;
|
|
324
|
+
g.fill(r.x, r.y, r.w, r.h, T.panel2);
|
|
325
|
+
const pts = this.items.filter((s) => s.lat && s.lon);
|
|
326
|
+
if (pts.length) {
|
|
327
|
+
// Bounds from percentiles so outlying shops don't shrink the map; when zoomed, the area around the selected shop
|
|
328
|
+
const lats = pts.map((s) => s.lat).sort((a, b) => a - b), lons = pts.map((s) => s.lon).sort((a, b) => a - b);
|
|
329
|
+
const q = (arr, t) => arr[Math.min(arr.length - 1, Math.max(0, Math.round(t * (arr.length - 1))))];
|
|
330
|
+
let lat0 = q(lats, 0.02), lat1 = q(lats, 0.98), lon0 = q(lons, 0.02), lon1 = q(lons, 0.98);
|
|
331
|
+
// In Moscow and the region, default to a frame around the MKAD
|
|
332
|
+
const inMoscow = (s) => Math.abs(s.lat - CENTER[0]) < 0.25 && Math.abs(s.lon - CENTER[1]) < 0.45;
|
|
333
|
+
const moscow = pts.filter(inMoscow).length >= pts.length / 2;
|
|
334
|
+
if (moscow && !this.f.subway) ({ lat0, lat1, lon0, lon1 } = MOSCOW_BOX);
|
|
335
|
+
if (this.zoom && sel?.lat) { const d = 0.03; lat0 = sel.lat - d; lat1 = sel.lat + d; lon0 = sel.lon - d * 1.8; lon1 = sel.lon + d * 1.8; }
|
|
336
|
+
if (lat1 - lat0 < 0.01) { lat0 -= 0.02; lat1 += 0.02; }
|
|
337
|
+
if (lon1 - lon0 < 0.01) { lon0 -= 0.03; lon1 += 0.03; }
|
|
338
|
+
const k = Math.cos(((lat0 + lat1) / 2) * Math.PI / 180);
|
|
339
|
+
const b = new Braille(r.w - 2, mh - 2);
|
|
340
|
+
const spanX = (lon1 - lon0) * k, spanY = lat1 - lat0;
|
|
341
|
+
const scale = Math.min((b.pw - 4) / spanX, (b.ph - 4) / spanY);
|
|
342
|
+
const ox = (b.pw - spanX * scale) / 2, oy = (b.ph - spanY * scale) / 2;
|
|
343
|
+
const proj = (s) => [ox + (s.lon - lon0) * k * scale, oy + (lat1 - s.lat) * scale];
|
|
344
|
+
if (moscow) {
|
|
345
|
+
// Backdrop: dotted MKAD, Garden Ring, Kremlin
|
|
346
|
+
const ring = MKAD.map(([lat, lon]) => proj({ lat, lon }));
|
|
347
|
+
for (let i = 1; i < ring.length; i++) b.line(ring[i - 1][0], ring[i - 1][1], ring[i][0], ring[i][1], T.line, 0);
|
|
348
|
+
for (let a = 0; a < 64; a++) { const t = (a / 64) * Math.PI * 2; const [px, py] = proj({ lat: CENTER[0] + Math.sin(t) * 0.022, lon: CENTER[1] + Math.cos(t) * 0.038 }); b.set(px, py, T.line, 0); }
|
|
349
|
+
const [kx, ky] = proj({ lat: CENTER[0], lon: CENTER[1] });
|
|
350
|
+
b.set(kx, ky, T.red, 2); b.set(kx + 1, ky, T.red, 2);
|
|
351
|
+
}
|
|
352
|
+
for (const s of pts) {
|
|
353
|
+
const [px, py] = proj(s);
|
|
354
|
+
const st = shopState(s);
|
|
355
|
+
const col = st?.courier ? T.mute : st && !st.open ? mix(T.red, T.panel2, 0.4) : T.green;
|
|
356
|
+
b.set(px, py, col, 1); b.set(px + 1, py, col, 1); b.set(px, py + 1, col, 1); b.set(px + 1, py + 1, col, 1);
|
|
357
|
+
}
|
|
358
|
+
if (sel?.lat) {
|
|
359
|
+
const [sx, sy] = proj(sel);
|
|
360
|
+
// Crosshairs
|
|
361
|
+
for (let i = 0; i < b.pw; i += 3) b.set(i, sy, T.mute, 0);
|
|
362
|
+
for (let j = 0; j < b.ph; j += 3) b.set(sx, j, T.mute, 0);
|
|
363
|
+
for (let a = 0; a < 24; a++) { const t = (a / 24) * Math.PI * 2; b.set(sx + Math.cos(t) * 4, sy + Math.sin(t) * 4, T.lime, 3); }
|
|
364
|
+
b.set(sx, sy, T.white, 4); b.set(sx + 1, sy, T.white, 4); b.set(sx, sy + 1, T.white, 4); b.set(sx + 1, sy + 1, T.white, 4);
|
|
365
|
+
}
|
|
366
|
+
b.draw(g, r.x + 1, r.y + 1);
|
|
367
|
+
// Scale bar
|
|
368
|
+
const kmPerDot = 111 / scale;
|
|
369
|
+
const nice = [0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500].find((v) => v / kmPerDot >= 10) ?? 500;
|
|
370
|
+
const cells = Math.max(2, Math.round(nice / kmPerDot / 2));
|
|
371
|
+
g.text(r.x + 2, r.y + mh - 1, "├" + "─".repeat(cells) + "┤", { fg: T.dim, bg: T.panel2 });
|
|
372
|
+
g.text(r.x + 4 + cells, r.y + mh - 1, `${String(nice).replace(".", ",")} км`, { fg: T.dim, bg: T.panel2 });
|
|
373
|
+
g.textR(r.x + r.w - 2, r.y + mh - 1, this.zoom ? "z — вся карта" : "z — приблизить", { fg: T.dim, bg: T.panel2 });
|
|
374
|
+
g.text(r.x + 2, r.y, ` КАРТА · ${pts.length} ${plural(pts.length, "магазин", "магазина", "магазинов")} `, { fg: T.lime, bold: true, bg: T.panel2 });
|
|
375
|
+
if (this.hasMore) g.textR(r.x + r.w - 2, r.y, " L — загрузить все ", { fg: T.dim, bg: T.panel2 });
|
|
376
|
+
app.hit(r.x, r.y, r.w, mh, { click: () => { this.zoom = !this.zoom; } });
|
|
377
|
+
}
|
|
378
|
+
if (!sel) return;
|
|
379
|
+
const y = r.y + mh;
|
|
380
|
+
g.hline(r.x, y, r.w, T.line);
|
|
381
|
+
const x = r.x + 2, w = r.w - 4;
|
|
382
|
+
const st = shopState(sel);
|
|
383
|
+
g.textW(x, y + 1, `${sel.city?.name ?? ""}, ${cleanAddress(sel)}`, w, { fg: T.white, bold: true, bg: T.panel2 });
|
|
384
|
+
g.textW(x, y + 2, [sel.subway?.name && `Ⓜ ${sel.subway.name}`, sel.schedule].filter(Boolean).join(" · "), w, { fg: T.sub, bg: T.panel2 });
|
|
385
|
+
if (st) g.pill(x, y + 3, st.text, { fg: T.bg, bg: st.courier ? T.violet : st.open ? T.lime : T.red, bold: true });
|
|
386
|
+
if (sel.phone?.length) g.text(x + (st ? strWidth(st.text) + 3 : 0), y + 3, `☏ ${sel.phone.join(", ")}`, { fg: T.sub, bg: T.panel2 });
|
|
387
|
+
let cx = x, cy = y + 5;
|
|
388
|
+
for (const f of sel.features ?? []) {
|
|
389
|
+
const wd = strWidth(f.name) + 2;
|
|
390
|
+
if (cx + wd > x + w) { cx = x; cy++; }
|
|
391
|
+
if (cy >= r.y + r.h) break;
|
|
392
|
+
g.pill(cx, cy, f.name.toLowerCase(), { fg: T.mint, bg: mix(T.mint, T.panel2, 0.85) });
|
|
393
|
+
cx += wd + 1;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|