DEV Community

Spot2
Spot2

Posted on

Spot2 Quadtree a primer

When we embarked on developing Spot2.mx, Mexico's premier commercial real estate platform, we underestimated the complexity of crafting an efficient geospatial search engine capable of handling addresses across diverse scenarios, from industrial warehouses and land plots to offices and retail spaces.

Our system integrates thousands of properties with points of interest (POIs) sourced from a variety of data providers, beyond just Google, including public databases, partner APIs, and proprietary feeds; to ensure comprehensive, precise and fast results. In the dynamic PropTech sector, real estate platforms face critical architectural decisions that shape user experience and operational efficiency.

A key question for engineering teams is whether to implement advanced segmentation maps, rely on traditional bounding boxes, or incorporate quadtrees for geospatial search functionality. Drawing from our experience scaling Spot2 to manage millions of property queries, we’ve found that the solution lies not in choosing one approach, but in strategically combining all three to optimize performance.

It’s well-known that sophisticated algorithms, including quadtrees, enable efficient management and visualization of large datasets, handling 50,000 to 100,000 entries with ease.

In this article, I’ll detail the step-by-step development of our system, highlighting the role of quadtrees alongside segmentation and bounding techniques. As CTO, my responsibilities include driving technical excellence, minimizing computational costs to ensure sustainable operations, and building scalable infrastructure to support business growth, not just in Mexico but for international expansion.

By blending quadtrees for hierarchical spatial indexing, segmentation for precise searches, and bounding boxes for rapid, broad queries, we’ve achieved a robust balance of accuracy, speed, and scalability. This hybrid approach has significantly enhanced user satisfaction and positioned Spot2 as a competitive leader in the PropTech market.

The problem

In an ideal world, maps would consist of uniform squares, simplifying surface area calculations and boundary definitions. However, the Mercator projection, widely used in digital mapping, distorts geography, creating significant variations across Mexican states.

These distortions result in irregular shapes, diverse dimensions, and unpredictable search patterns when users seek commercial properties like offices, warehouses, or retail spaces.

Consider a user searching for an office in Santa Fe, Mexico City. Santa Fe lacks a formal legal boundary, existing as a loosely defined neighborhood in the western part of the city. When querying within Álvaro Obregón borough and tailoring results to the user's application, as depicted in Images 1 and 2 below, mobile simulations often return an overwhelming number of spaces. This underscores the difficulty of translating vague, common-knowledge areas into precise geospatial queries.

