PostGIS ile Jeo-uzaysal Arama ve Konum Hizmetleri
Konuma göre arama, herhangi bir emlak platformunun kalbidir: Alıcıların %73'ü bir numaralı karar verici faktör olarak konumu işaret ediyor. Jeo-uzaysal bir arama uygulayın aynı zamanda hızlı, kesin e esnek gerektirir basit bir şehir filtresinden çok daha fazlası. Bu yazıda bir konum hizmetleri sistemi oluşturacağız kullanarak tamamla PostGIS PostgreSQL'de, gelişmiş mekansal indeksleme ve yakınlık araması ile H3 aracılığıyla, özel mahalleler için çokgen sorguları ve etkileşimli haritalarla entegrasyon.
Optimize edilmiş uzamsal indeksleme ile PostGIS etkileyici bir performansa ulaşır: 15-25ms yarıçap içi sorgular için, 8-12ms en yakın komşu aramaları için e 30-50ms Milyonlarca özellikten oluşan veri kümelerindeki karmaşık çokgen kesişimleri için.
Ne Öğreneceksiniz
- Gayrimenkul verileri için PostgreSQL'de PostGIS kurulumu ve konfigürasyonu
- Jeo-uzaysal modelleme: mahalleler ve alanlar için noktalar, çokgenler, geometriler
- Optimum performans için GIST ve BRIN ile mekansal indeksleme
- Yakınlık araması: ST_DWithin, ST_Distance, K-En Yakın Komşu
- Özelleştirilmiş "mahallemde" aramaları için çokgen sorgular
- Toplamalar için H3 (Uber Altıgen Hiyerarşik Mekansal Dizin)
- MapLibre GL JS ve OpenStreetMap ile entegrasyon
- Gayrimenkul ön yüzü için Express.js ile RESTful API
PostGIS: PostgreSQL'in Jeo-uzaysal Uzantısı
PostGIS PostgreSQL'e veri türleri, işlevler ve coğrafi operatörler ekleyerek onu dönüştürür tam bir Coğrafi Bilgi Sistemine (GIS) dönüştürülür. Gayrimenkul platformları, PostGIS ve tercihler için standart: hem düz geometrileri (metre cinsinden yansıtılan koordinatlar) hem de coğrafi geometrileri (enlem/boylam) yönetir Dünya eğriliği ile), her gayrimenkul kullanım durumunu kapsayan işlevlerle.
-- Abilitazione PostGIS e estensioni correlate
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;
CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;
CREATE EXTENSION IF NOT EXISTS address_standardizer;
CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder;
-- Verifica installazione
SELECT PostGIS_Version();
-- OUTPUT: 3.4.2 USE_GEOS=1 USE_PROJ=1 USE_STATS=1
-- Schema per dati immobiliari geospaziali
CREATE TABLE properties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
price NUMERIC(15,2) NOT NULL,
price_sqm NUMERIC(10,2) GENERATED ALWAYS AS (price / square_meters) STORED,
square_meters NUMERIC(8,2) NOT NULL,
rooms SMALLINT NOT NULL,
bathrooms SMALLINT NOT NULL,
property_type VARCHAR(50) NOT NULL, -- 'apartment', 'house', 'villa', 'commercial'
listing_type VARCHAR(20) NOT NULL, -- 'sale', 'rent'
status VARCHAR(20) NOT NULL DEFAULT 'active', -- 'active', 'sold', 'rented'
-- Dati geospaziali - GEOMETRY(Point, 4326) usa coordinate lat/lon standard
location GEOMETRY(Point, 4326) NOT NULL,
address JSONB NOT NULL, -- {"street": "...", "city": "...", "zip": "..."}
agent_id UUID REFERENCES agents(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indice spaziale GIST (obbligatorio per performance)
CREATE INDEX idx_properties_location
ON properties USING GIST(location);
-- Indici aggiuntivi per query composite
CREATE INDEX idx_properties_type_status
ON properties(property_type, status, listing_type);
CREATE INDEX idx_properties_price
ON properties(price) WHERE status = 'active';
Yakınlık Araması: Yarıçap İçindeki Özellikleri Bulma
En yaygın kullanım durumu: "Bana bu konuma 2 km mesafedeki daireleri göster".
ST_DWithin COĞRAFYA sütunlarında (GEOMETRY değil) metreler otomatik olarak birim olarak kullanılır
ve Dünyanın eğriliğini hesaba katarak uzun mesafelerde bile doğruluk sağlar.
-- Ricerca proprietà nel raggio con filtri multipli
-- ST_DWithin con GEOGRAPHY usa metri (più accurata di GEOMETRY per distanze reali)
-- Converti la colonna location in GEOGRAPHY per calcolo corretto
-- Oppure usa ST_DWithin con distanza in gradi (approx) su GEOMETRY
-- Esempio 1: Appartamenti in vendita entro 2km da Piazza Navona (Roma)
SELECT
p.id,
p.title,
p.price,
p.rooms,
p.square_meters,
ST_Distance(
p.location::geography,
ST_MakePoint(12.4731, 41.8991)::geography
) AS distance_meters,
ST_AsGeoJSON(p.location) AS geojson
FROM properties p
WHERE
p.status = 'active'
AND p.listing_type = 'sale'
AND p.property_type = 'apartment'
AND ST_DWithin(
p.location::geography,
ST_MakePoint(12.4731, 41.8991)::geography, -- lon, lat
2000 -- 2000 metri = 2km
)
AND p.rooms >= 2
AND p.price BETWEEN 200000 AND 500000
ORDER BY distance_meters ASC
LIMIT 50;
-- Esempio 2: K-Nearest Neighbor (le 10 più vicine)
-- Usa l'operatore <-> per KNN che sfrutta l'indice GIST in modo efficiente
SELECT
p.id,
p.title,
p.price,
p.location <-> ST_MakePoint(12.4731, 41.8991)::geometry AS distance_deg
FROM properties p
WHERE p.status = 'active'
ORDER BY distance_deg ASC
LIMIT 10;
Poligon Sorguları: Mahalleler ve Özel Bölgeler
Tanımlı alana (kullanıcı tarafından haritada çizilen veya mahalle olarak önceden tanımlanmış) göre arama yapmak için
ST_Within o ST_Intersects. Ayrıca ilgi alanlarını da uyguluyoruz
(okullar, parklar, ulaşım) tampon sorgularıyla.
-- Tabella quartieri con poligoni geospaziali
CREATE TABLE neighborhoods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
city VARCHAR(100) NOT NULL,
slug VARCHAR(200) UNIQUE NOT NULL,
boundary GEOMETRY(Polygon, 4326) NOT NULL, -- poligono del quartiere
metadata JSONB, -- {"population": 15000, "avg_price_sqm": 3500}
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_neighborhoods_boundary
ON neighborhoods USING GIST(boundary);
-- Funzione: trova proprietà in un quartiere specifico
CREATE OR REPLACE FUNCTION get_properties_in_neighborhood(
p_neighborhood_slug VARCHAR,
p_listing_type VARCHAR DEFAULT NULL,
p_min_price NUMERIC DEFAULT NULL,
p_max_price NUMERIC DEFAULT NULL
)
RETURNS TABLE(
property_id UUID,
title TEXT,
price NUMERIC,
rooms SMALLINT,
distance_to_center_m FLOAT
) AS $
DECLARE
v_centroid GEOMETRY;
BEGIN
SELECT ST_Centroid(boundary) INTO v_centroid
FROM neighborhoods WHERE slug = p_neighborhood_slug;
RETURN QUERY
SELECT
p.id,
p.title,
p.price,
p.rooms,
ST_Distance(p.location::geography, v_centroid::geography) AS dist
FROM properties p
JOIN neighborhoods n ON ST_Within(p.location, n.boundary)
WHERE
n.slug = p_neighborhood_slug
AND p.status = 'active'
AND (p_listing_type IS NULL OR p.listing_type = p_listing_type)
AND (p_min_price IS NULL OR p.price >= p_min_price)
AND (p_max_price IS NULL OR p.price <= p_max_price)
ORDER BY dist ASC;
END;
$ LANGUAGE plpgsql STABLE;
-- Ricerca per area disegnata dall'utente (polygon from frontend)
SELECT
p.id,
p.title,
p.price,
p.rooms
FROM properties p
WHERE
p.status = 'active'
AND ST_Within(
p.location,
ST_GeomFromGeoJSON(
-- GeoJSON inviato dal frontend (area disegnata su mappa)
'{"type":"Polygon","coordinates":[[[12.46,41.89],[12.49,41.89],[12.49,41.91],[12.46,41.91],[12.46,41.89]]]}'
)
);
-- Proprietà vicine a scuole (join spaziale)
SELECT DISTINCT
p.id,
p.title,
s.name AS nearest_school,
ST_Distance(p.location::geography, s.location::geography) AS school_distance_m
FROM properties p
JOIN schools s ON ST_DWithin(
p.location::geography,
s.location::geography,
500 -- entro 500 metri da una scuola
)
WHERE p.status = 'active'
ORDER BY p.id, school_distance_m;
H3: Altıgen Hiyerarşik Mekansal İndeks
H3 Uber ve altıgen tabanlı bir coğrafi konum indeksleme sistemi tarafından
Piyasa verilerinin verimli bir şekilde toplanması. Fiyat ısı haritaları, reklam yoğunluğu için idealdir
ve bölgelere göre pazar analizi. Uzantı h3-pg H3'ü doğrudan PostgreSQL'e entegre edin.
-- Installazione estensione H3
CREATE EXTENSION IF NOT EXISTS h3;
CREATE EXTENSION IF NOT EXISTS h3_postgis CASCADE;
-- Aggiungi colonna H3 alla tabella properties (risoluzione 9 = ~174m di lato)
ALTER TABLE properties
ADD COLUMN h3_index H3INDEX GENERATED ALWAYS AS (
h3_lat_lng_to_cell(
ST_Y(location), -- latitudine
ST_X(location), -- longitudine
9 -- risoluzione: 0 (globale) - 15 (edificio)
)
) STORED;
CREATE INDEX idx_properties_h3 ON properties(h3_index);
-- Aggregazione prezzi medi per cella H3 (heatmap)
SELECT
h3_index,
COUNT(*) AS property_count,
ROUND(AVG(price)::numeric, 0) AS avg_price,
ROUND(AVG(price_sqm)::numeric, 0) AS avg_price_sqm,
h3_cell_to_boundary_wkt(h3_index) AS cell_boundary_wkt
FROM properties
WHERE
status = 'active'
AND listing_type = 'sale'
AND property_type = 'apartment'
GROUP BY h3_index
HAVING COUNT(*) >= 3 -- minimo 3 proprietà per cella (privacy)
ORDER BY avg_price_sqm DESC;
-- Cerchia intorno a un punto usando celle H3 adiacenti
SELECT DISTINCT p.*
FROM properties p
JOIN (
SELECT unnest(h3_grid_disk(
h3_lat_lng_to_cell(41.8991, 12.4731, 9),
2 -- 2 ring = cerchio di circa 500m
)) AS h3_cell
) cells ON p.h3_index = cells.h3_cell
WHERE p.status = 'active';
Express.js ile RESTful API
Express.js ve TypeScript ile yazılmış bir RESTful API aracılığıyla jeouzaysal yetenekleri açığa çıkarıyoruz, Zod aracılığıyla giriş doğrulama ve pg (node-postgres) aracılığıyla veritabanına bağlantı ile.
import express from 'express';
import { Pool } from 'pg';
import { z } from 'zod';
const router = express.Router();
const db = new Pool({ connectionString: process.env['DATABASE_URL'] });
// Schema validazione con Zod
const ProximitySearchSchema = z.object({
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180),
radiusMeters: z.number().min(100).max(50000).default(2000),
listingType: z.enum(['sale', 'rent']).optional(),
propertyType: z.enum(['apartment', 'house', 'villa', 'commercial']).optional(),
minPrice: z.number().positive().optional(),
maxPrice: z.number().positive().optional(),
minRooms: z.number().int().min(1).optional(),
limit: z.number().int().min(1).max(200).default(50),
});
// GET /api/properties/nearby
router.get('/nearby', async (req, res) => {
const parsed = ProximitySearchSchema.safeParse({
...req.query,
lat: Number(req.query['lat']),
lon: Number(req.query['lon']),
radiusMeters: req.query['radiusMeters'] ? Number(req.query['radiusMeters']) : 2000,
minPrice: req.query['minPrice'] ? Number(req.query['minPrice']) : undefined,
maxPrice: req.query['maxPrice'] ? Number(req.query['maxPrice']) : undefined,
minRooms: req.query['minRooms'] ? Number(req.query['minRooms']) : undefined,
limit: req.query['limit'] ? Number(req.query['limit']) : 50,
});
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid parameters', details: parsed.error.issues });
}
const params = parsed.data;
try {
const result = await db.query(
`SELECT
p.id, p.title, p.price, p.rooms, p.square_meters,
p.property_type, p.listing_type,
ST_AsGeoJSON(p.location)::json AS location,
ST_Distance(p.location::geography, ST_MakePoint($2, $1)::geography) AS distance_m
FROM properties p
WHERE
p.status = 'active'
AND ST_DWithin(p.location::geography, ST_MakePoint($2, $1)::geography, $3)
AND ($4::text IS NULL OR p.listing_type = $4)
AND ($5::text IS NULL OR p.property_type = $5)
AND ($6::numeric IS NULL OR p.price >= $6)
AND ($7::numeric IS NULL OR p.price <= $7)
AND ($8::int IS NULL OR p.rooms >= $8)
ORDER BY distance_m ASC
LIMIT $9`,
[
params.lat, params.lon, params.radiusMeters,
params.listingType ?? null,
params.propertyType ?? null,
params.minPrice ?? null,
params.maxPrice ?? null,
params.minRooms ?? null,
params.limit,
]
);
return res.json({
count: result.rows.length,
center: { lat: params.lat, lon: params.lon },
radiusMeters: params.radiusMeters,
properties: result.rows,
});
} catch (err) {
console.error('Geospatial query error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/properties/neighborhood/:slug
router.get('/neighborhood/:slug', async (req, res) => {
const { slug } = req.params;
const { listingType, minPrice, maxPrice } = req.query;
try {
const result = await db.query(
`SELECT * FROM get_properties_in_neighborhood($1, $2, $3, $4)`,
[slug, listingType ?? null, minPrice ?? null, maxPrice ?? null]
);
return res.json({ count: result.rows.length, properties: result.rows });
} catch (err) {
console.error('Neighborhood query error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
MapLibre GL JS entegrasyonu
MapLibre GL JS ve entegrasyonlar için ideal olan Mapbox GL JS'nin açık kaynak çatalı OpenStreetMap ve özel döşeme sunucusuyla. Jeo-uzaysal API'leri etkileşimli bir haritayla entegre ediyoruz Kullanıcı görünüm penceresini hareket ettirdikçe veya yeniden boyutlandırdıkça sonuçları gerçek zamanlı olarak günceller.
import maplibregl from 'maplibre-gl';
export class PropertyMapManager {
private map: maplibregl.Map;
private searchTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(container: HTMLElement) {
this.map = new maplibregl.Map({
container,
style: 'https://demotiles.maplibre.org/style.json',
center: [12.4731, 41.8991], // Roma
zoom: 13,
});
this.map.on('load', () => this.initializeSources());
// Aggiorna risultati al termine del movimento mappa (debounced)
this.map.on('moveend', () => {
if (this.searchTimeout) clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => this.fetchPropertiesInView(), 300);
});
}
private initializeSources(): void {
// Source GeoJSON per proprietà (aggiornato dinamicamente)
this.map.addSource('properties', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
});
// Layer cluster circles
this.map.addLayer({
id: 'clusters',
type: 'circle',
source: 'properties',
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step', ['get', 'point_count'],
'#51bbd6', 10, '#f1f075', 30, '#f28cb1'
],
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 30, 40],
},
});
// Layer singoli punti
this.map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'properties',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#0066cc',
'circle-radius': 8,
'circle-stroke-width': 2,
'circle-stroke-color': '#fff',
},
});
// Click su punto singolo: mostra popup
this.map.on('click', 'unclustered-point', (e) => {
const feature = e.features?.[0];
if (!feature?.geometry || feature.geometry.type !== 'Point') return;
const props = feature.properties as {
title: string;
price: number;
rooms: number;
id: string;
};
new maplibregl.Popup()
.setLngLat(feature.geometry.coordinates as [number, number])
.setHTML(`
<div class="map-popup">
<h4>${props['title']}</h4>
<p>€${props['price'].toLocaleString('it-IT')} | ${props['rooms']} locali</p>
<a href="/property/${props['id']}">Dettagli →</a>
</div>
`)
.addTo(this.map);
});
}
private async fetchPropertiesInView(): Promise<void> {
const bounds = this.map.getBounds();
const center = this.map.getCenter();
const zoom = this.map.getZoom();
// Calcola raggio approssimativo dalla viewport
const radiusMeters = Math.min(
Math.pow(2, 15 - zoom) * 500,
50000
);
const response = await fetch(
`/api/properties/nearby?lat=${center.lat}&lon=${center.lng}&radiusMeters=${radiusMeters}&limit=200`
);
const data = await response.json();
// Converti in GeoJSON FeatureCollection
const geojson: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: data.properties.map((p: any) => ({
type: 'Feature',
geometry: p.location,
properties: {
id: p.id,
title: p.title,
price: p.price,
rooms: p.rooms,
},
})),
};
(this.map.getSource('properties') as maplibregl.GeoJSONSource)
.setData(geojson);
}
}
Performans Karşılaştırmaları ve Optimizasyonları
| Sorgu Türü | Veri kümeleri | İndekssiz | GIST İndeksi ile | H3 ile |
|---|---|---|---|---|
| Yarıçaptaki nokta (2km) | 1 milyon mülk | 2,3 saniye | 18ms | 12ms |
| K-En Yakın Komşu (10) | 1 milyon mülk | 4.1'ler | 9ms | 7ms |
| Poligon kesişimi | 500.000 mülk | 1.8s | 35ms | 20ms |
| Isı haritası toplama | 1 milyon mülk | 8,5s | 420ms | 45ms |
Gelişmiş Optimizasyonlar
- Gerçekleştirilmiş görünümler: Isı haritaları için H3 toplamalarını önceden hesaplayın ve bunları her 15 dakikada bir güncelleyin
- Kısmi indeksler: GIST dizini yalnızca etkin mülklerde (WHERE durumu='aktif')
- Bağlantı havuzu: Bağlantı gecikmesini azaltmak için PgBouncer'ı işlem modunda kullanın
- Yanıtları okuyun: Yatay olarak ölçeklendirmek için okuma sorgularını PostgreSQL çoğaltmasına yönlendirin
- Sonuç önbelleğe alma: Sık aramalar için Redis önbelleği (aynı sınırlayıcı kutu, aynı filtreler)
Koordinatlar: boylam, enlem (enlem, boylam değil)
PostGIS ve GeoJSON kullanım sırası boylam, enlem (x, y) geriye doğru
daha tanıdık olan enlem/boylam sırasına göre. ST_MakePoint(lon, lat). Bu ortak bir kaynaktır
Hata: Ters koordinatlar Avrupa yerine Pasifik'te noktalar üretiyor. Her zaman belgeleyin
API'deki düzeni koordine edin ve sonuçların sınırlayıcı kutularını kontrol eden akıl sağlığı testleri ekleyin.
Coğrafi Kodlama ve Ters Coğrafi Kodlama
Coğrafi kodlama (metinsel adres -> koordinatlar) ve ters coğrafi kodlama (koordinatlar -> adres) bunlar temel işlemlerdir. Üretim için en güvenilir sağlayıcılar şunlardır:
// Geocoding con Nominatim (OpenStreetMap, gratuito con rate limiting)
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number } | null> {
const encoded = encodeURIComponent(address);
const url = `https://nominatim.openstreetmap.org/search?q=${encoded}&format=json&limit=1`;
const response = await fetch(url, {
headers: { 'User-Agent': 'RealEstatePlatform/1.0 (contact@example.com)' },
});
if (!response.ok) return null;
const data = await response.json();
if (!data.length) return null;
return {
lat: parseFloat(data[0].lat),
lon: parseFloat(data[0].lon),
};
}
// Geocoding con Google Maps Platform (commerciale, alta qualità per indirizzi italiani)
export async function geocodeWithGoogle(address: string): Promise<{ lat: number; lon: number } | null> {
const apiKey = process.env['GOOGLE_MAPS_API_KEY'];
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}®ion=it`;
const response = await fetch(url);
const data = await response.json();
if (data.status !== 'OK' || !data.results.length) return null;
const location = data.results[0].geometry.location;
return { lat: location.lat, lon: location.lng };
}
// Inserimento proprietà con geocoding automatico
async function insertPropertyWithGeocoding(
pool: Pool,
property: Omit<PropertyInsert, 'location'>,
address: string
): Promise<string> {
const coords = await geocodeWithGoogle(address);
if (!coords) throw new Error(`Geocoding failed for: ${address}`);
const result = await pool.query(
`INSERT INTO properties (title, price, square_meters, rooms, bathrooms,
property_type, listing_type, location, address)
VALUES ($1, $2, $3, $4, $5, $6, $7,
ST_MakePoint($9, $8)::geometry,
$10::jsonb)
RETURNING id`,
[
property.title, property.price, property.squareMeters, property.rooms,
property.bathrooms, property.propertyType, property.listingType,
coords.lat, coords.lon,
JSON.stringify({ street: address, city: property.city, zip: property.zip }),
]
);
return result.rows[0].id;
}
Sonuçlar
PostGIS, PostgreSQL'i her türlü ihtiyacı yönetebilecek kurumsal bir GIS platformuna dönüştürüyor gayrimenkul sektörünün jeo-uzamsallığı: anlık yakınlık aramasından ısı haritaları için H3 toplamalara kadar, poligon sorgularından POI ile mekansal birleştirmelere kadar. MapLibre GL JS ve Nominatim/Google Haritalar ile kombinasyon kapsamlı, ölçeklenebilir, açık kaynaklı bir konum istihbarat sistemi oluşturur.







