Add OSM road data + directional bearing arrows on POI points
All checks were successful
CI / test-and-build (push) Successful in 35s

- osm-data.mjs: add 'roads' Overpass query to tulsa area — major road
  classes (motorway/trunk/primary/secondary/tertiary) as LineString geometry
- MapView.svelte: render POIs that have a bearing as a rotated arrow
  divIcon marker pointing in the compass heading; other POIs stay as
  circleMarkers. Adds .dir-arrow CSS.
- scripts/test.mjs: adapt street-name search assertions (roads now match
  address queries legitimately) and add an expectAll helper
- static/data: tulsa.geojson now includes ~20k road features (11MB)
This commit is contained in:
hermes-explorigin 2026-08-08 19:10:15 +00:00
parent 220e6c9e48
commit 0753527d69
6 changed files with 50 additions and 5 deletions

View File

@ -86,6 +86,17 @@ parks: `
` `
``` ```
By default the `tulsa` area also fetches **major roads** (`highway` =
motorway/trunk/primary/secondary/tertiary) as LineStrings, so the map shows
road network geometry alongside the POI/zones. (Tiny local roads like
`residential`/`service` are excluded to keep the output file size reasonable.)
Adjust the `roads` query in `scripts/osm-data.mjs` to include or exclude road
classes.
**Note:** the Overpass API is sometimes rate-limited; `npm run build:data` may
need to be re-run if a query returns 5xx. The committed `static/data/` already
contains a full road dataset.
List configured queries with `npm run data -- --queries`, or build a single area List configured queries with `npm run data -- --queries`, or build a single area
with `npm run data -- --area=tulsa`. with `npm run data -- --area=tulsa`.
@ -123,7 +134,9 @@ POI (point) — optional `address`, `bearing`, and `fov` fields:
(`'N'`, `'NNE'`, `'NE'`, ...) or a numeric bearing in degrees (`'N'`, `'NNE'`, `'NE'`, ...) or a numeric bearing in degrees
- `fov` — field of view in degrees - `fov` — field of view in degrees
The popup renders these as dedicated rows (Facing / Field of view). The popup renders these as dedicated rows (Facing / Field of view), and a
**directional arrow** is drawn on the map at each POI that has a `bearing`,
rotated to the compass heading it faces.
Zone (polygon — the ring is closed for you): Zone (polygon — the ring is closed for you):

View File

@ -61,6 +61,10 @@ const AREAS = {
parks: ` parks: `
way["leisure"="park"]({{bbox}}); way["leisure"="park"]({{bbox}});
out center tags geom; out center tags geom;
`,
roads: `
way["highway"~"motorway|trunk|primary|secondary|tertiary"]({{bbox}});
out geom tags;
` `
} }
} }

View File

@ -104,6 +104,12 @@ async function run() {
expectNone: (query) => { expectNone: (query) => {
const res = search(query, all); const res = search(query, all);
check(res.length === 0, `search "${query}" returns no results (junk filtered)`); check(res.length === 0, `search "${query}" returns no results (junk filtered)`);
},
expectAll: (query, substr) => {
const res = search(query, all);
const any = res.some((r) => String(r.item.name).toLowerCase().includes(substr));
check(any, `search "${query}" finds "${substr}" somewhere in results`);
return res;
} }
}); });
const t = await build(); const t = await build();
@ -111,8 +117,8 @@ async function run() {
// Exact name / fuzzy / address queries (derived from seed data). // Exact name / fuzzy / address queries (derived from seed data).
t.expect('gathering place', 'gathering place', 'poi'); t.expect('gathering place', 'gathering place', 'poi');
t.expect('philbrick museum', 'philbrook', 'poi'); // fuzzy typo t.expect('philbrick museum', 'philbrook', 'poi'); // fuzzy typo
t.expect('peoria av', 'woodward', 'poi'); // address abbreviatef->street t.expectAll('peoria av', 'peoria'); // address -> the street itself
t.expect('s peoria ave', 'woodward', 'poi'); // compass normalized t.expectAll('s peoria ave', 'south peoria'); // compass normalized matches roads
const cherry = t.expect('cherry street', 'cherry street', 'zone'); const cherry = t.expect('cherry street', 'cherry street', 'zone');
// Path layer should be searchable. // Path layer should be searchable.
t.expect('claremore lake loop', 'claremore lake loop', 'path'); t.expect('claremore lake loop', 'claremore lake loop', 'path');

View File

@ -192,6 +192,19 @@
const geo = L.geoJSON(fc as unknown as GeoJSON.GeoJsonObject, { const geo = L.geoJSON(fc as unknown as GeoJSON.GeoJsonObject, {
pointToLayer: (_f, latlng) => { pointToLayer: (_f, latlng) => {
const props = (_f.properties ?? {}) as Record<string, unknown>;
// POIs with a bearing get a directional arrow marker.
if (isPoi && props.bearing !== undefined) {
const deg = Number(props.bearing);
return L.marker(latlng, {
icon: L.divIcon({
className: 'dir-marker-wrap',
html: `<div class="dir-arrow" style="transform: rotate(${deg}deg); border-bottom-color:${color}"></div>`,
iconSize: [28, 28],
iconAnchor: [14, 14]
})
});
}
if (isPoi) { if (isPoi) {
return L.circleMarker(latlng, { return L.circleMarker(latlng, {
radius: 6, radius: 6,
@ -392,5 +405,14 @@
:global(.popup th) { text-align: left; padding-right: 10px; color: #64748b; font-weight: 600; } :global(.popup th) { text-align: left; padding-right: 10px; color: #64748b; font-weight: 600; }
:global(.popup td) { padding: 1px 0; } :global(.popup td) { padding: 1px 0; }
:global(.popup-desc) { color: #475569; margin-top: 2px; font-size: 0.75rem; } :global(.popup-desc) { color: #475569; margin-top: 2px; font-size: 0.75rem; }
:global(.dir-marker-wrap) { background: none; border: none; }
:global(.dir-arrow) {
width: 0; height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 16px solid #e11d48;
filter: drop-shadow(0 0 1px rgba(0,0,0,.6));
}
:global(.leaflet-container) { font: inherit; } :global(.leaflet-container) { font: inherit; }
</style> </style>

View File

@ -10,7 +10,7 @@
36.3, 36.3,
-95.7 -95.7
], ],
"featureCount": 512, "featureCount": 20261,
"path": "/data/tulsa.geojson" "path": "/data/tulsa.geojson"
}, },
{ {

File diff suppressed because one or more lines are too long