PostGIS を使用した地理空間検索および位置サービス
場所による検索はあらゆる不動産プラットフォームの中心です: 購入者の 73% 一番の決め手は場所だと指摘しています。次のような地理空間検索を実装します。 同時に 速い, 正確な e フレキシブル が必要です 単純な都市フィルターをはるかに超えています。この記事では、位置情報サービス システムを構築します。 使用して完了 ポストGIS PostgreSQL 上で、高度な空間インデックス作成、近接検索を使用 H3 経由、カスタム近隣のポリゴン クエリ、およびインタラクティブ マップとの統合。
最適化された空間インデックス作成により、PostGIS は優れたパフォーマンスを実現します。 15~25ミリ秒 半径内ポイントクエリの場合、 8~12ミリ秒 最近傍検索の場合 e 30~50ミリ秒 数百万のプロパティのデータセット上の複雑なポリゴン交差の場合。
何を学ぶか
- 不動産データ用の PostgreSQL での PostGIS のセットアップと構成
- 地理空間モデリング: 近隣およびエリアのポイント、ポリゴン、ジオメトリ
- 最適なパフォーマンスを実現する GIST および BRIN による空間インデックス作成
- 近接検索: ST_DWithin、ST_Distance、K 最近傍
- カスタマイズされた「近所」検索のためのポリゴン クエリ
- 集計用の H3 (Uber Hexagonal Hierarchical Spatial Index)
- MapLibre GL JS および OpenStreetMap との統合
- 不動産フロントエンド向け Express.js を使用した RESTful API
PostGIS: PostgreSQL の地理空間拡張
ポストGIS データ型、関数、地理空間演算子を PostgreSQL に追加し、PostgreSQL を変換します。 完全な地理情報システム (GIS) に変換します。不動産プラットフォームの場合は、PostGIS と選択 標準: 平面ジオメトリ (メートル単位で投影された座標) と地理的ジオメトリ (緯度/経度) の両方を管理します。 地球の曲率を含む)、あらゆる不動産のユースケースをカバーする機能を備えています。
-- 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';
近接検索: 半径内のプロパティの検索
最も一般的な使用例は、「この場所から 2 km 以内のアパートを見せて」です。
ST_DWithin GEOGRAPHY 列 (GEOMETRY ではない) では、単位としてメートルが自動的に使用されます
地球の曲率を考慮しており、長距離でも精度を保証します。
-- 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;
ポリゴン クエリ: 近傍とカスタム ゾーン
定義されたエリア (ユーザーが地図上に描画したもの、または近隣地域として事前に定義したもの) による検索には、以下が必要です。
ST_Within o ST_Intersects。関心のあるゾーンも実装します
(学校、公園、交通機関) バッファ クエリを使用します。
-- 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: 六角形の階層的空間インデックス
H3 Uber と六角形ベースの地理空間インデックス システムにより、
市場データの効率的な集約。価格ヒートマップ、広告密度に最適です
およびエリアごとの市場分析。拡張子 h3-pg H3 を PostgreSQL に直接統合します。
-- 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 を使用した RESTful API
Express.js と TypeScript で型指定された RESTful API を介して地理空間機能を公開します。 Zod による入力検証と pg (node-postgres) によるデータベースへの接続を使用します。
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 統合
MapLibre GL JS 統合に最適な Mapbox GL JS のオープンソース フォーク OpenStreetMap とカスタム タイル サーバーを使用します。地理空間 API をインタラクティブな地図と統合します これにより、ユーザーがビューポートを移動またはサイズ変更すると、結果がリアルタイムで更新されます。
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);
}
}
パフォーマンスのベンチマークと最適化
| クエリの種類 | データセット | インデックスなし | GISTインデックスあり | H3あり |
|---|---|---|---|---|
| 半径内の点 (2km) | 100万物件 | 2.3秒 | 18ミリ秒 | 12ミリ秒 |
| K最近隣 (10) | 100万物件 | 4.1秒 | 9ミリ秒 | 7ミリ秒 |
| ポリゴンの交差 | 500K プロパティ | 1.8秒 | 35ミリ秒 | 20ミリ秒 |
| ヒートマップ集約 | 100万物件 | 8.5秒 | 420ミリ秒 | 45ミリ秒 |
高度な最適化
- マテリアライズド ビュー: ヒートマップの H3 集計を事前計算し、15 分ごとに更新します
- 部分インデックス: アクティブなプロパティのみの GIST インデックス (WHERE status='active')
- 接続プーリング: トランザクション モードで PgBouncer を使用して、接続の遅延を短縮します。
- 返信を読む: 読み取りクエリを PostgreSQL レプリケーションにルーティングして水平方向にスケールします
- 結果のキャッシュ: 頻繁な検索のための Redis キャッシュ (同じ境界ボックス、同じフィルター)
座標: 経度、緯度 (緯度、経度ではありません)
PostGIS と GeoJSON の使用順序 経度、緯度 (x, y) を逆方向に
より一般的な緯度/経度の順序です。 ST_MakePoint(lon, lat)。これは一般的なソースです
バグ: 座標を逆にすると、ヨーロッパではなく太平洋に点が生成されます。常に文書化する
API で順序を調整し、結果の境界ボックスをチェックする健全性テストを追加します。
ジオコーディングとリバース ジオコーディング
ジオコーディング (テキスト住所 -> 座標) およびリバース ジオコーディング (座標 -> 住所) それらは基本的な操作です。運用環境の場合、最も信頼できるプロバイダーは次のとおりです。
// 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;
}
結論
PostGIS は、PostgreSQL をあらゆるニーズに対応できるエンタープライズ GIS プラットフォームに変換します。 不動産セクターの地理空間: 即時近接検索からヒートマップの H3 集計まで、 ポリゴン クエリから POI との空間結合まで。 MapLibre GL JS と Nominatim/Google Maps との組み合わせ 包括的でスケーラブルなオープンソースのロケーション インテリジェンス システムを作成します。







