SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

feat: carte 100% maison signée Groupe Ka — style vectoriel from scratch, pastilles de prix étirables, bonnes affaires Vrai-Prix

- map/immokaStyle.ts : fond de carte écrit à la main (schéma OpenMapTiles, tuiles libres) — papier chaud, eau froide, autoroutes blush, étiquettes fr
- map/pills.ts : pastilles de prix générées au canvas (stretchable images, icon-text-fit), variante rouge « sous l'estimation Vrai-Prix »
- MapView : clusters rouges, sélection, popup badge bonne affaire, légende, badge + attribution « Cartographie Groupe Ka »
- web.py : vp (valeur Vrai-Prix) dans listings.geojson

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 12, 2026) parent f2e76aa

5 changed files +279 −2

modified frontend/src/components/MapView.tsx +0 −0

Binary file not shown.

added frontend/src/map/immokaStyle.ts +186 −0
@@ -0,0 +1,186 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// map/immokaStyle.ts : style cartographique « Groupe Ka », écrit FROM SCRATCH
5 +// sur le schéma OpenMapTiles (tuiles vectorielles libres OpenFreeMap).
6 +// Palette maison : papier chaud, eau froide en contraste, routes crème,
7 +// autoroutes blush, étiquettes encre chocolat — la carte EST la marque.
8 +// -----------------------------------------------------------------------------
9 +import type { StyleSpecification } from "maplibre-gl";
10 +
11 +// Palette (alignée sur styles.css)
12 +const PAPER = "#f7f1ef"; // fond — papier chaud
13 +const WATER = "#cfdde4"; // eau — gris-bleu froid (contraste voulu)
14 +const GREEN_1 = "#e7ece0"; // couvert végétal léger
15 +const GREEN_2 = "#dde5d4"; // bois / forêt
16 +const PARK = "#dfe9d8"; // parcs
17 +const RESID = "#f2e9e6"; // trame résidentielle (à peine plus foncée)
18 +const BUILD = "#e9dcd8"; // bâtiments
19 +const ROAD = "#ffffff"; // voirie — crème/blanc
20 +const ROAD_CASE = "#e3d4d1"; // liseré de voirie
21 +const MOTOR = "#f3cfd0"; // autoroutes — blush (clin d'œil rouge Immo-Ka)
22 +const MOTOR_CASE = "#dfb0b3";
23 +const RAIL = "#d8c9c8";
24 +const BOUND = "#c49399"; // frontières — vieux rose
25 +const INK = "#3f3033"; // étiquettes — encre chocolat
26 +const INK_SOFT = "#7a6a6d";
27 +const WATER_INK = "#5e7b8a";
28 +
29 +const style: StyleSpecification = {
30 + version: 8,
31 + name: "Immo-Ka · Groupe Ka",
32 + glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
33 + sources: {
34 + omt: { type: "vector", url: "https://tiles.openfreemap.org/planet" },
35 + },
36 + layers: [
37 + { id: "bg", type: "background", paint: { "background-color": PAPER } },
38 +
39 + // --- occupation du sol -------------------------------------------------
40 + { id: "landcover-grass", type: "fill", source: "omt", "source-layer": "landcover",
41 + filter: ["in", ["get", "class"], ["literal", ["grass", "farmland", "wetland"]]],
42 + paint: { "fill-color": GREEN_1, "fill-opacity": 0.7 } },
43 + { id: "landcover-wood", type: "fill", source: "omt", "source-layer": "landcover",
44 + filter: ["==", ["get", "class"], "wood"],
45 + paint: { "fill-color": GREEN_2, "fill-opacity": ["interpolate", ["linear"], ["zoom"], 6, 0.5, 12, 0.8] } },
46 + { id: "landcover-ice", type: "fill", source: "omt", "source-layer": "landcover",
47 + filter: ["==", ["get", "class"], "ice"], paint: { "fill-color": "#f2f4f5" } },
48 + { id: "landuse-residential", type: "fill", source: "omt", "source-layer": "landuse",
49 + filter: ["in", ["get", "class"], ["literal", ["residential", "suburb", "neighbourhood"]]],
50 + paint: { "fill-color": RESID, "fill-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.35, 12, 0.75] } },
51 + { id: "park", type: "fill", source: "omt", "source-layer": "park",
52 + paint: { "fill-color": PARK, "fill-opacity": 0.85 } },
53 +
54 + // --- eau ---------------------------------------------------------------
55 + { id: "waterway", type: "line", source: "omt", "source-layer": "waterway",
56 + paint: { "line-color": WATER,
57 + "line-width": ["interpolate", ["exponential", 1.4], ["zoom"], 8, 0.6, 14, 2.5, 18, 6] } },
58 + { id: "water", type: "fill", source: "omt", "source-layer": "water",
59 + paint: { "fill-color": WATER } },
60 +
61 + // --- aéroports ----------------------------------------------------------
62 + { id: "aeroway", type: "line", source: "omt", "source-layer": "aeroway",
63 + minzoom: 11, filter: ["in", ["get", "class"], ["literal", ["runway", "taxiway"]]],
64 + paint: { "line-color": "#eee3e0",
65 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 11, 1, 16, 24] } },
66 +
67 + // --- tunnels (discrets, pointillés) --------------------------------------
68 + { id: "tunnel", type: "line", source: "omt", "source-layer": "transportation",
69 + filter: ["==", ["get", "brunnel"], "tunnel"],
70 + paint: { "line-color": ROAD_CASE, "line-dasharray": [2, 2],
71 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 8, 0.8, 14, 3, 18, 10] } },
72 +
73 + // --- voirie (liseré puis remplissage) ------------------------------------
74 + { id: "road-minor-case", type: "line", source: "omt", "source-layer": "transportation",
75 + minzoom: 12,
76 + filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
77 + ["in", ["get", "class"], ["literal", ["minor", "service", "track", "path"]]]],
78 + layout: { "line-cap": "round", "line-join": "round" },
79 + paint: { "line-color": ROAD_CASE,
80 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 1.4, 15, 4, 18, 14] } },
81 + { id: "road-minor", type: "line", source: "omt", "source-layer": "transportation",
82 + minzoom: 12,
83 + filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
84 + ["in", ["get", "class"], ["literal", ["minor", "service", "track", "path"]]]],
85 + layout: { "line-cap": "round", "line-join": "round" },
86 + paint: { "line-color": ROAD,
87 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 0.8, 15, 3, 18, 12] } },
88 + { id: "road-mid-case", type: "line", source: "omt", "source-layer": "transportation",
89 + minzoom: 9,
90 + filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
91 + ["in", ["get", "class"], ["literal", ["secondary", "tertiary"]]]],
92 + layout: { "line-cap": "round", "line-join": "round" },
93 + paint: { "line-color": ROAD_CASE,
94 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 9, 1.6, 14, 6, 18, 20] } },
95 + { id: "road-mid", type: "line", source: "omt", "source-layer": "transportation",
96 + minzoom: 9,
97 + filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
98 + ["in", ["get", "class"], ["literal", ["secondary", "tertiary"]]]],
99 + layout: { "line-cap": "round", "line-join": "round" },
100 + paint: { "line-color": ROAD,
101 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 9, 1, 14, 4.5, 18, 17] } },
102 + { id: "road-major-case", type: "line", source: "omt", "source-layer": "transportation",
103 + minzoom: 7,
104 + filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
105 + ["in", ["get", "class"], ["literal", ["primary", "trunk"]]]],
106 + layout: { "line-cap": "round", "line-join": "round" },
107 + paint: { "line-color": ROAD_CASE,
108 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 7, 1.2, 14, 7, 18, 24] } },
109 + { id: "road-major", type: "line", source: "omt", "source-layer": "transportation",
110 + minzoom: 7,
111 + filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
112 + ["in", ["get", "class"], ["literal", ["primary", "trunk"]]]],
113 + layout: { "line-cap": "round", "line-join": "round" },
114 + paint: { "line-color": ROAD,
115 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 7, 0.75, 14, 5, 18, 20] } },
116 + { id: "motorway-case", type: "line", source: "omt", "source-layer": "transportation",
117 + minzoom: 5, filter: ["==", ["get", "class"], "motorway"],
118 + layout: { "line-cap": "round", "line-join": "round" },
119 + paint: { "line-color": MOTOR_CASE,
120 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 5, 0.9, 14, 8, 18, 26] } },
121 + { id: "motorway", type: "line", source: "omt", "source-layer": "transportation",
122 + minzoom: 5, filter: ["==", ["get", "class"], "motorway"],
123 + layout: { "line-cap": "round", "line-join": "round" },
124 + paint: { "line-color": MOTOR,
125 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 5, 0.6, 14, 6, 18, 22] } },
126 + { id: "rail", type: "line", source: "omt", "source-layer": "transportation",
127 + minzoom: 10, filter: ["==", ["get", "class"], "rail"],
128 + paint: { "line-color": RAIL, "line-dasharray": [3, 3],
129 + "line-width": ["interpolate", ["linear"], ["zoom"], 10, 0.6, 16, 2] } },
130 +
131 + // --- bâtiments ------------------------------------------------------------
132 + { id: "building", type: "fill", source: "omt", "source-layer": "building",
133 + minzoom: 13,
134 + paint: { "fill-color": BUILD, "fill-outline-color": "#ddc9c4",
135 + "fill-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0.45, 16, 0.9] } },
136 +
137 + // --- frontières -------------------------------------------------------------
138 + { id: "boundary-prov", type: "line", source: "omt", "source-layer": "boundary",
139 + filter: ["all", ["==", ["get", "admin_level"], 4], ["!=", ["get", "maritime"], 1]],
140 + paint: { "line-color": BOUND, "line-dasharray": [4, 3], "line-width": 1 } },
141 + { id: "boundary-country", type: "line", source: "omt", "source-layer": "boundary",
142 + filter: ["all", ["==", ["get", "admin_level"], 2], ["!=", ["get", "maritime"], 1]],
143 + paint: { "line-color": BOUND,
144 + "line-width": ["interpolate", ["linear"], ["zoom"], 3, 0.8, 10, 2] } },
145 +
146 + // --- étiquettes ---------------------------------------------------------------
147 + { id: "water-label", type: "symbol", source: "omt", "source-layer": "water_name",
148 + layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
149 + "text-font": ["Noto Sans Italic"], "text-size": 12 },
150 + paint: { "text-color": WATER_INK, "text-halo-color": PAPER, "text-halo-width": 1 } },
151 + { id: "road-label", type: "symbol", source: "omt", "source-layer": "transportation_name",
152 + minzoom: 13,
153 + layout: { "symbol-placement": "line",
154 + "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
155 + "text-font": ["Noto Sans Regular"], "text-size": 11 },
156 + paint: { "text-color": INK_SOFT, "text-halo-color": PAPER, "text-halo-width": 1.5 } },
157 + { id: "place-village", type: "symbol", source: "omt", "source-layer": "place",
158 + minzoom: 9,
159 + filter: ["in", ["get", "class"], ["literal", ["village", "suburb", "neighbourhood", "hamlet"]]],
160 + layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
161 + "text-font": ["Noto Sans Regular"],
162 + "text-size": ["interpolate", ["linear"], ["zoom"], 9, 10.5, 14, 13] },
163 + paint: { "text-color": INK_SOFT, "text-halo-color": PAPER, "text-halo-width": 1.6 } },
164 + { id: "place-town", type: "symbol", source: "omt", "source-layer": "place",
165 + filter: ["==", ["get", "class"], "town"],
166 + layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
167 + "text-font": ["Noto Sans Regular"],
168 + "text-size": ["interpolate", ["linear"], ["zoom"], 6, 11, 12, 15] },
169 + paint: { "text-color": INK, "text-halo-color": PAPER, "text-halo-width": 1.6 } },
170 + { id: "place-city", type: "symbol", source: "omt", "source-layer": "place",
171 + filter: ["==", ["get", "class"], "city"],
172 + layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
173 + "text-font": ["Noto Sans Bold"],
174 + "text-size": ["interpolate", ["linear"], ["zoom"], 4, 12, 10, 17],
175 + "text-transform": "none" },
176 + paint: { "text-color": INK, "text-halo-color": PAPER, "text-halo-width": 1.8 } },
177 + { id: "place-state", type: "symbol", source: "omt", "source-layer": "place",
178 + maxzoom: 7, filter: ["==", ["get", "class"], "state"],
179 + layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
180 + "text-font": ["Noto Sans Regular"], "text-size": 11,
181 + "text-transform": "uppercase", "text-letter-spacing": 0.15 },
182 + paint: { "text-color": BOUND, "text-halo-color": PAPER, "text-halo-width": 1.5 } },
183 + ],
184 +};
185 +
186 +export default style;
added frontend/src/map/pills.ts +74 −0
@@ -0,0 +1,74 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// map/pills.ts : pastilles de prix pour la carte — images « étirables »
5 +// (stretchable images MapLibre) générées au canvas : la pastille s'adapte
6 +// à la largeur du texte (icon-text-fit) sans aucun sprite externe.
7 +// -----------------------------------------------------------------------------
8 +import type { Map as MLMap, StyleImageMetadata } from "maplibre-gl";
9 +
10 +const R = 13; // rayon des coins (px logiques)
11 +const H = 30; // hauteur de la pastille
12 +const W = 64; // largeur de base (étirée au besoin)
13 +const DPR = 2; // netteté écrans Retina
14 +
15 +function drawPill(fill: string, stroke: string, shadow = true): ImageData {
16 + const cv = document.createElement("canvas");
17 + cv.width = W * DPR;
18 + cv.height = (H + 6) * DPR; // marge pour l'ombre + la pointe
19 + const g = cv.getContext("2d")!;
20 + g.scale(DPR, DPR);
21 + if (shadow) {
22 + g.shadowColor = "rgba(26,18,20,0.28)";
23 + g.shadowBlur = 5;
24 + g.shadowOffsetY = 2;
25 + }
26 + // corps arrondi
27 + g.beginPath();
28 + g.roundRect(1, 1, W - 2, H - 2, R);
29 + g.fillStyle = fill;
30 + g.fill();
31 + g.shadowColor = "transparent";
32 + // petite pointe sous la pastille (ancrage visuel sur la propriété)
33 + g.beginPath();
34 + g.moveTo(W / 2 - 5, H - 1.5);
35 + g.lineTo(W / 2, H + 4);
36 + g.lineTo(W / 2 + 5, H - 1.5);
37 + g.closePath();
38 + g.fillStyle = fill;
39 + g.fill();
40 + g.strokeStyle = stroke;
41 + g.lineWidth = 1.4;
42 + g.beginPath();
43 + g.roundRect(1, 1, W - 2, H - 2, R);
44 + g.stroke();
45 + return g.getImageData(0, 0, cv.width, cv.height);
46 +}
47 +
48 +/** Enregistre les variantes de pastille dans la carte. */
49 +export function addPills(map: MLMap): void {
50 + const meta: Partial<StyleImageMetadata> = {
51 + pixelRatio: DPR,
52 + // zones étirables entre les coins arrondis — coordonnées PHYSIQUES (× DPR)
53 + // et non dégénérées (x1 < x2), sinon MapLibre rejette l'image en silence
54 + stretchX: [[(R + 1) * DPR, (W - R - 1) * DPR]],
55 + stretchY: [[(R + 1) * DPR, (H - R - 1) * DPR]],
56 + // zone où le texte peut vivre
57 + content: [8 * DPR, 5 * DPR, (W - 8) * DPR, (H - 5) * DPR],
58 + };
59 + if (!map.hasImage("pill"))
60 + map.addImage("pill", drawPill("#ffffff", "rgba(26,18,20,0.35)"), meta);
61 + if (!map.hasImage("pill-deal"))
62 + map.addImage("pill-deal", drawPill("#e23744", "#b3202b"), meta);
63 + if (!map.hasImage("pill-active"))
64 + map.addImage("pill-active", drawPill("#1a1214", "#1a1214"), meta);
65 +}
66 +
67 +/** « 425 k$ », « 1,25 M$ » — format compact des pastilles. */
68 +export function shortPrice(p: number | null | undefined): string {
69 + if (p == null) return "•";
70 + if (p >= 1_000_000)
71 + return `${(p / 1_000_000).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} M$`;
72 + if (p >= 10_000) return `${Math.round(p / 1000)} k$`;
73 + return `${p.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`;
74 +}
modified frontend/src/styles.css +13 −1
@@ -355,7 +355,17 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
355 355 .map-split { display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 18px; height: calc(100dvh - 200px); min-height: 420px; }
356 356 .map-list { overflow-y: auto; display: flex; flex-direction: column; gap: 14px; padding-right: 4px; scrollbar-width: thin; }
357 357 .map-list .card { margin: 0; flex: 0 0 auto; }
358 +.mapwrap { position: relative; width: 100%; height: 100%; }
358 359 .mapview { width: 100%; height: 100%; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); overflow: hidden; background: var(--surface-2); }
360 +.mv-brand { position: absolute; top: 10px; left: 10px; z-index: 3; display: flex; align-items: center; gap: 6px; padding: 5px 11px; background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: 999px; box-shadow: var(--shadow-off-soft); font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.04em; color: var(--ink-2); pointer-events: none; }
361 +.mv-brand b { color: var(--ink); }
362 +.mv-brand-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 0 3px var(--lime-soft); }
363 +.mv-legend { position: absolute; bottom: 26px; left: 10px; z-index: 3; display: flex; flex-direction: column; gap: 4px; padding: 8px 11px; background: rgba(255, 255, 255, 0.92); border: 1px solid var(--line); border-radius: var(--r-ctl); font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); pointer-events: none; }
364 +.mv-legend span { display: flex; align-items: center; gap: 7px; }
365 +.mv-lg-pill { display: inline-block; width: 22px; height: 12px; border-radius: 999px; background: #fff; border: 1.5px solid rgba(26, 18, 20, 0.35); }
366 +.mv-lg-deal { background: var(--lime); border-color: var(--green); }
367 +.mini-map .mv-brand, .mini-map .mv-legend { display: none; }
368 +.mini-map .mapwrap { height: 100%; }
359 369 .map-loading { display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-family: var(--font-mono); font-size: 13px; }
360 370 @media (max-width: 780px) { .map-split { grid-template-columns: 1fr; height: calc(100dvh - 230px); } .map-list { display: none; } .results-tools { width: 100%; justify-content: space-between; } }
361 371 .maplibregl-popup-content { padding: 0; border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--line-strong); box-shadow: var(--shadow-off); font-family: var(--font-body); }
@@ -367,8 +377,10 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
367 377 .mv-pop-title { font-size: 13px; color: var(--ink-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
368 378 .mv-pop-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); margin-top: 6px; }
369 379 .mv-pop-src { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
370 −.mv-pop-cta { display: block; margin-top: 10px; padding: 8px 10px; text-align: center; background: var(--ink); color: var(--lime); border-radius: var(--r-ctl); font-weight: 600; font-size: 13px; }
380 +.mv-pop-cta { display: block; margin-top: 10px; padding: 8px 10px; text-align: center; background: var(--ink); color: #fff; border-radius: var(--r-ctl); font-weight: 600; font-size: 13px; }
371 381 .mv-pop-cta:hover { background: var(--green-deep); }
382 +.mv-pop-deal { display: inline-block; margin-left: 6px; padding: 2px 8px; vertical-align: 3px; background: var(--lime); color: #fff; border-radius: 999px; font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.05em; }
383 +.maplibregl-ctrl-attrib { font-size: 10px; }
372 384
373 385 /* ================= Menu mobile ================= */
374 386 .menu-btn { display: none; position: relative; z-index: 60; width: 44px; height: 44px; margin-left: auto; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface); cursor: pointer; padding: 0; flex-direction: column; align-items: center; justify-content: center; gap: 5px; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.15); transition: box-shadow 0.15s ease, transform 0.15s ease; }
modified immoka/web.py +6 −1
@@ -162,7 +162,7 @@ def listings_geojson(
162 162 """Propriétés géolocalisées (marqueurs de carte, champs allégés)."""
163 163 con = db.connect()
164 164 sql = ("SELECT uid, title, address, price, price_label, property_type,"
165 − " bedrooms, bathrooms, source, city, sector, images, lat, lng"
165 + " bedrooms, bathrooms, source, city, sector, images, lat, lng, vraiprix"
166 166 " FROM listings WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL")
167 167 args: list = []
168 168 if city:
@@ -181,6 +181,10 @@ def listings_geojson(
181 181 features = []
182 182 for r in con.execute(sql, args).fetchall():
183 183 images = json.loads(r["images"] or "[]")
184 + try:
185 + vp_val = (json.loads(r["vraiprix"]) or {}).get("value")
186 + except (TypeError, ValueError):
187 + vp_val = None
184 188 features.append({
185 189 "type": "Feature",
186 190 "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},
@@ -191,6 +195,7 @@ def listings_geojson(
191 195 "bathrooms": r["bathrooms"], "source": r["source"],
192 196 "city": r["city"], "sector": r["sector"],
193 197 "image": images[0] if images else None,
198 + "vp": vp_val,
194 199 },
195 200 })
196 201 con.close()
197 202