PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2
7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 6.0.7 All 35 releases
mlsimport / includes / live / live-map.php

live-map.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.2, at includes/live/live-map.php

280 lines 10.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Live mode: the viewport map payload, answered from the MLS.
4 *
5 * One bbox search capped at the marker cap: when the in-view total fits, the
6 * records become full markers (photo/address/beds/baths — parity with the
7 * stored map); when it doesn't, the SaaS /clusters endpoint answers counted
8 * bubbles from the per-MLS index the daily sweep maintains (v1.1). The
9 * { type: 'zoom_in' } notice remains the fallback — for filtered maps (the
10 * cluster index is unfiltered by design) and for any endpoint failure.
11 *
12 * @package Mlsimport
13 */
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 /**
20 * Answer the viewport map payload from the MLS (seam #3).
21 *
22 * @param array|null $pre Prior short-circuit value.
23 * @param array $args Filter args incl. the viewport bbox.
24 * @param int $zoom Map zoom level (cluster grid sizing).
25 * @return array|null
26 */
27 function mlsimport_live_map_payload( $pre, $args, $zoom ) {
28 // Already answered by an earlier filter, or gate off: don't intervene.
29 if ( null !== $pre || ! mlsimport_live_mode_active() ) {
30 return $pre;
31 }
32
33 /** Filter the cap on individual markers before clustering kicks in. @since 6.5 */
34 $cap = max( 1, (int) apply_filters( 'mlsimport_map_marker_cap', Mlsimport_Standalone_Render::MAP_MARKER_CAP ) );
35
36 // The map shows the in-view set, never the grid's page size.
37 $args = (array) $args;
38 $args['limit'] = $cap;
39 $args['page'] = 1;
40
41 // Cluster-first (v1.1): on an unfiltered viewport the index's daily count
42 // already tells us bubbles will render, so the full record fetch is
43 // skipped instead of pulled and discarded. Filtered maps and endpoint
44 // failures answer null here and fall through to the live search.
45 $clusters = mlsimport_live_map_clusters( $args, (int) $zoom );
46 // Index already says the area is over the cap: return bubbles, skip the fetch.
47 if ( null !== $clusters && (int) $clusters['total'] > $cap ) {
48 return $clusters;
49 }
50
51 // Otherwise fetch the in-view records themselves.
52 $result = mlsimport_live_search( $args );
53 if ( ! is_array( $result ) ) {
54 // MLS unreachable, no warm cache: an empty viewport, not a fatal.
55 return array(
56 'type' => 'markers',
57 'total' => 0,
58 'markers' => array(),
59 );
60 }
61
62 // Over the cap — or more than the provider will return in one page
63 // (Bridge caps limit at 200): a partial pin set would silently
64 // misrepresent the area. Slightly-stale cluster bubbles beat the zoom-in
65 // notice; the notice remains the fallback when the index can't answer.
66 // Over the cap, or the provider returned fewer than the true total (a
67 // partial page): don't draw a misleading partial pin set.
68 $total = (int) $result['total'];
69 if ( $total > $cap || count( $result['records'] ) < $total ) {
70 // Prefer cluster bubbles when the index can answer; else the zoom-in notice.
71 if ( null !== $clusters ) {
72 return $clusters;
73 }
74 return array(
75 'type' => 'zoom_in',
76 'total' => $total,
77 );
78 }
79
80 // Under the cap: turn each record into a full marker (skipping coord-less ones).
81 $markers = array();
82 foreach ( $result['records'] as $record ) {
83 $marker = is_array( $record ) ? mlsimport_live_marker_from_record( $record ) : null;
84 if ( null !== $marker ) {
85 $markers[] = $marker;
86 }
87 }
88
89 // Markers + the true total + the set's bounds so the map can fit itself.
90 return array(
91 'type' => 'markers',
92 'total' => $total,
93 'markers' => $markers,
94 'bounds' => mlsimport_live_bounds_of_markers( $markers ),
95 );
96 }
97 add_filter( 'mlsimport_map_payload_pre', 'mlsimport_live_map_payload', 10, 3 );
98
99 /**
100 * Answer a filter set's overall map bounds from the MLS, so the map block can
101 * mount and fit itself with no local table.
102 *
103 * Unfiltered blocks get EXACT bounds from the SaaS coords index — one cached
104 * world-bbox /clusters ask, no record fetch. Filtered blocks (the index is
105 * unfiltered by design) keep the approximation: the bounds of the first
106 * marker-cap's worth of listings, from a cached search.
107 *
108 * @param array|null $pre Prior short-circuit value.
109 * @param array $params Filter params (no bbox).
110 * @return array|null
111 */
112 function mlsimport_live_map_bounds( $pre, $params ) {
113 // Already answered, or gate off: don't intervene.
114 if ( null !== $pre || ! mlsimport_live_mode_active() ) {
115 return $pre;
116 }
117
118 $params = (array) $params;
119
120 // Unfiltered block: get EXACT bounds from the coords index with one
121 // world-bbox /clusters ask — no record fetch needed.
122 if ( ! mlsimport_live_map_has_filters( $params ) ) {
123 $clusters = mlsimport_live_map_clusters(
124 array_merge(
125 $params,
126 array(
127 'lat_min' => -90,
128 'lat_max' => 90,
129 'lng_min' => -180,
130 'lng_max' => 180,
131 )
132 ),
133 0
134 );
135 if ( is_array( $clusters ) && isset( $clusters['bounds'] ) ) {
136 return $clusters['bounds'];
137 }
138 }
139
140 /** Filter the cap on individual markers before clustering kicks in. @since 6.5 */
141 $cap = max( 1, (int) apply_filters( 'mlsimport_map_marker_cap', Mlsimport_Standalone_Render::MAP_MARKER_CAP ) );
142
143 // Filtered block (or the index couldn't answer): approximate the bounds from
144 // the first marker-cap's worth of matching listings.
145 $params = (array) $params;
146 $params['limit'] = $cap;
147 $params['page'] = 1;
148
149 // Fetch that first page; on failure keep the prior value ($pre).
150 $result = mlsimport_live_search( $params );
151 if ( ! is_array( $result ) ) {
152 return $pre;
153 }
154
155 // Turn the records into markers (dropping coord-less ones)...
156 $markers = array();
157 foreach ( $result['records'] as $record ) {
158 $marker = is_array( $record ) ? mlsimport_live_marker_from_record( $record ) : null;
159 if ( null !== $marker ) {
160 $markers[] = $marker;
161 }
162 }
163
164 // ...and return their bounding box.
165 return mlsimport_live_bounds_of_markers( $markers );
166 }
167 add_filter( 'mlsimport_map_bounds_pre', 'mlsimport_live_map_bounds', 10, 2 );
168
169 /**
170 * Ask the SaaS /clusters endpoint for the viewport's cluster bubbles (v1.1).
171 *
172 * Only unfiltered maps qualify — the per-MLS index covers the whole active
173 * feed, so filtered counts would be wrong. The outcome (bubbles or a 'none'
174 * sentinel for any failure) is cached for the live TTL per quantized
175 * viewport, so neither map pans nor an undeployed endpoint produce per-pan
176 * SaaS traffic.
177 *
178 * @param array $args Filter args incl. the viewport bbox.
179 * @param int $zoom Map zoom level.
180 * @return array|null The { type: clusters } payload, or null (keep zoom_in).
181 */
182 function mlsimport_live_map_clusters( array $args, int $zoom ) {
183 // Filtered viewport: the index is unfiltered, so its counts would be wrong.
184 if ( mlsimport_live_map_has_filters( $args ) ) {
185 return null;
186 }
187
188 // The site's MLS id keys the index; build the quantized request params.
189 $options = get_option( 'mlsimport_admin_options' );
190 $mls_id = is_array( $options ) && isset( $options['mlsimport_mls_name'] ) ? (int) $options['mlsimport_mls_name'] : 0;
191 $params = mlsimport_live_clusters_params( $args, $zoom, $mls_id );
192 // No full viewport or no MLS id: can't ask the index.
193 if ( null === $params || $mls_id <= 0 ) {
194 return null;
195 }
196
197 // Cache the /clusters answer per quantized viewport; a 'none' sentinel is
198 // stored for any failure so an undeployed endpoint isn't re-hit per pan.
199 $data = mlsimport_live_remember(
200 mlsimport_live_cache_key( 'clusters', $params ),
201 static function () use ( $params ) {
202 $answer = ThemeImport::globalApiRequestSaas( 'clusters?' . http_build_query( $params ), array(), 'GET' );
203 $payload = mlsimport_live_clusters_payload( $answer );
204 return null !== $payload ? $payload : array( 'type' => 'none' );
205 }
206 );
207
208 // Only a real cluster payload counts; the 'none' sentinel maps back to null.
209 return is_array( $data ) && 'clusters' === ( $data['type'] ?? '' ) ? $data : null;
210 }
211
212 /**
213 * One map marker from a raw RESO record — same keys and formatting as the
214 * stored markers_from_data(), with the virtual single URL as the link.
215 *
216 * @param array $record Raw RESO property.
217 * @return array|null Null when the record has no coordinates.
218 */
219 function mlsimport_live_marker_from_record( array $record ) {
220 // Build the row so we get parsed coordinates; drop the marker without them.
221 $row = mlsimport_live_row_from_reso( $record );
222 if ( null === $row->latitude || null === $row->longitude ) {
223 return null;
224 }
225
226 // Spec formatter — mirrors mlsimport_format_amount(), as the stored
227 // marker builder does, so both maps read identically.
228 $fmt = static function ( $value ) {
229 if ( null === $value || '' === $value ) {
230 return '';
231 }
232 $f = (float) $value;
233 return ( (float) (int) $f === $f ) ? number_format( $f ) : number_format( $f, 1 );
234 };
235
236 // Marker title: the address, falling back to the listing key.
237 $title = isset( $record['UnparsedAddress'] ) ? trim( (string) $record['UnparsedAddress'] ) : '';
238 if ( '' === $title ) {
239 $title = $row->listing_key;
240 }
241
242 // The marker shape the map JS expects (post_id 0 — no real post behind it).
243 return array(
244 'post_id' => 0,
245 'lat' => (float) $row->latitude,
246 'lng' => (float) $row->longitude,
247 'price' => null !== $row->price ? (float) $row->price : null,
248 'title' => $title,
249 'url' => mlsimport_live_url( $row->listing_key ),
250 'image' => $row->thumb,
251 'beds' => $fmt( isset( $row->bedrooms ) ? $row->bedrooms : null ),
252 'baths' => $fmt( isset( $row->bathrooms ) ? $row->bathrooms : null ),
253 // Whole-number ft² (like the listing card + property map popup); $fmt keeps a
254 // decimal for non-integers, which would leak a fractional area.
255 'area' => ( isset( $row->living_area ) && null !== $row->living_area && '' !== $row->living_area ) ? number_format_i18n( (float) $row->living_area ) : '',
256 );
257 }
258
259 /**
260 * The bounding box of a marker set, in the shape the map JS refits to.
261 *
262 * @param array $markers Markers with lat/lng.
263 * @return array|null Null when there are no markers.
264 */
265 function mlsimport_live_bounds_of_markers( array $markers ) {
266 // No markers, no box.
267 if ( array() === $markers ) {
268 return null;
269 }
270 // Pull the lat/lng columns and take their extremes.
271 $lats = array_column( $markers, 'lat' );
272 $lngs = array_column( $markers, 'lng' );
273 return array(
274 'lat_min' => (float) min( $lats ),
275 'lat_max' => (float) max( $lats ),
276 'lng_min' => (float) min( $lngs ),
277 'lng_max' => (float) max( $lngs ),
278 );
279 }
280