From 3625e7f60962104e68b0f628e15518ee3fe2999d Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Wed, 27 May 2026 04:48:34 -0400 Subject: [PATCH 1/3] spec: playground & restroom amenity POI types (#418) Co-Authored-By: Claude Opus 4.7 (1M context) --- .specify/specs/027-amenity-poi-types/plan.md | 114 +++++++++++++++++ .specify/specs/027-amenity-poi-types/spec.md | 121 +++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 .specify/specs/027-amenity-poi-types/plan.md create mode 100644 .specify/specs/027-amenity-poi-types/spec.md diff --git a/.specify/specs/027-amenity-poi-types/plan.md b/.specify/specs/027-amenity-poi-types/plan.md new file mode 100644 index 00000000..d951ac35 --- /dev/null +++ b/.specify/specs/027-amenity-poi-types/plan.md @@ -0,0 +1,114 @@ +# Implementation Plan: Playground & Restroom Amenity POI Types + +> **Spec ID:** 027-amenity-poi-types +> **Status:** Planning +> **Last Updated:** 2026-05-27 +> **Estimated Effort:** M + +## Summary + +Add two icon-table POI types (playground, restroom) that auto-classify and appear +in the legend; seed ~240 OSM-sourced amenity POIs that fall inside park +boundaries (committed snapshot + idempotent importer keyed on `osm_id`); and skip +those types in batch news/events collection via a configurable excluded-types +setting and a small backend POI classifier. + +## Architecture + +### Data flow — types & legend +`icons` table rows → public `GET /api/admin/icons` → `iconConfig` in App.jsx → +`Map.jsx` builds legend from enabled icons + `iconUtils.getDestinationIconTypeFromConfig` +classifies each POI by name keyword → activity fallback. Adding two rows is all +that's needed; no frontend code change. + +### Data flow — OSM import +Overpass (`leisure=playground`, `amenity=toilets`, 2-county bbox) → filter to +points inside `boundary_type='park'` polygons (done during dev) → committed +`backend/data/osm/amenities.json` (240 features: osm_id, kind, lat, lon, osm_name, +park) → `import-osm-amenities.js` upserts point POIs by `osm_id`. + +### Data flow — collection skip +`getAllPoisForCollection` / `getPoisForTierCollection` load +`news_collection_excluded_types` + icon config, classify each candidate via +`backend/utils/poiClassify.js`, and drop excluded types before returning ids. + +## Implementation Steps + +### Phase 1: Types, schema, icons +- [ ] `backend/migrations/066_add_amenity_poi_types.sql`: insert playground + + restroom icons; `ALTER TABLE pois ADD COLUMN osm_id` + unique partial index; + seed `news_collection_excluded_types` setting. +- [ ] `frontend/public/icons/playground.svg`, `restroom.svg` (32×32 badge style). + +### Phase 2: OSM import +- [ ] `backend/data/osm/amenities.json` — committed snapshot (240 features). *(done)* +- [ ] `backend/migrations/import-osm-amenities.js` — read snapshot, build display + name (osm_name → `" "` with per-park collision suffix), upsert + point POIs by `osm_id` (poi_roles `{point}`, primary_activities, lat/lng, + navigation_*, brief_description, collection_tier `monthly`). Idempotent. + +### Phase 3: Collection skip +- [ ] `backend/utils/poiClassify.js` — `classifyPoiType(name, primaryActivities, iconConfig)` + (port of the keyword→fallback core of `getDestinationIconTypeFromConfig`). +- [ ] Modify `getAllPoisForCollection` + `getPoisForTierCollection` in + `newsService.js` to drop excluded types (load setting + icon config once). + +### Phase 4: Tests +- [ ] Unit test `poiClassify` (playground/restroom/other) and that the excluded + set removes amenity POIs from selection. + +## File Changes + +### New Files +| File | Purpose | +|------|---------| +| `backend/migrations/066_add_amenity_poi_types.sql` | icons + osm_id column + setting | +| `backend/migrations/import-osm-amenities.js` | idempotent OSM POI importer | +| `backend/data/osm/amenities.json` | committed 240-feature OSM snapshot | +| `backend/utils/poiClassify.js` | backend POI type classifier | +| `frontend/public/icons/playground.svg`, `restroom.svg` | type icons | +| `backend/tests/poiClassify.test.js` | unit tests | + +### Modified Files +| File | Changes | +|------|---------| +| `backend/services/newsService.js` | excluded-type filtering in the two selection helpers | + +## Database Migrations + +`066_add_amenity_poi_types.sql` — idempotent (ON CONFLICT / IF NOT EXISTS), +re-runs safely every deploy. The OSM POI rows are loaded by the importer +(`node /app/migrations/import-osm-amenities.js`), run once at deploy after the +image is pulled — same manual pattern as `load-county-state-boundaries.js`. + +## Testing Strategy + +### Manual +1. Map legend shows Playground + Restroom toggles with icons; toggling filters markers. +2. Click a park (e.g. Rocky River Reservation) → playground/restroom pins inside it. +3. Amenity POI sidebar shows no News/Events tabs. +4. Trigger a batch collection dry-run / inspect selected ids → no amenity POIs. +5. Regular POIs still collect. + +### Build / Gates +- [ ] `./run.sh build` passes +- [ ] `./run.sh test` (new unit test) — run by /deploy after merge +- [ ] gourmand `--full .` clean +- [ ] Gatehouse review clean + +## Rollback Plan +1. Revert the PR. The `osm_id` column and seeded icons are additive and harmless; + amenity POIs can be soft-deleted by `osm_id` if needed. + +## Risks and Mitigations +| Risk | Impact | Mitigation | +|------|--------|------------| +| Duplicate POIs on re-import | Med | Upsert keyed on unique `osm_id` | +| Misclassification skips a real POI from collection | Med | Classify against full icon config; excluded set is narrow + admin-configurable | +| OSM names mostly missing | Low | Fall back to `" "` with collision suffix | +| Stale OSM data | Low | Snapshot committed + dated; refetch to refresh | + +## Changelog +| Date | Changes | +|------|---------| +| 2026-05-27 | Initial plan | diff --git a/.specify/specs/027-amenity-poi-types/spec.md b/.specify/specs/027-amenity-poi-types/spec.md new file mode 100644 index 00000000..ae7b47fa --- /dev/null +++ b/.specify/specs/027-amenity-poi-types/spec.md @@ -0,0 +1,121 @@ +# Specification: Playground & Restroom Amenity POI Types + +> **Spec ID:** 027-amenity-poi-types +> **Status:** Draft +> **Version:** 0.1.0 +> **Author:** Scott McCarty +> **Date:** 2026-05-27 + +## Overview + +Parents want to find parks with good playgrounds and restrooms. This adds +**Playground** and **Restroom** as first-class map POI types (filterable legend +entries), seeds real locations inside our park boundaries from OpenStreetMap, and +excludes these amenity types from news/events collection (they have no news and +collecting for them wastes API budget). + +Closes #418. + +## User Stories + +### Find amenities + +**US-027-1: Filter the map for playgrounds & restrooms** +> As a parent, I want Playground and Restroom to appear as their own toggleable +> types in the map legend so I can see which parks have them. + +Acceptance Criteria: +- [ ] `playground` and `restroom` appear as POI types in the legend with distinct icons. +- [ ] Toggling them shows/hides those markers like any other type. +- [ ] A POI named "… Playground"/"… Restroom" (or with `primary_activities` + Playground/Restroom) renders with the correct icon. +- [ ] Works for anonymous visitors (legend reads public `/api/admin/icons`). + +**US-027-2: Real amenity locations inside parks** +> As a parent, I want actual playground and restroom locations shown inside the +> parks, sourced from OpenStreetMap. + +Acceptance Criteria: +- [ ] Playgrounds (`leisure=playground`) and restrooms (`amenity=toilets`) that + fall inside an existing **park** boundary are imported as point POIs + (~240 features across 42 parks). +- [ ] Each is named from its OSM `name` tag, else `" Playground/Restroom"` + (numeric suffix on collision within a park). +- [ ] Import is idempotent — re-running creates no duplicates (keyed on OSM id). + +### Don't waste collection budget + +**US-027-3: Skip news/events collection for amenity types** +> As an operator, I want news/events collection to skip playground and restroom +> POIs so we don't spend API budget on POIs that never have news. + +Acceptance Criteria: +- [ ] Batch collection (all-POIs and per-tier) excludes POIs classified as an + excluded type. +- [ ] The excluded type set is admin-configurable (`news_collection_excluded_types`), + defaulting to `["playground","restroom"]`. +- [ ] News/Events tabs naturally stay hidden for these POIs (0 counts). + +## Data Model + +### Schema Changes + +```sql +-- Provenance + idempotency key for OSM-sourced POIs +ALTER TABLE pois ADD COLUMN IF NOT EXISTS osm_id TEXT; +CREATE UNIQUE INDEX IF NOT EXISTS idx_pois_osm_id ON pois (osm_id) WHERE osm_id IS NOT NULL; + +-- New map types (filterable legend entries, keyword/activity classified) +INSERT INTO icons (name, label, svg_filename, title_keywords, activity_fallbacks, sort_order) VALUES + ('playground', 'Playground', 'playground.svg', 'playground,play area', 'Playground', 19), + ('restroom', 'Restroom', 'restroom.svg', 'restroom,restrooms,bathroom,toilet,toilets', 'Restroom', 20) +ON CONFLICT (name) DO NOTHING; + +-- Default excluded-from-collection types +INSERT INTO admin_settings (key, value) VALUES + ('news_collection_excluded_types', '["playground","restroom"]') +ON CONFLICT (key) DO NOTHING; +``` + +`pois.geom` and the icons/legend system are otherwise unchanged. + +## API Endpoints + +No new endpoints. Existing `/api/admin/icons` (public GET) surfaces the new types +to the map legend automatically. + +## UI/UX Requirements + +No new components. The legend is generated from `iconConfig`, so the two new icon +rows appear and filter automatically. Two new 32×32 SVGs in +`frontend/public/icons/` (`playground.svg`, `restroom.svg`) matching the existing +circle-badge style. + +## Non-Functional Requirements + +**NFR-027-1: Idempotent import** — re-running the OSM importer (or re-deploying) +must not duplicate POIs; keyed on `osm_id`. + +**NFR-027-2: Reproducible data** — the OSM snapshot is committed +(`backend/data/osm/amenities.json`); the importer is deterministic and needs no +network at deploy time. + +**NFR-027-3: No regression** — non-amenity POIs collect news/events exactly as +before; only excluded types are dropped from batch selection. + +## Dependencies + +- Builds on the icons/legend system (migration 065) and park boundaries (#198, 044). +- OSM data: Overpass API `leisure=playground` + `amenity=toilets`, filtered to + features inside `boundary_type='park'` polygons. + +## Open Questions + +None — model (icons-table types), collection skip (by type), and data scope +(OSM import, park-contained) resolved. + +## Changelog + +| Version | Date | Changes | +|---------|------|---------| +| 0.1.0 | 2026-05-27 | Initial draft | From 0dcd33c6b2f6491215cebebdecf3ee552593f209 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Wed, 27 May 2026 13:41:39 -0400 Subject: [PATCH 2/3] feat: playground & restroom amenity POI types (#418) Add Playground and Restroom as map POI types so parents can find parks with good amenities, seed real locations from OpenStreetMap, and exclude these amenity types from news/events collection. - migration 066: playground + restroom icon types (name-classified, no activity fallbacks so a park that merely offers a playground keeps its own icon); pois.osm_id provenance/idempotency column; icons.default_hidden so amenities sit in the legend but start toggled off (avoid clutter); news_collection_excluded_types setting (default playground,restroom). - OSM import: backend/data/osm/amenities.json (240 features inside park boundaries: 201 restrooms, 39 playgrounds) + idempotent importer (import-osm-amenities.js) upserting point POIs by osm_id, named by containing park. - collection skip: backend/utils/poiClassify.js + newsService selection drops POIs whose type is excluded (name classification) or whose sole activity is a dedicated amenity. - frontend: default_hidden types start unchecked; OSM (ODbL) attribution on the satellite base layer. - two amenity icons (playground.svg, restroom.svg); poiClassify unit tests. Closes #418 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/data/osm/amenities.json | 1922 +++++++++++++++++ .../migrations/066_add_amenity_poi_types.sql | 48 + backend/migrations/import-osm-amenities.js | 109 + backend/routes/admin.js | 2 +- backend/services/newsService.js | 51 +- backend/tests/poiClassify.unit.test.js | 61 + backend/utils/poiClassify.js | 48 + frontend/public/icons/playground.svg | 7 + frontend/public/icons/restroom.svg | 10 + frontend/src/App.jsx | 3 +- frontend/src/components/Map.jsx | 2 +- 11 files changed, 2256 insertions(+), 7 deletions(-) create mode 100644 backend/data/osm/amenities.json create mode 100644 backend/migrations/066_add_amenity_poi_types.sql create mode 100644 backend/migrations/import-osm-amenities.js create mode 100644 backend/tests/poiClassify.unit.test.js create mode 100644 backend/utils/poiClassify.js create mode 100644 frontend/public/icons/playground.svg create mode 100644 frontend/public/icons/restroom.svg diff --git a/backend/data/osm/amenities.json b/backend/data/osm/amenities.json new file mode 100644 index 00000000..6f32c6d4 --- /dev/null +++ b/backend/data/osm/amenities.json @@ -0,0 +1,1922 @@ +[ + { + "osm_id": "node10121751979", + "kind": "restroom", + "lat": 41.372601, + "lon": -81.6130157, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node10676833085", + "kind": "restroom", + "lat": 41.2622703, + "lon": -81.5599946, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node10707691068", + "kind": "restroom", + "lat": 41.2170147, + "lon": -81.5257278, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node10762282822", + "kind": "restroom", + "lat": 41.1390045, + "lon": -81.5747406, + "osm_name": null, + "park": "F.A. Seiberling Nature Realm" + }, + { + "osm_id": "node10859526305", + "kind": "restroom", + "lat": 41.499276, + "lon": -81.7115625, + "osm_name": null, + "park": "Cleveland Lakefront Reservation" + }, + { + "osm_id": "node10859526306", + "kind": "restroom", + "lat": 41.4987731, + "lon": -81.7155029, + "osm_name": null, + "park": "Cleveland Lakefront Reservation" + }, + { + "osm_id": "node11020856749", + "kind": "restroom", + "lat": 41.5020874, + "lon": -81.4893153, + "osm_name": null, + "park": "Acacia Reservation" + }, + { + "osm_id": "node1137180456", + "kind": "restroom", + "lat": 41.1592267, + "lon": -81.5744783, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node11916192151", + "kind": "restroom", + "lat": 41.3924917, + "lon": -81.6308308, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node11978458897", + "kind": "restroom", + "lat": 41.224304, + "lon": -81.5105508, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node12115369022", + "kind": "playground", + "lat": 41.1240971, + "lon": -81.5312301, + "osm_name": null, + "park": "Cascade Valley Metro Park" + }, + { + "osm_id": "node12956673576", + "kind": "restroom", + "lat": 41.5409742, + "lon": -81.6338902, + "osm_name": null, + "park": "Cleveland Lakefront Reservation" + }, + { + "osm_id": "node1297928834", + "kind": "playground", + "lat": 41.4465345, + "lon": -81.7251191, + "osm_name": null, + "park": "Brookside Reservation" + }, + { + "osm_id": "node13037793077", + "kind": "restroom", + "lat": 41.1721688, + "lon": -81.5718225, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node13101163329", + "kind": "restroom", + "lat": 41.2178774, + "lon": -81.5790801, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node13115472762", + "kind": "restroom", + "lat": 41.1325073, + "lon": -81.4220798, + "osm_name": null, + "park": "Munroe Falls Metro Park" + }, + { + "osm_id": "node13119545302", + "kind": "playground", + "lat": 41.1303703, + "lon": -81.4230127, + "osm_name": null, + "park": "Munroe Falls Metro Park" + }, + { + "osm_id": "node13119546749", + "kind": "playground", + "lat": 41.1329089, + "lon": -81.4237524, + "osm_name": null, + "park": "Munroe Falls Metro Park" + }, + { + "osm_id": "node13120496070", + "kind": "restroom", + "lat": 41.2776985, + "lon": -81.5372488, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node13120522503", + "kind": "restroom", + "lat": 41.2318264, + "lon": -81.5540243, + "osm_name": null, + "park": "Deep Lock Quarry Metro Park" + }, + { + "osm_id": "node13529495791", + "kind": "restroom", + "lat": 41.3904225, + "lon": -81.5767491, + "osm_name": null, + "park": "Bedford Reservation" + }, + { + "osm_id": "node13568292708", + "kind": "restroom", + "lat": 41.3897753, + "lon": -81.6951007, + "osm_name": null, + "park": "West Creek Reservation" + }, + { + "osm_id": "node13568292732", + "kind": "restroom", + "lat": 41.3897834, + "lon": -81.6951233, + "osm_name": null, + "park": "West Creek Reservation" + }, + { + "osm_id": "node13568292734", + "kind": "restroom", + "lat": 41.3908488, + "lon": -81.6917027, + "osm_name": null, + "park": "West Creek Reservation" + }, + { + "osm_id": "node1375183996", + "kind": "restroom", + "lat": 41.2309378, + "lon": -81.4934969, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node13836877296", + "kind": "restroom", + "lat": 41.3189718, + "lon": -81.5880832, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node13836877297", + "kind": "restroom", + "lat": 41.3189741, + "lon": -81.5881326, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node2141868525", + "kind": "restroom", + "lat": 41.3790138, + "lon": -81.551066, + "osm_name": null, + "park": "Bedford Reservation" + }, + { + "osm_id": "node2141917787", + "kind": "restroom", + "lat": 41.3758499, + "lon": -81.573669, + "osm_name": null, + "park": "Bedford Reservation" + }, + { + "osm_id": "node2148542124", + "kind": "restroom", + "lat": 41.3739723, + "lon": -81.5721534, + "osm_name": null, + "park": "Bedford Reservation" + }, + { + "osm_id": "node2153810573", + "kind": "restroom", + "lat": 41.5796073, + "lon": -81.4287785, + "osm_name": null, + "park": "North Chagrin Reservation" + }, + { + "osm_id": "node2155072348", + "kind": "restroom", + "lat": 41.3524398, + "lon": -81.5920963, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node2158266182", + "kind": "restroom", + "lat": 41.5612571, + "lon": -81.430438, + "osm_name": null, + "park": "North Chagrin Reservation" + }, + { + "osm_id": "node2202420723", + "kind": "restroom", + "lat": 41.5746917, + "lon": -81.4226424, + "osm_name": null, + "park": "North Chagrin Reservation" + }, + { + "osm_id": "node2202420738", + "kind": "restroom", + "lat": 41.5745894, + "lon": -81.4214193, + "osm_name": null, + "park": "North Chagrin Reservation" + }, + { + "osm_id": "node2235614557", + "kind": "restroom", + "lat": 41.5501387, + "lon": -81.4177555, + "osm_name": null, + "park": "North Chagrin Reservation" + }, + { + "osm_id": "node2243047166", + "kind": "restroom", + "lat": 41.5690731, + "lon": -81.4191489, + "osm_name": null, + "park": "North Chagrin Reservation" + }, + { + "osm_id": "node2251256936", + "kind": "restroom", + "lat": 41.5615033, + "lon": -81.4346334, + "osm_name": null, + "park": "North Chagrin Reservation" + }, + { + "osm_id": "node2283472002", + "kind": "restroom", + "lat": 41.4072496, + "lon": -81.8863243, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "node2414613006", + "kind": "restroom", + "lat": 41.4569989, + "lon": -81.4066279, + "osm_name": null, + "park": "South Chagrin Reservation" + }, + { + "osm_id": "node2504525020", + "kind": "restroom", + "lat": 41.5391942, + "lon": -81.5219986, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "node2505390692", + "kind": "restroom", + "lat": 41.5601654, + "lon": -81.532329, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "node2505390702", + "kind": "restroom", + "lat": 41.5620921, + "lon": -81.5319856, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "node2506476021", + "kind": "playground", + "lat": 41.5394272, + "lon": -81.5219353, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "node2509774902", + "kind": "restroom", + "lat": 41.5479255, + "lon": -81.5285592, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "node2516687875", + "kind": "restroom", + "lat": 41.276319, + "lon": -81.5402439, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node2519251318", + "kind": "restroom", + "lat": 41.3770279, + "lon": -81.5589703, + "osm_name": null, + "park": "Bedford Reservation" + }, + { + "osm_id": "node2519265063", + "kind": "restroom", + "lat": 41.3805781, + "lon": -81.5346332, + "osm_name": null, + "park": "Bedford Reservation" + }, + { + "osm_id": "node2520419082", + "kind": "restroom", + "lat": 41.5406539, + "lon": -81.6286528, + "osm_name": null, + "park": "Cleveland Lakefront Reservation" + }, + { + "osm_id": "node2525740698", + "kind": "restroom", + "lat": 41.5020872, + "lon": -81.489358, + "osm_name": null, + "park": "Acacia Reservation" + }, + { + "osm_id": "node2525828377", + "kind": "restroom", + "lat": 41.3908153, + "lon": -81.6917462, + "osm_name": null, + "park": "West Creek Reservation" + }, + { + "osm_id": "node2679582997", + "kind": "restroom", + "lat": 41.3154487, + "lon": -81.6165565, + "osm_name": null, + "park": "Brecksville Reservation" + }, + { + "osm_id": "node2679583003", + "kind": "restroom", + "lat": 41.3154527, + "lon": -81.6186942, + "osm_name": null, + "park": "Brecksville Reservation" + }, + { + "osm_id": "node2679583027", + "kind": "restroom", + "lat": 41.3196251, + "lon": -81.6155058, + "osm_name": null, + "park": "Brecksville Reservation" + }, + { + "osm_id": "node2679583028", + "kind": "restroom", + "lat": 41.3181743, + "lon": -81.6156821, + "osm_name": null, + "park": "Brecksville Reservation" + }, + { + "osm_id": "node2679583062", + "kind": "restroom", + "lat": 41.307817, + "lon": -81.6020229, + "osm_name": null, + "park": "Brecksville Reservation" + }, + { + "osm_id": "node2757357899", + "kind": "restroom", + "lat": 41.3015754, + "lon": -81.8033928, + "osm_name": null, + "park": "Mill Stream Run Reservation" + }, + { + "osm_id": "node2786496941", + "kind": "restroom", + "lat": 41.3156644, + "lon": -81.6010038, + "osm_name": null, + "park": "Brecksville Reservation" + }, + { + "osm_id": "node2851907817", + "kind": "restroom", + "lat": 41.2262941, + "lon": -81.5152929, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node2851910941", + "kind": "restroom", + "lat": 41.2243049, + "lon": -81.5104772, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node2851910954", + "kind": "restroom", + "lat": 41.2320797, + "lon": -81.5072738, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node2867556015", + "kind": "restroom", + "lat": 41.3863932, + "lon": -81.5371677, + "osm_name": null, + "park": "Bedford Reservation" + }, + { + "osm_id": "node2867556025", + "kind": "restroom", + "lat": 41.3846877, + "lon": -81.5243936, + "osm_name": null, + "park": "Bedford Reservation" + }, + { + "osm_id": "node2867556036", + "kind": "restroom", + "lat": 41.3732017, + "lon": -81.5046475, + "osm_name": null, + "park": "Bedford Reservation" + }, + { + "osm_id": "node2937937111", + "kind": "restroom", + "lat": 41.4237721, + "lon": -81.4213917, + "osm_name": null, + "park": "South Chagrin Reservation" + }, + { + "osm_id": "node2937937160", + "kind": "restroom", + "lat": 41.4185272, + "lon": -81.4192953, + "osm_name": null, + "park": "South Chagrin Reservation" + }, + { + "osm_id": "node2941979574", + "kind": "restroom", + "lat": 41.4337199, + "lon": -81.4177753, + "osm_name": null, + "park": "South Chagrin Reservation" + }, + { + "osm_id": "node2953596613", + "kind": "restroom", + "lat": 41.4006065, + "lon": -81.8864987, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "node2953596933", + "kind": "restroom", + "lat": 41.4047691, + "lon": -81.8840418, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "node2961881365", + "kind": "restroom", + "lat": 41.4332255, + "lon": -81.8448262, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "node2961881408", + "kind": "restroom", + "lat": 41.4327047, + "lon": -81.8485223, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "node2986102859", + "kind": "restroom", + "lat": 41.4901381, + "lon": -81.9339643, + "osm_name": null, + "park": "Huntington Reservation" + }, + { + "osm_id": "node2986102861", + "kind": "restroom", + "lat": 41.4900261, + "lon": -81.9310068, + "osm_name": null, + "park": "Huntington Reservation" + }, + { + "osm_id": "node2986102864", + "kind": "restroom", + "lat": 41.486667, + "lon": -81.9338194, + "osm_name": null, + "park": "Huntington Reservation" + }, + { + "osm_id": "node2986138404", + "kind": "restroom", + "lat": 41.4425483, + "lon": -81.7552954, + "osm_name": null, + "park": "Big Creek Reservation" + }, + { + "osm_id": "node2986138410", + "kind": "restroom", + "lat": 41.4020351, + "lon": -81.7580542, + "osm_name": null, + "park": "Big Creek Reservation" + }, + { + "osm_id": "node2986138417", + "kind": "playground", + "lat": 41.3804378, + "lon": -81.8409555, + "osm_name": "Mastadon Play Pit Play Area", + "park": "Big Creek Reservation" + }, + { + "osm_id": "node2986174403", + "kind": "restroom", + "lat": 41.3635574, + "lon": -81.8581176, + "osm_name": null, + "park": "Mill Stream Run Reservation" + }, + { + "osm_id": "node2986174415", + "kind": "restroom", + "lat": 41.3547407, + "lon": -81.8546829, + "osm_name": null, + "park": "Mill Stream Run Reservation" + }, + { + "osm_id": "node2986174419", + "kind": "restroom", + "lat": 41.3552443, + "lon": -81.8495652, + "osm_name": null, + "park": "Mill Stream Run Reservation" + }, + { + "osm_id": "node2986236008", + "kind": "restroom", + "lat": 41.3338914, + "lon": -81.8335773, + "osm_name": null, + "park": "Mill Stream Run Reservation" + }, + { + "osm_id": "node2986236013", + "kind": "restroom", + "lat": 41.3341387, + "lon": -81.8274293, + "osm_name": null, + "park": "Mill Stream Run Reservation" + }, + { + "osm_id": "node2986519822", + "kind": "playground", + "lat": 41.4343864, + "lon": -81.8443419, + "osm_name": "Swingset", + "park": "Rocky River Reservation" + }, + { + "osm_id": "node2986519823", + "kind": "playground", + "lat": 41.4335579, + "lon": -81.8443848, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "node2986520656", + "kind": "restroom", + "lat": 41.4791695, + "lon": -81.832508, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "node2986520658", + "kind": "playground", + "lat": 41.4780683, + "lon": -81.8317462, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "node2986687776", + "kind": "restroom", + "lat": 41.2208544, + "lon": -81.7203914, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "node2986687811", + "kind": "restroom", + "lat": 41.2204569, + "lon": -81.6979976, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "node2986687814", + "kind": "restroom", + "lat": 41.2185362, + "lon": -81.7030598, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "node2986687817", + "kind": "restroom", + "lat": 41.2114941, + "lon": -81.70892, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "node2986687822", + "kind": "restroom", + "lat": 41.204207, + "lon": -81.7262368, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "node2986687832", + "kind": "restroom", + "lat": 41.2040133, + "lon": -81.7269878, + "osm_name": "Ledge Lake Bath House", + "park": "Hinckley Reservation" + }, + { + "osm_id": "node3195055708", + "kind": "restroom", + "lat": 41.3865764, + "lon": -81.5953812, + "osm_name": null, + "park": "Valley View Woods" + }, + { + "osm_id": "node3195055710", + "kind": "playground", + "lat": 41.3867434, + "lon": -81.5956736, + "osm_name": null, + "park": "Valley View Woods" + }, + { + "osm_id": "node3195055711", + "kind": "playground", + "lat": 41.3870272, + "lon": -81.5929082, + "osm_name": null, + "park": "Valley View Woods" + }, + { + "osm_id": "node3445659035", + "kind": "restroom", + "lat": 41.4111826, + "lon": -81.4140311, + "osm_name": null, + "park": "South Chagrin Reservation" + }, + { + "osm_id": "node3618330016", + "kind": "restroom", + "lat": 41.2895578, + "lon": -81.5640521, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node4022750202", + "kind": "playground", + "lat": 41.5598247, + "lon": -81.5313771, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "node4035709934", + "kind": "restroom", + "lat": 41.4295271, + "lon": -81.6060899, + "osm_name": null, + "park": "Garfield Park Reservation" + }, + { + "osm_id": "node4035709947", + "kind": "restroom", + "lat": 41.4294947, + "lon": -81.6019106, + "osm_name": null, + "park": "Garfield Park Reservation" + }, + { + "osm_id": "node4035712675", + "kind": "restroom", + "lat": 41.4329632, + "lon": -81.6051565, + "osm_name": null, + "park": "Garfield Park Reservation" + }, + { + "osm_id": "node4036690720", + "kind": "playground", + "lat": 41.4286193, + "lon": -81.60683, + "osm_name": null, + "park": "Garfield Park Reservation" + }, + { + "osm_id": "node4044801548", + "kind": "restroom", + "lat": 41.5699001, + "lon": -81.4377953, + "osm_name": null, + "park": "North Chagrin Reservation" + }, + { + "osm_id": "node4064237415", + "kind": "restroom", + "lat": 41.4229027, + "lon": -81.4168596, + "osm_name": null, + "park": "South Chagrin Reservation" + }, + { + "osm_id": "node4066048430", + "kind": "restroom", + "lat": 41.4322004, + "lon": -81.604702, + "osm_name": null, + "park": "Garfield Park Reservation" + }, + { + "osm_id": "node4069224516", + "kind": "restroom", + "lat": 41.4134042, + "lon": -81.4594332, + "osm_name": null, + "park": "South Chagrin Reservation" + }, + { + "osm_id": "node4127019880", + "kind": "restroom", + "lat": 41.2709841, + "lon": -81.5558063, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node4218284432", + "kind": "restroom", + "lat": 41.372215, + "lon": -81.6137067, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node4270946681", + "kind": "restroom", + "lat": 41.306772, + "lon": -81.398587, + "osm_name": null, + "park": "Liberty Park" + }, + { + "osm_id": "node4351736404", + "kind": "restroom", + "lat": 41.1842838, + "lon": -81.5826273, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node4464194737", + "kind": "playground", + "lat": 41.4901601, + "lon": -81.9332669, + "osm_name": null, + "park": "Huntington Reservation" + }, + { + "osm_id": "node4464194784", + "kind": "restroom", + "lat": 41.4895333, + "lon": -81.9345944, + "osm_name": null, + "park": "Huntington Reservation" + }, + { + "osm_id": "node4465446059", + "kind": "restroom", + "lat": 41.4336882, + "lon": -81.6587505, + "osm_name": null, + "park": "Ohio & Erie Canal Reservation" + }, + { + "osm_id": "node4465446844", + "kind": "restroom", + "lat": 41.4545856, + "lon": -81.6592269, + "osm_name": null, + "park": "Washington Reservation" + }, + { + "osm_id": "node4529249945", + "kind": "restroom", + "lat": 41.561726, + "lon": -81.4357488, + "osm_name": null, + "park": "North Chagrin Reservation" + }, + { + "osm_id": "node4531011957", + "kind": "restroom", + "lat": 41.3568005, + "lon": -81.8241964, + "osm_name": null, + "park": "Big Creek Reservation" + }, + { + "osm_id": "node4531012608", + "kind": "restroom", + "lat": 41.380378, + "lon": -81.8425807, + "osm_name": null, + "park": "Big Creek Reservation" + }, + { + "osm_id": "node4545860956", + "kind": "restroom", + "lat": 41.2216249, + "lon": -81.7309028, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "node4545861521", + "kind": "restroom", + "lat": 41.203166, + "lon": -81.7006004, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "node4636077527", + "kind": "playground", + "lat": 41.3761302, + "lon": -81.5742223, + "osm_name": null, + "park": "Bedford Reservation" + }, + { + "osm_id": "node4714314685", + "kind": "restroom", + "lat": 41.0125077, + "lon": -81.3940918, + "osm_name": null, + "park": "Springfield Bog Metro Park" + }, + { + "osm_id": "node4719518875", + "kind": "playground", + "lat": 41.3629687, + "lon": -81.8588522, + "osm_name": null, + "park": "Mill Stream Run Reservation" + }, + { + "osm_id": "node4731424491", + "kind": "restroom", + "lat": 41.3043636, + "lon": -81.7537599, + "osm_name": null, + "park": "Brecksville Reservation" + }, + { + "osm_id": "node4732830529", + "kind": "restroom", + "lat": 41.3023289, + "lon": -81.7378319, + "osm_name": null, + "park": "Brecksville Reservation" + }, + { + "osm_id": "node4884571736", + "kind": "playground", + "lat": 41.2165105, + "lon": -81.7145979, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "node4884571737", + "kind": "restroom", + "lat": 41.2171251, + "lon": -81.7144119, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "node4904842313", + "kind": "restroom", + "lat": 41.3630752, + "lon": -81.8546659, + "osm_name": null, + "park": "Mill Stream Run Reservation" + }, + { + "osm_id": "node4972122536", + "kind": "restroom", + "lat": 41.1277701, + "lon": -81.5397336, + "osm_name": null, + "park": "Sand Run Metro Park" + }, + { + "osm_id": "node4974367973", + "kind": "restroom", + "lat": 41.4133342, + "lon": -81.4233188, + "osm_name": null, + "park": "South Chagrin Reservation" + }, + { + "osm_id": "node4980291106", + "kind": "restroom", + "lat": 41.2680072, + "lon": -81.6392055, + "osm_name": null, + "park": "Furnace Run Metro Park" + }, + { + "osm_id": "node5036948730", + "kind": "playground", + "lat": 41.4504556, + "lon": -81.6580381, + "osm_name": null, + "park": "Washington Reservation" + }, + { + "osm_id": "node5102173322", + "kind": "restroom", + "lat": 41.0917526, + "lon": -81.5186707, + "osm_name": null, + "park": "Cascade Park" + }, + { + "osm_id": "node5275328754", + "kind": "restroom", + "lat": 41.2141827, + "lon": -81.5309797, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node5747753898", + "kind": "restroom", + "lat": 41.4996367, + "lon": -81.7108324, + "osm_name": null, + "park": "Cleveland Lakefront Reservation" + }, + { + "osm_id": "node5825507126", + "kind": "playground", + "lat": 41.0592966, + "lon": -81.5425897, + "osm_name": null, + "park": "Summit Lake Park" + }, + { + "osm_id": "node5982333359", + "kind": "restroom", + "lat": 41.1378062, + "lon": -81.5649645, + "osm_name": null, + "park": "Sand Run Metro Park" + }, + { + "osm_id": "node6003953365", + "kind": "restroom", + "lat": 41.1334234, + "lon": -81.596668, + "osm_name": null, + "park": "Sand Run Metro Park" + }, + { + "osm_id": "node6011450296", + "kind": "restroom", + "lat": 41.2611433, + "lon": -81.683192, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "node6214892937", + "kind": "playground", + "lat": 41.331648, + "lon": -81.4091136, + "osm_name": "Nature Play", + "park": "Liberty Park" + }, + { + "osm_id": "node6283260364", + "kind": "restroom", + "lat": 41.1340915, + "lon": -81.5600071, + "osm_name": null, + "park": "Sand Run Metro Park" + }, + { + "osm_id": "node6311827098", + "kind": "playground", + "lat": 41.3903862, + "lon": -81.5772039, + "osm_name": null, + "park": "Bedford Reservation" + }, + { + "osm_id": "node6311827109", + "kind": "restroom", + "lat": 41.389769, + "lon": -81.5923124, + "osm_name": null, + "park": "Valley View Woods" + }, + { + "osm_id": "node6486880885", + "kind": "restroom", + "lat": 41.0784253, + "lon": -81.4557772, + "osm_name": null, + "park": "Goodyear Heights Metro Park" + }, + { + "osm_id": "node6510003585", + "kind": "restroom", + "lat": 41.1703681, + "lon": -81.5902836, + "osm_name": null, + "park": "O'Neil Woods Metro Park" + }, + { + "osm_id": "node6712076870", + "kind": "playground", + "lat": 41.448979, + "lon": -81.7239299, + "osm_name": null, + "park": "Brookside Reservation" + }, + { + "osm_id": "node6795730985", + "kind": "restroom", + "lat": 41.1670844, + "lon": -81.5670037, + "osm_name": null, + "park": "Hampton Hills Metro Park" + }, + { + "osm_id": "node8040270852", + "kind": "restroom", + "lat": 41.2207808, + "lon": -81.5116417, + "osm_name": "Men's Toilets", + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node8040276264", + "kind": "restroom", + "lat": 41.2207497, + "lon": -81.5112938, + "osm_name": "Women's Bathroom", + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "node8279554267", + "kind": "playground", + "lat": 41.5606933, + "lon": -81.5329362, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "node8762263552", + "kind": "restroom", + "lat": 41.4887874, + "lon": -81.7382427, + "osm_name": null, + "park": "Cleveland Lakefront Reservation" + }, + { + "osm_id": "node8762286830", + "kind": "restroom", + "lat": 41.4865533, + "lon": -81.743656, + "osm_name": null, + "park": "Cleveland Lakefront Reservation" + }, + { + "osm_id": "node8762604693", + "kind": "playground", + "lat": 41.5834593, + "lon": -81.568323, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "node8762652383", + "kind": "restroom", + "lat": 41.5829163, + "lon": -81.5693881, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "node8763018942", + "kind": "restroom", + "lat": 41.0110472, + "lon": -81.5176427, + "osm_name": null, + "park": "Firestone Metro Park" + }, + { + "osm_id": "node8763018943", + "kind": "restroom", + "lat": 41.012577, + "lon": -81.5136469, + "osm_name": null, + "park": "Firestone Metro Park" + }, + { + "osm_id": "node8815953337", + "kind": "restroom", + "lat": 41.0168175, + "lon": -81.5149407, + "osm_name": null, + "park": "Firestone Metro Park" + }, + { + "osm_id": "node9230679589", + "kind": "restroom", + "lat": 41.4092779, + "lon": -81.8820007, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "node9588942854", + "kind": "restroom", + "lat": 41.4921596, + "lon": -81.7359486, + "osm_name": null, + "park": "Cleveland Lakefront Reservation" + }, + { + "osm_id": "node9741336929", + "kind": "restroom", + "lat": 41.1293802, + "lon": -81.5526594, + "osm_name": null, + "park": "Sand Run Metro Park" + }, + { + "osm_id": "node9818859742", + "kind": "restroom", + "lat": 41.4888053, + "lon": -81.7382911, + "osm_name": null, + "park": "Cleveland Lakefront Reservation" + }, + { + "osm_id": "node9854608444", + "kind": "restroom", + "lat": 41.139266, + "lon": -81.5649835, + "osm_name": null, + "park": "Sand Run Metro Park" + }, + { + "osm_id": "node9854608453", + "kind": "restroom", + "lat": 41.1303178, + "lon": -81.5680391, + "osm_name": null, + "park": "Sand Run Metro Park" + }, + { + "osm_id": "node9910733104", + "kind": "restroom", + "lat": 41.1227772, + "lon": -81.5249043, + "osm_name": null, + "park": "Cascade Valley Metro Park" + }, + { + "osm_id": "node9935556868", + "kind": "restroom", + "lat": 41.3608849, + "lon": -81.8588261, + "osm_name": null, + "park": "Mill Stream Run Reservation" + }, + { + "osm_id": "way1005349290", + "kind": "restroom", + "lat": 41.4332349, + "lon": -81.8448275, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "way1005349294", + "kind": "restroom", + "lat": 41.4349193, + "lon": -81.8442683, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "way1005349296", + "kind": "playground", + "lat": 41.4332898, + "lon": -81.8441266, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "way1033100035", + "kind": "restroom", + "lat": 41.4895403, + "lon": -81.9345986, + "osm_name": null, + "park": "Huntington Reservation" + }, + { + "osm_id": "way1038682275", + "kind": "playground", + "lat": 41.486458, + "lon": -81.7460066, + "osm_name": "The Lindsey Family Play Space", + "park": "Cleveland Lakefront Reservation" + }, + { + "osm_id": "way1044285011", + "kind": "restroom", + "lat": 41.42995, + "lon": -81.6657215, + "osm_name": null, + "park": "Ohio & Erie Canal Reservation" + }, + { + "osm_id": "way1079665169", + "kind": "restroom", + "lat": 41.582074, + "lon": -81.4177858, + "osm_name": null, + "park": "North Chagrin Reservation" + }, + { + "osm_id": "way1082530189", + "kind": "restroom", + "lat": 41.1201718, + "lon": -81.5169127, + "osm_name": null, + "park": "Cascade Valley Metro Park" + }, + { + "osm_id": "way1083291220", + "kind": "restroom", + "lat": 41.1220865, + "lon": -81.5227153, + "osm_name": null, + "park": "Cascade Valley Metro Park" + }, + { + "osm_id": "way1083291222", + "kind": "restroom", + "lat": 41.1195374, + "lon": -81.5237149, + "osm_name": null, + "park": "Cascade Valley Metro Park" + }, + { + "osm_id": "way1149973620", + "kind": "restroom", + "lat": 41.2394779, + "lon": -81.5378296, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way1155071912", + "kind": "restroom", + "lat": 41.219643, + "lon": -81.575901, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way1156484519", + "kind": "restroom", + "lat": 41.1273396, + "lon": -81.5458675, + "osm_name": null, + "park": "Sand Run Metro Park" + }, + { + "osm_id": "way1157720751", + "kind": "restroom", + "lat": 41.1208065, + "lon": -81.4934425, + "osm_name": null, + "park": "Gorge Metro Park" + }, + { + "osm_id": "way1163505913", + "kind": "restroom", + "lat": 41.3112209, + "lon": -81.7846409, + "osm_name": null, + "park": "Mill Stream Run Reservation" + }, + { + "osm_id": "way1174428106", + "kind": "restroom", + "lat": 41.2333892, + "lon": -81.5693289, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way1221204668", + "kind": "restroom", + "lat": 41.229704, + "lon": -81.5539495, + "osm_name": null, + "park": "Deep Lock Quarry Metro Park" + }, + { + "osm_id": "way1249928792", + "kind": "restroom", + "lat": 41.4453405, + "lon": -81.8293357, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "way1267521419", + "kind": "playground", + "lat": 41.3804303, + "lon": -81.8409429, + "osm_name": "Mastadon Play Pit Play Area", + "park": "Big Creek Reservation" + }, + { + "osm_id": "way1267995343", + "kind": "restroom", + "lat": 41.453983, + "lon": -81.6621353, + "osm_name": null, + "park": "Washington Reservation" + }, + { + "osm_id": "way1286938353", + "kind": "restroom", + "lat": 41.1509808, + "lon": -81.550829, + "osm_name": null, + "park": "Hampton Hills Mountain Bike Area" + }, + { + "osm_id": "way1294767426", + "kind": "playground", + "lat": 41.1659651, + "lon": -81.5365896, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way1308288116", + "kind": "restroom", + "lat": 41.1238763, + "lon": -81.5310094, + "osm_name": null, + "park": "Cascade Valley Metro Park" + }, + { + "osm_id": "way1330031009", + "kind": "playground", + "lat": 41.4902682, + "lon": -81.9333607, + "osm_name": "Karen's Way Play Space", + "park": "Huntington Reservation" + }, + { + "osm_id": "way1335708746", + "kind": "restroom", + "lat": 41.1282915, + "lon": -81.521784, + "osm_name": null, + "park": "Cascade Valley Metro Park" + }, + { + "osm_id": "way1359965592", + "kind": "restroom", + "lat": 41.4416124, + "lon": -81.8349511, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "way1388871213", + "kind": "restroom", + "lat": 41.2307395, + "lon": -81.5232341, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way1421600772", + "kind": "restroom", + "lat": 41.2232298, + "lon": -81.7116889, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "way1421600776", + "kind": "restroom", + "lat": 41.2246356, + "lon": -81.7081998, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "way1421600777", + "kind": "restroom", + "lat": 41.2221404, + "lon": -81.7129131, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "way1421600787", + "kind": "restroom", + "lat": 41.217792, + "lon": -81.7159193, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "way1421600795", + "kind": "restroom", + "lat": 41.2266774, + "lon": -81.7195273, + "osm_name": null, + "park": "Hinckley Reservation" + }, + { + "osm_id": "way1422943492", + "kind": "restroom", + "lat": 41.3014254, + "lon": -81.5998556, + "osm_name": null, + "park": "Brecksville Reservation" + }, + { + "osm_id": "way1424147645", + "kind": "restroom", + "lat": 41.3846824, + "lon": -81.5394062, + "osm_name": null, + "park": "Bedford Reservation" + }, + { + "osm_id": "way1424841214", + "kind": "restroom", + "lat": 41.2044367, + "lon": -81.5816274, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way1425465590", + "kind": "restroom", + "lat": 41.3819678, + "lon": -81.481892, + "osm_name": null, + "park": "South Chagrin Reservation" + }, + { + "osm_id": "way1425468558", + "kind": "restroom", + "lat": 41.4203404, + "lon": -81.4236848, + "osm_name": null, + "park": "South Chagrin Reservation" + }, + { + "osm_id": "way1427112920", + "kind": "restroom", + "lat": 41.1306391, + "lon": -81.4346757, + "osm_name": null, + "park": "Munroe Falls Metro Park" + }, + { + "osm_id": "way1427123267", + "kind": "restroom", + "lat": 41.1302572, + "lon": -81.424582, + "osm_name": null, + "park": "Munroe Falls Metro Park" + }, + { + "osm_id": "way1427125657", + "kind": "restroom", + "lat": 41.1326473, + "lon": -81.4252635, + "osm_name": null, + "park": "Munroe Falls Metro Park" + }, + { + "osm_id": "way1427238338", + "kind": "restroom", + "lat": 41.3000021, + "lon": -81.6072731, + "osm_name": null, + "park": "Brecksville Reservation" + }, + { + "osm_id": "way297209389", + "kind": "restroom", + "lat": 41.1907638, + "lon": -81.5597446, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way297209391", + "kind": "restroom", + "lat": 41.190749, + "lon": -81.5599941, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way297212149", + "kind": "restroom", + "lat": 41.1918888, + "lon": -81.5624295, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way297212150", + "kind": "restroom", + "lat": 41.1920268, + "lon": -81.5624016, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way310182539", + "kind": "restroom", + "lat": 41.0829699, + "lon": -81.4480869, + "osm_name": null, + "park": "Goodyear Heights Metro Park" + }, + { + "osm_id": "way321927910", + "kind": "playground", + "lat": 41.1676295, + "lon": -81.5362099, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way375405469", + "kind": "playground", + "lat": 41.4929316, + "lon": -81.7341905, + "osm_name": null, + "park": "Cleveland Lakefront Reservation" + }, + { + "osm_id": "way375610560", + "kind": "restroom", + "lat": 41.4860415, + "lon": -81.7468382, + "osm_name": null, + "park": "Cleveland Lakefront Reservation" + }, + { + "osm_id": "way377646390", + "kind": "restroom", + "lat": 41.4391244, + "lon": -81.8462193, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "way377646393", + "kind": "restroom", + "lat": 41.4689982, + "lon": -81.8329459, + "osm_name": null, + "park": "Rocky River Reservation" + }, + { + "osm_id": "way422748570", + "kind": "restroom", + "lat": 41.0755037, + "lon": -81.4510398, + "osm_name": null, + "park": "Goodyear Heights Metro Park" + }, + { + "osm_id": "way425109411", + "kind": "playground", + "lat": 41.5480948, + "lon": -81.528423, + "osm_name": "Euclid Creek Playground", + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "way437233917", + "kind": "restroom", + "lat": 41.2122548, + "lon": -81.5456137, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way462785361", + "kind": "restroom", + "lat": 41.5859851, + "lon": -81.5657686, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "way462785367", + "kind": "playground", + "lat": 41.5875234, + "lon": -81.5618468, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "way462785368", + "kind": "playground", + "lat": 41.5875469, + "lon": -81.56163, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "way462785370", + "kind": "restroom", + "lat": 41.58707, + "lon": -81.5633549, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "way462785375", + "kind": "restroom", + "lat": 41.5829031, + "lon": -81.5693631, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "way478297268", + "kind": "playground", + "lat": 41.1860348, + "lon": -81.5884168, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way491806317", + "kind": "restroom", + "lat": 41.2629429, + "lon": -81.5587285, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way575122002", + "kind": "restroom", + "lat": 41.4169457, + "lon": -81.4153677, + "osm_name": null, + "park": "South Chagrin Reservation" + }, + { + "osm_id": "way610031851", + "kind": "playground", + "lat": 41.3014966, + "lon": -81.5990664, + "osm_name": null, + "park": "Brecksville Reservation" + }, + { + "osm_id": "way622499187", + "kind": "playground", + "lat": 41.3166724, + "lon": -81.4174869, + "osm_name": null, + "park": "Liberty Park" + }, + { + "osm_id": "way622499200", + "kind": "restroom", + "lat": 41.3173608, + "lon": -81.4175533, + "osm_name": null, + "park": "Liberty Park" + }, + { + "osm_id": "way713940619", + "kind": "restroom", + "lat": 41.4500697, + "lon": -81.7217981, + "osm_name": null, + "park": "Brookside Reservation" + }, + { + "osm_id": "way714941822", + "kind": "restroom", + "lat": 41.5745638, + "lon": -81.4176246, + "osm_name": null, + "park": "North Chagrin Reservation" + }, + { + "osm_id": "way714941824", + "kind": "playground", + "lat": 41.5751638, + "lon": -81.4176432, + "osm_name": null, + "park": "North Chagrin Reservation" + }, + { + "osm_id": "way746531257", + "kind": "restroom", + "lat": 41.2006932, + "lon": -81.5715733, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way749959910", + "kind": "restroom", + "lat": 41.3043783, + "lon": -81.753855, + "osm_name": null, + "park": "Brecksville Reservation" + }, + { + "osm_id": "way751089153", + "kind": "restroom", + "lat": 41.1924006, + "lon": -81.5611354, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way751089154", + "kind": "restroom", + "lat": 41.1922441, + "lon": -81.5611693, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way817947652", + "kind": "restroom", + "lat": 41.2431781, + "lon": -81.5491636, + "osm_name": null, + "park": "Cuyahoga Valley National Park" + }, + { + "osm_id": "way854646225", + "kind": "playground", + "lat": 41.5832467, + "lon": -81.5683928, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "way891132942", + "kind": "restroom", + "lat": 41.5604721, + "lon": -81.5331986, + "osm_name": null, + "park": "Euclid Creek Reservation" + }, + { + "osm_id": "way999320970", + "kind": "restroom", + "lat": 41.4901637, + "lon": -81.9341409, + "osm_name": null, + "park": "Huntington Reservation" + } +] diff --git a/backend/migrations/066_add_amenity_poi_types.sql b/backend/migrations/066_add_amenity_poi_types.sql new file mode 100644 index 00000000..6e304f4a --- /dev/null +++ b/backend/migrations/066_add_amenity_poi_types.sql @@ -0,0 +1,48 @@ +-- Migration 066: Playground & Restroom amenity POI types (#418) +-- Adds two map types, an osm_id provenance/idempotency column, and the default +-- set of POI types excluded from news/events collection. + +BEGIN; + +-- 1. New map types — auto-classified by name keyword / activity, auto-listed in +-- the legend (Map.jsx builds it from the icons table). +-- +-- sort_order doubles as classification priority: getDestinationIconTypeFromConfig +-- (and the backend classifier) iterate icons by sort_order and the first NAME +-- keyword match wins. Amenity names are generated as " Restroom", and park +-- names often contain another type's keyword (e.g. "Mill Stream Run Reservation" +-- matches 'mill' -> historic). Give amenities the lowest sort_order so their +-- explicit 'restroom'/'playground' keyword is matched first. The legend sorts by +-- label, so this does not affect legend order. +-- +-- NO activity_fallbacks: a POI "is" a playground/restroom only if it is a +-- dedicated amenity (named so), not because it lists Playground/Restroom among +-- the many activities a full park offers — otherwise e.g. "Valley View Woods +-- Park" (Hiking, …, Playground) would render as a playground. +INSERT INTO icons (name, label, svg_filename, title_keywords, activity_fallbacks, sort_order) +VALUES + ('playground', 'Playground', 'playground.svg', 'playground,play area', NULL, 1), + ('restroom', 'Restroom', 'restroom.svg', 'restroom,restrooms,bathroom,toilet,toilets', NULL, 2) +ON CONFLICT (name) DO NOTHING; + +-- Ensure amenity classification priority + no activity fallbacks even if the +-- rows already existed from a prior run. +UPDATE icons SET sort_order = 1, activity_fallbacks = NULL WHERE name = 'playground'; +UPDATE icons SET sort_order = 2, activity_fallbacks = NULL WHERE name = 'restroom'; + +-- 2. Provenance + idempotency key for OpenStreetMap-sourced POIs +ALTER TABLE pois ADD COLUMN IF NOT EXISTS osm_id TEXT; +CREATE UNIQUE INDEX IF NOT EXISTS idx_pois_osm_id ON pois (osm_id) WHERE osm_id IS NOT NULL; + +-- 2b. Types in the legend but toggled OFF on first load (avoid clutter). The +-- type still appears in the legend; users opt in. Amenities are dense, so +-- they default hidden. +ALTER TABLE icons ADD COLUMN IF NOT EXISTS default_hidden BOOLEAN DEFAULT FALSE; +UPDATE icons SET default_hidden = TRUE WHERE name IN ('playground', 'restroom'); + +-- 3. POI types excluded from news/events collection (amenities have no news) +INSERT INTO admin_settings (key, value) +VALUES ('news_collection_excluded_types', '["playground","restroom"]') +ON CONFLICT (key) DO NOTHING; + +COMMIT; diff --git a/backend/migrations/import-osm-amenities.js b/backend/migrations/import-osm-amenities.js new file mode 100644 index 00000000..390e66d3 --- /dev/null +++ b/backend/migrations/import-osm-amenities.js @@ -0,0 +1,109 @@ +/** + * Import OpenStreetMap playground & restroom amenities as point POIs (#418). + * + * Companion to 066_add_amenity_poi_types.sql (which adds the icon types, the + * osm_id column, and the excluded-from-collection setting). This script loads the + * committed snapshot in backend/data/osm/amenities.json — playgrounds + * (leisure=playground) and restrooms (amenity=toilets) that fall inside a park + * boundary, pre-filtered and assigned to their smallest containing park. + * + * Idempotent: upserts keyed on osm_id, so re-running (or re-deploying) never + * duplicates POIs. Run after the SQL migrations: + * + * node backend/migrations/import-osm-amenities.js # local + * node /app/migrations/import-osm-amenities.js # in container + * + * Refresh the snapshot by re-querying Overpass for the two tags in the region + * bbox and re-filtering to boundary_type='park' polygons. + */ + +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import pkg from 'pg'; +const { Pool } = pkg; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const pool = new Pool({ + host: process.env.PGHOST || 'localhost', + port: parseInt(process.env.PGPORT || '5432'), + database: process.env.PGDATABASE || 'rotv', + user: process.env.PGUSER || 'postgres', + password: process.env.PGPASSWORD +}); + +const KIND_META = { + playground: { activity: 'Playground', label: 'Playground', blurb: 'Playground' }, + restroom: { activity: 'Restroom', label: 'Restroom', blurb: 'Public restroom' } +}; + +const snapshot = JSON.parse( + readFileSync(join(__dirname, '..', 'data', 'osm', 'amenities.json'), 'utf-8') +); + +console.log(`Importing ${snapshot.length} OSM amenities...\n`); + +// Tracks " " base-name usage so collisions get a stable numeric +// suffix; processing follows snapshot order, so names are reproducible. +const nameCounts = new Map(); +let inserted = 0; +let updated = 0; +let skipped = 0; + +for (const feature of snapshot) { + const meta = KIND_META[feature.kind]; + if (!meta) { + console.warn(` SKIP ${feature.osm_id} — unknown kind '${feature.kind}'`); + skipped++; + continue; + } + + // Display name: OSM name tag, else " " with a suffix on collision. + let name = feature.osm_name; + if (!name) { + const base = `${feature.park} ${meta.label}`; + const seen = (nameCounts.get(base) || 0) + 1; + nameCounts.set(base, seen); + name = seen === 1 ? base : `${base} ${seen}`; + } + + const description = `${meta.blurb} in ${feature.park}.`; + + // node123 -> node/123, way45 -> way/45 + const osmMatch = /^([a-z]+?)s?(\d+)$/.exec(feature.osm_id); + const moreInfoLink = osmMatch + ? `https://www.openstreetmap.org/${osmMatch[1]}/${osmMatch[2]}` + : null; + + try { + const upsert = await pool.query( + `INSERT INTO pois ( + name, poi_roles, latitude, longitude, + geom, primary_activities, brief_description, collection_tier, + osm_id, more_info_link, has_primary_image, created_at, updated_at + ) + VALUES ( + $1, ARRAY['point']::text[], $2::double precision, $3::double precision, + ST_SetSRID(ST_MakePoint($3::double precision, $2::double precision), 4326), $4, $5, 'monthly', + $6, $7, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT (osm_id) WHERE osm_id IS NOT NULL DO UPDATE SET + latitude = EXCLUDED.latitude, + longitude = EXCLUDED.longitude, + geom = EXCLUDED.geom, + primary_activities = EXCLUDED.primary_activities, + updated_at = CURRENT_TIMESTAMP + RETURNING (xmax = 0) AS is_insert`, + [name, feature.lat, feature.lon, meta.activity, description, feature.osm_id, moreInfoLink] + ); + if (upsert.rows[0].is_insert) inserted++; + else updated++; + } catch (err) { + console.error(` ERROR ${feature.osm_id} (${name}) — ${err.message}`); + skipped++; + } +} + +console.log(`\nDone. inserted=${inserted} updated=${updated} skipped=${skipped}`); +await pool.end(); diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 53443142..89116f5f 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -1157,7 +1157,7 @@ export function createAdminRouter(pool, invalidateMosaicCache) { router.get('/icons', async (req, res) => { try { const iconRows = await pool.query( - 'SELECT id, name, label, svg_filename, svg_content, title_keywords, activity_fallbacks, sort_order, enabled, drive_file_id FROM icons ORDER BY sort_order, name' + 'SELECT id, name, label, svg_filename, svg_content, title_keywords, activity_fallbacks, sort_order, enabled, default_hidden, drive_file_id FROM icons ORDER BY sort_order, name' ); res.json(iconRows.rows); } catch (error) { diff --git a/backend/services/newsService.js b/backend/services/newsService.js index 38df66fe..76e70b3d 100644 --- a/backend/services/newsService.js +++ b/backend/services/newsService.js @@ -120,6 +120,7 @@ export class BrowserOverloadError extends Error { import { searchNewsUrls } from './serperService.js'; import { getDomainReputation } from './moderationService.js'; import { loadListSetting } from './filterLists.js'; +import { classifyPoiType } from '../utils/poiClassify.js'; import fs from 'fs'; function debugLog(message) { @@ -1749,12 +1750,54 @@ function checkDomainOwnership(sourceUrl, currentPoiId, domainMap) { return null; } +/** + * Drop POIs whose type is in news_collection_excluded_types (e.g. playground, + * restroom) — amenities that never have news/events (#418). Returns the surviving + * ids in input order. + * + * A POI is excluded if its name/activity classification is an excluded type, OR + * if its primary_activities directly include one of those types' activities. The + * second check is authoritative: name-keyword classification can misfire when a + * POI's name contains another type's keyword (e.g. "Ledge Lake Bath House" -> + * 'house' -> historic), but an explicit Restroom/Playground activity is certain. + */ +async function filterExcludedTypePois(pool, rows) { + const excludedTypes = (await loadListSetting(pool, 'news_collection_excluded_types')) + .filter(t => typeof t === 'string'); + if (excludedTypes.length === 0) return rows.map(r => r.id); + + const excluded = new Set(excludedTypes); + const iconConfig = (await pool.query( + 'SELECT name, label, title_keywords, activity_fallbacks, enabled FROM icons ORDER BY sort_order, name' + )).rows; + + // The label of each excluded type is its dedicated-amenity activity + // ('Playground', 'Restroom'). A POI is a dedicated amenity only when that is + // its SOLE activity — a full park that merely offers a playground (a + // multi-activity list) still collects news. + const dedicatedActivities = new Set( + iconConfig + .filter(icon => excluded.has(icon.name) && icon.label) + .map(icon => icon.label.trim().toLowerCase()) + ); + + const isDedicatedAmenity = (primaryActivities) => + dedicatedActivities.has((primaryActivities || '').trim().toLowerCase()); + + return rows + .filter(r => + !excluded.has(classifyPoiType(r.name, r.primary_activities, iconConfig)) && + !isDedicatedAmenity(r.primary_activities) + ) + .map(r => r.id); +} + export async function getAllPoisForCollection(pool) { const excludedIds = (await loadListSetting(pool, 'news_collection_excluded_pois')) .filter(id => Number.isInteger(id)); const collectionPoiRows = await pool.query( - `SELECT id FROM pois + `SELECT id, name, primary_activities FROM pois WHERE (deleted IS NULL OR deleted = FALSE) AND poi_roles && ARRAY['point','organization','river']::text[] ${excludedIds.length > 0 ? 'AND id != ALL($1)' : ''} @@ -1767,7 +1810,7 @@ export async function getAllPoisForCollection(pool) { name`, excludedIds.length > 0 ? [excludedIds] : [] ); - return collectionPoiRows.rows.map(r => r.id); + return filterExcludedTypePois(pool, collectionPoiRows.rows); } export async function getPoisForTierCollection(pool, tier) { @@ -1788,7 +1831,7 @@ export async function getPoisForTierCollection(pool, tier) { } const tierPoiRows = await pool.query( - `SELECT id FROM pois + `SELECT id, name, primary_activities FROM pois WHERE (deleted IS NULL OR deleted = FALSE) AND poi_roles && ARRAY['point','organization','river']::text[] AND collection_tier = $1 @@ -1802,7 +1845,7 @@ export async function getPoisForTierCollection(pool, tier) { name`, params ); - return tierPoiRows.rows.map(r => r.id); + return filterExcludedTypePois(pool, tierPoiRows.rows); } export async function runTierNewsCollection(pool, tier, sheets = null) { diff --git a/backend/tests/poiClassify.unit.test.js b/backend/tests/poiClassify.unit.test.js new file mode 100644 index 00000000..a7aef2da --- /dev/null +++ b/backend/tests/poiClassify.unit.test.js @@ -0,0 +1,61 @@ +/** + * POI Classifier Unit Tests (#418) + * classifyPoiType mirrors the frontend icon classifier: name keyword first, + * then primary_activities fallback. Drives news/events collection skip for + * amenity types (playground, restroom). + */ +import { describe, it, expect } from 'vitest'; +import { classifyPoiType } from '../utils/poiClassify.js'; + +// Amenity types carry the lowest sort_order so their explicit keyword wins over a +// park-name keyword (e.g. 'mill' -> historic) — see migration 066. +// Ordered by sort_order (classification priority), matching production: amenities +// first, then visitor-center ahead of historic (migration 065/066). Amenity types +// have NO activity_fallbacks — a POI "is" a playground/restroom only when named +// so, not because a full park lists it among many offered activities. +const iconConfig = [ + { name: 'playground', title_keywords: 'playground,play area', activity_fallbacks: null }, + { name: 'restroom', title_keywords: 'restroom,restrooms,bathroom,toilet,toilets', activity_fallbacks: null }, + { name: 'visitor-center', title_keywords: 'visitor center,museum', activity_fallbacks: 'Information' }, + { name: 'nature', title_keywords: 'nature,preserve', activity_fallbacks: 'Nature Study,Wildlife Viewing' }, + { name: 'historic', title_keywords: 'historic,history,house,mill,lock', activity_fallbacks: 'Historical Tours' }, +]; + +describe('classifyPoiType', () => { + it('classifies a restroom by name keyword', () => { + expect(classifyPoiType('Sand Run Metro Park Restroom', 'Restroom', iconConfig)).toBe('restroom'); + }); + + it('classifies a playground by name keyword', () => { + expect(classifyPoiType('Bedford Reservation Playground', 'Playground', iconConfig)).toBe('playground'); + }); + + it('lets the amenity keyword win over a park-name keyword (mill -> historic)', () => { + // 'restroom' (lower sort_order) is checked before 'historic' matches 'mill' + expect(classifyPoiType('Mill Stream Run Reservation Restroom', 'Restroom', iconConfig)).toBe('restroom'); + }); + + it('does NOT icon a full park as an amenity just because it offers one', () => { + // Amenity types have no activity_fallbacks, so a Playground in a multi-activity + // list does not flip the park's icon (collection exclusion handles dedicated + // amenities separately via an exact single-activity match). + expect(classifyPoiType('Valley View Woods Park', 'Hiking, Picnicking, Playground', iconConfig)).not.toBe('playground'); + }); + + it('classifies a non-amenity POI normally', () => { + expect(classifyPoiType('Boston Mill Visitor Center', 'Information', iconConfig)).toBe('visitor-center'); + }); + + it('returns default with no signal', () => { + expect(classifyPoiType('Some Field', '', iconConfig)).toBe('default'); + }); + + it('returns default when iconConfig is empty', () => { + expect(classifyPoiType('X Restroom', 'Restroom', [])).toBe('default'); + }); + + it('ignores disabled icons', () => { + const cfg = [{ name: 'restroom', title_keywords: 'restroom', activity_fallbacks: 'Restroom', enabled: false }]; + expect(classifyPoiType('Park Restroom', 'Restroom', cfg)).toBe('default'); + }); +}); diff --git a/backend/utils/poiClassify.js b/backend/utils/poiClassify.js new file mode 100644 index 00000000..08ad9d31 --- /dev/null +++ b/backend/utils/poiClassify.js @@ -0,0 +1,48 @@ +/** + * Backend POI type classifier. + * + * Mirrors the core of frontend/src/utils/iconUtils.js + * getDestinationIconTypeFromConfig: match the POI name against each icon's + * title_keywords (whole word), then fall back to matching primary_activities + * against activity_fallbacks. Used by news/events collection to skip POIs whose + * type is excluded (e.g. playground, restroom). + * + * The role-based shortcuts (mtb/trail/etc.) from the frontend are intentionally + * omitted — collection only operates on point/organization/river POIs. + */ + +function matchesWholeWord(text, keyword) { + const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`\\b${escaped}\\b`, 'i').test(text); +} + +/** + * @param {string} name - POI name + * @param {string} primaryActivities - comma-separated activities + * @param {Array<{name:string,title_keywords?:string,activity_fallbacks?:string,enabled?:boolean}>} iconConfig + * @returns {string} icon/type name, or 'default' + */ +export function classifyPoiType(name, primaryActivities, iconConfig) { + if (!Array.isArray(iconConfig) || iconConfig.length === 0) return 'default'; + + const poiName = (name || '').toLowerCase(); + const poiActivities = (primaryActivities || '').toLowerCase(); + + for (const icon of iconConfig) { + if (icon.enabled === false || !icon.title_keywords) continue; + for (const keyword of icon.title_keywords.split(',')) { + const k = keyword.trim().toLowerCase(); + if (k && matchesWholeWord(poiName, k)) return icon.name; + } + } + + for (const icon of iconConfig) { + if (icon.enabled === false || !icon.activity_fallbacks) continue; + for (const activity of icon.activity_fallbacks.split(',')) { + const a = activity.trim().toLowerCase(); + if (a && matchesWholeWord(poiActivities, a)) return icon.name; + } + } + + return 'default'; +} diff --git a/frontend/public/icons/playground.svg b/frontend/public/icons/playground.svg new file mode 100644 index 00000000..15112f21 --- /dev/null +++ b/frontend/public/icons/playground.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/icons/restroom.svg b/frontend/public/icons/restroom.svg new file mode 100644 index 00000000..f80cf28f --- /dev/null +++ b/frontend/public/icons/restroom.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 491196d6..fb53d99c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1098,9 +1098,10 @@ function AppContent() { const hasInitializedVisibleTypes = useRef(false); useEffect(() => { if (iconConfig && iconConfig.length > 0 && !hasInitializedVisibleTypes.current) { + // default_hidden types (e.g. amenities) stay in the legend but start toggled off const enabledTypes = new Set( iconConfig - .filter(icon => icon.enabled !== false) + .filter(icon => icon.enabled !== false && icon.default_hidden !== true) .map(icon => icon.name) ); if (!enabledTypes.has('default')) { diff --git a/frontend/src/components/Map.jsx b/frontend/src/components/Map.jsx index e66677c2..02c479d7 100644 --- a/frontend/src/components/Map.jsx +++ b/frontend/src/components/Map.jsx @@ -1442,7 +1442,7 @@ function Map({ destinations, selectedPoi, selectedIsLinear, onSelectPoi, isAdmin > {useSatellite ? ( ) : ( From 7bd131b2c6884359e66b9a875ca84878931881a9 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Wed, 27 May 2026 13:43:33 -0400 Subject: [PATCH 3/3] docs: clarify iconConfig ordering + xmax sentinel (PR #426 review) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/migrations/import-osm-amenities.js | 2 +- backend/utils/poiClassify.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/migrations/import-osm-amenities.js b/backend/migrations/import-osm-amenities.js index 390e66d3..ba4b71d3 100644 --- a/backend/migrations/import-osm-amenities.js +++ b/backend/migrations/import-osm-amenities.js @@ -94,7 +94,7 @@ for (const feature of snapshot) { geom = EXCLUDED.geom, primary_activities = EXCLUDED.primary_activities, updated_at = CURRENT_TIMESTAMP - RETURNING (xmax = 0) AS is_insert`, + RETURNING (xmax = 0) AS is_insert`, // xmax = 0 means a fresh INSERT; non-zero = ON CONFLICT UPDATE [name, feature.lat, feature.lon, meta.activity, description, feature.osm_id, moreInfoLink] ); if (upsert.rows[0].is_insert) inserted++; diff --git a/backend/utils/poiClassify.js b/backend/utils/poiClassify.js index 08ad9d31..cc9da755 100644 --- a/backend/utils/poiClassify.js +++ b/backend/utils/poiClassify.js @@ -18,8 +18,10 @@ function matchesWholeWord(text, keyword) { /** * @param {string} name - POI name - * @param {string} primaryActivities - comma-separated activities + * @param {string} primaryActivities - comma-separated activities (pois.primary_activities is TEXT) * @param {Array<{name:string,title_keywords?:string,activity_fallbacks?:string,enabled?:boolean}>} iconConfig + * MUST be ordered by sort_order ascending (classification priority): the first + * matching icon wins, so callers query `ORDER BY sort_order, name`. * @returns {string} icon/type name, or 'default' */ export function classifyPoiType(name, primaryActivities, iconConfig) {