brickwise 0.1.7 → 0.1.21

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.
@@ -0,0 +1,188 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { createHash } from "crypto";
3
+ import { getDb, now } from "./db";
4
+
5
+ // Overpass instances are flaky under load; try each twice, in order.
6
+ const OVERPASS_ENDPOINTS = [
7
+ "https://overpass-api.de/api/interpreter",
8
+ "https://overpass-api.de/api/interpreter",
9
+ "https://overpass.private.coffee/api/interpreter",
10
+ "https://overpass.kumi.systems/api/interpreter",
11
+ ];
12
+ const RADIUS_M = 2000;
13
+ const TTL_MS = 30 * 24 * 3600 * 1000;
14
+
15
+ export type PoiKind = "gym" | "cafe";
16
+
17
+ // OSM tag filters + default label per kind.
18
+ const POI = {
19
+ gym: { tag: `["leisure"="fitness_centre"]`, label: "Gym" },
20
+ cafe: { tag: `["amenity"="cafe"]`, label: "Café" },
21
+ } as const;
22
+
23
+ export interface Poi {
24
+ name: string;
25
+ lat: number;
26
+ lon: number;
27
+ distance_m: number;
28
+ }
29
+
30
+ function haversineM(lat1: number, lon1: number, lat2: number, lon2: number): number {
31
+ const R = 6371000;
32
+ const toRad = (d: number) => (d * Math.PI) / 180;
33
+ const dLat = toRad(lat2 - lat1);
34
+ const dLon = toRad(lon2 - lon1);
35
+ const a =
36
+ Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
37
+ return 2 * R * Math.asin(Math.sqrt(a));
38
+ }
39
+
40
+ // Overpass allows very few concurrent requests per IP — serialize ours.
41
+ let queue: Promise<unknown> = Promise.resolve();
42
+
43
+ /** All POIs of `kind` within 2 km, nearest first; cached by ~100 m grid cell. */
44
+ export function poisNear(lat: number, lon: number, kind: PoiKind): Promise<Poi[] | null> {
45
+ const task = queue.then(() => doPoisNear(lat, lon, kind));
46
+ queue = task.catch(() => null);
47
+ return task;
48
+ }
49
+
50
+ /** null = Overpass unavailable (not cached); [] = confirmed none nearby. */
51
+ async function doPoisNear(lat: number, lon: number, kind: PoiKind): Promise<Poi[] | null> {
52
+ const db = getDb();
53
+ const geoKey = `${lat.toFixed(3)},${lon.toFixed(3)}`;
54
+ const cached = db.prepare(`SELECT * FROM poi_list_cache WHERE geo_key = ? AND kind = ?`).get(geoKey, kind) as any;
55
+ if (cached && Date.now() - Date.parse(cached.fetched_at) < TTL_MS) {
56
+ return JSON.parse(cached.json);
57
+ }
58
+
59
+ const tag = POI[kind].tag;
60
+ const query = `[out:json][timeout:15];(node${tag}(around:${RADIUS_M},${lat},${lon});way${tag}(around:${RADIUS_M},${lat},${lon}););out center 80;`;
61
+ const j = await overpassJson(query, 12000);
62
+ if (!j) return null; // all endpoints down: unknown, not cached
63
+ const pois: Poi[] = [];
64
+ for (const el of j.elements || []) {
65
+ const eLat = el.lat ?? el.center?.lat;
66
+ const eLon = el.lon ?? el.center?.lon;
67
+ if (eLat == null || eLon == null) continue;
68
+ pois.push({
69
+ name: el.tags?.name || POI[kind].label,
70
+ lat: eLat,
71
+ lon: eLon,
72
+ distance_m: Math.round(haversineM(lat, lon, eLat, eLon)),
73
+ });
74
+ }
75
+ pois.sort((a, b) => a.distance_m - b.distance_m);
76
+ db.prepare(`INSERT OR REPLACE INTO poi_list_cache (geo_key, kind, json, fetched_at) VALUES (?, ?, ?, ?)`).run(
77
+ geoKey,
78
+ kind,
79
+ JSON.stringify(pois),
80
+ now()
81
+ );
82
+ return pois;
83
+ }
84
+
85
+ /** POST an Overpass query, trying each endpoint until one answers; null if all fail. */
86
+ async function overpassJson(query: string, timeoutMs: number): Promise<any | null> {
87
+ for (const endpoint of OVERPASS_ENDPOINTS) {
88
+ try {
89
+ const res = await fetch(endpoint, {
90
+ method: "POST",
91
+ body: "data=" + encodeURIComponent(query),
92
+ headers: {
93
+ "Content-Type": "application/x-www-form-urlencoded",
94
+ "User-Agent": "brickwise-local/0.1 (personal use)",
95
+ },
96
+ signal: AbortSignal.timeout(timeoutMs),
97
+ });
98
+ if (!res.ok) continue;
99
+ const j = await res.json();
100
+ if (j.remark && !(j.elements || []).length) continue;
101
+ return j;
102
+ } catch {
103
+ continue;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * All POIs of `kind` near ANY of the given points, in a SINGLE Overpass query
111
+ * (one request for the whole map, not one per property). Cached by the point set.
112
+ */
113
+ export function poisForPoints(
114
+ points: { lat: number; lon: number }[],
115
+ kind: PoiKind
116
+ ): Promise<{ pois: Poi[]; failed: boolean }> {
117
+ const task = queue.then(() => doPoisForPoints(points, kind));
118
+ queue = task.catch(() => ({ pois: [] as Poi[], failed: true }));
119
+ return task;
120
+ }
121
+
122
+ async function doPoisForPoints(
123
+ points: { lat: number; lon: number }[],
124
+ kind: PoiKind
125
+ ): Promise<{ pois: Poi[]; failed: boolean }> {
126
+ const db = getDb();
127
+ // One ~100 m cell per cluster of nearby favorites (key format == doPoisNear's).
128
+ const cellMap = new Map<string, { lat: number; lon: number }>();
129
+ for (const p of points) {
130
+ const key = `${p.lat.toFixed(3)},${p.lon.toFixed(3)}`;
131
+ if (!cellMap.has(key)) cellMap.set(key, { lat: p.lat, lon: p.lon });
132
+ }
133
+ if (!cellMap.size) return { pois: [], failed: false };
134
+
135
+ const dedupe = (list: Poi[]) => {
136
+ const m = new Map<string, Poi>();
137
+ for (const p of list) m.set(`${p.lat.toFixed(5)},${p.lon.toFixed(5)}`, p);
138
+ return [...m.values()];
139
+ };
140
+ const fresh = (row: any) => row && Date.now() - Date.parse(row.fetched_at) < TTL_MS;
141
+ const get = (key: string) => db.prepare(`SELECT * FROM poi_list_cache WHERE geo_key = ? AND kind = ?`).get(key, kind) as any;
142
+ const put = (key: string, pois: Poi[]) =>
143
+ db.prepare(`INSERT OR REPLACE INTO poi_list_cache (geo_key, kind, json, fetched_at) VALUES (?, ?, ?, ?)`).run(key, kind, JSON.stringify(pois), now());
144
+
145
+ const allKeys = [...cellMap.keys()].sort();
146
+ const multiKey = "multi:" + createHash("md5").update(allKeys.join(";")).digest("hex");
147
+
148
+ // 1) Whole-set cache.
149
+ const multi = get(multiKey);
150
+ if (fresh(multi)) return { pois: JSON.parse(multi.json), failed: false };
151
+
152
+ // 2) Reuse per-cell cache; only query Overpass for what's missing.
153
+ const collected: Poi[] = [];
154
+ const missing: { lat: number; lon: number }[] = [];
155
+ for (const [key, c] of cellMap) {
156
+ const cc = get(key);
157
+ if (fresh(cc)) collected.push(...(JSON.parse(cc.json) as Poi[]));
158
+ else missing.push(c);
159
+ }
160
+ if (missing.length === 0) {
161
+ const pois = dedupe(collected);
162
+ put(multiKey, pois);
163
+ return { pois, failed: false };
164
+ }
165
+
166
+ // 3) One combined Overpass query for the missing cells.
167
+ const tag = POI[kind].tag;
168
+ const clauses = missing
169
+ .map((c) => `node${tag}(around:${RADIUS_M},${c.lat},${c.lon});way${tag}(around:${RADIUS_M},${c.lat},${c.lon});`)
170
+ .join("");
171
+ const query = `[out:json][timeout:90];(${clauses});out center 600;`;
172
+ const j = await overpassJson(query, 40000);
173
+ if (!j) {
174
+ // 4) Overpass down: return whatever we already had cached (partial).
175
+ const pois = dedupe(collected);
176
+ return { pois, failed: pois.length === 0 };
177
+ }
178
+
179
+ for (const el of j.elements || []) {
180
+ const eLat = el.lat ?? el.center?.lat;
181
+ const eLon = el.lon ?? el.center?.lon;
182
+ if (eLat == null || eLon == null) continue;
183
+ collected.push({ name: el.tags?.name || POI[kind].label, lat: eLat, lon: eLon, distance_m: 0 });
184
+ }
185
+ const pois = dedupe(collected);
186
+ put(multiKey, pois);
187
+ return { pois, failed: false };
188
+ }
package/src/lib/store.ts CHANGED
@@ -176,6 +176,24 @@ export function listFavorites(): FavoriteListing[] {
176
176
  .all() as FavoriteListing[];
177
177
  }
178
178
 
179
+ /**
180
+ * Liquidity proxy: number of DLD transactions per tower registered on/after
181
+ * `sinceISO`, optionally restricted to one kind ('buy' | 'rent'). Raw totals are
182
+ * capped at ~100/kind by scraping, so recent activity — not the lifetime count —
183
+ * is what separates a liquid tower from a stale one. Returned as tower_slug → count.
184
+ */
185
+ export function txCountByTowerSince(sinceISO: string, kind?: "buy" | "rent"): Map<string, number> {
186
+ const db = getDb();
187
+ const rows = (
188
+ kind
189
+ ? db.prepare(`SELECT tower_slug, COUNT(*) n FROM transactions WHERE date >= ? AND kind = ? GROUP BY tower_slug`).all(sinceISO, kind)
190
+ : db.prepare(`SELECT tower_slug, COUNT(*) n FROM transactions WHERE date >= ? GROUP BY tower_slug`).all(sinceISO)
191
+ ) as { tower_slug: string; n: number }[];
192
+ const m = new Map<string, number>();
193
+ for (const r of rows) m.set(r.tower_slug, r.n);
194
+ return m;
195
+ }
196
+
179
197
  export function listAnalyzed(): AnalyzedListing[] {
180
198
  return getDb()
181
199
  .prepare(
@@ -0,0 +1,11 @@
1
+ // Shared yield → color scale used by both the map pins and the list badge.
2
+ // ≤6% red (mediocre) → 10%+ turquoise (espectacular); 8–9% around green (bueno).
3
+ export const YIELD_LOW = 0.06;
4
+ export const YIELD_HIGH = 0.1;
5
+
6
+ export function yieldColor(y: number | null): string {
7
+ if (y == null) return "#8a8a8a";
8
+ const t = Math.min(1, Math.max(0, (y - YIELD_LOW) / (YIELD_HIGH - YIELD_LOW)));
9
+ const hue = t * 174; // 0° red → 174° turquoise
10
+ return `hsl(${Math.round(hue)}, 66%, 44%)`;
11
+ }