brickwise 0.1.7 → 0.1.15
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/package.json +1 -1
- package/src/app/api/cafes/route.ts +16 -0
- package/src/app/api/gyms/route.ts +7 -21
- package/src/app/favorites/page.tsx +199 -95
- package/src/app/globals.css +18 -5
- package/src/components/ReformScenario.tsx +195 -40
- package/src/components/YieldBadge.tsx +6 -10
- package/src/lib/db.ts +8 -0
- package/src/lib/gyms.ts +5 -89
- package/src/lib/pois.ts +188 -0
- package/src/lib/yieldColor.ts +11 -0
package/package.json
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { poisForPoints } from "@/lib/pois";
|
|
3
|
+
import { listFavorites } from "@/lib/store";
|
|
4
|
+
|
|
5
|
+
/** All cafés near any located favorite, in a single Overpass query. */
|
|
6
|
+
export async function GET() {
|
|
7
|
+
try {
|
|
8
|
+
const points = listFavorites()
|
|
9
|
+
.filter((f) => f.lat != null && f.lon != null)
|
|
10
|
+
.map((f) => ({ lat: f.lat!, lon: f.lon! }));
|
|
11
|
+
const { pois, failed } = await poisForPoints(points, "cafe");
|
|
12
|
+
return NextResponse.json({ cafes: pois, favoritesQueried: points.length, failed });
|
|
13
|
+
} catch (e) {
|
|
14
|
+
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
2
|
+
import { poisForPoints } from "@/lib/pois";
|
|
3
3
|
import { listFavorites } from "@/lib/store";
|
|
4
4
|
|
|
5
|
-
/** All gyms
|
|
5
|
+
/** All gyms near any located favorite, in a single Overpass query. */
|
|
6
6
|
export async function GET() {
|
|
7
7
|
try {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (gyms == null) {
|
|
14
|
-
failed++;
|
|
15
|
-
continue;
|
|
16
|
-
}
|
|
17
|
-
for (const g of gyms) {
|
|
18
|
-
const key = `${g.lat.toFixed(5)},${g.lon.toFixed(5)}`;
|
|
19
|
-
if (!seen.has(key)) seen.set(key, { name: g.name, lat: g.lat, lon: g.lon });
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return NextResponse.json({
|
|
23
|
-
gyms: [...seen.values()],
|
|
24
|
-
favoritesQueried: favorites.length,
|
|
25
|
-
failed,
|
|
26
|
-
});
|
|
8
|
+
const points = listFavorites()
|
|
9
|
+
.filter((f) => f.lat != null && f.lon != null)
|
|
10
|
+
.map((f) => ({ lat: f.lat!, lon: f.lon! }));
|
|
11
|
+
const { pois, failed } = await poisForPoints(points, "gym");
|
|
12
|
+
return NextResponse.json({ gyms: pois, favoritesQueried: points.length, failed });
|
|
27
13
|
} catch (e) {
|
|
28
14
|
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
29
15
|
}
|
|
@@ -6,6 +6,7 @@ import "leaflet/dist/leaflet.css";
|
|
|
6
6
|
import type { LayerGroup, Map as LeafletMap, Marker } from "leaflet";
|
|
7
7
|
import YieldBadge from "@/components/YieldBadge";
|
|
8
8
|
import { fmtAED, fmtDist, fmtSqft } from "@/lib/format";
|
|
9
|
+
import { yieldColor } from "@/lib/yieldColor";
|
|
9
10
|
|
|
10
11
|
interface FavRow {
|
|
11
12
|
id: string;
|
|
@@ -29,36 +30,25 @@ interface FavRow {
|
|
|
29
30
|
gym_distance_m: number | null;
|
|
30
31
|
gym_name: string | null;
|
|
31
32
|
price_per_sqft: number | null;
|
|
33
|
+
description: string | null;
|
|
32
34
|
notes: string;
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
const MORE_SVG = (
|
|
38
|
+
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
|
39
|
+
<circle cx="11" cy="11" r="7" />
|
|
40
|
+
<path d="M11 8v6M8 11h6M20 20l-3.5-3.5" />
|
|
41
|
+
</svg>
|
|
42
|
+
);
|
|
43
|
+
|
|
35
44
|
const GYM_SVG = `<svg viewBox="0 0 24 24" width="14" height="14" fill="#fff" xmlns="http://www.w3.org/2000/svg"><path d="M2 10h2v4H2zM5 8h2v8H5zM17 8h2v8h-2zM20 10h2v4h-2zM8 11h8v2H8z"/></svg>`;
|
|
45
|
+
const CAFE_SVG = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M4 8h12v5a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8z"/><path d="M16 9h2a2 2 0 0 1 0 4h-2"/><path d="M6 3v2M10 3v2M14 3v2"/></svg>`;
|
|
36
46
|
|
|
37
47
|
function matchesBedFilter(r: { bedrooms: number | null }, bedFilter: number[]): boolean {
|
|
38
48
|
return bedFilter.length === 0 || (r.bedrooms != null && bedFilter.includes(r.bedrooms));
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
|
|
42
|
-
* Relative color scale: red (lowest yield in the set) → turquoise (highest).
|
|
43
|
-
* Absolute thresholds made every Dubai listing green because yields are all high;
|
|
44
|
-
* scaling to the actual min/max makes the differences readable on the map.
|
|
45
|
-
*/
|
|
46
|
-
function yieldColor(y: number | null, min: number, max: number): string {
|
|
47
|
-
if (y == null) return "#8a8a8a";
|
|
48
|
-
const t = max > min ? Math.min(1, Math.max(0, (y - min) / (max - min))) : 0.5;
|
|
49
|
-
const hue = t * 174; // 0° red → 174° turquoise
|
|
50
|
-
return `hsl(${Math.round(hue)}, 66%, 44%)`;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Min/max of the located favorites' yields, for the relative color scale. */
|
|
54
|
-
function yieldRange(rows: FavRow[] | null): { min: number; max: number } {
|
|
55
|
-
const ys = (rows ?? [])
|
|
56
|
-
.filter((r) => r.lat != null && r.lon != null && r.gross_yield != null)
|
|
57
|
-
.map((r) => r.gross_yield as number);
|
|
58
|
-
return ys.length ? { min: Math.min(...ys), max: Math.max(...ys) } : { min: 0, max: 1 };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function popupHtml(r: FavRow, min: number, max: number): string {
|
|
51
|
+
function popupHtml(r: FavRow): string {
|
|
62
52
|
const img = r.images?.[0]?.medium || r.images?.[0]?.small;
|
|
63
53
|
const yieldTxt = r.gross_yield != null ? (r.gross_yield * 100).toFixed(1) + " %" : "sin datos";
|
|
64
54
|
return `
|
|
@@ -67,7 +57,7 @@ function popupHtml(r: FavRow, min: number, max: number): string {
|
|
|
67
57
|
<div style="font-weight:600;margin-top:6px;line-height:1.3">${r.title}</div>
|
|
68
58
|
<div style="color:#666;font-size:12px;margin-top:2px">${r.tower_name ?? ""}</div>
|
|
69
59
|
<div style="margin-top:4px;font-size:14px">
|
|
70
|
-
Rentabilidad: <span style="color:${yieldColor(r.gross_yield
|
|
60
|
+
Rentabilidad: <span style="color:${yieldColor(r.gross_yield)};font-weight:700">${yieldTxt}</span>
|
|
71
61
|
</div>
|
|
72
62
|
<div style="margin-top:6px;display:flex;gap:10px;font-size:12px">
|
|
73
63
|
<a href="/listing/${r.id}">Ver análisis</a>
|
|
@@ -87,6 +77,44 @@ const SAVED_URL = "https://www.propertyfinder.ae/en/user/saved-properties";
|
|
|
87
77
|
// strips javascript: hrefs.
|
|
88
78
|
const BOOKMARKLET = String.raw`javascript:(function(){var u=new Set();document.querySelectorAll('a[href]').forEach(function(a){var h=(a.href||'').split('?')[0];if(/\/plp\/.*-\d+\.html$/.test(h))u.add(h)});try{var n=document.getElementById('__NEXT_DATA__');if(n){(JSON.stringify(JSON.parse(n.textContent)).match(/https?:\/\/[^"']*\/plp\/[^"']*-\d+\.html/g)||[]).forEach(function(x){u.add(x.split('?')[0])})}}catch(e){}var l=[...u];if(!l.length){alert('No encontre pisos guardados aqui. Abre tu pagina de Guardados de Property Finder con la sesion iniciada.');return}var t=l.join('\n');if(navigator.clipboard){navigator.clipboard.writeText(t).then(function(){alert(l.length+' piso(s) copiados. Abre Brickwise y pulsa "Pegar de Property Finder".')},function(){prompt('Copia estos enlaces y pegalos en Brickwise:',t)})}else{prompt('Copia estos enlaces y pegalos en Brickwise:',t)}})();`;
|
|
89
79
|
|
|
80
|
+
function LayerToggle({
|
|
81
|
+
label,
|
|
82
|
+
color,
|
|
83
|
+
loading,
|
|
84
|
+
visible,
|
|
85
|
+
count,
|
|
86
|
+
onToggle,
|
|
87
|
+
}: {
|
|
88
|
+
label: string;
|
|
89
|
+
color: string;
|
|
90
|
+
loading: boolean;
|
|
91
|
+
visible: boolean;
|
|
92
|
+
count: number | null;
|
|
93
|
+
onToggle: () => void;
|
|
94
|
+
}) {
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
onClick={onToggle}
|
|
99
|
+
disabled={loading}
|
|
100
|
+
className="btn-font flex w-full items-center gap-2 rounded px-2 py-1.5 text-[11px] hover:bg-neutral-100 disabled:opacity-60 dark:hover:bg-neutral-800"
|
|
101
|
+
>
|
|
102
|
+
<span
|
|
103
|
+
className="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border"
|
|
104
|
+
style={{ background: visible ? color : "transparent", borderColor: color, opacity: 0.85 }}
|
|
105
|
+
>
|
|
106
|
+
{visible && (
|
|
107
|
+
<svg viewBox="0 0 24 24" width="9" height="9" fill="none" stroke="#fff" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
|
|
108
|
+
<path d="M5 12l5 5L20 6" />
|
|
109
|
+
</svg>
|
|
110
|
+
)}
|
|
111
|
+
</span>
|
|
112
|
+
<span className="flex-1 text-left">{label}</span>
|
|
113
|
+
<span className="text-neutral-500">{loading ? "Buscando…" : count != null ? count : ""}</span>
|
|
114
|
+
</button>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
export default function FavoritesMapPage() {
|
|
91
119
|
const [rows, setRows] = useState<FavRow[] | null>(null);
|
|
92
120
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
@@ -104,13 +132,19 @@ export default function FavoritesMapPage() {
|
|
|
104
132
|
const [gymsVisible, setGymsVisible] = useState(false);
|
|
105
133
|
const [gymsLoading, setGymsLoading] = useState(false);
|
|
106
134
|
const [gymCount, setGymCount] = useState<number | null>(null);
|
|
135
|
+
const [cafesVisible, setCafesVisible] = useState(false);
|
|
136
|
+
const [cafesLoading, setCafesLoading] = useState(false);
|
|
137
|
+
const [cafeCount, setCafeCount] = useState<number | null>(null);
|
|
138
|
+
const [showLayers, setShowLayers] = useState(false);
|
|
107
139
|
const [sortBy, setSortBy] = useState<"yield" | "price">("yield");
|
|
108
140
|
const [bedFilter, setBedFilter] = useState<number[]>([]);
|
|
141
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
109
142
|
|
|
110
143
|
const mapRef = useRef<LeafletMap | null>(null);
|
|
111
144
|
const leafletRef = useRef<typeof import("leaflet") | null>(null);
|
|
112
145
|
const markersRef = useRef<Record<string, Marker>>({});
|
|
113
146
|
const gymLayerRef = useRef<LayerGroup | null>(null);
|
|
147
|
+
const cafeLayerRef = useRef<LayerGroup | null>(null);
|
|
114
148
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
115
149
|
const didFitRef = useRef(false);
|
|
116
150
|
const processing = useRef(false);
|
|
@@ -193,6 +227,7 @@ export default function FavoritesMapPage() {
|
|
|
193
227
|
mapRef.current = null;
|
|
194
228
|
markersRef.current = {};
|
|
195
229
|
gymLayerRef.current = null;
|
|
230
|
+
cafeLayerRef.current = null;
|
|
196
231
|
didFitRef.current = false;
|
|
197
232
|
setMapReady(false);
|
|
198
233
|
};
|
|
@@ -205,20 +240,19 @@ export default function FavoritesMapPage() {
|
|
|
205
240
|
if (!L || !map || !mapReady || !rows) return;
|
|
206
241
|
Object.values(markersRef.current).forEach((m) => m.remove());
|
|
207
242
|
markersRef.current = {};
|
|
208
|
-
const { min, max } = yieldRange(rows);
|
|
209
243
|
const bounds: [number, number][] = [];
|
|
210
244
|
for (const r of rows) {
|
|
211
245
|
if (r.lat == null || r.lon == null) continue;
|
|
212
246
|
const y = r.gross_yield != null ? (r.gross_yield * 100).toFixed(1) + "%" : "—";
|
|
213
247
|
const icon = L.divIcon({
|
|
214
248
|
className: "",
|
|
215
|
-
html: `<div class="bw-pin" style="--pin-color:${yieldColor(r.gross_yield
|
|
249
|
+
html: `<div class="bw-pin" style="--pin-color:${yieldColor(r.gross_yield)}"><div class="bw-pin-label">${y}</div><div class="bw-pin-tail"></div></div>`,
|
|
216
250
|
iconSize: [0, 0],
|
|
217
251
|
iconAnchor: [0, 0],
|
|
218
252
|
});
|
|
219
253
|
const marker = L.marker([r.lat, r.lon], { icon })
|
|
220
254
|
.addTo(map)
|
|
221
|
-
.bindPopup(popupHtml(r
|
|
255
|
+
.bindPopup(popupHtml(r), { maxWidth: 260, offset: [0, -34] });
|
|
222
256
|
marker.on("mouseover", () => {
|
|
223
257
|
setHoveredId(r.id);
|
|
224
258
|
document.getElementById(`fav-${r.id}`)?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
@@ -326,52 +360,81 @@ export default function FavoritesMapPage() {
|
|
|
326
360
|
}
|
|
327
361
|
};
|
|
328
362
|
|
|
329
|
-
|
|
363
|
+
// Build a Leaflet layer of POI markers (gyms / cafés) with a Google Maps popup.
|
|
364
|
+
const buildPoiLayer = (
|
|
365
|
+
L: typeof import("leaflet"),
|
|
366
|
+
items: { name: string; lat: number; lon: number }[],
|
|
367
|
+
cssClass: string,
|
|
368
|
+
svg: string
|
|
369
|
+
) => {
|
|
370
|
+
const layer = L.layerGroup();
|
|
371
|
+
for (const p of items) {
|
|
372
|
+
const icon = L.divIcon({ className: "", html: `<div class="${cssClass}">${svg}</div>`, iconSize: [0, 0], iconAnchor: [0, 0] });
|
|
373
|
+
const safeName = p.name.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
374
|
+
// Business name in the query, exact spot in the /@lat,lon path (not the query text):
|
|
375
|
+
// a named POI opens its own Maps place card; the coordinates never clutter the search
|
|
376
|
+
// box and only bias the map to Dubai so the right branch/spot is what's shown.
|
|
377
|
+
const mapsUrl = `https://www.google.com/maps/search/${encodeURIComponent(p.name)}/@${p.lat},${p.lon},17z`;
|
|
378
|
+
L.marker([p.lat, p.lon], { icon })
|
|
379
|
+
.bindPopup(
|
|
380
|
+
`<b>${safeName}</b><br><a href="${mapsUrl}" target="_blank" rel="noopener noreferrer" style="font-size:12px">Ver en Google Maps ↗</a>`,
|
|
381
|
+
{ offset: [0, -16] }
|
|
382
|
+
)
|
|
383
|
+
.addTo(layer);
|
|
384
|
+
}
|
|
385
|
+
return layer;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const togglePoi = async (opts: {
|
|
389
|
+
api: string;
|
|
390
|
+
dataKey: string;
|
|
391
|
+
cssClass: string;
|
|
392
|
+
svg: string;
|
|
393
|
+
layerRef: React.MutableRefObject<LayerGroup | null>;
|
|
394
|
+
visible: boolean;
|
|
395
|
+
setVisible: (v: boolean) => void;
|
|
396
|
+
setLoading: (v: boolean) => void;
|
|
397
|
+
setCount: (v: number | null) => void;
|
|
398
|
+
}) => {
|
|
330
399
|
const map = mapRef.current;
|
|
331
400
|
const L = leafletRef.current;
|
|
332
401
|
if (!map || !L) return;
|
|
333
|
-
if (
|
|
334
|
-
if (
|
|
335
|
-
else
|
|
336
|
-
|
|
402
|
+
if (opts.layerRef.current) {
|
|
403
|
+
if (opts.visible) map.removeLayer(opts.layerRef.current);
|
|
404
|
+
else opts.layerRef.current.addTo(map);
|
|
405
|
+
opts.setVisible(!opts.visible);
|
|
337
406
|
return;
|
|
338
407
|
}
|
|
339
|
-
|
|
408
|
+
opts.setLoading(true);
|
|
340
409
|
try {
|
|
341
|
-
const res = await fetch(
|
|
410
|
+
const res = await fetch(opts.api);
|
|
342
411
|
const j = await res.json();
|
|
343
412
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
const icon = L.divIcon({
|
|
347
|
-
className: "",
|
|
348
|
-
html: `<div class="bw-gym">${GYM_SVG}</div>`,
|
|
349
|
-
iconSize: [0, 0],
|
|
350
|
-
iconAnchor: [0, 0],
|
|
351
|
-
});
|
|
352
|
-
const safeName = g.name.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
353
|
-
// Link to the exact coordinates, not a text search: a name search gets biased
|
|
354
|
-
// to the viewer's own location (e.g. Andorra), while coordinates always drop
|
|
355
|
-
// the pin on this gym's spot in Dubai.
|
|
356
|
-
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${g.lat}%2C${g.lon}`;
|
|
357
|
-
L.marker([g.lat, g.lon], { icon })
|
|
358
|
-
.bindPopup(
|
|
359
|
-
`<b>${safeName}</b><br><a href="${mapsUrl}" target="_blank" rel="noopener noreferrer" style="font-size:12px">Ver en Google Maps ↗</a>`,
|
|
360
|
-
{ offset: [0, -16] }
|
|
361
|
-
)
|
|
362
|
-
.addTo(layer);
|
|
363
|
-
}
|
|
413
|
+
const items = j[opts.dataKey] as { name: string; lat: number; lon: number }[];
|
|
414
|
+
const layer = buildPoiLayer(L, items, opts.cssClass, opts.svg);
|
|
364
415
|
layer.addTo(map);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
416
|
+
opts.layerRef.current = layer;
|
|
417
|
+
opts.setCount(items.length);
|
|
418
|
+
opts.setVisible(true);
|
|
368
419
|
} catch {
|
|
369
|
-
|
|
420
|
+
opts.setCount(null);
|
|
370
421
|
} finally {
|
|
371
|
-
|
|
422
|
+
opts.setLoading(false);
|
|
372
423
|
}
|
|
373
424
|
};
|
|
374
425
|
|
|
426
|
+
const toggleGyms = () =>
|
|
427
|
+
togglePoi({
|
|
428
|
+
api: "/api/gyms", dataKey: "gyms", cssClass: "bw-gym", svg: GYM_SVG,
|
|
429
|
+
layerRef: gymLayerRef, visible: gymsVisible, setVisible: setGymsVisible, setLoading: setGymsLoading, setCount: setGymCount,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const toggleCafes = () =>
|
|
433
|
+
togglePoi({
|
|
434
|
+
api: "/api/cafes", dataKey: "cafes", cssClass: "bw-cafe", svg: CAFE_SVG,
|
|
435
|
+
layerRef: cafeLayerRef, visible: cafesVisible, setVisible: setCafesVisible, setLoading: setCafesLoading, setCount: setCafeCount,
|
|
436
|
+
});
|
|
437
|
+
|
|
375
438
|
// --- Resizable divider ---------------------------------------------------
|
|
376
439
|
const startDrag = (e: React.MouseEvent) => {
|
|
377
440
|
e.preventDefault();
|
|
@@ -435,11 +498,24 @@ export default function FavoritesMapPage() {
|
|
|
435
498
|
<div className="min-w-0 flex-1">
|
|
436
499
|
<div className="flex items-start justify-between gap-2">
|
|
437
500
|
<button
|
|
438
|
-
onClick={() =>
|
|
439
|
-
className="
|
|
440
|
-
title="
|
|
501
|
+
onClick={() => setExpandedId((id) => (id === r.id ? null : r.id))}
|
|
502
|
+
className="flex min-w-0 items-center gap-1 text-left font-sans text-sm font-semibold normal-case tracking-normal hover:underline"
|
|
503
|
+
title="Ver previsualización"
|
|
441
504
|
>
|
|
442
|
-
{r.title}
|
|
505
|
+
<span className="truncate">{r.title}</span>
|
|
506
|
+
<svg
|
|
507
|
+
viewBox="0 0 24 24"
|
|
508
|
+
width="12"
|
|
509
|
+
height="12"
|
|
510
|
+
fill="none"
|
|
511
|
+
stroke="currentColor"
|
|
512
|
+
strokeWidth="2.5"
|
|
513
|
+
strokeLinecap="round"
|
|
514
|
+
strokeLinejoin="round"
|
|
515
|
+
className={`shrink-0 text-neutral-400 transition-transform ${expandedId === r.id ? "rotate-180" : ""}`}
|
|
516
|
+
>
|
|
517
|
+
<path d="M6 9l6 6 6-6" />
|
|
518
|
+
</svg>
|
|
443
519
|
</button>
|
|
444
520
|
<button
|
|
445
521
|
onClick={() => remove(r.id)}
|
|
@@ -486,6 +562,29 @@ export default function FavoritesMapPage() {
|
|
|
486
562
|
</span>
|
|
487
563
|
</div>
|
|
488
564
|
|
|
565
|
+
{/* Preview desplegable al pulsar el título */}
|
|
566
|
+
{expandedId === r.id && (
|
|
567
|
+
<div className="mt-2 rounded-md border border-neutral-200 p-2 dark:border-neutral-800">
|
|
568
|
+
{r.images.length > 0 && (
|
|
569
|
+
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
|
570
|
+
{r.images.slice(0, 10).map((img, i) => (
|
|
571
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
572
|
+
<img key={i} src={img.medium} alt="" className="h-24 w-36 shrink-0 rounded object-cover" loading="lazy" />
|
|
573
|
+
))}
|
|
574
|
+
</div>
|
|
575
|
+
)}
|
|
576
|
+
{r.description && (
|
|
577
|
+
<p className="mt-1.5 line-clamp-4 whitespace-pre-line text-xs text-neutral-500">{r.description}</p>
|
|
578
|
+
)}
|
|
579
|
+
<Link
|
|
580
|
+
href={`/listing/${r.id}`}
|
|
581
|
+
className="btn-font mt-2 inline-flex items-center gap-1.5 rounded bg-blue-600 px-3 py-1.5 text-xs font-bold text-white hover:bg-blue-500"
|
|
582
|
+
>
|
|
583
|
+
{MORE_SVG} Ver más
|
|
584
|
+
</Link>
|
|
585
|
+
</div>
|
|
586
|
+
)}
|
|
587
|
+
|
|
489
588
|
<textarea
|
|
490
589
|
className="mt-2 h-9 w-full resize-none rounded-md border border-transparent bg-neutral-50 px-2 py-1 text-xs outline-none focus:border-neutral-300 dark:bg-neutral-950 dark:focus:border-neutral-700"
|
|
491
590
|
defaultValue={r.notes}
|
|
@@ -521,32 +620,37 @@ export default function FavoritesMapPage() {
|
|
|
521
620
|
value={input}
|
|
522
621
|
onChange={(e) => setInput(e.target.value)}
|
|
523
622
|
/>
|
|
524
|
-
<div className="mt-2 flex
|
|
623
|
+
<div className="mt-2 flex flex-col gap-1.5">
|
|
525
624
|
<button
|
|
526
625
|
type="submit"
|
|
527
626
|
disabled={!input.trim() || busy}
|
|
528
|
-
className="rounded-lg bg-blue-600
|
|
627
|
+
className="w-full rounded-lg bg-blue-600 py-2 text-xs font-bold text-white hover:bg-blue-500 disabled:opacity-50"
|
|
529
628
|
>
|
|
530
629
|
{busy ? `Analizando ${queue.length}…` : "Añadir y analizar"}
|
|
531
630
|
</button>
|
|
532
|
-
<
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
631
|
+
<div className="relative">
|
|
632
|
+
<button
|
|
633
|
+
type="button"
|
|
634
|
+
onClick={() => setShowLayers((v) => !v)}
|
|
635
|
+
disabled={(rows?.length ?? 0) === 0}
|
|
636
|
+
className="flex w-full items-center justify-center gap-1 rounded-lg border border-neutral-300 py-2 text-xs font-semibold hover:bg-neutral-100 disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
|
637
|
+
>
|
|
638
|
+
Puntos cercanos
|
|
639
|
+
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform ${showLayers ? "rotate-180" : ""}`}>
|
|
640
|
+
<path d="M6 9l6 6 6-6" />
|
|
641
|
+
</svg>
|
|
642
|
+
</button>
|
|
643
|
+
{showLayers && (
|
|
644
|
+
<div className="absolute inset-x-0 top-full z-30 mt-1 rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-800 dark:bg-neutral-900">
|
|
645
|
+
<LayerToggle label="Gimnasios" color="#b84f30" loading={gymsLoading} visible={gymsVisible} count={gymCount} onToggle={toggleGyms} />
|
|
646
|
+
<LayerToggle label="Cafeterías" color="#6f4e37" loading={cafesLoading} visible={cafesVisible} count={cafeCount} onToggle={toggleCafes} />
|
|
647
|
+
</div>
|
|
648
|
+
)}
|
|
649
|
+
</div>
|
|
546
650
|
<button
|
|
547
651
|
type="button"
|
|
548
652
|
onClick={() => setShowSync((v) => !v)}
|
|
549
|
-
className={`rounded-lg border
|
|
653
|
+
className={`w-full rounded-lg border py-2 text-xs font-semibold ${
|
|
550
654
|
showSync
|
|
551
655
|
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
|
552
656
|
: "border-neutral-300 hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
|
@@ -682,23 +786,23 @@ export default function FavoritesMapPage() {
|
|
|
682
786
|
{/* ------------------------------------------------ map */}
|
|
683
787
|
<div className="relative min-h-0 min-w-0 flex-1">
|
|
684
788
|
<div ref={containerRef} className="absolute inset-0" />
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
789
|
+
<div className="pointer-events-none absolute bottom-4 left-3 z-[500] rounded border border-black/10 bg-white/90 px-2.5 py-2 text-neutral-700 shadow-lg dark:border-white/10 dark:bg-neutral-900/90 dark:text-neutral-200">
|
|
790
|
+
<div className="btn-font mb-1 text-[9px] text-neutral-500">Rentabilidad anual</div>
|
|
791
|
+
<div
|
|
792
|
+
className="h-2 w-52 rounded-sm"
|
|
793
|
+
style={{ background: "linear-gradient(90deg, hsl(0,66%,44%), hsl(87,66%,44%), hsl(174,66%,44%))" }}
|
|
794
|
+
/>
|
|
795
|
+
<div className="mt-1 flex w-52 justify-between text-[10px] font-semibold">
|
|
796
|
+
<span>Mediocre</span>
|
|
797
|
+
<span>Bueno</span>
|
|
798
|
+
<span>Espectacular</span>
|
|
799
|
+
</div>
|
|
800
|
+
<div className="flex w-52 justify-between text-[9px] text-neutral-500">
|
|
801
|
+
<span>≤6%</span>
|
|
802
|
+
<span>8–9%</span>
|
|
803
|
+
<span>10%+</span>
|
|
804
|
+
</div>
|
|
805
|
+
</div>
|
|
702
806
|
</div>
|
|
703
807
|
</div>
|
|
704
808
|
);
|
package/src/app/globals.css
CHANGED
|
@@ -159,17 +159,30 @@ button[class*="bg-blue-5"],
|
|
|
159
159
|
border-color: rgba(255, 255, 255, 0.4);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
/*
|
|
163
|
-
.bw-gym
|
|
162
|
+
/* POI markers (gym / café): semi-transparent square with a white icon. */
|
|
163
|
+
.bw-gym,
|
|
164
|
+
.bw-cafe {
|
|
164
165
|
width: 24px;
|
|
165
166
|
height: 24px;
|
|
166
167
|
border-radius: 2px;
|
|
167
|
-
|
|
168
|
-
border: 1px solid rgba(255, 255, 255, 0.85);
|
|
168
|
+
border: 1px solid rgba(255, 255, 255, 0.7);
|
|
169
169
|
display: flex;
|
|
170
170
|
align-items: center;
|
|
171
171
|
justify-content: center;
|
|
172
|
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.
|
|
172
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
|
173
173
|
transform: translate(-50%, -50%);
|
|
174
174
|
cursor: pointer;
|
|
175
|
+
transition: opacity 0.12s ease;
|
|
176
|
+
}
|
|
177
|
+
.bw-gym {
|
|
178
|
+
background: rgba(184, 79, 48, 0.68); /* brick, transparent */
|
|
179
|
+
opacity: 0.9;
|
|
180
|
+
}
|
|
181
|
+
.bw-cafe {
|
|
182
|
+
background: rgba(111, 78, 55, 0.68); /* coffee, transparent */
|
|
183
|
+
opacity: 0.9;
|
|
184
|
+
}
|
|
185
|
+
.bw-gym:hover,
|
|
186
|
+
.bw-cafe:hover {
|
|
187
|
+
opacity: 1;
|
|
175
188
|
}
|
|
@@ -4,7 +4,6 @@ import { useState } from "react";
|
|
|
4
4
|
import { fmtAED, fmtPct } from "@/lib/format";
|
|
5
5
|
import { AnalysisRow } from "@/lib/types";
|
|
6
6
|
|
|
7
|
-
const DEFAULT_AED_PER_SQFT = 300;
|
|
8
7
|
const SIZE_TOLERANCE = 0.2;
|
|
9
8
|
|
|
10
9
|
interface Props {
|
|
@@ -15,30 +14,101 @@ interface Props {
|
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* Itemized reform model. The rent uplift you can claim depends on the SCOPE of
|
|
18
|
+
* the reform (which items you do), not on the money you type in: each item
|
|
19
|
+
* captures a share ("weight") of the median→P75 spread. Costs only affect the
|
|
20
|
+
* payback. This prevents "cheap reform + full P75 rent" from inflating yields.
|
|
21
|
+
*
|
|
22
|
+
* Default costs are rough Dubai light-reform figures per sqft; edit them freely.
|
|
21
23
|
*/
|
|
24
|
+
const ITEMS = {
|
|
25
|
+
mobiliario: { label: "Mobiliario", defaults: { comprado: 45, medida: 100 }, weights: { comprado: 0.4, medida: 0.5 } },
|
|
26
|
+
suelo: { label: "Suelo", perSqft: 55, weight: 0.25 },
|
|
27
|
+
paredes: { label: "Paredes (pintura y arreglos)", perSqft: 12, weight: 0.2 },
|
|
28
|
+
techos: { label: "Techos (falso techo e iluminación)", perSqft: 20, weight: 0.1 },
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
type FurnitureType = "comprado" | "medida";
|
|
32
|
+
|
|
33
|
+
interface ReformState {
|
|
34
|
+
mobiliario: { on: boolean; tipo: FurnitureType; cost: number };
|
|
35
|
+
suelo: { on: boolean; cost: number };
|
|
36
|
+
paredes: { on: boolean; cost: number };
|
|
37
|
+
techos: { on: boolean; cost: number };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function defaultState(sizeSqft: number | null): ReformState {
|
|
41
|
+
const s = sizeSqft ?? 500;
|
|
42
|
+
return {
|
|
43
|
+
mobiliario: { on: true, tipo: "comprado", cost: Math.round(s * ITEMS.mobiliario.defaults.comprado) },
|
|
44
|
+
suelo: { on: true, cost: Math.round(s * ITEMS.suelo.perSqft) },
|
|
45
|
+
paredes: { on: true, cost: Math.round(s * ITEMS.paredes.perSqft) },
|
|
46
|
+
techos: { on: false, cost: Math.round(s * ITEMS.techos.perSqft) },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const REFORM_HELP =
|
|
51
|
+
"Comparamos los alquileres de pisos de TAMAÑO SIMILAR (±20%) del mismo edificio (24 meses). " +
|
|
52
|
+
"P25 = piso sin reformar; P75 = bien reformado; mediana = el típico. La reforma solo puede capturar " +
|
|
53
|
+
"el margen mediana→P75, y CADA PARTIDA captura una parte: mobiliario 40% (50% si es a medida), " +
|
|
54
|
+
"suelo 25%, paredes 20%, techos 10%. Marcar o desmarcar partidas cambia la renta esperada; " +
|
|
55
|
+
"el coste que escribas solo cambia el payback — así una reforma barata no infla la rentabilidad.";
|
|
56
|
+
|
|
57
|
+
/** Small "?" badge with a hover tooltip. */
|
|
58
|
+
function InfoTip({ text }: { text: string }) {
|
|
59
|
+
return (
|
|
60
|
+
<span className="group relative ml-1 inline-flex align-middle normal-case">
|
|
61
|
+
<span className="flex h-4 w-4 cursor-help items-center justify-center rounded-full border border-neutral-400 text-[10px] font-bold text-neutral-500 dark:border-neutral-500">
|
|
62
|
+
?
|
|
63
|
+
</span>
|
|
64
|
+
<span className="pointer-events-none absolute left-0 top-5 z-30 w-72 rounded-md border border-neutral-200 bg-white p-2.5 text-[11px] font-normal leading-snug text-neutral-600 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
|
|
65
|
+
{text}
|
|
66
|
+
</span>
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
22
71
|
export default function ReformScenario({ listingId, price, sizeSqft, analysis }: Props) {
|
|
23
|
-
const [
|
|
24
|
-
const fallback =
|
|
72
|
+
const [state, setState] = useState<ReformState>(() => {
|
|
73
|
+
const fallback = defaultState(sizeSqft);
|
|
25
74
|
if (typeof window === "undefined") return fallback;
|
|
26
|
-
|
|
27
|
-
|
|
75
|
+
try {
|
|
76
|
+
const saved = JSON.parse(localStorage.getItem(`bw-reform-${listingId}`) ?? "");
|
|
77
|
+
// Older versions stored a plain number — ignore those.
|
|
78
|
+
if (saved && typeof saved === "object" && saved.mobiliario) return saved as ReformState;
|
|
79
|
+
} catch {}
|
|
80
|
+
return fallback;
|
|
28
81
|
});
|
|
29
82
|
|
|
30
83
|
const { rent_p25, rent_p75, median_rent, rent_n, asking_yield } = analysis;
|
|
31
84
|
if (rent_p25 == null || rent_p75 == null || median_rent == null || rent_n < 3) return null;
|
|
32
85
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
86
|
+
const update = (next: ReformState) => {
|
|
87
|
+
setState(next);
|
|
88
|
+
localStorage.setItem(`bw-reform-${listingId}`, JSON.stringify(next));
|
|
36
89
|
};
|
|
37
90
|
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
91
|
+
const uplift = rent_p75 - median_rent; // full spread captured by a complete reform
|
|
92
|
+
const weightOf = {
|
|
93
|
+
mobiliario: state.mobiliario.on ? ITEMS.mobiliario.weights[state.mobiliario.tipo] : 0,
|
|
94
|
+
suelo: state.suelo.on ? ITEMS.suelo.weight : 0,
|
|
95
|
+
paredes: state.paredes.on ? ITEMS.paredes.weight : 0,
|
|
96
|
+
techos: state.techos.on ? ITEMS.techos.weight : 0,
|
|
97
|
+
};
|
|
98
|
+
const scope = Math.min(1, weightOf.mobiliario + weightOf.suelo + weightOf.paredes + weightOf.techos);
|
|
99
|
+
const totalCost =
|
|
100
|
+
(state.mobiliario.on ? state.mobiliario.cost : 0) +
|
|
101
|
+
(state.suelo.on ? state.suelo.cost : 0) +
|
|
102
|
+
(state.paredes.on ? state.paredes.cost : 0) +
|
|
103
|
+
(state.techos.on ? state.techos.cost : 0);
|
|
104
|
+
|
|
105
|
+
const expectedRent = median_rent + uplift * scope;
|
|
106
|
+
const capturedUplift = expectedRent - median_rent;
|
|
107
|
+
const postReformYield = expectedRent / (price + totalCost);
|
|
108
|
+
const paybackYears = capturedUplift > 0 && totalCost > 0 ? totalCost / capturedUplift : null;
|
|
41
109
|
const spread = (rent_p75 - rent_p25) / median_rent;
|
|
110
|
+
const medianPct =
|
|
111
|
+
rent_p75 > rent_p25 ? Math.min(96, Math.max(4, ((median_rent - rent_p25) / (rent_p75 - rent_p25)) * 100)) : 50;
|
|
42
112
|
|
|
43
113
|
const spreadReading =
|
|
44
114
|
spread >= 0.15
|
|
@@ -51,16 +121,48 @@ export default function ReformScenario({ listingId, price, sizeSqft, analysis }:
|
|
|
51
121
|
? `${Math.round(sizeSqft * (1 - SIZE_TOLERANCE))}–${Math.round(sizeSqft * (1 + SIZE_TOLERANCE))} sqft`
|
|
52
122
|
: null;
|
|
53
123
|
|
|
124
|
+
const rowCls = "flex items-center gap-2 py-1";
|
|
125
|
+
const inputCls =
|
|
126
|
+
"w-24 rounded border border-neutral-300 bg-white px-1.5 py-0.5 text-right text-xs outline-none focus:border-blue-500 disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-950";
|
|
127
|
+
|
|
128
|
+
const itemRow = (
|
|
129
|
+
key: "suelo" | "paredes" | "techos",
|
|
130
|
+
label: string,
|
|
131
|
+
weight: number
|
|
132
|
+
) => (
|
|
133
|
+
<label className={rowCls}>
|
|
134
|
+
<input
|
|
135
|
+
type="checkbox"
|
|
136
|
+
checked={state[key].on}
|
|
137
|
+
onChange={(e) => update({ ...state, [key]: { ...state[key], on: e.target.checked } })}
|
|
138
|
+
className="h-3.5 w-3.5 accent-blue-600"
|
|
139
|
+
/>
|
|
140
|
+
<span className="flex-1 text-xs">{label}</span>
|
|
141
|
+
<span className="text-[10px] text-neutral-400">+{Math.round(weight * 100)}%</span>
|
|
142
|
+
<input
|
|
143
|
+
type="number"
|
|
144
|
+
min={0}
|
|
145
|
+
step={1000}
|
|
146
|
+
disabled={!state[key].on}
|
|
147
|
+
value={state[key].cost}
|
|
148
|
+
suppressHydrationWarning
|
|
149
|
+
onChange={(e) => update({ ...state, [key]: { ...state[key], cost: Number(e.target.value) } })}
|
|
150
|
+
className={inputCls}
|
|
151
|
+
/>
|
|
152
|
+
</label>
|
|
153
|
+
);
|
|
154
|
+
|
|
54
155
|
return (
|
|
55
156
|
<div className="rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
|
56
157
|
<h3 className="text-sm font-bold">
|
|
57
|
-
Escenario de reforma
|
|
158
|
+
Escenario de reforma
|
|
159
|
+
<InfoTip text={REFORM_HELP} />{" "}
|
|
58
160
|
<span className="font-normal text-neutral-500">
|
|
59
161
|
({rent_n} alquileres comparables{band ? ` · ${band}` : ""})
|
|
60
162
|
</span>
|
|
61
163
|
</h3>
|
|
62
164
|
|
|
63
|
-
<div className="mt-3 grid gap-4 lg:grid-cols-[
|
|
165
|
+
<div className="mt-3 grid gap-4 lg:grid-cols-[1fr_auto_1.15fr]">
|
|
64
166
|
{/* Rent range of comparables */}
|
|
65
167
|
<div>
|
|
66
168
|
<div className="mb-1 flex justify-between text-[11px] text-neutral-500">
|
|
@@ -68,20 +170,32 @@ export default function ReformScenario({ listingId, price, sizeSqft, analysis }:
|
|
|
68
170
|
<span>mediana</span>
|
|
69
171
|
<span>P75 (reformado)</span>
|
|
70
172
|
</div>
|
|
71
|
-
<div className="relative h-2 rounded-full bg-
|
|
173
|
+
<div className="relative h-2.5 rounded-full bg-neutral-200 dark:bg-neutral-800">
|
|
72
174
|
{rent_p75 > rent_p25 && (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
175
|
+
<>
|
|
176
|
+
{/* upside captured by the CURRENT scope */}
|
|
177
|
+
<div
|
|
178
|
+
className="absolute inset-y-0 rounded-r-full bg-emerald-400 dark:bg-emerald-600"
|
|
179
|
+
style={{ left: `${medianPct}%`, width: `${(100 - medianPct) * scope}%` }}
|
|
180
|
+
title={`Margen capturado por esta reforma (${Math.round(scope * 100)}%)`}
|
|
181
|
+
/>
|
|
182
|
+
<span
|
|
183
|
+
className="absolute -top-[3px] h-[17px] w-[3px] -translate-x-1/2 rounded bg-neutral-900 dark:bg-white"
|
|
184
|
+
style={{ left: `${medianPct}%` }}
|
|
185
|
+
title={`mediana ${fmtAED(median_rent)}`}
|
|
186
|
+
/>
|
|
187
|
+
</>
|
|
78
188
|
)}
|
|
79
189
|
</div>
|
|
80
|
-
<div className="mt-1 flex justify-between text-xs font-semibold">
|
|
190
|
+
<div className="mt-1.5 flex justify-between text-xs font-semibold">
|
|
81
191
|
<span>{fmtAED(rent_p25)}</span>
|
|
82
192
|
<span className="text-neutral-500">{fmtAED(median_rent)}</span>
|
|
83
193
|
<span>{fmtAED(rent_p75)}</span>
|
|
84
194
|
</div>
|
|
195
|
+
<div className="mt-1 flex items-center gap-1.5 text-[10px] text-neutral-500">
|
|
196
|
+
<span className="inline-block h-2 w-3 rounded-sm bg-emerald-400 dark:bg-emerald-600" />
|
|
197
|
+
margen capturado por esta reforma ({Math.round(scope * 100)}% del total)
|
|
198
|
+
</div>
|
|
85
199
|
<p className={`mt-2 text-xs ${spreadReading.cls}`}>
|
|
86
200
|
Dispersión {fmtPct(spread, 0)} de la mediana — {spreadReading.txt}
|
|
87
201
|
</p>
|
|
@@ -89,30 +203,71 @@ export default function ReformScenario({ listingId, price, sizeSqft, analysis }:
|
|
|
89
203
|
|
|
90
204
|
<div className="hidden w-px bg-neutral-200 lg:block dark:bg-neutral-800" />
|
|
91
205
|
|
|
92
|
-
{/*
|
|
206
|
+
{/* Itemized reform */}
|
|
93
207
|
<div className="text-sm">
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
208
|
+
<div className="mb-1 flex items-center justify-between">
|
|
209
|
+
<span className="text-xs font-semibold text-neutral-600 dark:text-neutral-400">Partidas de la reforma</span>
|
|
210
|
+
<span className="text-[10px] text-neutral-400">% del margen · coste AED</span>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
{/* Mobiliario: checkbox + type selector */}
|
|
214
|
+
<label className={rowCls}>
|
|
215
|
+
<input
|
|
216
|
+
type="checkbox"
|
|
217
|
+
checked={state.mobiliario.on}
|
|
218
|
+
onChange={(e) => update({ ...state, mobiliario: { ...state.mobiliario, on: e.target.checked } })}
|
|
219
|
+
className="h-3.5 w-3.5 accent-blue-600"
|
|
220
|
+
/>
|
|
221
|
+
<span className="text-xs">Mobiliario</span>
|
|
222
|
+
<select
|
|
223
|
+
disabled={!state.mobiliario.on}
|
|
224
|
+
value={state.mobiliario.tipo}
|
|
225
|
+
onChange={(e) => {
|
|
226
|
+
const tipo = e.target.value as FurnitureType;
|
|
227
|
+
const cost = sizeSqft ? Math.round(sizeSqft * ITEMS.mobiliario.defaults[tipo]) : state.mobiliario.cost;
|
|
228
|
+
update({ ...state, mobiliario: { ...state.mobiliario, tipo, cost } });
|
|
229
|
+
}}
|
|
230
|
+
className="rounded border border-neutral-300 bg-white px-1 py-0.5 text-[11px] disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-950"
|
|
231
|
+
>
|
|
232
|
+
<option value="comprado">comprado</option>
|
|
233
|
+
<option value="medida">a medida</option>
|
|
234
|
+
</select>
|
|
235
|
+
<span className="flex-1" />
|
|
236
|
+
<span className="text-[10px] text-neutral-400">
|
|
237
|
+
+{Math.round(ITEMS.mobiliario.weights[state.mobiliario.tipo] * 100)}%
|
|
238
|
+
</span>
|
|
239
|
+
<input
|
|
240
|
+
type="number"
|
|
241
|
+
min={0}
|
|
242
|
+
step={1000}
|
|
243
|
+
disabled={!state.mobiliario.on}
|
|
244
|
+
value={state.mobiliario.cost}
|
|
245
|
+
suppressHydrationWarning
|
|
246
|
+
onChange={(e) => update({ ...state, mobiliario: { ...state.mobiliario, cost: Number(e.target.value) } })}
|
|
247
|
+
className={inputCls}
|
|
248
|
+
/>
|
|
97
249
|
</label>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
250
|
+
|
|
251
|
+
{itemRow("suelo", "Suelo", ITEMS.suelo.weight)}
|
|
252
|
+
{itemRow("paredes", "Paredes (pintura y arreglos)", ITEMS.paredes.weight)}
|
|
253
|
+
{itemRow("techos", "Techos (falso techo, iluminación)", ITEMS.techos.weight)}
|
|
254
|
+
|
|
255
|
+
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-1.5 border-t border-neutral-200 pt-2 text-xs dark:border-neutral-800">
|
|
256
|
+
<span className="text-neutral-500">Coste total</span>
|
|
257
|
+
<b>{fmtAED(totalCost)}</b>
|
|
258
|
+
<span className="text-neutral-500">Renta esperada post-reforma</span>
|
|
259
|
+
<b>
|
|
260
|
+
{fmtAED(Math.round(expectedRent))}
|
|
261
|
+
<span className="ml-1 font-normal text-neutral-400">({Math.round(scope * 100)}% del margen)</span>
|
|
262
|
+
</b>
|
|
108
263
|
<span className="text-neutral-500">Yield actual (mediana / precio)</span>
|
|
109
264
|
<b>{fmtPct(asking_yield)}</b>
|
|
110
|
-
<span className="text-neutral-500">Yield post-reforma
|
|
265
|
+
<span className="text-neutral-500">Yield post-reforma</span>
|
|
111
266
|
<b className={postReformYield > (asking_yield ?? 0) ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}>
|
|
112
267
|
{fmtPct(postReformYield)}
|
|
113
268
|
</b>
|
|
114
|
-
<span className="text-neutral-500">Renta extra al año
|
|
115
|
-
<b>{fmtAED(
|
|
269
|
+
<span className="text-neutral-500">Renta extra al año</span>
|
|
270
|
+
<b>{fmtAED(Math.round(capturedUplift))}</b>
|
|
116
271
|
<span className="text-neutral-500">Payback de la reforma</span>
|
|
117
272
|
<b>{paybackYears != null ? `${paybackYears.toFixed(1)} años` : "—"}</b>
|
|
118
273
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fmtPct } from "@/lib/format";
|
|
2
|
+
import { yieldColor } from "@/lib/yieldColor";
|
|
2
3
|
|
|
3
4
|
export default function YieldBadge({ value, n }: { value: number | null; n?: number }) {
|
|
4
5
|
if (value == null)
|
|
@@ -7,18 +8,13 @@ export default function YieldBadge({ value, n }: { value: number | null; n?: num
|
|
|
7
8
|
sin datos
|
|
8
9
|
</span>
|
|
9
10
|
);
|
|
10
|
-
const color =
|
|
11
|
-
value >= 0.07
|
|
12
|
-
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
|
|
13
|
-
: value >= 0.055
|
|
14
|
-
? "bg-lime-100 text-lime-800 dark:bg-lime-950 dark:text-lime-300"
|
|
15
|
-
: value >= 0.04
|
|
16
|
-
? "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
|
17
|
-
: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300";
|
|
18
11
|
return (
|
|
19
|
-
<span
|
|
12
|
+
<span
|
|
13
|
+
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
|
14
|
+
style={{ background: yieldColor(value) }}
|
|
15
|
+
>
|
|
20
16
|
{fmtPct(value)}
|
|
21
|
-
{n != null && <span className="font-normal opacity-
|
|
17
|
+
{n != null && <span className="font-normal opacity-80">({n} tx)</span>}
|
|
22
18
|
</span>
|
|
23
19
|
);
|
|
24
20
|
}
|
package/src/lib/db.ts
CHANGED
|
@@ -100,6 +100,14 @@ CREATE TABLE IF NOT EXISTS gyms_list_cache (
|
|
|
100
100
|
fetched_at TEXT NOT NULL
|
|
101
101
|
);
|
|
102
102
|
|
|
103
|
+
CREATE TABLE IF NOT EXISTS poi_list_cache (
|
|
104
|
+
geo_key TEXT NOT NULL,
|
|
105
|
+
kind TEXT NOT NULL,
|
|
106
|
+
json TEXT NOT NULL,
|
|
107
|
+
fetched_at TEXT NOT NULL,
|
|
108
|
+
PRIMARY KEY (geo_key, kind)
|
|
109
|
+
);
|
|
110
|
+
|
|
103
111
|
CREATE TABLE IF NOT EXISTS favorites (
|
|
104
112
|
listing_id TEXT PRIMARY KEY REFERENCES listings(id),
|
|
105
113
|
notes TEXT NOT NULL DEFAULT '',
|
package/src/lib/gyms.ts
CHANGED
|
@@ -1,104 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
import { getDb, now } from "./db";
|
|
1
|
+
import { poisNear, Poi } from "./pois";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
const OVERPASS_ENDPOINTS = [
|
|
6
|
-
"https://overpass-api.de/api/interpreter",
|
|
7
|
-
"https://overpass-api.de/api/interpreter",
|
|
8
|
-
"https://overpass.private.coffee/api/interpreter",
|
|
9
|
-
"https://overpass.kumi.systems/api/interpreter",
|
|
10
|
-
];
|
|
11
|
-
const RADIUS_M = 2000;
|
|
12
|
-
const TTL_MS = 30 * 24 * 3600 * 1000;
|
|
13
|
-
|
|
14
|
-
export interface Gym {
|
|
15
|
-
name: string;
|
|
16
|
-
lat: number;
|
|
17
|
-
lon: number;
|
|
18
|
-
distance_m: number;
|
|
19
|
-
}
|
|
3
|
+
export type Gym = Poi;
|
|
20
4
|
|
|
21
5
|
export interface GymResult {
|
|
22
6
|
gym_name: string | null;
|
|
23
7
|
distance_m: number | null;
|
|
24
8
|
}
|
|
25
9
|
|
|
26
|
-
|
|
27
|
-
const R = 6371000;
|
|
28
|
-
const toRad = (d: number) => (d * Math.PI) / 180;
|
|
29
|
-
const dLat = toRad(lat2 - lat1);
|
|
30
|
-
const dLon = toRad(lon2 - lon1);
|
|
31
|
-
const a =
|
|
32
|
-
Math.sin(dLat / 2) ** 2 +
|
|
33
|
-
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
|
34
|
-
return 2 * R * Math.asin(Math.sqrt(a));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Overpass allows very few concurrent requests per IP — serialize ours.
|
|
38
|
-
let queue: Promise<unknown> = Promise.resolve();
|
|
39
|
-
|
|
40
|
-
/** All gyms (OSM fitness centres) within 2 km, nearest first; cached by ~100 m grid cell. */
|
|
10
|
+
/** All gyms (OSM fitness centres) within 2 km, nearest first; cached. */
|
|
41
11
|
export function gymsNear(lat: number, lon: number): Promise<Gym[] | null> {
|
|
42
|
-
|
|
43
|
-
queue = task.catch(() => null);
|
|
44
|
-
return task;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** null = Overpass unavailable (not cached); [] = confirmed no gyms nearby. */
|
|
48
|
-
async function doGymsNear(lat: number, lon: number): Promise<Gym[] | null> {
|
|
49
|
-
const db = getDb();
|
|
50
|
-
const geoKey = `${lat.toFixed(3)},${lon.toFixed(3)}`;
|
|
51
|
-
const cached = db.prepare(`SELECT * FROM gyms_list_cache WHERE geo_key = ?`).get(geoKey) as any;
|
|
52
|
-
if (cached && Date.now() - Date.parse(cached.fetched_at) < TTL_MS) {
|
|
53
|
-
return JSON.parse(cached.gyms_json);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const query = `[out:json][timeout:15];(node["leisure"="fitness_centre"](around:${RADIUS_M},${lat},${lon});way["leisure"="fitness_centre"](around:${RADIUS_M},${lat},${lon}););out center 60;`;
|
|
57
|
-
for (const endpoint of OVERPASS_ENDPOINTS) {
|
|
58
|
-
try {
|
|
59
|
-
const res = await fetch(endpoint, {
|
|
60
|
-
method: "POST",
|
|
61
|
-
body: "data=" + encodeURIComponent(query),
|
|
62
|
-
headers: {
|
|
63
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
64
|
-
// overpass-api.de answers 406 to requests without a User-Agent
|
|
65
|
-
"User-Agent": "brickwise-local/0.1 (personal use)",
|
|
66
|
-
},
|
|
67
|
-
signal: AbortSignal.timeout(20000),
|
|
68
|
-
});
|
|
69
|
-
if (!res.ok) continue;
|
|
70
|
-
const j = await res.json();
|
|
71
|
-
// Overloaded instances answer 200 + empty elements + a "remark" runtime error.
|
|
72
|
-
if (j.remark && !(j.elements || []).length) continue;
|
|
73
|
-
const gyms: Gym[] = [];
|
|
74
|
-
for (const el of j.elements || []) {
|
|
75
|
-
const eLat = el.lat ?? el.center?.lat;
|
|
76
|
-
const eLon = el.lon ?? el.center?.lon;
|
|
77
|
-
if (eLat == null || eLon == null) continue;
|
|
78
|
-
gyms.push({
|
|
79
|
-
name: el.tags?.name || "Gym",
|
|
80
|
-
lat: eLat,
|
|
81
|
-
lon: eLon,
|
|
82
|
-
distance_m: Math.round(haversineM(lat, lon, eLat, eLon)),
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
gyms.sort((a, b) => a.distance_m - b.distance_m);
|
|
86
|
-
db.prepare(
|
|
87
|
-
`INSERT OR REPLACE INTO gyms_list_cache (geo_key, gyms_json, fetched_at) VALUES (?, ?, ?)`
|
|
88
|
-
).run(geoKey, JSON.stringify(gyms), now());
|
|
89
|
-
return gyms;
|
|
90
|
-
} catch {
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return null; // all endpoints down: unknown, not cached
|
|
12
|
+
return poisNear(lat, lon, "gym");
|
|
95
13
|
}
|
|
96
14
|
|
|
97
15
|
/** Nearest gym within 2 km (derived from the cached list). */
|
|
98
16
|
export async function nearestGym(lat: number, lon: number): Promise<GymResult> {
|
|
99
17
|
const gyms = await gymsNear(lat, lon);
|
|
100
18
|
const best = gyms?.[0];
|
|
101
|
-
return best
|
|
102
|
-
? { gym_name: best.name, distance_m: best.distance_m }
|
|
103
|
-
: { gym_name: null, distance_m: null };
|
|
19
|
+
return best ? { gym_name: best.name, distance_m: best.distance_m } : { gym_name: null, distance_m: null };
|
|
104
20
|
}
|
package/src/lib/pois.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|