Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions app/controllers/api2_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ class API2Controller < ApplicationController
require("xmlrpc/client")
require("api2")

include CorsHeaders

disable_filters

# wrapped parameters break JSON requests in the unit tests.
Expand Down Expand Up @@ -173,12 +175,14 @@ def do_render_json
render(layout: false, template: "/api2/results")
end

# Only set CORS headers for GET requests, allowing only GET
def set_cors_headers
return unless request.method == "GET"

response.set_header("Access-Control-Allow-Origin", "*")
response.set_header("Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept")
response.set_header("Access-Control-Allow-Methods", "GET")
super
end

def cors_allowed_methods
%w[GET].freeze
end
end
30 changes: 30 additions & 0 deletions app/controllers/concerns/cors_headers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

# Adds CORS headers for API endpoints.
#
# Usage:
# include CorsHeaders
# before_action :set_cors_headers
#
# By default, allows GET, POST, OPTIONS methods. Override `cors_allowed_methods`
# to customize.
module CorsHeaders
extend ActiveSupport::Concern

ALLOWED_HEADERS = "Origin, X-Requested-With, Content-Type, Accept"
DEFAULT_METHODS = %w[GET POST OPTIONS].freeze

private

def set_cors_headers
response.set_header("Access-Control-Allow-Origin", "*")
response.set_header("Access-Control-Allow-Headers", ALLOWED_HEADERS)
response.set_header("Access-Control-Allow-Methods",
cors_allowed_methods.join(", "))
end

# Override in controller to restrict allowed methods
def cors_allowed_methods
DEFAULT_METHODS
end
end
20 changes: 20 additions & 0 deletions app/controllers/geo/base_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module Geo
# Base controller for geographic services (elevation, geocoding, etc.)
# These endpoints proxy external APIs to avoid exposing API keys
# and to provide a stable interface.
class BaseController < ApplicationController
include CorsHeaders

disable_filters
before_action :set_cors_headers
layout false

private

def render_error(message, status: :bad_request)
render(json: { error: message }, status: status)
end
end
end
90 changes: 90 additions & 0 deletions app/controllers/geo/elevations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# frozen_string_literal: true

module Geo
# Proxies elevation requests to Open-Elevation API.
# This provides a stable interface and avoids exposing external API details.
#
# Usage:
# GET /geo/elevation?locations=39.7391,-104.9847|40.0,-105.0
# POST /geo/elevation with JSON body: { "locations": [...] }
#
class ElevationsController < BaseController
OPEN_ELEVATION_URL = "https://api.open-elevation.com/api/v1/lookup"

# GET /geo/elevation?locations=lat1,lng1|lat2,lng2
# POST /geo/elevation with JSON body
def show
locations = parse_locations
return render_error("No locations provided") if locations.blank?

result = fetch_elevations(locations)
render(json: result)
rescue StandardError => e
Rails.logger.error("Elevation API error: #{e.message}")
render_error("Failed to fetch elevations", status: :service_unavailable)
end

private

def parse_locations
if request.post? && request.content_type&.include?("application/json")
parse_json_locations
else
parse_query_locations
end
end

# Parse locations from query string: "lat1,lng1|lat2,lng2"
def parse_query_locations
return [] if params[:locations].blank?

params[:locations].split("|").map do |pair|
lat, lng = pair.split(",").map(&:to_f)
{ "latitude" => lat, "longitude" => lng }
end
end

# Parse locations from JSON body: [{"lat": 1, "lng": 2}, ...]
def parse_json_locations
body = JSON.parse(request.body.read)
locations = body["locations"] || []
locations.map do |loc|
{
"latitude" => loc["lat"] || loc["latitude"],
"longitude" => loc["lng"] || loc["longitude"]
}
end
rescue JSON::ParserError
[]
end

def fetch_elevations(locations)
response = elevation_http_client.request(elevation_request(locations))
parse_elevation_response(response)
end

def elevation_http_client
uri = URI(OPEN_ELEVATION_URL)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 5
http.read_timeout = 10
http
end

def elevation_request(locations)
req = Net::HTTP::Post.new(URI(OPEN_ELEVATION_URL))
req["Content-Type"] = "application/json"
req.body = { locations: locations }.to_json
req
end

