57b752f54e
- Manual properties linked to MLS now inherit status (Active/Pending/Closed) and days_on_market from the MLS listing dynamically - Properties not in MLS default to Closed status - Clone feature now auto-populates listing agent by matching MLS ID to Agent CPT - Description formatter detects embedded headers (unpunctuated text after sentences) and splits them into separate paragraphs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
609 lines
20 KiB
PHP
Executable File
609 lines
20 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* MLS Map Clustering
|
|
*
|
|
* Server-side geohash-based clustering for efficient map rendering.
|
|
* Handles ~30k properties by returning clustered markers based on zoom level.
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
class MLS_Cluster {
|
|
|
|
/**
|
|
* Geohash characters
|
|
*/
|
|
const GEOHASH_CHARS = '0123456789bcdefghjkmnpqrstuvwxyz';
|
|
|
|
/**
|
|
* Target pixel spacing between cluster centers
|
|
* With 30px cluster icons, 45px gap gives 75px total spacing
|
|
*/
|
|
const CLUSTER_PIXEL_SPACING = 60;
|
|
|
|
/**
|
|
* Pixel spacing for density dots (smaller, more numerous)
|
|
*/
|
|
const DENSITY_DOT_SPACING = 40;
|
|
|
|
/**
|
|
* Denser spacing for very zoomed out views (40% more dense = 60% of normal)
|
|
*/
|
|
const DENSITY_DOT_SPACING_DENSE = 24;
|
|
|
|
/**
|
|
* Maximum properties to return as individual markers
|
|
* Above this threshold, return clusters
|
|
*/
|
|
const MAX_INDIVIDUAL_MARKERS = 500;
|
|
|
|
/**
|
|
* Minimum properties before any grouping kicks in
|
|
* Below this, always show individual markers
|
|
*/
|
|
const MIN_FOR_GROUPING = 30;
|
|
|
|
/**
|
|
* Zoom thresholds for visualization modes
|
|
*/
|
|
const ZOOM_DENSE_MAX = 5; // 1-5: density dots (40% more dense)
|
|
const ZOOM_DENSITY_MAX = 8; // 6-8: density dots (normal)
|
|
const ZOOM_CLUSTER_MAX = 15; // 9-15: numbered clusters
|
|
// 16+: individual markers
|
|
|
|
/**
|
|
* Database instance
|
|
*/
|
|
private $db;
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
public function __construct(MLS_DB $db) {
|
|
$this->db = $db;
|
|
}
|
|
|
|
/**
|
|
* Get state filter SQL clause
|
|
*
|
|
* @return string SQL clause or empty string
|
|
*/
|
|
private function get_state_filter() {
|
|
if (!defined('MLS_ALLOWED_STATES') || empty(MLS_ALLOWED_STATES)) {
|
|
return '';
|
|
}
|
|
|
|
global $wpdb;
|
|
$states = array_map(function($state) use ($wpdb) {
|
|
return $wpdb->prepare('%s', $state);
|
|
}, MLS_ALLOWED_STATES);
|
|
return 'state_or_province IN (' . implode(',', $states) . ')';
|
|
}
|
|
|
|
/**
|
|
* Get the TBD address exclusion filter
|
|
* Excludes properties with "TBD" as street number
|
|
*
|
|
* @return string SQL clause
|
|
*/
|
|
private function get_tbd_exclusion_filter() {
|
|
return "(street_number IS NULL OR (street_number != 'TBD' AND street_number NOT LIKE 'TBD %'))";
|
|
}
|
|
|
|
/**
|
|
* Encode latitude/longitude to geohash
|
|
*
|
|
* @param float $lat Latitude
|
|
* @param float $lng Longitude
|
|
* @param int $precision Geohash precision (1-12)
|
|
* @return string Geohash string
|
|
*/
|
|
public function encode($lat, $lng, $precision = 6) {
|
|
$lat_range = array(-90.0, 90.0);
|
|
$lng_range = array(-180.0, 180.0);
|
|
$hash = '';
|
|
$bits = array(16, 8, 4, 2, 1);
|
|
$bit = 0;
|
|
$ch = 0;
|
|
$is_lng = true;
|
|
|
|
while (strlen($hash) < $precision) {
|
|
if ($is_lng) {
|
|
$mid = ($lng_range[0] + $lng_range[1]) / 2;
|
|
if ($lng >= $mid) {
|
|
$ch |= $bits[$bit];
|
|
$lng_range[0] = $mid;
|
|
} else {
|
|
$lng_range[1] = $mid;
|
|
}
|
|
} else {
|
|
$mid = ($lat_range[0] + $lat_range[1]) / 2;
|
|
if ($lat >= $mid) {
|
|
$ch |= $bits[$bit];
|
|
$lat_range[0] = $mid;
|
|
} else {
|
|
$lat_range[1] = $mid;
|
|
}
|
|
}
|
|
|
|
$is_lng = !$is_lng;
|
|
$bit++;
|
|
|
|
if ($bit === 5) {
|
|
$hash .= self::GEOHASH_CHARS[$ch];
|
|
$bit = 0;
|
|
$ch = 0;
|
|
}
|
|
}
|
|
|
|
return $hash;
|
|
}
|
|
|
|
/**
|
|
* Decode geohash to latitude/longitude bounds
|
|
*
|
|
* @param string $hash Geohash string
|
|
* @return array Array with lat_min, lat_max, lng_min, lng_max, lat_center, lng_center
|
|
*/
|
|
public function decode($hash) {
|
|
$lat_range = array(-90.0, 90.0);
|
|
$lng_range = array(-180.0, 180.0);
|
|
$is_lng = true;
|
|
|
|
for ($i = 0; $i < strlen($hash); $i++) {
|
|
$c = $hash[$i];
|
|
$cd = strpos(self::GEOHASH_CHARS, $c);
|
|
|
|
for ($j = 0; $j < 5; $j++) {
|
|
$mask = 1 << (4 - $j);
|
|
if ($is_lng) {
|
|
$mid = ($lng_range[0] + $lng_range[1]) / 2;
|
|
if ($cd & $mask) {
|
|
$lng_range[0] = $mid;
|
|
} else {
|
|
$lng_range[1] = $mid;
|
|
}
|
|
} else {
|
|
$mid = ($lat_range[0] + $lat_range[1]) / 2;
|
|
if ($cd & $mask) {
|
|
$lat_range[0] = $mid;
|
|
} else {
|
|
$lat_range[1] = $mid;
|
|
}
|
|
}
|
|
$is_lng = !$is_lng;
|
|
}
|
|
}
|
|
|
|
return array(
|
|
'lat_min' => $lat_range[0],
|
|
'lat_max' => $lat_range[1],
|
|
'lng_min' => $lng_range[0],
|
|
'lng_max' => $lng_range[1],
|
|
'lat_center' => ($lat_range[0] + $lat_range[1]) / 2,
|
|
'lng_center' => ($lng_range[0] + $lng_range[1]) / 2,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Calculate grid cell size in degrees for a given zoom level
|
|
*
|
|
* Uses Leaflet/OSM tile math to determine what geographic distance
|
|
* corresponds to the target pixel spacing at the given zoom.
|
|
*
|
|
* @param int $zoom Map zoom level (1-20)
|
|
* @param float $lat Center latitude (affects Mercator projection)
|
|
* @param int $pixel_spacing Target pixel spacing (defaults to CLUSTER_PIXEL_SPACING)
|
|
* @return array [lat_step, lng_step] in degrees
|
|
*/
|
|
public function get_grid_step_for_zoom($zoom, $lat = 45.0, $pixel_spacing = null) {
|
|
$zoom = max(3, min(20, (int) $zoom));
|
|
$pixel_spacing = $pixel_spacing ?: self::CLUSTER_PIXEL_SPACING;
|
|
|
|
// Degrees per pixel at zoom level (longitude)
|
|
// 360 degrees / (256 pixels * 2^zoom tiles)
|
|
$degrees_per_pixel_lng = 360.0 / (256 * pow(2, $zoom));
|
|
|
|
// Latitude degrees per pixel (adjusted for Mercator at given latitude)
|
|
// At the equator it's the same, at poles it's compressed
|
|
$lat_rad = deg2rad(abs($lat));
|
|
$degrees_per_pixel_lat = $degrees_per_pixel_lng * cos($lat_rad);
|
|
|
|
// Calculate step size to achieve target pixel spacing
|
|
$lng_step = $pixel_spacing * $degrees_per_pixel_lng;
|
|
$lat_step = $pixel_spacing * $degrees_per_pixel_lat;
|
|
|
|
return array($lat_step, $lng_step);
|
|
}
|
|
|
|
/**
|
|
* Get clustered markers for map viewport
|
|
*
|
|
* @param array $args Query arguments
|
|
* - bounds: array(sw_lat, sw_lng, ne_lat, ne_lng)
|
|
* - zoom: int Map zoom level
|
|
* - status: string Property status filter
|
|
* - property_type: string Property type filter
|
|
* - city: string City filter
|
|
* - min_price, max_price: int Price range
|
|
* - min_beds: int Minimum bedrooms
|
|
* @return array Array with 'clusters' or 'markers' depending on density
|
|
*/
|
|
public function get_clusters($args = array()) {
|
|
global $wpdb;
|
|
|
|
$defaults = array(
|
|
'bounds' => null,
|
|
'zoom' => 10,
|
|
'status' => 'Active',
|
|
'property_type' => null,
|
|
'city' => null,
|
|
'min_price' => null,
|
|
'max_price' => null,
|
|
'min_beds' => null,
|
|
);
|
|
|
|
$args = wp_parse_args($args, $defaults);
|
|
$table = $this->db->properties_table();
|
|
|
|
// Build WHERE clause
|
|
// Exclude properties with invalid coordinates from map display
|
|
// Also exclude properties with no price or price < 100 (invalid data)
|
|
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL', 'coordinates_invalid = 0', 'list_price >= 100');
|
|
$values = array();
|
|
|
|
// Add state filter (MN, IA only)
|
|
$state_filter = $this->get_state_filter();
|
|
if ($state_filter) {
|
|
$where[] = $state_filter;
|
|
}
|
|
|
|
// Exclude TBD addresses
|
|
$where[] = $this->get_tbd_exclusion_filter();
|
|
|
|
if ($args['status']) {
|
|
$where[] = 'standard_status = %s';
|
|
$values[] = $args['status'];
|
|
}
|
|
|
|
if ($args['property_type']) {
|
|
$where[] = 'property_type = %s';
|
|
$values[] = $args['property_type'];
|
|
}
|
|
|
|
if ($args['city']) {
|
|
$where[] = 'city = %s';
|
|
$values[] = $args['city'];
|
|
}
|
|
|
|
if ($args['min_price']) {
|
|
$where[] = 'list_price >= %d';
|
|
$values[] = (int) $args['min_price'];
|
|
}
|
|
|
|
if ($args['max_price']) {
|
|
$where[] = 'list_price <= %d';
|
|
$values[] = (int) $args['max_price'];
|
|
}
|
|
|
|
if ($args['min_beds']) {
|
|
$where[] = 'bedrooms_total >= %d';
|
|
$values[] = (int) $args['min_beds'];
|
|
}
|
|
|
|
// Add bounds filter (BETWEEN is efficient for rectangular viewport queries)
|
|
if ($args['bounds'] && is_array($args['bounds']) && count($args['bounds']) === 4) {
|
|
list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds'];
|
|
$where[] = 'latitude BETWEEN %f AND %f';
|
|
$where[] = 'longitude BETWEEN %f AND %f';
|
|
$values[] = (float) $sw_lat;
|
|
$values[] = (float) $ne_lat;
|
|
$values[] = (float) $sw_lng;
|
|
$values[] = (float) $ne_lng;
|
|
}
|
|
|
|
$where_sql = implode(' AND ', $where);
|
|
|
|
// First, get total count to decide clustering strategy
|
|
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
|
|
if (!empty($values)) {
|
|
$total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $values));
|
|
} else {
|
|
$total = (int) $wpdb->get_var($count_sql);
|
|
}
|
|
|
|
// If few properties, always show individual markers (no grouping)
|
|
if ($total <= self::MIN_FOR_GROUPING) {
|
|
return $this->get_individual_markers($where_sql, $values, $total);
|
|
}
|
|
|
|
// Calculate center latitude for Mercator adjustment
|
|
$center_lat = 45.0; // Default Minnesota
|
|
if ($args['bounds'] && count($args['bounds']) === 4) {
|
|
$center_lat = ($args['bounds'][0] + $args['bounds'][2]) / 2;
|
|
}
|
|
|
|
$zoom = (int) $args['zoom'];
|
|
|
|
// Determine visualization mode based on zoom level
|
|
// Zoom 1-5: Density dots (40% more dense)
|
|
if ($zoom <= self::ZOOM_DENSE_MAX) {
|
|
return $this->get_density_data($where_sql, $values, $zoom, $center_lat, $total, self::DENSITY_DOT_SPACING_DENSE);
|
|
}
|
|
|
|
// Zoom 6-11: Density dots (normal spacing)
|
|
if ($zoom <= self::ZOOM_DENSITY_MAX) {
|
|
return $this->get_density_data($where_sql, $values, $zoom, $center_lat, $total, self::DENSITY_DOT_SPACING);
|
|
}
|
|
|
|
// Zoom 9-15: Always use server-side clusters (let server handle grouping)
|
|
if ($zoom <= self::ZOOM_CLUSTER_MAX) {
|
|
return $this->get_cluster_data($where_sql, $values, $zoom, $center_lat, $total);
|
|
}
|
|
|
|
// Zoom 16+: Individual markers (no clustering needed at this zoom)
|
|
return $this->get_individual_markers($where_sql, $values, $total);
|
|
}
|
|
|
|
/**
|
|
* Get heatmap data (just coordinates for client-side rendering)
|
|
*
|
|
* @param string $where_sql WHERE clause
|
|
* @param array $values Prepared values
|
|
* @param int $total Total count
|
|
* @return array
|
|
*/
|
|
private function get_heatmap_data($where_sql, $values, $total) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
|
|
// Get sampled points for heatmap (limit to prevent overwhelming the client)
|
|
// Use grid-based sampling to get representative distribution
|
|
$sql = "SELECT latitude, longitude
|
|
FROM {$table}
|
|
WHERE {$where_sql}
|
|
LIMIT 10000";
|
|
|
|
if (!empty($values)) {
|
|
$results = $wpdb->get_results($wpdb->prepare($sql, $values));
|
|
} else {
|
|
$results = $wpdb->get_results($sql);
|
|
}
|
|
|
|
$points = array();
|
|
foreach ($results as $row) {
|
|
$points[] = array(
|
|
(float) $row->latitude,
|
|
(float) $row->longitude,
|
|
1.0 // intensity
|
|
);
|
|
}
|
|
|
|
return array(
|
|
'type' => 'heatmap',
|
|
'total' => $total,
|
|
'point_count' => count($points),
|
|
'points' => $points,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get density dot data (clustered points with count for coloring)
|
|
*
|
|
* @param string $where_sql WHERE clause
|
|
* @param array $values Prepared values
|
|
* @param int $zoom Map zoom level
|
|
* @param float $center_lat Center latitude for Mercator adjustment
|
|
* @param int $total Total count
|
|
* @param int $pixel_spacing Pixel spacing for grid cells
|
|
* @return array
|
|
*/
|
|
private function get_density_data($where_sql, $values, $zoom, $center_lat, $total, $pixel_spacing = null) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
$pixel_spacing = $pixel_spacing ?: self::DENSITY_DOT_SPACING;
|
|
|
|
// Use smaller grid cells for density dots
|
|
list($lat_step, $lng_step) = $this->get_grid_step_for_zoom($zoom, $center_lat, $pixel_spacing);
|
|
|
|
$sql = "SELECT
|
|
FLOOR(latitude / %f) as lat_cell,
|
|
FLOOR(longitude / %f) as lng_cell,
|
|
COUNT(*) as count,
|
|
AVG(latitude) as avg_lat,
|
|
AVG(longitude) as avg_lng
|
|
FROM {$table}
|
|
WHERE {$where_sql}
|
|
GROUP BY lat_cell, lng_cell
|
|
HAVING count >= 1";
|
|
|
|
$grid_values = array_merge(array($lat_step, $lng_step), $values);
|
|
$results = $wpdb->get_results($wpdb->prepare($sql, $grid_values));
|
|
|
|
$dots = array();
|
|
foreach ($results as $row) {
|
|
$dots[] = array(
|
|
'lat' => (float) $row->avg_lat,
|
|
'lng' => (float) $row->avg_lng,
|
|
'count' => (int) $row->count,
|
|
);
|
|
}
|
|
|
|
return array(
|
|
'type' => 'density',
|
|
'total' => $total,
|
|
'dot_count' => count($dots),
|
|
'zoom' => $zoom,
|
|
'dots' => $dots,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get individual property markers
|
|
*
|
|
* @param string $where_sql WHERE clause
|
|
* @param array $values Prepared values
|
|
* @param int $total Total count
|
|
* @return array
|
|
*/
|
|
private function get_individual_markers($where_sql, $values, $total) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
|
|
$sql = "SELECT listing_key, latitude, longitude, list_price, street_number,
|
|
street_name, street_suffix, city, bedrooms_total,
|
|
bathrooms_total, living_area, standard_status
|
|
FROM {$table}
|
|
WHERE {$where_sql}
|
|
ORDER BY modification_timestamp DESC
|
|
LIMIT 5000";
|
|
|
|
if (!empty($values)) {
|
|
$results = $wpdb->get_results($wpdb->prepare($sql, $values));
|
|
} else {
|
|
$results = $wpdb->get_results($sql);
|
|
}
|
|
|
|
$markers = array();
|
|
foreach ($results as $property) {
|
|
// Format address
|
|
$address_parts = array();
|
|
if ($property->street_number) {
|
|
$address_parts[] = $property->street_number;
|
|
}
|
|
if ($property->street_name) {
|
|
$address_parts[] = $property->street_name;
|
|
}
|
|
if ($property->street_suffix) {
|
|
$address_parts[] = $property->street_suffix;
|
|
}
|
|
$street = implode(' ', $address_parts);
|
|
$full_address = $street ? $street . ', ' . $property->city : $property->city;
|
|
|
|
// Get image URL with signature
|
|
$image_url = function_exists('mls_get_image_url')
|
|
? mls_get_image_url($property->listing_key, 1, 'thumb')
|
|
: '';
|
|
|
|
$markers[] = array(
|
|
'id' => $property->listing_key,
|
|
'lat' => (float) $property->latitude,
|
|
'lng' => (float) $property->longitude,
|
|
'price' => '$' . number_format($property->list_price),
|
|
'address' => $full_address,
|
|
'url' => home_url('/properties/?listing=' . urlencode($property->listing_key)),
|
|
'beds' => $property->bedrooms_total,
|
|
'baths' => $property->bathrooms_total,
|
|
'sqft' => $property->living_area,
|
|
'status' => $property->standard_status,
|
|
'image' => $image_url,
|
|
);
|
|
}
|
|
|
|
return array(
|
|
'type' => 'markers',
|
|
'total' => $total,
|
|
'count' => count($markers),
|
|
'markers' => $markers,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get clustered data using grid-based grouping
|
|
*
|
|
* @param string $where_sql WHERE clause
|
|
* @param array $values Prepared values
|
|
* @param int $zoom Map zoom level
|
|
* @param float $center_lat Center latitude for Mercator adjustment
|
|
* @param int $total Total count
|
|
* @return array
|
|
*/
|
|
private function get_cluster_data($where_sql, $values, $zoom, $center_lat, $total) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
|
|
// Calculate grid cell size based on zoom level and target pixel spacing
|
|
list($lat_step, $lng_step) = $this->get_grid_step_for_zoom($zoom, $center_lat);
|
|
|
|
// Use FLOOR to group coordinates into cells
|
|
$sql = "SELECT
|
|
FLOOR(latitude / %f) as lat_cell,
|
|
FLOOR(longitude / %f) as lng_cell,
|
|
COUNT(*) as count,
|
|
AVG(latitude) as avg_lat,
|
|
AVG(longitude) as avg_lng,
|
|
MIN(list_price) as min_price,
|
|
MAX(list_price) as max_price
|
|
FROM {$table}
|
|
WHERE {$where_sql}
|
|
GROUP BY lat_cell, lng_cell
|
|
HAVING count >= 1";
|
|
|
|
$grid_values = array_merge(array($lat_step, $lng_step), $values);
|
|
$results = $wpdb->get_results($wpdb->prepare($sql, $grid_values));
|
|
|
|
$clusters = array();
|
|
foreach ($results as $row) {
|
|
$clusters[] = array(
|
|
'lat' => (float) $row->avg_lat,
|
|
'lng' => (float) $row->avg_lng,
|
|
'count' => (int) $row->count,
|
|
'min_price' => (int) $row->min_price,
|
|
'max_price' => (int) $row->max_price,
|
|
);
|
|
}
|
|
|
|
return array(
|
|
'type' => 'clusters',
|
|
'total' => $total,
|
|
'cluster_count' => count($clusters),
|
|
'zoom' => $zoom,
|
|
'grid_size_deg' => array('lat' => $lat_step, 'lng' => $lng_step),
|
|
'clusters' => $clusters,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get total count with coordinates
|
|
*
|
|
* @param array $args Filter arguments
|
|
* @return int
|
|
*/
|
|
public function get_total_with_coords($args = array()) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
// Exclude properties with invalid coordinates or invalid price
|
|
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL', 'coordinates_invalid = 0', 'list_price >= 100');
|
|
$values = array();
|
|
|
|
// Add state filter (MN, IA only)
|
|
$state_filter = $this->get_state_filter();
|
|
if ($state_filter) {
|
|
$where[] = $state_filter;
|
|
}
|
|
|
|
// Exclude TBD addresses
|
|
$where[] = $this->get_tbd_exclusion_filter();
|
|
|
|
if (!empty($args['status'])) {
|
|
$where[] = 'standard_status = %s';
|
|
$values[] = $args['status'];
|
|
}
|
|
|
|
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
|
|
|
|
if (!empty($values)) {
|
|
return (int) $wpdb->get_var($wpdb->prepare($sql, $values));
|
|
}
|
|
|
|
return (int) $wpdb->get_var($sql);
|
|
}
|
|
}
|