diff --git a/src/runtime/components/feed-version-map-viewer.vue b/src/runtime/components/feed-version-map-viewer.vue index 12d32e41..2c027c68 100644 --- a/src/runtime/components/feed-version-map-viewer.vue +++ b/src/runtime/components/feed-version-map-viewer.vue @@ -15,22 +15,24 @@ :zoom="zoom ? zoom : null" :circle-radius="circleRadius" :circle-color="circleColor" + :enable-hover="enableHover" + :highlighted-stop-feature-id="highlightedStopFeatureId" @set-agency-features="agencyFeatures = $event" @map-click="mapClick" @set-zoom="currentZoom = $event" />
- - Select routes -
- Use your cursor to highlight routes -
+

Select routes and stops

+

Use your cursor to highlight route lines.

+

Click for details.

+
@@ -72,6 +74,7 @@ query ($limit: Int=100, $agency_ids: [Int!], $after:Int!=0, $route_ids: [Int!], stop_id stop_name geometry + location_type } } agency { @@ -123,7 +126,15 @@ export default { zoom: { type: Number, default: null }, enableScrollZoom: { type: Boolean, default: false }, circleRadius: { type: Number, default: 1 }, - circleColor: { type: String, default: '#f03b20' } + circleColor: { type: String, default: '#f03b20' }, + enableHover: { + type: Boolean, + default: true + }, + highlightedStopFeatureId: { + type: [String, Number], + default: null + } }, data () { return { @@ -173,18 +184,17 @@ export default { const features = [] for (const feature of this.routes) { for (const g of feature.route_stops || []) { - if (!(g.stop.location_type !== 0 || g.stop.location_type !== 2)) { - continue + if (g.stop.location_type === 0 || g.stop.location_type === 2) { + const fcopy = Object.assign({}, g.stop) + delete fcopy.geometry + delete fcopy.__typename + features.push({ + type: 'Feature', + geometry: g.stop.geometry, + properties: fcopy, + id: g.stop.id + }) } - const fcopy = Object.assign({}, g.stop) - delete fcopy.geometry - delete fcopy.__typename - features.push({ - type: 'Feature', - geometry: g.stop.geometry, - properties: fcopy, - id: g.stop.id - }) } } return features @@ -195,7 +205,7 @@ export default { this.$emit('setRouteFeatures', v) }, stopFeatures (v) { - this.$emit('setSopFeatures', v) + this.$emit('setStopFeatures', v) } }, methods: { diff --git a/src/runtime/components/map-layers.js b/src/runtime/components/map-layers.js index 2d61cd68..e14c2c70 100644 --- a/src/runtime/components/map-layers.js +++ b/src/runtime/components/map-layers.js @@ -1,3 +1,5 @@ +import { stopColors } from '../constants/stop-colors' + const headways = { high: 600, medium: 1200, @@ -17,19 +19,109 @@ const colors = { metro: '#ff0000', metrooutline: '#ffffff', other: '#E6A615', - stop: '#007cbf' + stop: stopColors.stop, + stopNode: stopColors.entrance, + stopEntrance: stopColors.entrance, + stopGeneric: stopColors.node, + stopBoarding: stopColors.boarding +} + +const LocationTypes = { + STOP: 0, // Stop/Platform + STATION: 1, // Station + ENTRANCE: 2, // Entrance/Exit + NODE: 3, // Generic Node + BOARDING: 4 // Boarding Area } const stopLayers = [ + // Add hover/active layer first + { + name: 'stop-active', + type: 'circle', + source: 'stops', + minzoom: 14, + paint: { + 'circle-radius': [ + 'case', + ['boolean', ['feature-state', 'hover'], false], + 8, + 6 + ], + 'circle-color': [ + 'case', + ['>', ['get', 'location_type'], 1], + colors.stopNode, + colors.stop + ], + 'circle-opacity': [ + 'case', + ['boolean', ['feature-state', 'hover'], false], + 0.8, + 0.0 + ], + 'circle-stroke-width': [ + 'case', + ['boolean', ['feature-state', 'hover'], false], + 6, + 0 + ], + 'circle-stroke-color': [ + 'case', + ['boolean', ['feature-state', 'hover'], false], + colors.active, + '#ffffff' + ] + }, + filter: [ + 'any', + ['==', ['get', 'location_type'], LocationTypes.STOP], + ['==', ['get', 'location_type'], LocationTypes.STATION], + ['==', ['get', 'location_type'], LocationTypes.ENTRANCE] + ] + }, + // Regular stops layer { name: 'stops', type: 'circle', source: 'stops', + minzoom: 14, paint: { - 'circle-color': '#000', + 'circle-color': [ + 'case', + ['>', ['get', 'location_type'], 1], + colors.stopNode, + colors.stop + ], 'circle-radius': 4, - 'circle-opacity': 0.75 - } + 'circle-opacity': 0.75, + 'circle-stroke-width': [ + 'case', + ['==', ['get', 'location_type'], 2], + 2, + ['==', ['get', 'location_type'], 3], + 2, + ['==', ['get', 'location_type'], 4], + 2, + 0 + ], + 'circle-stroke-color': [ + 'case', + ['==', ['get', 'location_type'], 2], + colors.stopEntrance, + ['==', ['get', 'location_type'], 3], + colors.stopGeneric, + ['==', ['get', 'location_type'], 4], + colors.stopBoarding, + '#ffffff' + ] + }, + filter: [ + 'any', + ['==', ['get', 'location_type'], LocationTypes.STOP], + ['==', ['get', 'location_type'], LocationTypes.STATION], + ['==', ['get', 'location_type'], LocationTypes.ENTRANCE] + ] } ] @@ -203,4 +295,64 @@ const routeLayers = [ } ] -export default { headways, colors, stopLayers, routeLayers } +// Add these new layer definitions +const otherLayers = { + polygons: { + id: 'polygons', + type: 'fill', + source: 'polygons', + paint: { + 'fill-color': '#ccc', + 'fill-opacity': 0.2 + } + }, + polygonsOutline: { + id: 'polygons-outline', + type: 'line', + source: 'polygons', + paint: { + 'line-width': 2, + 'line-color': '#000', + 'line-opacity': 0.2 + } + }, + points: { + id: 'points', + type: 'circle', + source: 'points', + paint: { + 'circle-color': ['coalesce', ['get', 'marker-color'], '#f03b20'], + 'circle-radius': ['coalesce', ['get', 'marker-radius'], 1], + 'circle-opacity': 0.4 + } + }, + lines: { + id: 'lines', + type: 'line', + source: 'lines', + paint: { + 'line-color': ['coalesce', ['get', 'stroke'], '#000'], + 'line-width': ['coalesce', ['get', 'stroke-width'], 2], + 'line-opacity': 1.0 + } + } +} + +// Add route layer defaults +const routeLayerDefaults = { + type: 'line', + layout: { + 'line-cap': 'round', + 'line-join': 'round' + } +} + +export default { + headways, + colors, + stopLayers, + routeLayers, + otherLayers, + routeLayerDefaults, + LocationTypes +} diff --git a/src/runtime/components/map-options-modal.vue b/src/runtime/components/map-options-modal.vue new file mode 100644 index 00000000..c00f9f5f --- /dev/null +++ b/src/runtime/components/map-options-modal.vue @@ -0,0 +1,168 @@ + + + + + \ No newline at end of file diff --git a/src/runtime/components/map-route-list.vue b/src/runtime/components/map-route-list.vue deleted file mode 100644 index 03c0bd4a..00000000 --- a/src/runtime/components/map-route-list.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - diff --git a/src/runtime/components/map-route-stop-list.vue b/src/runtime/components/map-route-stop-list.vue new file mode 100644 index 00000000..425f0c20 --- /dev/null +++ b/src/runtime/components/map-route-stop-list.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/runtime/components/map-viewer.vue b/src/runtime/components/map-viewer.vue index fd556e05..1d738ad9 100644 --- a/src/runtime/components/map-viewer.vue +++ b/src/runtime/components/map-viewer.vue @@ -30,13 +30,32 @@ export default { hash: { type: Boolean, default: false }, features: { type: Array, default () { return [] } + }, + showStopTypes: { + type: Object, + default: () => ({ + 0: true, // Stop/Platform + 1: true, // Station + 2: true, // Entrance + 3: false, // Node + 4: false // Boarding Area + }) + }, + enableHover: { + type: Boolean, + default: true + }, + highlightedStopFeatureId: { + type: [String, Number], + default: null } }, data () { return { map: null, marker: null, - hovering: [], + hoveringRoutes: [], + hoveringStops: [], markerLayer: null } }, @@ -68,10 +87,35 @@ export default { zoom () { this.map.jumpTo({ center: this.center, zoom: this.zoom }) }, - markers(v) { + markers (v) { this.drawMarkers(v) + }, + showStopTypes: { + handler() { + this.updateFilters() + }, + deep: true + }, + highlightedStopFeatureId: { + handler(newId) { + if (!this.map) return + + // Wait for map and source to be ready + this.map.once('load', () => { + console.log('Setting highlight state for stop:', { + id: newId, + source: 'stops', + features: this.stopFeatures.map(f => f.id) + }) + this.map.setFeatureState( + { source: 'stops', id: newId }, + { hover: true } + ) + }) + } } }, + mounted () { if (this.features) { nextTick(() => { this.initMap() }) @@ -147,38 +191,70 @@ export default { }) }, updateFeatures () { + console.log('Updating features:', { + stopFeatures: this.stopFeatures.length, + routeFeatures: this.routeFeatures.length, + usingTiles: { + stops: !!this.stopTiles, + routes: !!this.routeTiles + } + }) const polygons = this.features.filter((s) => { return s.geometry.type === 'MultiPolygon' || s.geometry.type === 'Polygon' }) const points = this.features.filter((s) => { return s.geometry.type === 'Point' }) const lines = this.features.filter((s) => { return s.geometry.type === 'LineString' }) - // check if map is initialized... TODO: this could be improved to try again + + // check if map is initialized... const p = this.map.getSource('polygons') if (!p) { + console.warn('Map not initialized yet') return } + this.map.getSource('polygons').setData({ type: 'FeatureCollection', features: polygons }) this.map.getSource('lines').setData({ type: 'FeatureCollection', features: lines }) this.map.getSource('points').setData({ type: 'FeatureCollection', features: points }) - // this.map.getSource('stops').setData({ type: 'FeatureCollection', features: this.stopFeatures }) - // this.map.getSource('routes').setData({ type: 'FeatureCollection', features: this.routeFeatures }) - this.fitFeatures() - }, - updateFilters () { - for (const v of mapLayers.stopLayers) { - const f = (v.filter || []).slice() - if (f.length === 0) { - f.push('all') - } - // Hide all routes? - if (this.hideTiles) { - f.push(['==', 'route_id', '']) - } - if (f.length > 1) { - this.map.setFilter(v.name, f) - } else { - this.map.setFilter(v.name, null) + + // Only update if using GeoJSON sources + if (!this.stopTiles) { + console.log('Updating GeoJSON stops source:', { + featureCount: this.stopFeatures.length, + features: this.stopFeatures.map(f => ({ + id: f.id, + type: f.type, + properties: f.properties + })) + }) + this.map.getSource('stops').setData({ + type: 'FeatureCollection', + features: this.stopFeatures + }) + + // Set highlight state after updating source + if (this.highlightedStopFeatureId) { + this.map.setFeatureState( + { source: 'stops', id: this.highlightedStopFeatureId }, + { hover: true } + ) } } + if (!this.routeTiles) { + console.log('Updating GeoJSON routes source:', { + featureCount: this.routeFeatures.length, + features: this.routeFeatures.map(f => ({ + id: f.id, + type: f.type, + properties: f.properties + })) + }) + this.map.getSource('routes').setData({ + type: 'FeatureCollection', + features: this.routeFeatures + }) + } + this.fitFeatures() + }, + updateFilters () { for (const v of mapLayers.routeLayers) { const f = (v.filter || []).slice() if (f.length === 0) { @@ -204,8 +280,35 @@ export default { this.map.setFilter(v.name, null) } } + + // Update stop layer filters + for (const layer of mapLayers.stopLayers) { + let filter = ['any'] + + // Show stop types based on showStopTypes prop + const visibleTypes = Object.entries(this.showStopTypes) + .filter(([_, visible]) => visible) + .map(([type]) => Number(type)) + + for (const type of visibleTypes) { + filter.push(['==', ['get', 'location_type'], type]) + } + + if (this.hideTiles) { + filter.push(['==', 'stop_id', '']) + } + + this.map.setFilter(layer.name, filter) + } }, createSources () { + console.log('Creating sources:', { + routeTiles: this.routeTiles, + stopTiles: this.stopTiles, + routeFeatures: this.routeFeatures.length, + stopFeatures: this.stopFeatures.length + }) + this.map.addSource('polygons', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } @@ -218,8 +321,10 @@ export default { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) - // Add route/stop sources, with geojson features as fallbacks + // Add route/stop sources + if (this.routeTiles) { + console.log('Adding vector tile source for routes:', this.routeTiles) this.map.addSource('routes', { type: 'vector', tiles: [this.routeTiles.url], @@ -227,11 +332,16 @@ export default { maxzoom: this.routeTiles.maxzoom || 14 }) } else { + console.log('Adding GeoJSON source for routes:', { + featureCount: this.routeFeatures.length, + firstFeature: this.routeFeatures[0] + }) this.map.addSource('routes', { type: 'geojson', data: { type: 'FeatureCollection', features: this.routeFeatures } }) } + if (this.stopTiles) { this.map.addSource('stops', { type: 'vector', @@ -247,59 +357,14 @@ export default { } }, createLayers () { - // Other feature layers - this.map.addLayer({ - id: 'polygons', - type: 'fill', - source: 'polygons', - layout: {}, - paint: { - 'fill-color': '#ccc', - 'fill-opacity': 0.2 - } - }) - this.map.addLayer({ - id: 'polygons-outline', - type: 'line', - source: 'polygons', - layout: {}, - paint: { - 'line-width': 2, - 'line-color': '#000', - 'line-opacity': 0.2 - } - }) - this.map.addLayer({ - id: 'points', - type: 'circle', - source: 'points', - paint: { - 'circle-color': ['coalesce', ['get', 'marker-color'], this.circleColor], - 'circle-radius': ['coalesce', ['get', 'marker-radius'], this.circleRadius], - 'circle-opacity': 0.4 - } - }) - this.map.addLayer({ - id: 'lines', - type: 'line', - source: 'lines', - layout: {}, - paint: { - 'line-color': ['coalesce', ['get', 'stroke'], '#000'], - 'line-width': ['coalesce', ['get', 'stroke-width'], 2], - 'line-opacity': 1.0 - } - }) - // Route/Stop layers + console.log('Creating layers with paint properties:', mapLayers) + + // Add route layers for (const v of mapLayers.routeLayers) { const layer = { + ...mapLayers.routeLayerDefaults, id: v.name, - type: 'line', source: 'routes', - layout: { - 'line-cap': 'round', - 'line-join': 'round' - }, minzoom: v.minzoom || 0, paint: v.paint } @@ -311,6 +376,8 @@ export default { } this.map.addLayer(layer) } + + // Add stop layers for (const v of mapLayers.stopLayers) { const layer = { id: v.name, @@ -326,15 +393,25 @@ export default { } this.map.addLayer(layer) } - // add labels last + + // Add other feature layers + Object.values(mapLayers.otherLayers).forEach(layer => { + this.map.addLayer({ + ...layer, + source: layer.id === 'polygons-outline' ? 'polygons' : layer.id + }) + }) + + // Add labels last for (const labelLayer of labels('protomaps-base', 'grayscale')) { this.map.addLayer(labelLayer) } + // Set initial show generated geometry this.updateFilters() }, - drawMarkers(markers) { + drawMarkers (markers) { for (const m of this.markerLayer) { m.remove() } @@ -391,29 +468,147 @@ export default { this.$emit('mapMove', { zoom: this.map.getZoom(), bbox: this.map.getBounds().toArray() }) }, mapMouseMove (e) { + // Skip hover effects if disabled + if (!this.enableHover) { + return + } + const map = this.map - const features = map.queryRenderedFeatures(e.point, { layers: ['route-active'] }) - map.getCanvas().style.cursor = 'pointer' - for (const k of this.hovering) { - map.setFeatureState( - { source: 'routes', id: k, sourceLayer: this.routeTiles ? this.routeTiles.id : null }, - { hover: false } - ) + let routeFeatures = [] + let stopFeatures = [] + + // Query features + const routeLayers = this.routeTiles ? ['route-active'] : mapLayers.routeLayers.map(layer => layer.name) + const stopLayer = this.stopTiles ? 'stop-active' : 'stops' + + // Query all route layers + for (const layer of routeLayers) { + if (map.getLayer(layer)) { + try { + const features = map.queryRenderedFeatures(e.point, { layers: [layer] }) + routeFeatures = routeFeatures.concat(features) + } catch (err) { + console.warn(`Error querying route layer ${layer}:`, err) + } + } } - this.hovering = [] - for (const v of features) { - this.hovering.push(v.id) - map.setFeatureState({ source: 'routes', id: v.id, sourceLayer: this.routeTiles ? this.routeTiles.id : null }, { hover: true }) + + // Query stop layer + if (map.getLayer(stopLayer)) { + try { + stopFeatures = map.queryRenderedFeatures(e.point, { layers: [stopLayer] }) + } catch (err) { + console.warn('Error querying stop features:', err) + } } + + // Update cursor + map.getCanvas().style.cursor = stopFeatures.length || routeFeatures.length ? 'pointer' : '' + + // Handle route hover states + for (const k of this.hoveringRoutes) { + for (const layer of routeLayers) { + if (map.getLayer(layer)) { + try { + map.setFeatureState( + { source: 'routes', sourceLayer: this.routeTiles ? 'routes' : undefined, id: k }, + { hover: false } + ) + } catch (err) { + console.warn(`Error removing hover state from route ${k} in layer ${layer}:`, err) + } + } + } + } + this.hoveringRoutes = [] + + // Set hover state for found routes + for (const v of routeFeatures) { + this.hoveringRoutes.push(v.id) + for (const layer of routeLayers) { + if (map.getLayer(layer)) { + try { + map.setFeatureState( + { source: 'routes', sourceLayer: this.routeTiles ? 'routes' : undefined, id: v.id }, + { hover: true } + ) + } catch (err) { + console.warn(`Error setting hover state for route ${v.id} in layer ${layer}:`, err) + } + } + } + } + + // Handle stop hover states + for (const k of this.hoveringStops) { + if (map.getLayer(stopLayer)) { + try { + map.setFeatureState( + { source: 'stops', id: k, sourceLayer: this.stopTiles ? this.stopTiles.id : null }, + { hover: false } + ) + } catch (err) { + console.warn('Error removing stop hover state:', err) + } + } + } + this.hoveringStops = [] + + for (const v of stopFeatures) { + this.hoveringStops.push(v.id) + if (map.getLayer(stopLayer)) { + try { + map.setFeatureState( + { source: 'stops', id: v.id, sourceLayer: this.stopTiles ? this.stopTiles.id : null }, + { hover: true } + ) + } catch (err) { + console.warn('Error setting stop hover state:', err) + } + } + } + + // Always process and emit features const agencyFeatures = {} - for (const v of features) { + const processFeature = (v) => { + try { + if (v.properties.route_id) { + // Handle route const agencyId = v.properties.agency_name - const routeId = v.properties.route_id if (agencyFeatures[agencyId] == null) { - agencyFeatures[agencyId] = {} + agencyFeatures[agencyId] = { routes: {}, stops: {} } + } + agencyFeatures[agencyId].routes[v.properties.route_id] = v.properties + } else { + // Handle stop + const agencies = JSON.parse(v.properties.agencies || '[]') + + const agencyId = agencies[0]?.agency_name || 'undefined' + if (agencyFeatures[agencyId] == null) { + agencyFeatures[agencyId] = { routes: {}, stops: {} } + } + + const stopData = { + id: v.properties.stop_id, + stop_id: v.properties.stop_id, + stop_name: v.properties.stop_name, + location_type: v.properties.location_type || 0, + onestop_id: v.properties.onestop_id, + feed_onestop_id: v.properties.feed_onestop_id, + feed_version_sha1: v.properties.feed_version_sha1, + agencies: v.properties.agencies + } + + agencyFeatures[agencyId].stops[v.properties.stop_id] = stopData + } + } catch (err) { + console.warn('Error processing feature:', err, v) } - agencyFeatures[agencyId][routeId] = v.properties } + + routeFeatures.forEach(processFeature) + stopFeatures.forEach(processFeature) + this.$emit('setAgencyFeatures', agencyFeatures) } } diff --git a/src/runtime/components/pages/map.vue b/src/runtime/components/pages/map.vue index 41f3dea7..e1160d38 100644 --- a/src/runtime/components/pages/map.vue +++ b/src/runtime/components/pages/map.vue @@ -16,6 +16,7 @@ :auto-fit="false" :hide-tiles="activeTab === DIRECTIONS_TAB" map-class="tall" + :show-stop-types="showStopTypes" @set-zoom="mapSetZoom" @set-agency-features="routesSetAgencyFeatures" @map-click="mapClick" @@ -24,42 +25,40 @@
- - + + -
- diff --git a/src/runtime/components/route-stop-select.vue b/src/runtime/components/route-stop-select.vue new file mode 100644 index 00000000..fe4ebc51 --- /dev/null +++ b/src/runtime/components/route-stop-select.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/runtime/components/stop-icon.vue b/src/runtime/components/stop-icon.vue new file mode 100644 index 00000000..438c3add --- /dev/null +++ b/src/runtime/components/stop-icon.vue @@ -0,0 +1,71 @@ + + + + + \ No newline at end of file diff --git a/src/runtime/constants/stop-colors.ts b/src/runtime/constants/stop-colors.ts new file mode 100644 index 00000000..95982f86 --- /dev/null +++ b/src/runtime/constants/stop-colors.ts @@ -0,0 +1,7 @@ +export const stopColors = { + stop: '#000000', // Stop/Platform (type 0) + station: '#000000', // Station (type 1) + entrance: '#808080', // Entrance/Exit (type 2) + node: '#ff0000', // Generic Node (type 3) + boarding: '#00ff00' // Boarding Area (type 4) +} \ No newline at end of file diff --git a/src/runtime/types/filters.d.ts b/src/runtime/types/filters.d.ts new file mode 100644 index 00000000..5109b5c7 --- /dev/null +++ b/src/runtime/types/filters.d.ts @@ -0,0 +1,22 @@ +declare module '@vue/runtime-core' { + interface ComponentCustomProperties { + $filters: { + makeRouteLink: ( + onestop_id: string | null, + feed_onestop_id: string | null, + feed_version_sha1: string | null, + route_id: string | null, + id: number | null, + linkVersion: boolean + ) => string; + makeStopLink: ( + onestop_id: string | null, + feed_onestop_id: string | null, + feed_version_sha1: string | null, + stop_id: string | null, + id: number | null, + linkVersion: boolean + ) => string; + } + } +} \ No newline at end of file