--- 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 ```bash # Full rebuild clustering:update --populate # Backfill NULL tile_keys (50k chunks) clustering:update --all # Recluster all global + tile zoom levels clustering:update --team=5 # Cluster a specific team clustering:update --all-teams # Cluster all teams with photos clustering:update --stats # Show statistics + integrity check clustering:update --explain # Show query execution plan (combine with --all) # Interactive menu (no flags) clustering:update # Shows choice() menu: all, populate, both, team, all-teams, stats # Incremental (scheduled every 5 minutes) clustering:process-dirty # Process dirty tiles + teams clustering:process-dirty --limit=100 --team-limit=20 # Diagnostic clustering:check-migration # Verify columns, indexes, PK, data integrity ``` **Scheduler** (`Kernel.php`): `clustering:process-dirty` runs every 5 minutes. `clustering:update --all --all-teams` runs nightly at 00:10. ## Invariants 1. **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). 2. **PhotoObserver uses two thresholds.** Global tile dirty: `>= ADMIN_APPROVED`. Team dirty: `>= VERIFIED`. 3. **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. 4. **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`. 5. **`tile_key` must be populated before per-tile clustering works.** Always run `--populate` before `--all`. 6. **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) ```sql PRIMARY KEY (team_id, tile_key, zoom, year, cell_x, cell_y) team_id UNSIGNED INT -- 0 = global, N = team-specific tile_key UNSIGNED INT -- 4294967295 = global sentinel zoom INT -- 0-16 year SMALLINT UNSIGNED -- 0 = all-time cell_x, cell_y INT -- Grid cell coordinates lat, lon DOUBLE -- Centroid point_count BIGINT UNSIGNED -- Photos in cluster grid_size DECIMAL(6,3) ``` ### Dirty tiles table ```sql 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.