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 $where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL'); $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 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 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 12-15: Numbered clusters (or individual if low count) if ($zoom <= self::ZOOM_CLUSTER_MAX) { if ($total <= self::MAX_INDIVIDUAL_MARKERS) { return $this->get_individual_markers($where_sql, $values, $total); } return $this->get_cluster_data($where_sql, $values, $zoom, $center_lat, $total); } // Zoom 16+: Individual markers 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; $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 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(); $where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL'); $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); } }