From 95a4587888f35a1bce44357882b631be5f442e0c Mon Sep 17 00:00:00 2001 From: "Taro L. Saito" Date: Thu, 5 Feb 2026 12:07:31 -0800 Subject: [PATCH 1/2] feature: Add Geolocation API support to uni-dom Provides reactive geolocation tracking using the browser's Geolocation API: - GeoPosition case class for position data (lat, lng, altitude, accuracy, etc.) - GeoError enum for error handling (PermissionDenied, PositionUnavailable, Timeout) - GeoOptions for configuring requests (high accuracy, timeout, max age) - Geolocation.getCurrentPosition() for one-shot position requests - Geolocation.watch() returns PositionWatcher with Rx[Option[GeoPosition]] Co-Authored-By: Claude Opus 4.5 --- plans/2026-02-05-geolocation.md | 117 +++++++++ .../scala/wvlet/uni/dom/GeolocationTest.scala | 185 +++++++++++++ .../scala/wvlet/uni/dom/Geolocation.scala | 245 ++++++++++++++++++ .../src/main/scala/wvlet/uni/dom/all.scala | 6 + 4 files changed, 553 insertions(+) create mode 100644 plans/2026-02-05-geolocation.md create mode 100644 uni-dom-test/src/test/scala/wvlet/uni/dom/GeolocationTest.scala create mode 100644 uni/.js/src/main/scala/wvlet/uni/dom/Geolocation.scala diff --git a/plans/2026-02-05-geolocation.md b/plans/2026-02-05-geolocation.md new file mode 100644 index 00000000..cc932ba5 --- /dev/null +++ b/plans/2026-02-05-geolocation.md @@ -0,0 +1,117 @@ +# Geolocation API Support for uni-dom + +## Overview + +Add a `Geolocation` object that provides reactive geolocation tracking using the browser's Geolocation API. + +## Goals + +1. Provide reactive position tracking with `Rx[GeoPosition]` +2. Support both one-shot and continuous position watching +3. Handle errors gracefully (permission denied, unavailable, timeout) +4. Support configuration options (high accuracy, timeout, max age) + +## API Design + +### Core Types + +```scala +// Position data from the Geolocation API +case class GeoPosition( + latitude: Double, + longitude: Double, + altitude: Option[Double], + accuracy: Double, + altitudeAccuracy: Option[Double], + heading: Option[Double], + speed: Option[Double], + timestamp: Long +) + +// Geolocation errors +enum GeoError: + case PermissionDenied + case PositionUnavailable + case Timeout + case NotSupported + +// Options for position requests +case class GeoOptions( + enableHighAccuracy: Boolean = false, + timeout: Long = Long.MaxValue, + maximumAge: Long = 0 +) +``` + +### Geolocation Object + +```scala +object Geolocation: + // Check if geolocation is supported + def isSupported: Boolean + + // One-shot position request + def getCurrentPosition( + onSuccess: GeoPosition => Unit, + onError: GeoError => Unit = _ => (), + options: GeoOptions = GeoOptions() + ): Unit + + // Continuous position watching - returns a watcher that can be cancelled + def watch(options: GeoOptions = GeoOptions()): PositionWatcher + + class PositionWatcher extends Cancelable: + def position: Rx[Option[GeoPosition]] // None until first position + def error: Rx[Option[GeoError]] // None if no error + def lastPosition: Option[GeoPosition] + def lastError: Option[GeoError] + def cancel: Unit +``` + +### Usage Examples + +```scala +import wvlet.uni.dom.all.* + +// Check if supported +if Geolocation.isSupported then + // One-shot position + Geolocation.getCurrentPosition( + onSuccess = pos => println(s"Location: ${pos.latitude}, ${pos.longitude}"), + onError = err => println(s"Error: ${err}") + ) + +// Continuous watching with reactive updates +val watcher = Geolocation.watch(GeoOptions(enableHighAccuracy = true)) + +div( + watcher.position.map { + case Some(pos) => span(s"Lat: ${pos.latitude}, Lon: ${pos.longitude}") + case None => span("Waiting for location...") + }, + watcher.error.map { + case Some(GeoError.PermissionDenied) => span(cls := "error", "Location access denied") + case Some(err) => span(cls := "error", s"Error: ${err}") + case None => DomNode.empty + } +) + +// Don't forget to cancel when done +watcher.cancel +``` + +## Implementation Notes + +- Use `dom.window.navigator.geolocation` from scala-js-dom +- Convert PositionError codes to GeoError enum +- Handle null/undefined values from optional coordinates +- PositionWatcher uses RxVar internally for reactive updates + +## Test Plan + +- Test GeoPosition case class +- Test GeoError enum values +- Test GeoOptions defaults +- Test Geolocation.isSupported returns Boolean +- Test PositionWatcher returns proper Rx types +- Test watch() returns PositionWatcher diff --git a/uni-dom-test/src/test/scala/wvlet/uni/dom/GeolocationTest.scala b/uni-dom-test/src/test/scala/wvlet/uni/dom/GeolocationTest.scala new file mode 100644 index 00000000..e77001d3 --- /dev/null +++ b/uni-dom-test/src/test/scala/wvlet/uni/dom/GeolocationTest.scala @@ -0,0 +1,185 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.uni.dom + +import wvlet.uni.test.UniTest +import wvlet.uni.dom.all.* +import wvlet.uni.rx.Rx + +class GeolocationTest extends UniTest: + + test("GeoPosition case class holds position data"): + val pos = GeoPosition( + latitude = 37.7749, + longitude = -122.4194, + altitude = Some(10.0), + accuracy = 50.0, + altitudeAccuracy = Some(5.0), + heading = Some(90.0), + speed = Some(1.5), + timestamp = 1234567890L + ) + pos.latitude shouldBe 37.7749 + pos.longitude shouldBe -122.4194 + pos.altitude shouldBe Some(10.0) + pos.accuracy shouldBe 50.0 + pos.altitudeAccuracy shouldBe Some(5.0) + pos.heading shouldBe Some(90.0) + pos.speed shouldBe Some(1.5) + pos.timestamp shouldBe 1234567890L + + test("GeoPosition with optional fields as None"): + val pos = GeoPosition( + latitude = 40.7128, + longitude = -74.0060, + altitude = None, + accuracy = 100.0, + altitudeAccuracy = None, + heading = None, + speed = None, + timestamp = 9876543210L + ) + pos.altitude shouldBe None + pos.altitudeAccuracy shouldBe None + pos.heading shouldBe None + pos.speed shouldBe None + + test("GeoError enum has all error types"): + GeoError.PermissionDenied shouldMatch { case GeoError.PermissionDenied => + } + GeoError.PositionUnavailable shouldMatch { case GeoError.PositionUnavailable => + } + GeoError.Timeout shouldMatch { case GeoError.Timeout => + } + GeoError.NotSupported shouldMatch { case GeoError.NotSupported => + } + + test("GeoError values are distinct"): + val errors = Seq( + GeoError.PermissionDenied, + GeoError.PositionUnavailable, + GeoError.Timeout, + GeoError.NotSupported + ) + errors.distinct.size shouldBe 4 + + test("GeoOptions has sensible defaults"): + val opts = GeoOptions() + opts.enableHighAccuracy shouldBe false + opts.timeout shouldBe Long.MaxValue + opts.maximumAge shouldBe 0L + + test("GeoOptions can be customized"): + val opts = GeoOptions(enableHighAccuracy = true, timeout = 10000L, maximumAge = 60000L) + opts.enableHighAccuracy shouldBe true + opts.timeout shouldBe 10000L + opts.maximumAge shouldBe 60000L + + test("Geolocation.isSupported returns Boolean"): + val supported = Geolocation.isSupported + supported shouldMatch { case _: Boolean => + } + + test("Geolocation.watch returns PositionWatcher"): + val watcher = Geolocation.watch() + watcher shouldMatch { case _: Geolocation.PositionWatcher => + } + watcher.cancel + + test("Geolocation.watch with options returns PositionWatcher"): + val watcher = Geolocation.watch(GeoOptions(enableHighAccuracy = true)) + watcher shouldMatch { case _: Geolocation.PositionWatcher => + } + watcher.cancel + + test("PositionWatcher.position returns Rx[Option[GeoPosition]]"): + val watcher = Geolocation.watch() + val pos = watcher.position + pos shouldMatch { case _: Rx[?] => + } + watcher.cancel + + test("PositionWatcher.error returns Rx[Option[GeoError]]"): + val watcher = Geolocation.watch() + val err = watcher.error + err shouldMatch { case _: Rx[?] => + } + watcher.cancel + + test("PositionWatcher.lastPosition returns Option[GeoPosition]"): + val watcher = Geolocation.watch() + val pos = watcher.lastPosition + pos shouldMatch { case _: Option[?] => + } + watcher.cancel + + test("PositionWatcher.lastError returns Option[GeoError]"): + val watcher = Geolocation.watch() + val err = watcher.lastError + err shouldMatch { case _: Option[?] => + } + watcher.cancel + + test("PositionWatcher can be cancelled"): + val watcher = Geolocation.watch() + watcher.cancel + // Should not throw + + test("PositionWatcher can be cancelled multiple times"): + val watcher = Geolocation.watch() + watcher.cancel + watcher.cancel + // Should not throw + + test("Geolocation.getCurrentPosition accepts callbacks"): + var called = false + Geolocation.getCurrentPosition(onSuccess = _ => called = true, onError = _ => called = true) + // In jsdom environment without geolocation, this should call onError + // We just verify the call doesn't throw + + test("Geolocation.getCurrentPosition with options"): + Geolocation.getCurrentPosition( + onSuccess = _ => (), + onError = _ => (), + options = GeoOptions(enableHighAccuracy = true, timeout = 5000L) + ) + // Should not throw + + test("PositionWatcher position reactive stream emits values"): + val watcher = Geolocation.watch() + var emittedValue = false + val cancel = watcher + .position + .run { _ => + emittedValue = true + } + // Initial value should be emitted + emittedValue shouldBe true + cancel.cancel + watcher.cancel + + test("PositionWatcher error reactive stream emits values"): + val watcher = Geolocation.watch() + var emittedValue = false + val cancel = watcher + .error + .run { _ => + emittedValue = true + } + // Initial value should be emitted + emittedValue shouldBe true + cancel.cancel + watcher.cancel + +end GeolocationTest diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/Geolocation.scala b/uni/.js/src/main/scala/wvlet/uni/dom/Geolocation.scala new file mode 100644 index 00000000..ccdd2bfc --- /dev/null +++ b/uni/.js/src/main/scala/wvlet/uni/dom/Geolocation.scala @@ -0,0 +1,245 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.uni.dom + +import org.scalajs.dom +import wvlet.uni.rx.{Cancelable, Rx, RxVar} + +import scala.scalajs.js +import scala.scalajs.js.UndefOr + +/** + * Position data from the Geolocation API. + * + * @param latitude + * Latitude in decimal degrees + * @param longitude + * Longitude in decimal degrees + * @param altitude + * Altitude in meters above sea level, if available + * @param accuracy + * Accuracy of latitude/longitude in meters + * @param altitudeAccuracy + * Accuracy of altitude in meters, if available + * @param heading + * Direction of travel in degrees (0 = north), if available + * @param speed + * Speed in meters per second, if available + * @param timestamp + * Time when the position was acquired (milliseconds since epoch) + */ +case class GeoPosition( + latitude: Double, + longitude: Double, + altitude: Option[Double], + accuracy: Double, + altitudeAccuracy: Option[Double], + heading: Option[Double], + speed: Option[Double], + timestamp: Long +) + +/** + * Geolocation error types. + */ +enum GeoError: + /** User denied the request for geolocation */ + case PermissionDenied + + /** Location information is unavailable */ + case PositionUnavailable + + /** The request to get user location timed out */ + case Timeout + + /** Geolocation is not supported in this browser */ + case NotSupported + +/** + * Options for geolocation requests. + * + * @param enableHighAccuracy + * If true, request the most accurate position available (may be slower and use more power) + * @param timeout + * Maximum time in milliseconds to wait for a position (default: no timeout) + * @param maximumAge + * Maximum age in milliseconds of a cached position to accept (default: 0, always get fresh) + */ +case class GeoOptions( + enableHighAccuracy: Boolean = false, + timeout: Long = Long.MaxValue, + maximumAge: Long = 0 +) + +/** + * Reactive geolocation tracking using the browser's Geolocation API. + * + * Usage: + * {{{ + * import wvlet.uni.dom.all.* + * + * // One-shot position request + * Geolocation.getCurrentPosition( + * onSuccess = pos => println(s"Location: ${pos.latitude}, ${pos.longitude}"), + * onError = err => println(s"Error: ${err}") + * ) + * + * // Continuous watching + * val watcher = Geolocation.watch() + * div( + * watcher.position.map { + * case Some(pos) => span(s"${pos.latitude}, ${pos.longitude}") + * case None => span("Waiting for location...") + * } + * ) + * + * // Clean up when done + * watcher.cancel + * }}} + */ +object Geolocation: + + /** + * Check if geolocation is supported in the current browser. + */ + def isSupported: Boolean = + !js.isUndefined(dom.window.navigator.geolocation) && dom.window.navigator.geolocation != null + + /** + * Request the current position once. + * + * @param onSuccess + * Called with the position when successful + * @param onError + * Called with the error if the request fails + * @param options + * Options for the position request + */ + def getCurrentPosition( + onSuccess: GeoPosition => Unit, + onError: GeoError => Unit = _ => (), + options: GeoOptions = GeoOptions() + ): Unit = + if !isSupported then + onError(GeoError.NotSupported) + else + val geo = dom.window.navigator.geolocation + geo.getCurrentPosition( + successCallback = (pos: dom.Position) => onSuccess(toGeoPosition(pos)), + errorCallback = (err: dom.PositionError) => onError(toGeoError(err)), + options = toPositionOptions(options) + ) + + /** + * Start watching the position continuously. + * + * @param options + * Options for position watching + * @return + * A PositionWatcher that provides reactive position updates + */ + def watch(options: GeoOptions = GeoOptions()): PositionWatcher = PositionWatcher(options) + + /** + * A position watcher that provides reactive updates as the position changes. + */ + class PositionWatcher(options: GeoOptions) extends Cancelable: + private val positionVar: RxVar[Option[GeoPosition]] = Rx.variable(None) + private val errorVar: RxVar[Option[GeoError]] = Rx.variable(None) + private var watchId: Option[Int] = None + + // Start watching immediately if supported + if !isSupported then + errorVar := Some(GeoError.NotSupported) + else + val geo = dom.window.navigator.geolocation + val id = geo.watchPosition( + successCallback = + (pos: dom.Position) => + positionVar := Some(toGeoPosition(pos)) + errorVar := None + , + errorCallback = (err: dom.PositionError) => errorVar := Some(toGeoError(err)), + options = toPositionOptions(options) + ) + watchId = Some(id) + + /** + * Reactive stream of position updates. Emits None until the first position is acquired. + */ + def position: Rx[Option[GeoPosition]] = positionVar + + /** + * Reactive stream of errors. Emits None when there is no error. + */ + def error: Rx[Option[GeoError]] = errorVar + + /** + * Get the last known position, if any. + */ + def lastPosition: Option[GeoPosition] = positionVar.get + + /** + * Get the last error, if any. + */ + def lastError: Option[GeoError] = errorVar.get + + /** + * Stop watching the position. + */ + override def cancel: Unit = + watchId.foreach { id => + if isSupported then + dom.window.navigator.geolocation.clearWatch(id) + } + watchId = None + + end PositionWatcher + + private def toGeoPosition(pos: dom.Position): GeoPosition = + val coords = pos.coords + GeoPosition( + latitude = coords.latitude, + longitude = coords.longitude, + altitude = toOption(coords.altitude), + accuracy = coords.accuracy, + altitudeAccuracy = toOption(coords.altitudeAccuracy), + heading = toOption(coords.heading), + speed = toOption(coords.speed), + timestamp = pos.timestamp.toLong + ) + + private def toGeoError(err: dom.PositionError): GeoError = + err.code match + case 1 => + GeoError.PermissionDenied + case 2 => + GeoError.PositionUnavailable + case 3 => + GeoError.Timeout + case _ => + GeoError.PositionUnavailable + + private def toPositionOptions(options: GeoOptions): dom.PositionOptions = + val opts = js.Dynamic.literal() + opts.enableHighAccuracy = options.enableHighAccuracy + if options.timeout != Long.MaxValue then + opts.timeout = options.timeout.toDouble + if options.maximumAge > 0 then + opts.maximumAge = options.maximumAge.toDouble + opts.asInstanceOf[dom.PositionOptions] + + private def toOption(value: UndefOr[Double]): Option[Double] = value.toOption.filterNot(_.isNaN) + +end Geolocation diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/all.scala b/uni/.js/src/main/scala/wvlet/uni/dom/all.scala index c65438b8..ed90f640 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/all.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/all.scala @@ -128,6 +128,12 @@ object all extends HtmlTags with HtmlAttrs with SvgTags with SvgAttrs: export wvlet.uni.dom.DragData export wvlet.uni.dom.DragState + // Geolocation + export wvlet.uni.dom.Geolocation + export wvlet.uni.dom.GeoPosition + export wvlet.uni.dom.GeoError + export wvlet.uni.dom.GeoOptions + /** * Re-export helper functions. */ From 10fb694baf4d86760e12603573a8bd23cbd01f74 Mon Sep 17 00:00:00 2001 From: "Taro L. Saito" Date: Thu, 5 Feb 2026 12:59:56 -0800 Subject: [PATCH 2/2] fix: Address review feedback for Geolocation - Add safer isSupported check for non-browser environments (Node.js) - Improve Rx stream documentation to clarify initial None values - Add comments documenting error code mapping - Make toOption more robust for undefined/NaN handling Co-Authored-By: Claude Opus 4.5 --- .../scala/wvlet/uni/dom/Geolocation.scala | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/Geolocation.scala b/uni/.js/src/main/scala/wvlet/uni/dom/Geolocation.scala index ccdd2bfc..44f9c867 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/Geolocation.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/Geolocation.scala @@ -111,10 +111,17 @@ case class GeoOptions( object Geolocation: /** - * Check if geolocation is supported in the current browser. + * Check if geolocation is supported in the current environment. Returns false in non-browser + * environments (e.g., Node.js) or if the Geolocation API is unavailable. */ def isSupported: Boolean = - !js.isUndefined(dom.window.navigator.geolocation) && dom.window.navigator.geolocation != null + try + !js.isUndefined(dom.window) && !js.isUndefined(dom.window.navigator) && + !js.isUndefined(dom.window.navigator.geolocation) && + dom.window.navigator.geolocation != null + catch + case _: Throwable => + false /** * Request the current position once. @@ -176,12 +183,15 @@ object Geolocation: watchId = Some(id) /** - * Reactive stream of position updates. Emits None until the first position is acquired. + * Reactive stream of position updates. The initial value is `None`, and it emits + * `Some(position)` each time a new position is acquired. On success, the error stream is + * cleared to `None`. */ def position: Rx[Option[GeoPosition]] = positionVar /** - * Reactive stream of errors. Emits None when there is no error. + * Reactive stream of errors. The initial value is `None` (or `Some(NotSupported)` if + * geolocation is unavailable). Emits `Some(error)` when an error occurs. */ def error: Rx[Option[GeoError]] = errorVar @@ -220,6 +230,8 @@ object Geolocation: timestamp = pos.timestamp.toLong ) + // Error codes defined by the Geolocation API spec: + // 1 = PERMISSION_DENIED, 2 = POSITION_UNAVAILABLE, 3 = TIMEOUT private def toGeoError(err: dom.PositionError): GeoError = err.code match case 1 => @@ -229,7 +241,7 @@ object Geolocation: case 3 => GeoError.Timeout case _ => - GeoError.PositionUnavailable + GeoError.PositionUnavailable // Fallback for unknown codes private def toPositionOptions(options: GeoOptions): dom.PositionOptions = val opts = js.Dynamic.literal() @@ -240,6 +252,15 @@ object Geolocation: opts.maximumAge = options.maximumAge.toDouble opts.asInstanceOf[dom.PositionOptions] - private def toOption(value: UndefOr[Double]): Option[Double] = value.toOption.filterNot(_.isNaN) + // Convert JS undefined/null/NaN values to None for optional coordinate fields + private def toOption(value: UndefOr[Double]): Option[Double] = + if js.isUndefined(value) then + None + else + val v = value.asInstanceOf[Double] + if v.isNaN then + None + else + Some(v) end Geolocation