These challenges raise critical business questions:

  1. Incentivizing Continued Exploration: How do we encourage users to refine searches by zone, street, municipality, or neighborhood while ensuring the system highlights the most relevant spaces? Additionally, how do we enable AI systems, both our own search bot and external platforms that may scrape our marketplace, to intelligently navigate and extract useful information from our platform for users? (We'll explore our full search engine in a later article.) The immediate focus is on presenting map points and dots to empower independent user exploration without overwhelming them.

  2. Balancing Precision and Breadth: Should we strictly display properties within a defined area, or include nearby options? A tightly constrained polygon filter might exclude offices just outside the boundary, potentially losing valuable client leads. These business decisions shape our matching algorithm to maximize efficiency and user satisfaction.

To tackle these, we analyze various search approaches and their implications:

  • Searching by address: Direct address queries offer precision but struggle with Mexico's common issues. Unlike many regions, we often lack informal street names, and some neighborhoods are not officially registered with the government, complicating rigid boundary definitions. As a result, we may need to create our own polygons to accurately represent these areas.

  • Searching by bounding boxes: These rectangular overlays are effective for broad searches but often include irrelevant results due to their rigid geometry, missing the nuances of irregular neighborhoods. Additionally, rendering numerous points on a map can overwhelm devices, as not all users have phones capable of efficiently displaying numerous points. Speed and performance are critical to maintain a seamless experience.

  • Corner Cases: Properties on a polygon's border create ambiguity: should they be included or excluded? The answer depends on how boundaries are defined, impacting whether results align with user expectations.

To address these, we've crafted an equilibrated map view where users aren't flooded with spaces initially. As they zoom in, similar to platforms like Airbnb, more properties appear progressively. Achieving this without performance bottlenecks raises another question: How do we maintain a balanced view efficiently? This is where quadtrees, with their hierarchical spatial indexing, become crucial.

Finally, why not create our own map service, as Google does with its fast-rendering layers for Google Maps? While building a proprietary map could allow full control over rendering the points in the maps, it comes with trade-offs when we want to align with our design guidelines and support real-time updates when users publish new spaces, significant development costs, ongoing maintenance, and the complexity of scaling a custom map to handle dynamic updates across diverse devices. Reflecting on this, I found this article insightful during our exploration of this quest.

Foundations: What are bounding boxes?

If we were to ask a large language model (LLM) to explain bounding boxes in geospatial search, it might respond: "A bounding box represents the simplest form of geospatial querying. It's essentially a rectangular coordinate system defined by four boundaries, typically the minimum and maximum latitudes and longitudes, that create a searchable area on a map.

For example, when users search within this rectangle, the system can efficiently return all properties located within these coordinates." To expand on this, bounding boxes are also known as axis-aligned bounding boxes (AABB) because they align with the coordinate axes (latitude and longitude in geospatial contexts).

They are usually represented by two corner points: the southwest corner (minimum latitude, minimum longitude) and the northeast corner (maximum latitude, maximum longitude). This minimal representation makes them computationally efficient for range queries in databases.

However, bounding boxes have limitations: they don't account for the Earth's curvature in simple implementations (though adjustments can be made for projections like Mercator), they struggle with crossing the antimeridian (e.g., the 180° longitude line), and they may include irrelevant areas when approximating irregular shapes, leading to over-inclusive results that require further refinement.

Bounding boxes are commonly represented in code as objects or structures holding these coordinate pairs. For instance, the Mexican bbox (bounding box abbreviature) would be:

{
  "south": 14.39,        // Minimum latitude (southwest corner)
  "west": -118.6,       // Minimum longitude (southwest corner)
  "north": 32.72,       // Maximum latitude (northeast corner)
  "east": -86.49        // Maximum longitude (northeast corner)
}
Enter fullscreen mode Exit fullscreen mode

Note: more decimal numbers, more precision.

To visualize this for a larger area, such as encapsulating the entire country of Mexico, we can use tools likeKlokantech BoundingBox . This generates a rectangle defined by Mexico's geographic extents, providing just the corner points on a map. From there, we can perform calculations to filter properties or points of interest (POIs) within those coordinates, as shown in the following image:

Imagine needing to plot a vast number of points, say, in the order of 100,000, while ensuring they all fit precisely within a specific rectangular area. The bounding box's coordinate-based filtering makes this precision possible by quickly discarding points outside the defined limits, reducing computational load. As users zoom in on the map, the system dynamically adjusts by recalculating smaller bounding boxes to match the visible area.

This approach ensures that only relevant data is loaded and rendered, preventing performance issues and maintaining a smooth user experience. Note that the bounding box itself does not inherently support hierarchical refinement; this capability relies on additional techniques or data structures, which we will explore later.

However, sharing a bounding box isn't always straightforward. Since it consists of just two pairs of coordinates (or four values total), conveying this to a client can feel abstract and impersonal. For example, imagine telling a client, "We found the perfect space for you within the bounds of latitude 14.39 to 32.72 and longitude -118.6 to -86.49." It's technically accurate but lacks the intuitive appeal of a visual map or named location, which is why bounding boxes are often used internally for querying rather than direct communication.

This challenge becomes even more pronounced in mobile experiences, where users have varying screen widths and heights. The map viewport must adapt dynamically, recalculating the bounding box to fit the device's display while still showing results relevant to the area the user intends to explore.

For instance, on a narrow smartphone screen, the bounding box might need to elongate vertically to match the aspect ratio, potentially altering the scope of visible properties. This requires responsive design techniques to ensure the search remains intuitive and efficient, avoiding scenarios where users see incomplete or irrelevant results due to mismatched dimensions.

Even though bounding boxes are used in this field (real estate), we find many results in the AI field, machine learning and others.

Foundations: What is quadtree?

Quadtrees are a type of tree data structure used for partitioning a two-dimensional space by recursively subdividing it into four quadrants or regions. This makes them particularly effective for spatial indexing in applications like geospatial search engines, where efficient querying of points, ranges, or nearest neighbors is crucial. In the context of Spot2's marketplace, quadtrees allow us to manage and query large datasets of properties and points of interest (POIs) across Mexico's diverse geography without overwhelming computational resources.

The primary motivation for using quadtrees stems from the need to handle spatial data efficiently. Traditional methods, such as scanning an entire list of points for each query, become prohibitively slow with datasets in the range of 50,000 to 100,000 entries or even millions in scaled systems. Quadtrees address this by providing logarithmic-time complexity for insertions, deletions, and searches in balanced scenarios, typically O(log n) where n is the number of points. This efficiency is vital for real-time map interactions, such as zooming or panning, where users expect instant results without lag.

Key benefits include:

  • Spatial Partitioning: They divide space adaptively based on data density, concentrating subdivisions in crowded areas (e.g., urban centers like Mexico City) while keeping sparse regions (e.g., rural areas) undivided, optimizing memory and query speed.

  • Integration with maps: Complements tools like Google Maps or OpenStreetMap by enabling fast rendering of markers, preventing overload on devices with limited capabilities. And this is one of the most important features we needed, because it was very important to show that we have spots in the map but not to show at all of them or even just to show the most representative from their zone.

  • Range Queries: Ideal for bounding box searches, as they quickly prune irrelevant branches, returning only points within a specified rectangular area.

  • Scalability: Supports dynamic updates, such as adding new properties in real-time, without rebuilding the entire structure.

If we were to ask a large language model (LLM) to define a quadtree, it might say: "A quadtree is a tree where each internal node has exactly four children, used to partition a 2D plane by recursively subdividing it into four quadrants. It's commonly applied in computer graphics, geographic information systems (GIS), and collision detection to accelerate spatial queries.

Segmenting the Bounding Box

The core of a quadtree involves recursively segmenting a bounding box into four equal quadrants until a stopping criterion is met, such as a maximum depth or a threshold number of points per node. Starting with an initial bounding box (e.g., encompassing all of Mexico as defined by coordinates south: 14.39, west: -118.6, north: 32.72, east: -86.49), the process works as follows:

  • Define the Root Node: The root represents the entire bounding box.

  • Subdivide: Calculate the midpoints for latitude and longitude to split the box into four quadrants:

  1. Northwest: (mid_lat to north, west to mid_long)
  2. Northeast: (mid_lat to north, mid_long to east)
  3. Southwest: (south to mid_lat, west to mid_long)
  4. Southeast: (south to mid_lat, mid_long to east) Where mid_lat = (south + north) / 2 and mid_long = (west + east) / 2.
  • Recursion: For each quadrant, if it contains more points than a threshold (e.g., 4-10 points) and hasn't reached max depth (e.g., 8-12 levels), subdivide further. Otherwise, it becomes a leaf node holding the points.

  • Insertion: When adding a point, traverse from the root, determining which quadrant it belongs to based on its coordinates, and insert it into the appropriate leaf or subdivide if necessary.

This adaptive subdivision ensures dense areas like Santa Fe in Mexico City are finely partitioned, while vast empty lands require minimal nodes.

Here's a simplified JavaScript example of a Quadtree class with segmentation logic:

class QuadTree {
  constructor(boundary, capacity, depth = 0, maxDepth = 8) {
    this.boundary = boundary;  // { south, west, north, east }
    this.capacity = capacity;  // Max points per node
    this.points = [];          // Stored points
    this.divided = false;      // Has subdivided?
    this.depth = depth;
    this.maxDepth = maxDepth;
    this.children = null;      // [nw, ne, sw, se]
  }

  subdivide() {
    const midLat = (this.boundary.south + this.boundary.north) / 2;
    const midLong = (this.boundary.west + this.boundary.east) / 2;

    const nw = { south: midLat, west: this.boundary.west, north: this.boundary.north, east: midLong };
    const ne = { south: midLat, west: midLong, north: this.boundary.north, east: this.boundary.east };
    const sw = { south: this.boundary.south, west: this.boundary.west, north: midLat, east: midLong };
    const se = { south: this.boundary.south, west: midLong, north: midLat, east: this.boundary.east };

    this.children = [
      new QuadTree(nw, this.capacity, this.depth + 1, this.maxDepth),
      new QuadTree(ne, this.capacity, this.depth + 1, this.maxDepth),
      new QuadTree(sw, this.capacity, this.depth + 1, this.maxDepth),
      new QuadTree(se, this.capacity, this.depth + 1, this.maxDepth)
    ];
    this.divided = true;

    // Redistribute existing points to children
    for (let point of this.points) {
      this.insert(point);
    }
    this.points = [];
  }

  insert(point) {
    if (!this.contains(point)) return false;

    if (!this.divided && this.points.length < this.capacity && this.depth < this.maxDepth) {
      this.points.push(point);
      return true;
    }

    if (!this.divided && this.depth < this.maxDepth) {
      this.subdivide();
    }

    // Insert into appropriate child
    for (let child of this.children) {
      if (child.insert(point)) return true;
    }
    return false;
  }

  contains(point) {
    return point.lat >= this.boundary.south && point.lat <= this.boundary.north &&
           point.long >= this.boundary.west && point.long <= this.boundary.east;
  }
}
Enter fullscreen mode Exit fullscreen mode

Storing Quadtrees

Quadtrees are stored as a hierarchical tree structure in memory or persisted in databases. Each node includes:

  • Boundary: The bounding box it represents.

  • Points: A list of data points (e.g., property coordinates with metadata like address, type) if it's a leaf.

  • Children: References to four child nodes if subdivided.

  • Metadata: Optional depth, capacity, etc.

For persistence, we can serialize the tree (e.g., to JSON) and save for fast retrieval, in our case we did it using redis (we’ll explain later why we migrated to OpenSearch).

Retrieving Data from Quadtrees

Retrieval involves traversing the tree to find points within a query region, such as a bounding box from a map viewport:

  1. Start at Root: Check if the query box intersects the node's boundary.
  2. Prune: If no intersection, skip the node.
  3. Leaf Node: If intersected and a leaf, check each point for inclusion in the query.
  4. Internal Node: If intersected, recurse into children. This culls irrelevant quadrants efficiently.

For example, a range query method:

query(range, found = []) {
  if (!this.intersects(range)) return;

  if (this.divided) {
    for (let child of this.children) {
      child.query(range, found);
    }
  } else {
    for (let point of this.points) {
      if (this.pointInRange(point, range)) {
        found.push(point);
      }
    }
  }
  return found;
}

intersects(range) {
  // Check if two bounding boxes overlap
  return !(range.south > this.boundary.north || range.north < this.boundary.south ||
           range.west > this.boundary.east || range.east < this.boundary.west);
}

pointInRange(point, range) {
  return point.lat >= range.south && point.lat <= range.north &&
         point.long >= range.west && point.long <= range.east;
}
Enter fullscreen mode Exit fullscreen mode

This approach ensures fast retrieval, even for zoomed-in views, by loading only pertinent data.

Solutions

Implementation evolution

In our journey to build a robust geospatial search engine for Spot2.mx, we began with a straightforward implementation using MySQL, which served as our initial database for storing property data including coordinates for locations across Mexico. MySQL's spatial extensions allowed us to define geometry types such as points for individual properties and polygons for neighborhood boundaries, enabling basic queries like checking if a point falls within a bounding box or a specific area.

For instance, we stored latitude and longitude as POINT types in a table structured with columns for property ID, address details, and the spatial point, then used functions like ST_Contains or ST_Within to filter results based on user-defined search areas. This approach was simple to set up and integrated well with our early backend, where we could execute SQL queries to retrieve lists of properties matching a given bounding box defined by minimum and maximum latitudes and longitudes.

However, as our dataset grew to tens of thousands of entries and user queries increased in frequency, we encountered performance bottlenecks because MySQL's spatial indexes, while functional for small-scale operations, relied on disk-based storage that led to slower response times during peak usage, especially when handling concurrent searches or complex filters involving additional attributes like property type or size.

The need for faster, more scalable retrieval prompted us to explore in-memory solutions that could handle geospatial computations more efficiently without sacrificing accuracy.

Transitioning to Redis marked a significant step forward in optimizing our search performance, as we leveraged its in-memory key-value store to create a spatial index for rapid lookups. We imported our property data into Redis using GEOADD commands to store points with their latitudes, longitudes, and associated metadata as members of sorted sets keyed by region or category, allowing us to perform radius-based searches initially but adapting for bounding box queries through custom Lua scripts (which honestly is really different from a simple SQL query).

These scripts enabled us to define a bounding box by passing parameters for south, west, north, and east coordinates, then iterate over potential candidates using GEORADIUS or GEOPOS to filter those strictly within the box, returning a list of property IDs that we could fetch details for from our primary database. For example, a Lua script might look like this, where we evaluate the script on the Redis server to minimize network overhead:

local function in_bbox(lat, lon, south, west, north, east) return lat >= south and lat <= north and lon >= west and lon <= east end; 
local keys = redis.call('GEORADIUS', 'properties', center_lon, center_lat, radius, 'km', 'WITHCOORD'); 
local results = {}; 
for i, v in ipairs(keys) do local coord = v[2]; 
if in_bbox(coord[2], coord[1], south, west, north, east) 
then table.insert(results, v[1]) end end; 
return results. 
Enter fullscreen mode Exit fullscreen mode

This method drastically reduced query times to sub-millisecond levels for datasets up to 100,000 points, as everything operated in memory, and it supported real-time updates when new properties were added via GEOADD without rebuilding indexes.

Despite these advantages, Redis's geospatial features were primarily designed for proximity searches rather than complex polygon intersections, and as our requirements evolved to include more intricate zone definitions beyond simple rectangles, we found limitations in scaling Lua scripts for high-complexity operations, particularly when integrating multiple data sources for points of interest, which pushed us toward a dedicated search engine capable of handling advanced geospatial analytics.

Finally, the decision to migrate to OpenSearch represented a pivotal upgrade, driven by the need for lightning-fast retrieval across large volumes of data while supporting sophisticated query types that combined text search, filters, and geospatial constraints.

OpenSearch, an open-source fork of Elasticsearch, provided built-in support for geo_point and geo_shape fields, allowing us to index properties as documents with coordinates and perform bounding box queries using the geo_bounding_box filter, which efficiently narrows results based on top-left and bottom-right corners. We set up our index with mappings like:

{
  "properties": {
    "location": {
      "type": "geo_point"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Then used queries such as:

{
  "query": {
    "geo_bounding_box": {
      "location": {
        "top_left": {
          "lat": 32.72,
          "lon": -118.6
        },
        "bottom_right": {
          "lat": 14.39,
          "lon": -86.49
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

To retrieve matching documents in milliseconds, even under heavy load. This migration also facilitated aggregation features for summarizing results, like bucketing properties by neighborhood, and integrated seamlessly with our existing pipeline for ingesting data from various sources including public APIs and proprietary feeds. Beyond speed, OpenSearch's scalability through clustering ensured we could handle growth beyond Mexico, preparing for international expansion without overhauling our infrastructure.

As part of enhancing precision, we experimented with integrating a pipeline that incorporated data polygons to match against user-defined zones, where we would define neighborhood boundaries as multi-polygon shapes sourced from government datasets or custom drawings, then use spatial functions to check intersections or containment. In OpenSearch, this involved indexing polygons as geo_shape and querying with geo_shape filters to find properties within them, but we also attempted subtraction operations to exclude certain sub-areas, such as restricted zones or overlapping municipalities, by computing differences between polygons before applying filters.

However, this approach proved problematic in practice because subtraction often resulted in overly strict boundaries that inadvertently cut out important spaces near edges or in ambiguously defined areas like “el Bajío” or “Corredor Polanco”, leading to scenarios where viable properties were not displayed to clients and potentially losing business opportunities.

For example, a property slightly outside a subtracted polygon segment might be excluded despite being practically within the user's intended search area, frustrating both tenants and landlords who expected comprehensive results.

After evaluating the trade-offs through user feedback and A/B testing, we concluded that relying solely on bounding boxes provided greater flexibility, as they allowed us to tweak the visible area dynamically based on zoom levels or user preferences without rigid exclusions, ensuring a broader yet relevant set of options that empowered clients to discover the right space for their business needs while maintaining performance.

Improvements we did along the way:

  • Early classification, instead of always relying on the coordinates, we classify the spots in the polygons after they’re submitted (It's really fast but less precise since we have complex geographies but works for aggregation operations).

  • Implementing a mix machine for our properties, in which we can control how much of which type of space can be seen given the interest of one person (i.e. show more spaces from retail when the user wants it).

  • SEO improvements, since we had problems with the related bounding boxes, even though those are precise points, we need to create URLs that can be searchable by crawlers and AIs so we did improvements.

  • Remember that OpenSearch is NoSQL but the source of truth is still our relational database.

We encountered many challenges when we implemented the uniqueness filters to avoid repeated listings, but we can cover that in another article.

SEO and discovery optimization

URL Structure Evolution

Our search URL architecture evolved to support both approaches:

Legacy (Bounding Box Only):
https://spot2.mx/buscar?ty=13%2C11%2C9%2C15&pt=3&pa=1&cu=1&bbx=...

Enhanced (Similarity + Geospatial):
https://spot2.mx/buscar?q=Ciudad+de+México&ty=13&pt=3&pa=1

Future-Proofing your architecture

Scalability planning

When choosing between segmentation maps and bounding boxes, consider:

Team resources

  • Do you have the expertise to implement and maintain complex geospatial systems?

  • What's your timeline for implementation and iteration?

User base scale

  • How many concurrent searches do you expect?

  • What's your projected growth trajectory?

Data complexity

  • How diverse are your property types and attributes?

  • Do you need advanced filtering and aggregation capabilities?

Technology integration roadmap

For PropTech platforms planning similar implementations:

  • Start with MVP bounding boxes for rapid market validation, there are several libraries that implement out of the box. The important is to validate with your users.

  • Implement analytics tracking to understand user search patterns.

  • Gradually introduce segmentation based on usage data.

  • Consider hybrid architecture as you scale beyond initial limitations.

The Strategic Path Forward

The choice between segmentation maps and bounding boxes isn't binary—it's strategic. At Spot2.mx, we've learned that successful geospatial search implementation requires understanding your users' needs, technical constraints, and growth trajectory.

Our hybrid approach combining OpenSearch's specialized search capabilities with MySQL's transactional reliability has delivered:

  • 7s to 500ms (for state like surface zooms) increase in speed of 1300%

  • Significant reduction in database load

  • Enhanced user engagement through better search relevance

  • Scalable foundation for future feature development

For CTOs and engineering teams facing similar decisions, consider starting with bounding boxes for rapid development, but plan your architecture to accommodate more sophisticated segmentation as your platform matures.

The real estate technology landscape continues evolving rapidly. The platforms that succeed will be those that make informed architectural decisions based on real user needs rather than theoretical optimization, and which let other systems (like AI providers) utilize from an intuitive way your platform using well-formed URLs.

Top comments (0)