| name | clustering-system |
| description | ClusteringService, tile keys, dirty tiles/teams, clustering commands, ClusterController GeoJSON API, PhotoObserver dirty marking, and map cluster rendering. |
Clustering System
Hierarchical grid-based clustering for map visualization. Photos are grouped into clusters at 9 zoom levels (0, 2, 4, 6, 8, 10, 12, 14, 16) using a two-tier strategy:
- Global (zoom 0-6): Single query across all verified photos (
verified >= 2)
- Per-tile (zoom 8-16): Uses pre-computed tile keys and generated columns for performance
Team clustering is unified into the same clusters table via team_id column (0 = global, N = team-specific).
Key Files
config/clustering.php — Grid sizes, zoom levels, tile size, TTL, limits
app/Services/Clustering/ClusteringService.php — Core clustering logic (raw SQL, not Eloquent)
app/Http/Controllers/Clusters/ClusterController.php — Public API endpoint (GeoJSON + ETag)
app/Http/Controllers/Teams/TeamsClusterController.php — Team cluster API (GeoJSON + bbox). points() selects summary (not result_string).
app/Observers/PhotoObserver.php — Dirty tile/team marking + school privacy
app/Console/Commands/Clusters/UpdateClusters.php — clustering:update (full rebuild)
app/Console/Commands/Clusters/ProcessDirtyTiles.php — clustering:process-dirty (incremental)
app/Console/Commands/Clusters/CheckMigrationStatus.php — clustering:check-migration (diagnostic)
app/Models/Cluster.php — Eloquent model (composite PK, $timestamps = false)
resources/js/stores/maps/clusters/index.js — Pinia store for cluster data
resources/js/stores/maps/points/requests.js — Points store: GET_POINTS() with page, year, date, username, signal params
resources/js/views/Maps/helpers/clustersHelper.js — Frontend cluster rendering + interactions
resources/js/views/Maps/helpers/mapLifecycleHelper.js — Map init (always adds cluster layer), cleanup, health checks
resources/js/views/Maps/helpers/pointsHelper.js — Points view, pagination, stats, abort signals
tests/Feature/Map/Clusters/ClusteringTest.php — Core clustering tests
tests/Feature/Map/Clusters/ClusteringApiTest.php — API endpoint tests
tests/Feature/Map/Clusters/TeamClusteringTest.php — Team clustering tests
Artisan Commands
clustering:update --populate
clustering:update --all
clustering:update --team=5
clustering:update --all-teams
clustering:update --stats
clustering:update --explain
clustering:update
clustering:process-dirty
clustering:process-dirty --limit=100 --team-limit=20
clustering:check-migration
Scheduler (Kernel.php): clustering:process-dirty runs every 5 minutes. clustering:update --all --all-teams runs nightly at 00:10.
Invariants
- Global clustering uses
verified >= 2. ADMIN_APPROVED and above. Team clustering uses verified >= 1 (tagged, so school students see their uploads on the team map before teacher approval).
- PhotoObserver uses two thresholds. Global tile dirty:
>= ADMIN_APPROVED. Team dirty: >= VERIFIED.
- ClusteringService uses raw SQL, not Eloquent. All clustering queries use
DB::statement() with INSERT...SELECT. The Cluster model exists but is not used by the pipeline.
- Team clusters are in the same table as global clusters.
team_id = 0 for global, team_id = N for team-specific. All global queries MUST filter WHERE team_id = 0.
tile_key must be populated before per-tile clustering works. Always run --populate before --all.
- Global sentinel tile key is
4294967295 (UINT max). Global zoom clusters use this value. Per-tile clusters use the actual tile key.
Architecture
Photo saved/deleted
→ PhotoObserver marks tile dirty (if verified >= ADMIN_APPROVED)
→ PhotoObserver marks team dirty (if verified >= VERIFIED and has team_id)
→ clustering:process-dirty (scheduler, every 5 min)
→ ClusteringService::clusterTile() — one tile across zooms 8-16
→ ClusteringService::clusterTeam() — one team across zooms 0-16
Nightly full rebuild:
→ clustering:update --all --all-teams
Tile Key Computation
Formula with 0.25° tile size (1440 x 720 grid):
latIndex = FLOOR((lat + 90) / 0.25)
lonIndex = FLOOR((lon + 180) / 0.25)
tileKey = latIndex * 1440 + lonIndex
Database Schema
Clusters table (composite PK)
PRIMARY KEY (team_id, tile_key, zoom, year, cell_x, cell_y)
team_id UNSIGNED INT
tile_key UNSIGNED INT
zoom INT
year SMALLINT UNSIGNED
cell_x, cell_y INT
lat, lon DOUBLE
point_count BIGINT UNSIGNED
grid_size DECIMAL(6,3)
Dirty tiles table
dirty_tiles: tile_key (PK), changed_at, attempts
Uses upsert with backoff: after 3 attempts, changed_at advances by 5 minutes. Auto-cleaned after 24 hours.
Note: dirty_teams table was dropped (2026-03-14). Team clustering is now on-demand only via clustering:update --team=ID or --all-teams.
is_public changes trigger dirty tile marking. PhotoObserver fires dirty tile logic when a photo's is_public changes (e.g. via PATCH /api/v3/photos/{id}/visibility). When a private-by-choice photo is made public (and is verified), its tile is marked dirty so the cluster updates. When a photo is made private, the tile is similarly marked dirty to remove it from future cluster renders.
API Endpoints
GET /api/clusters — Public. Params: zoom, bbox[], lat, lon. Returns GeoJSON FeatureCollection with ETag caching (304 support). Limit 5,000 clusters.
GET /api/clusters/zoom-levels — Available zoom configurations.
GET /api/teams/clusters/{team} — Auth required. Same bbox filtering and GeoJSON format.
Common Mistakes
- Using
verified = 2 instead of verified >= 2. Photos at BBOX_APPLIED (3), BBOX_VERIFIED (4), AI_READY (5) must be included.
- Forgetting
team_id = 0 in global queries. Without this, team clusters leak into public map data.
- Running
--all without --populate first. Photos without tile_key are excluded from per-tile clustering.
- Assuming the Cluster model is used by the pipeline. ClusteringService uses raw SQL for performance.
- Scheduling deleted commands. The old
clusters:generate-all and clusters:generate-team-clusters are deleted. Use clustering:update and clustering:process-dirty.
- Not flushing cluster cache after regeneration.
clustering:update --all and --all-teams auto-flush clusters:v5:* cache keys. Cache prefix has NO colon separator: openlittermap_cacheclusters:v5:*.
- Conditionally adding cluster layer to map.
mapLifecycleHelper.js ALWAYS adds the clusters GeoJSON layer to the map instance, even when initial fetch returns 0 features. Without this, subsequent cluster loads after panning/zooming don't render.
- Ignoring params in
GET_POINTS(). The store method must destructure and pass page, year, fromDate, toDate, username, signal to the backend. The API returns pagination as page (not current_page) at root level — pointsHelper.getPaginationData() normalizes this.