Add server-side clustering for map with 30k+ properties
- Remove 1000 property limit from count display - Add MLS_Cluster class for geohash-based server-side clustering - Add AJAX endpoint for dynamic cluster loading based on viewport/zoom - Update property-results.php and ajax-handlers.php to use efficient counting - Update map JavaScript to fetch clusters dynamically as user pans/zooms - Server returns clusters at low zoom, individual markers at high zoom - Fixes property count showing 1000 instead of actual ~30k properties 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,422 @@
|
|||||||
|
<?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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom level to geohash precision mapping
|
||||||
|
* Lower precision = larger geographic area = more clustering
|
||||||
|
*/
|
||||||
|
const ZOOM_PRECISION = array(
|
||||||
|
3 => 2, // ~630km cells - continent level
|
||||||
|
4 => 2,
|
||||||
|
5 => 2,
|
||||||
|
6 => 3, // ~78km cells - state level
|
||||||
|
7 => 3,
|
||||||
|
8 => 3,
|
||||||
|
9 => 4, // ~20km cells - county level
|
||||||
|
10 => 4,
|
||||||
|
11 => 4,
|
||||||
|
12 => 5, // ~2.4km cells - city level
|
||||||
|
13 => 5,
|
||||||
|
14 => 5,
|
||||||
|
15 => 6, // ~610m cells - neighborhood level
|
||||||
|
16 => 6,
|
||||||
|
17 => 6,
|
||||||
|
18 => 7, // ~76m cells - street level
|
||||||
|
19 => 7,
|
||||||
|
20 => 8, // Individual points
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum properties to return as individual markers
|
||||||
|
* Above this threshold, return clusters
|
||||||
|
*/
|
||||||
|
const MAX_INDIVIDUAL_MARKERS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database instance
|
||||||
|
*/
|
||||||
|
private $db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
public function __construct(MLS_DB $db) {
|
||||||
|
$this->db = $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get precision for zoom level
|
||||||
|
*
|
||||||
|
* @param int $zoom Map zoom level (1-20)
|
||||||
|
* @return int Geohash precision
|
||||||
|
*/
|
||||||
|
public function get_precision_for_zoom($zoom) {
|
||||||
|
$zoom = max(3, min(20, (int) $zoom));
|
||||||
|
|
||||||
|
if (isset(self::ZOOM_PRECISION[$zoom])) {
|
||||||
|
return self::ZOOM_PRECISION[$zoom];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to precision 5 for unknown zoom levels
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL');
|
||||||
|
$values = array();
|
||||||
|
|
||||||
|
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 if provided
|
||||||
|
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 low count or high zoom, return individual markers
|
||||||
|
$precision = $this->get_precision_for_zoom($args['zoom']);
|
||||||
|
|
||||||
|
if ($total <= self::MAX_INDIVIDUAL_MARKERS || $args['zoom'] >= 18) {
|
||||||
|
return $this->get_individual_markers($where_sql, $values, $total);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return clusters
|
||||||
|
return $this->get_cluster_data($where_sql, $values, $precision, $total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
$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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'type' => 'markers',
|
||||||
|
'total' => $total,
|
||||||
|
'count' => count($markers),
|
||||||
|
'markers' => $markers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get clustered data using geohash grouping
|
||||||
|
*
|
||||||
|
* @param string $where_sql WHERE clause
|
||||||
|
* @param array $values Prepared values
|
||||||
|
* @param int $precision Geohash precision
|
||||||
|
* @param int $total Total count
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private function get_cluster_data($where_sql, $values, $precision, $total) {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$table = $this->db->properties_table();
|
||||||
|
|
||||||
|
// Use MySQL SUBSTR to extract geohash prefix for grouping
|
||||||
|
// We compute geohash on-the-fly using latitude/longitude
|
||||||
|
// For better performance with 30k+ records, we use grid-based clustering
|
||||||
|
|
||||||
|
// Calculate grid cell size based on precision
|
||||||
|
// Approximate degrees per geohash precision level
|
||||||
|
$lat_step = 180 / pow(2, (int)($precision * 2.5));
|
||||||
|
$lng_step = 360 / pow(2, (int)($precision * 2.5));
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
'precision' => $precision,
|
||||||
|
'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();
|
||||||
|
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL');
|
||||||
|
$values = array();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -353,6 +353,31 @@ class MLS_Query {
|
|||||||
$values[] = $args['city'];
|
$values[] = $args['city'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($args['county'])) {
|
||||||
|
$where[] = 'county = %s';
|
||||||
|
$values[] = $args['county'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($args['min_price'])) {
|
||||||
|
$where[] = 'list_price >= %d';
|
||||||
|
$values[] = (int) $args['min_price'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($args['max_price'])) {
|
||||||
|
$where[] = 'list_price <= %d';
|
||||||
|
$values[] = (int) $args['max_price'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($args['min_beds'])) {
|
||||||
|
$where[] = 'bedrooms_total >= %d';
|
||||||
|
$values[] = (int) $args['min_beds'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($args['min_baths'])) {
|
||||||
|
$where[] = 'bathrooms_total >= %d';
|
||||||
|
$values[] = (int) $args['min_baths'];
|
||||||
|
}
|
||||||
|
|
||||||
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
|
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
|
||||||
|
|
||||||
if (!empty($values)) {
|
if (!empty($values)) {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ final class MLS_Plugin {
|
|||||||
private $media_handler;
|
private $media_handler;
|
||||||
private $image_endpoint;
|
private $image_endpoint;
|
||||||
private $query;
|
private $query;
|
||||||
|
private $cluster;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get single instance
|
* Get single instance
|
||||||
@@ -87,6 +88,7 @@ final class MLS_Plugin {
|
|||||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-media-handler.php';
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-media-handler.php';
|
||||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-image-endpoint.php';
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-image-endpoint.php';
|
||||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-query.php';
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-query.php';
|
||||||
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-cluster.php';
|
||||||
|
|
||||||
// Activation/Deactivation
|
// Activation/Deactivation
|
||||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-activator.php';
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-activator.php';
|
||||||
@@ -137,6 +139,11 @@ final class MLS_Plugin {
|
|||||||
$this->logger
|
$this->logger
|
||||||
);
|
);
|
||||||
$this->query = new MLS_Query($this->db);
|
$this->query = new MLS_Query($this->db);
|
||||||
|
$this->cluster = new MLS_Cluster($this->db);
|
||||||
|
|
||||||
|
// Register AJAX handlers
|
||||||
|
add_action('wp_ajax_mls_get_clusters', array($this, 'ajax_get_clusters'));
|
||||||
|
add_action('wp_ajax_nopriv_mls_get_clusters', array($this, 'ajax_get_clusters'));
|
||||||
|
|
||||||
// Initialize admin
|
// Initialize admin
|
||||||
if (is_admin()) {
|
if (is_admin()) {
|
||||||
@@ -223,6 +230,43 @@ final class MLS_Plugin {
|
|||||||
public function get_query() {
|
public function get_query() {
|
||||||
return $this->query;
|
return $this->query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Cluster instance
|
||||||
|
*/
|
||||||
|
public function get_cluster() {
|
||||||
|
return $this->cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler for getting map clusters
|
||||||
|
*/
|
||||||
|
public function ajax_get_clusters() {
|
||||||
|
// Parse input
|
||||||
|
$zoom = isset($_REQUEST['zoom']) ? (int) $_REQUEST['zoom'] : 10;
|
||||||
|
$bounds = isset($_REQUEST['bounds']) ? array_map('floatval', (array) $_REQUEST['bounds']) : null;
|
||||||
|
$status = isset($_REQUEST['status']) ? sanitize_text_field($_REQUEST['status']) : 'Active';
|
||||||
|
$property_type = isset($_REQUEST['property_type']) ? sanitize_text_field($_REQUEST['property_type']) : null;
|
||||||
|
$city = isset($_REQUEST['city']) ? sanitize_text_field($_REQUEST['city']) : null;
|
||||||
|
$min_price = isset($_REQUEST['min_price']) ? (int) $_REQUEST['min_price'] : null;
|
||||||
|
$max_price = isset($_REQUEST['max_price']) ? (int) $_REQUEST['max_price'] : null;
|
||||||
|
$min_beds = isset($_REQUEST['min_beds']) ? (int) $_REQUEST['min_beds'] : null;
|
||||||
|
|
||||||
|
$args = array(
|
||||||
|
'zoom' => $zoom,
|
||||||
|
'bounds' => $bounds,
|
||||||
|
'status' => $status,
|
||||||
|
'property_type' => $property_type ?: null,
|
||||||
|
'city' => $city ?: null,
|
||||||
|
'min_price' => $min_price ?: null,
|
||||||
|
'max_price' => $max_price ?: null,
|
||||||
|
'min_beds' => $min_beds ?: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->cluster->get_clusters($args);
|
||||||
|
|
||||||
|
wp_send_json_success($result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -106,52 +106,20 @@ $view_class = $show_map ? 'is-map-view' : 'is-grid-view';
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
// Load MLS properties for map markers
|
// Get initial filter values from URL for map clustering
|
||||||
$markers = array();
|
$initial_filters = array(
|
||||||
|
'status' => isset($_GET['property_status']) ? sanitize_text_field($_GET['property_status']) : 'Active',
|
||||||
if (function_exists('mls_get_properties')) {
|
'property_type' => isset($_GET['property_type']) ? sanitize_text_field($_GET['property_type']) : '',
|
||||||
$mls_properties = mls_get_properties(array(
|
'city' => isset($_GET['property_location']) ? sanitize_text_field($_GET['property_location']) : '',
|
||||||
'status' => 'Active',
|
'min_price' => isset($_GET['min_price']) ? intval($_GET['min_price']) : '',
|
||||||
'limit' => 1000, // Reasonable limit for map performance
|
'max_price' => isset($_GET['max_price']) ? intval($_GET['max_price']) : '',
|
||||||
'orderby' => 'modification_timestamp',
|
'min_beds' => isset($_GET['beds']) ? intval($_GET['beds']) : '',
|
||||||
'order' => 'DESC',
|
|
||||||
));
|
|
||||||
|
|
||||||
foreach ($mls_properties as $property) {
|
|
||||||
// Skip properties without coordinates
|
|
||||||
if (empty($property->latitude) || empty($property->longitude)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
$markers[] = array(
|
|
||||||
'id' => $property->listing_key,
|
|
||||||
'lat' => (float) $property->latitude,
|
|
||||||
'lng' => (float) $property->longitude,
|
|
||||||
'title' => $full_address,
|
|
||||||
'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,
|
|
||||||
'photo' => null, // Placeholder - photos will be added later
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
// Get total property count with coordinates for display
|
||||||
|
$total_with_coords = 0;
|
||||||
|
if (function_exists('mls_get_property_count')) {
|
||||||
|
$total_with_coords = mls_get_property_count(array('status' => $initial_filters['status'] ?: 'Active'));
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<!-- Leaflet CSS -->
|
<!-- Leaflet CSS -->
|
||||||
@@ -165,8 +133,10 @@ if (function_exists('mls_get_properties')) {
|
|||||||
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js" crossorigin=""></script>
|
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js" crossorigin=""></script>
|
||||||
<script>
|
<script>
|
||||||
var homeprozMapData = {
|
var homeprozMapData = {
|
||||||
properties: <?php echo json_encode($markers); ?>,
|
isMapView: <?php echo $show_map ? 'true' : 'false'; ?>,
|
||||||
isMapView: <?php echo $show_map ? 'true' : 'false'; ?>
|
clusterEndpoint: '<?php echo admin_url('admin-ajax.php'); ?>',
|
||||||
|
initialFilters: <?php echo json_encode($initial_filters); ?>,
|
||||||
|
totalProperties: <?php echo (int) $total_with_coords; ?>
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -34,40 +34,42 @@ function homeproz_ajax_filter_properties() {
|
|||||||
$sort = isset($_POST['sort']) ? sanitize_text_field($_POST['sort']) : 'newest';
|
$sort = isset($_POST['sort']) ? sanitize_text_field($_POST['sort']) : 'newest';
|
||||||
$paged = isset($_POST['paged']) ? intval($_POST['paged']) : 1;
|
$paged = isset($_POST['paged']) ? intval($_POST['paged']) : 1;
|
||||||
|
|
||||||
// Build MLS query args
|
// Build filter args for count and properties
|
||||||
$per_page = 12;
|
$per_page = 12;
|
||||||
$mls_args = array(
|
$filter_args = array(
|
||||||
'status' => $property_status ?: 'Active',
|
'status' => $property_status ?: 'Active',
|
||||||
'limit' => 1000, // Get all for counting, then paginate
|
|
||||||
'orderby' => 'modification_timestamp',
|
|
||||||
'order' => 'DESC',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Map filter values to MLS query args
|
|
||||||
if ($property_type) {
|
if ($property_type) {
|
||||||
$mls_args['property_type'] = $property_type;
|
$filter_args['property_type'] = $property_type;
|
||||||
}
|
}
|
||||||
if ($property_location) {
|
if ($property_location) {
|
||||||
$mls_args['city'] = $property_location;
|
$filter_args['city'] = $property_location;
|
||||||
}
|
}
|
||||||
if ($min_price) {
|
if ($min_price) {
|
||||||
$mls_args['min_price'] = $min_price;
|
$filter_args['min_price'] = $min_price;
|
||||||
}
|
}
|
||||||
if ($max_price) {
|
if ($max_price) {
|
||||||
$mls_args['max_price'] = $max_price;
|
$filter_args['max_price'] = $max_price;
|
||||||
}
|
}
|
||||||
if ($beds) {
|
if ($beds) {
|
||||||
$mls_args['min_beds'] = $beds;
|
$filter_args['min_beds'] = $beds;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all matching properties
|
// Get total count efficiently from database
|
||||||
$all_properties = mls_get_properties($mls_args);
|
$total = mls_get_property_count($filter_args);
|
||||||
|
|
||||||
// Handle pagination manually
|
|
||||||
$total = count($all_properties);
|
|
||||||
$max_pages = ceil($total / $per_page);
|
$max_pages = ceil($total / $per_page);
|
||||||
$offset = ($paged - 1) * $per_page;
|
|
||||||
$paged_properties = array_slice($all_properties, $offset, $per_page);
|
// Build query args for paginated results
|
||||||
|
$mls_args = array_merge($filter_args, array(
|
||||||
|
'limit' => $per_page,
|
||||||
|
'offset' => ($paged - 1) * $per_page,
|
||||||
|
'orderby' => 'modification_timestamp',
|
||||||
|
'order' => 'DESC',
|
||||||
|
));
|
||||||
|
|
||||||
|
// Fetch only the properties we need for this page
|
||||||
|
$paged_properties = mls_get_properties($mls_args);
|
||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
|
|
||||||
@@ -140,50 +142,13 @@ function homeproz_ajax_filter_properties() {
|
|||||||
<?php
|
<?php
|
||||||
$html = ob_get_clean();
|
$html = ob_get_clean();
|
||||||
|
|
||||||
// Build markers data for map view from MLS properties
|
// Return filter params for map clustering endpoint
|
||||||
$markers = array();
|
// The frontend will call the clustering endpoint separately with these
|
||||||
|
|
||||||
foreach ($all_properties as $property) {
|
|
||||||
// Skip properties without coordinates
|
|
||||||
if (empty($property->latitude) || empty($property->longitude)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
$markers[] = array(
|
|
||||||
'id' => $property->listing_key,
|
|
||||||
'lat' => (float) $property->latitude,
|
|
||||||
'lng' => (float) $property->longitude,
|
|
||||||
'title' => $full_address,
|
|
||||||
'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,
|
|
||||||
'photo' => null, // Placeholder - photos will be added later
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
wp_send_json_success(array(
|
wp_send_json_success(array(
|
||||||
'html' => $html,
|
'html' => $html,
|
||||||
'found_posts' => $total,
|
'found_posts' => $total,
|
||||||
'max_pages' => $max_pages,
|
'max_pages' => $max_pages,
|
||||||
'markers' => $markers,
|
'filters' => $filter_args,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
add_action('wp_ajax_homeproz_filter_properties', 'homeproz_ajax_filter_properties');
|
add_action('wp_ajax_homeproz_filter_properties', 'homeproz_ajax_filter_properties');
|
||||||
|
|||||||
@@ -11,24 +11,32 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Property Map Manager
|
* Property Map Manager
|
||||||
|
* Uses server-side clustering for efficient rendering of 30k+ properties
|
||||||
*/
|
*/
|
||||||
var PropertyMap = {
|
var PropertyMap = {
|
||||||
map: null,
|
map: null,
|
||||||
markers: {}, // Object keyed by property ID
|
markers: {}, // Object keyed by property ID
|
||||||
markerCluster: null, // MarkerClusterGroup
|
clusterLayer: null, // Layer group for server clusters
|
||||||
|
markerCluster: null, // MarkerClusterGroup for individual markers
|
||||||
selectedPropertyId: null,
|
selectedPropertyId: null,
|
||||||
hoveredPropertyId: null,
|
hoveredPropertyId: null,
|
||||||
baseZIndex: 400,
|
baseZIndex: 400,
|
||||||
|
currentFilters: {},
|
||||||
|
isLoading: false,
|
||||||
|
loadTimeout: null,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the map
|
* Initialize the map
|
||||||
*/
|
*/
|
||||||
init: function(initialProperties) {
|
init: function(filters) {
|
||||||
var $mapContainer = $('#property-map');
|
var $mapContainer = $('#property-map');
|
||||||
if (!$mapContainer.length || typeof L === 'undefined') {
|
if (!$mapContainer.length || typeof L === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store initial filters
|
||||||
|
this.currentFilters = filters || {};
|
||||||
|
|
||||||
// Initialize map centered on Minnesota
|
// Initialize map centered on Minnesota
|
||||||
this.map = L.map('property-map').setView([45.0, -93.5], 7);
|
this.map = L.map('property-map').setView([45.0, -93.5], 7);
|
||||||
|
|
||||||
@@ -37,13 +45,16 @@
|
|||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
}).addTo(this.map);
|
}).addTo(this.map);
|
||||||
|
|
||||||
// Create marker cluster group
|
// Create layer for server-side clusters
|
||||||
|
this.clusterLayer = L.layerGroup().addTo(this.map);
|
||||||
|
|
||||||
|
// Create marker cluster group for individual markers (when zoomed in)
|
||||||
this.markerCluster = L.markerClusterGroup({
|
this.markerCluster = L.markerClusterGroup({
|
||||||
maxClusterRadius: 50,
|
maxClusterRadius: 50,
|
||||||
spiderfyOnMaxZoom: true,
|
spiderfyOnMaxZoom: true,
|
||||||
showCoverageOnHover: false,
|
showCoverageOnHover: false,
|
||||||
zoomToBoundsOnClick: true,
|
zoomToBoundsOnClick: true,
|
||||||
disableClusteringAtZoom: 15,
|
disableClusteringAtZoom: 18,
|
||||||
chunkedLoading: true,
|
chunkedLoading: true,
|
||||||
chunkInterval: 200,
|
chunkInterval: 200,
|
||||||
chunkDelay: 50,
|
chunkDelay: 50,
|
||||||
@@ -64,39 +75,130 @@
|
|||||||
});
|
});
|
||||||
this.map.addLayer(this.markerCluster);
|
this.map.addLayer(this.markerCluster);
|
||||||
|
|
||||||
// Add initial markers
|
// Bind map events for dynamic loading
|
||||||
if (initialProperties && initialProperties.length > 0) {
|
var self = this;
|
||||||
this.updateMarkers(initialProperties);
|
this.map.on('moveend zoomend', function() {
|
||||||
}
|
self.loadClusters();
|
||||||
|
});
|
||||||
|
|
||||||
// Bind card hover events
|
// Bind card hover events
|
||||||
this.bindCardHoverEvents();
|
this.bindCardHoverEvents();
|
||||||
|
|
||||||
|
// Load initial clusters
|
||||||
|
this.loadClusters();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create marker icon with specified color
|
* Load clusters/markers from server based on viewport
|
||||||
* Pin size: 16.5x22 (aspect ratio 0.75)
|
|
||||||
*/
|
*/
|
||||||
createIcon: function(color) {
|
loadClusters: function() {
|
||||||
color = color || 'red';
|
if (!this.map) return;
|
||||||
return L.divIcon({
|
|
||||||
className: 'property-marker property-marker-' + color,
|
var self = this;
|
||||||
html: '<div class="marker-pin"></div>',
|
|
||||||
iconSize: [17, 22],
|
// Debounce rapid requests
|
||||||
iconAnchor: [8, 22],
|
clearTimeout(this.loadTimeout);
|
||||||
popupAnchor: [0, -22]
|
this.loadTimeout = setTimeout(function() {
|
||||||
|
self._doLoadClusters();
|
||||||
|
}, 150);
|
||||||
|
},
|
||||||
|
|
||||||
|
_doLoadClusters: function() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
var bounds = this.map.getBounds();
|
||||||
|
var zoom = this.map.getZoom();
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
var requestData = {
|
||||||
|
action: 'mls_get_clusters',
|
||||||
|
zoom: zoom,
|
||||||
|
bounds: [
|
||||||
|
bounds.getSouthWest().lat,
|
||||||
|
bounds.getSouthWest().lng,
|
||||||
|
bounds.getNorthEast().lat,
|
||||||
|
bounds.getNorthEast().lng
|
||||||
|
],
|
||||||
|
status: this.currentFilters.status || 'Active',
|
||||||
|
property_type: this.currentFilters.property_type || '',
|
||||||
|
city: this.currentFilters.city || '',
|
||||||
|
min_price: this.currentFilters.min_price || '',
|
||||||
|
max_price: this.currentFilters.max_price || '',
|
||||||
|
min_beds: this.currentFilters.min_beds || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: homeprozMapData.clusterEndpoint,
|
||||||
|
type: 'GET',
|
||||||
|
data: requestData,
|
||||||
|
success: function(response) {
|
||||||
|
if (response.success) {
|
||||||
|
if (response.data.type === 'clusters') {
|
||||||
|
self.renderClusters(response.data.clusters);
|
||||||
|
} else {
|
||||||
|
self.renderMarkers(response.data.markers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
self.isLoading = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update map markers with new property data
|
* Render server-side clusters
|
||||||
*/
|
*/
|
||||||
updateMarkers: function(properties) {
|
renderClusters: function(clusters) {
|
||||||
if (!this.map || !this.markerCluster) {
|
// Clear both layers
|
||||||
return;
|
this.clusterLayer.clearLayers();
|
||||||
|
this.markerCluster.clearLayers();
|
||||||
|
this.markers = {};
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
clusters.forEach(function(cluster) {
|
||||||
|
var size = 'small';
|
||||||
|
if (cluster.count >= 100) {
|
||||||
|
size = 'large';
|
||||||
|
} else if (cluster.count >= 10) {
|
||||||
|
size = 'medium';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing markers and reset state
|
var icon = L.divIcon({
|
||||||
|
html: '<div><span>' + cluster.count + '</span></div>',
|
||||||
|
className: 'marker-cluster marker-cluster-' + size + ' server-cluster',
|
||||||
|
iconSize: L.point(40, 40)
|
||||||
|
});
|
||||||
|
|
||||||
|
var marker = L.marker([cluster.lat, cluster.lng], { icon: icon });
|
||||||
|
|
||||||
|
// Click to zoom in
|
||||||
|
marker.on('click', function() {
|
||||||
|
self.map.setView([cluster.lat, cluster.lng], self.map.getZoom() + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tooltip with price range
|
||||||
|
var priceRange = '$' + self.formatNumber(cluster.min_price);
|
||||||
|
if (cluster.max_price !== cluster.min_price) {
|
||||||
|
priceRange += ' - $' + self.formatNumber(cluster.max_price);
|
||||||
|
}
|
||||||
|
marker.bindTooltip(cluster.count + ' properties<br>' + priceRange, {
|
||||||
|
className: 'cluster-tooltip'
|
||||||
|
});
|
||||||
|
|
||||||
|
self.clusterLayer.addLayer(marker);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render individual markers (when zoomed in or low count)
|
||||||
|
*/
|
||||||
|
renderMarkers: function(properties) {
|
||||||
|
// Clear both layers
|
||||||
|
this.clusterLayer.clearLayers();
|
||||||
this.markerCluster.clearLayers();
|
this.markerCluster.clearLayers();
|
||||||
this.markers = {};
|
this.markers = {};
|
||||||
this.selectedPropertyId = null;
|
this.selectedPropertyId = null;
|
||||||
@@ -140,9 +242,36 @@
|
|||||||
|
|
||||||
// Bulk add markers for performance
|
// Bulk add markers for performance
|
||||||
this.markerCluster.addLayers(markersToAdd);
|
this.markerCluster.addLayers(markersToAdd);
|
||||||
|
},
|
||||||
|
|
||||||
// Fit bounds to show all markers
|
/**
|
||||||
this.fitBounds(properties);
|
* Update filters and reload
|
||||||
|
*/
|
||||||
|
updateFilters: function(filters) {
|
||||||
|
this.currentFilters = filters || {};
|
||||||
|
this.loadClusters();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format number with commas
|
||||||
|
*/
|
||||||
|
formatNumber: function(num) {
|
||||||
|
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create marker icon with specified color
|
||||||
|
* Pin size: 16.5x22 (aspect ratio 0.75)
|
||||||
|
*/
|
||||||
|
createIcon: function(color) {
|
||||||
|
color = color || 'red';
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'property-marker property-marker-' + color,
|
||||||
|
html: '<div class="marker-pin"></div>',
|
||||||
|
iconSize: [17, 22],
|
||||||
|
iconAnchor: [8, 22],
|
||||||
|
popupAnchor: [0, -22]
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -280,23 +409,6 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Fit map bounds to show all properties
|
|
||||||
*/
|
|
||||||
fitBounds: function(properties) {
|
|
||||||
if (!this.map || !properties || properties.length === 0) {
|
|
||||||
// Reset to default view if no properties
|
|
||||||
if (this.map) {
|
|
||||||
this.map.setView([43.6480, -93.3685], 10);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var bounds = L.latLngBounds(properties.map(function(p) {
|
|
||||||
return [p.lat, p.lng];
|
|
||||||
}));
|
|
||||||
this.map.fitBounds(bounds, { padding: [50, 50] });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var PropertyFilters = {
|
var PropertyFilters = {
|
||||||
@@ -430,9 +542,9 @@
|
|||||||
self.$results.html(response.data.html);
|
self.$results.html(response.data.html);
|
||||||
self.isFirstLoad = false;
|
self.isFirstLoad = false;
|
||||||
|
|
||||||
// Update map markers if map is active
|
// Update map with new filter params
|
||||||
if (response.data.markers) {
|
if (response.data.filters) {
|
||||||
PropertyMap.updateMarkers(response.data.markers);
|
PropertyMap.updateFilters(response.data.filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate layout after content update
|
// Recalculate layout after content update
|
||||||
@@ -553,8 +665,8 @@
|
|||||||
this.isAboveBreakpoint = window.innerWidth >= this.breakpoint;
|
this.isAboveBreakpoint = window.innerWidth >= this.breakpoint;
|
||||||
|
|
||||||
// Initialize map if above breakpoint, map view selected, and we have data
|
// Initialize map if above breakpoint, map view selected, and we have data
|
||||||
if (this.isAboveBreakpoint && this.isMapView && typeof homeprozMapData !== 'undefined' && homeprozMapData.properties) {
|
if (this.isAboveBreakpoint && this.isMapView && typeof homeprozMapData !== 'undefined') {
|
||||||
PropertyMap.init(homeprozMapData.properties);
|
PropertyMap.init(homeprozMapData.initialFilters || {});
|
||||||
this.mapInitialized = true;
|
this.mapInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,7 +693,7 @@
|
|||||||
|
|
||||||
// Initialize map if not already done
|
// Initialize map if not already done
|
||||||
if (!this.mapInitialized && typeof homeprozMapData !== 'undefined') {
|
if (!this.mapInitialized && typeof homeprozMapData !== 'undefined') {
|
||||||
PropertyMap.init(homeprozMapData.properties);
|
PropertyMap.init(homeprozMapData.initialFilters || {});
|
||||||
this.mapInitialized = true;
|
this.mapInitialized = true;
|
||||||
} else if (PropertyMap.map) {
|
} else if (PropertyMap.map) {
|
||||||
// Invalidate size to fix map rendering after show
|
// Invalidate size to fix map rendering after show
|
||||||
|
|||||||
@@ -474,3 +474,30 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server-side cluster marker styling
|
||||||
|
.server-cluster {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover div {
|
||||||
|
transform: scale(1.1);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cluster tooltip styling
|
||||||
|
.cluster-tooltip {
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
border-top-color: var(--color-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,39 +35,41 @@ $current_beds = isset($_GET['beds']) ? intval($_GET['beds']) : '';
|
|||||||
$paged = get_query_var('paged') ? get_query_var('paged') : 1;
|
$paged = get_query_var('paged') ? get_query_var('paged') : 1;
|
||||||
$per_page = 12;
|
$per_page = 12;
|
||||||
|
|
||||||
// Build MLS query args
|
// Build filter args for count and properties
|
||||||
$mls_args = array(
|
$filter_args = array(
|
||||||
'status' => $current_status ?: 'Active',
|
'status' => $current_status ?: 'Active',
|
||||||
'limit' => 1000, // Get all for counting, then paginate
|
|
||||||
'orderby' => 'modification_timestamp',
|
|
||||||
'order' => 'DESC',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Map filter values to MLS query args
|
|
||||||
if ($current_type) {
|
if ($current_type) {
|
||||||
$mls_args['property_type'] = $current_type;
|
$filter_args['property_type'] = $current_type;
|
||||||
}
|
}
|
||||||
if ($current_location) {
|
if ($current_location) {
|
||||||
$mls_args['city'] = $current_location;
|
$filter_args['city'] = $current_location;
|
||||||
}
|
}
|
||||||
if ($current_min_price) {
|
if ($current_min_price) {
|
||||||
$mls_args['min_price'] = $current_min_price;
|
$filter_args['min_price'] = $current_min_price;
|
||||||
}
|
}
|
||||||
if ($current_max_price) {
|
if ($current_max_price) {
|
||||||
$mls_args['max_price'] = $current_max_price;
|
$filter_args['max_price'] = $current_max_price;
|
||||||
}
|
}
|
||||||
if ($current_beds) {
|
if ($current_beds) {
|
||||||
$mls_args['min_beds'] = $current_beds;
|
$filter_args['min_beds'] = $current_beds;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all matching properties
|
// Get total count efficiently from database
|
||||||
$all_properties = mls_get_properties($mls_args);
|
$total = mls_get_property_count($filter_args);
|
||||||
|
|
||||||
// Handle pagination manually
|
|
||||||
$total = count($all_properties);
|
|
||||||
$max_pages = ceil($total / $per_page);
|
$max_pages = ceil($total / $per_page);
|
||||||
$offset = ($paged - 1) * $per_page;
|
|
||||||
$paged_properties = array_slice($all_properties, $offset, $per_page);
|
// Build query args for paginated results
|
||||||
|
$mls_args = array_merge($filter_args, array(
|
||||||
|
'limit' => $per_page,
|
||||||
|
'offset' => ($paged - 1) * $per_page,
|
||||||
|
'orderby' => 'modification_timestamp',
|
||||||
|
'order' => 'DESC',
|
||||||
|
));
|
||||||
|
|
||||||
|
// Fetch only the properties we need for this page
|
||||||
|
$paged_properties = mls_get_properties($mls_args);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<!-- Results Meta -->
|
<!-- Results Meta -->
|
||||||
|
|||||||
Reference in New Issue
Block a user