def parse_elevation_response(response)
if response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
else
{ "error" => "Upstream API error", "status" => response.code }
end
end
end
end
2 changes: 1 addition & 1 deletion app/helpers/form_locations_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def elevation_directions
def elevation_request_button
tag.button(
:form_locations_get_elevation.l,
type: :button, class: "btn btn-default",
id: "get_elevation_btn", type: :button, class: "btn btn-default",
data: { map_target: "getElevation", action: "map#getElevations",
map_points_param: "input", map_type_param: "rectangle" }
)
Expand Down
73 changes: 45 additions & 28 deletions app/javascript/controllers/geocode_controller.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Controller } from "@hotwired/stimulus"
import { Loader } from "@googlemaps/js-api-loader"
import { convert } from "geo-coordinates-parser"
import { post } from "@rails/request.js"

// Connects to data-controller="geocode"
// Sort of the "lite" version of the map controller: no map, no markers, but it
Expand All @@ -11,7 +12,7 @@ export default class extends Controller {
"highInput", "lowInput", "placeInput", "locationId",
"latInput", "lngInput", "altInput", "getElevation"]
static outlets = ["autocompleter--location"]
static values = { needElevations: Boolean, default: true }
static values = { needElevations: { type: Boolean, default: true } }

connect() {
this.element.dataset.geocode = "connected"
Expand All @@ -29,8 +30,6 @@ export default class extends Controller {
this.lastGeolocatedAddress = ""

this.libraries = ["maps", "geocoding", "marker"]
if (this.needElevationsValue == true)
this.libraries.push("elevation")

const loader = new Loader({
apiKey: "AIzaSyCxT5WScc3b99_2h2Qfy5SX6sTnE1CX3FA",
Expand All @@ -42,8 +41,6 @@ export default class extends Controller {
.load()
.then((google) => {
this.geocoder = new google.maps.Geocoder()
if (this.needElevationsValue == true)
this.elevationService = new google.maps.ElevationService()
})
.catch((e) => {
console.error("error loading gmaps: " + e)
Expand Down Expand Up @@ -254,22 +251,31 @@ export default class extends Controller {
// "Get Elevation" button on a form sends this param
if (this.hasGetElevationTarget &&
points.hasOwnProperty('params') && points.params?.points === "input") {
type = points.params?.type // Get type BEFORE overwriting points
points = this.sampleElevationPoints() // from marker or rectangle
type = points.params?.type
}

const locationElevationRequest = { locations: points }
// Use MO's backend proxy to Open-Elevation API
this.fetchElevations(points, type)
}

this.elevationService.getElevationForLocations(locationElevationRequest,
(results, status) => {
if (status === google.maps.ElevationStatus.OK) {
if (results[0]) {
this.updateElevationInputs(results, type)
} else {
console.log({ status })
}
}
async fetchElevations(points, type) {
try {
const response = await post("/geo/elevation", {
body: JSON.stringify({ locations: points }),
responseKind: "json"
})
if (response.ok) {
const data = await response.json
if (data.results && data.results.length > 0) {
this.updateElevationInputs(data.results, type)
} else if (data.error) {
console.log("Elevation API error:", data.error)
}
}
} catch (e) {
console.log("Elevation fetch failed:", e)
}
}

// defined in the map controller
Expand All @@ -280,18 +286,29 @@ export default class extends Controller {
placeClosestRectangle(viewport, extents) {
}

// Computes an array of arrays of [lat, lng] from a set of bounds on the fly
// Returns array of Google Map points {lat:, lng:} LatLngLiteral objects
// Does not actually get elevations from the API.
// Only lat/lng points that can be sent for elevations.
// Generates a 9x9 grid of sample points (81 total) within the bounds.
// Returns array of Google Map points {lat:, lng:} LatLngLiteral objects.
// More points = better min/max elevation estimates for large/mountainous areas.
sampleElevationPointsOf(bounds) {
return [
{ lat: bounds?.south, lng: bounds?.west },
{ lat: bounds?.north, lng: bounds?.west },
{ lat: bounds?.north, lng: bounds?.east },
{ lat: bounds?.south, lng: bounds?.east },
this.centerFromBounds(bounds)
]
if (!bounds?.north || !bounds?.south || !bounds?.east || !bounds?.west) {
return []
}

const points = []
const gridSize = 9
const latStep = (bounds.north - bounds.south) / (gridSize - 1)
const lngStep = (bounds.east - bounds.west) / (gridSize - 1)

for (let i = 0; i < gridSize; i++) {
for (let j = 0; j < gridSize; j++) {
points.push({
lat: bounds.south + (i * latStep),
lng: bounds.west + (j * lngStep)
})
}
}

return points
}

// Computes the center of a Google Maps Rectangle's LatLngBoundsLiteral object
Expand Down Expand Up @@ -329,7 +346,7 @@ export default class extends Controller {
this.roundOff(parseFloat(results[0].elevation))
}
if (this.hasGetElevationTarget)
this.getElevationTarget.disabled = true
this.getElevationTarget.classList.add("d-none")
}

// Using a regular expression
Expand Down
37 changes: 29 additions & 8 deletions app/javascript/controllers/map_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,12 @@ export default class extends GeocodeController {
this.geolocate_buffer = 0
this.marker_edit_buffer = 0
this.rectangle_edit_buffer = 0
this.elevation_buffer = 0
this.ignorePlaceInput = false
this.lastGeocodedLatLng = { lat: null, lng: null }
this.lastGeolocatedAddress = ""

this.libraries = ["maps", "geocoding", "marker"]
if (this.needElevationsValue == true)
this.libraries.push("elevation")

const loader = new Loader({
apiKey: "AIzaSyCxT5WScc3b99_2h2Qfy5SX6sTnE1CX3FA",
Expand Down Expand Up @@ -84,8 +83,6 @@ export default class extends GeocodeController {
loader
.load()
.then((google) => {
if (this.needElevationsValue == true)
this.elevationService = new google.maps.ElevationService()
this.geocoder = new google.maps.Geocoder()
// Everything except the obs form map: draw the map.
if (!(this.map_type === "observation" && this.editable)) {
Expand Down Expand Up @@ -225,8 +222,8 @@ export default class extends GeocodeController {
// Moving the marker means we're no longer on the image lat/lng
this.dispatch("reenableBtns")
}
// this.sampleElevationCenterOf(newPosition)
this.getElevations([newPosition], "point")
// Debounce elevation requests to avoid flooding the API
this.debouncedGetElevations([newPosition], "point")
this.map.panTo(newPosition)
})
})
Expand Down Expand Up @@ -317,7 +314,10 @@ export default class extends GeocodeController {
const newBounds = this.rectangle.getBounds()?.toJSON() // nsew object
// this.verbose({ newBounds })
this.updateBoundsInputs(newBounds)
this.getElevations(this.sampleElevationPointsOf(newBounds), "rectangle")
// Debounce elevation requests to avoid flooding the API
this.debouncedGetElevations(
this.sampleElevationPointsOf(newBounds), "rectangle"
)
this.map.fitBounds(newBounds)
})
})
Expand Down Expand Up @@ -652,14 +652,35 @@ export default class extends GeocodeController {
let points
if (this.marker) {
const position = this.marker.getPosition().toJSON()
points = [position] // this.sampleElevationCenterOf(position)
points = [position]
} else if (this.rectangle) {
const bounds = this.rectangle.getBounds().toJSON()
points = this.sampleElevationPointsOf(bounds)
} else if (this.hasNorthInputTarget) {
// Fallback: read from input fields if no map objects exist
const bounds = {
north: parseFloat(this.northInputTarget.value),
south: parseFloat(this.southInputTarget.value),
east: parseFloat(this.eastInputTarget.value),
west: parseFloat(this.westInputTarget.value)
}
if (!isNaN(bounds.north) && !isNaN(bounds.south) &&
!isNaN(bounds.east) && !isNaN(bounds.west)) {
points = this.sampleElevationPointsOf(bounds)
}
}
return points
}

// Debounce elevation requests to avoid flooding the Open-Elevation API.
// Waits 500ms after the last call before actually making the request.
debouncedGetElevations(points, type) {
clearTimeout(this.elevation_buffer)
this.elevation_buffer = setTimeout(() => {
this.getElevations(points, type)
}, 500)
}

// ------------------------------- DEBUGGING ------------------------------

// helpDebug() {
Expand Down
Loading
Loading