Repository: maplibre/maplibre-compose
Pull request #364: Add pointer pin widget
Author: login: westnordost
Created: 2025-05-26T00:26:55Z | Merged: 2025-05-26T23:42:57Z

## Description

Add a new widget to the material3 library. A pointer pin:

https://github.com/user-attachments/assets/da7cb4d5-023d-40cd-b6c8-7d162b9e5652

## Test plan

- Test on both platforms.
- Might want to test different sizes for the pointer pin content, too. 
- Might want to check if it works fine with rotation and tilt
- Might want to check if it works fine in edge-to-edge

## Checklist

This pull request primarily adds a feature.

To my knowledge, I am not making breaking changes.

I don't have access to a macOS device to test iOS changes right now.

I have tested the changes insofar as can be seen in the video (Android only).


Diff:
diff --git a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
index 37090058a..a599fccda 100644
--- a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
+++ b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
@@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
 import dev.sargunv.maplibrecompose.compose.CameraState
 import dev.sargunv.maplibrecompose.compose.MaplibreMap
 import dev.sargunv.maplibrecompose.compose.layer.CircleLayer
@@ -44,6 +45,7 @@ import dev.sargunv.maplibrecompose.demoapp.PositionVectorConverter
 import dev.sargunv.maplibrecompose.demoapp.supportsLayers
 import dev.sargunv.maplibrecompose.expressions.dsl.const
 import dev.sargunv.maplibrecompose.expressions.dsl.offset
+import dev.sargunv.maplibrecompose.material3.controls.PointerPinButton
 import io.github.dellisd.spatialk.geojson.Point
 import io.github.dellisd.spatialk.geojson.Position
 import kotlin.math.roundToInt
@@ -93,6 +95,15 @@ object CameraFollowDemo : Demo {
               )
             }
           }
+
+          PointerPinButton(
+            cameraState = cameraState,
+            targetPosition = animatedPosition,
+            onClick = { isFollowing = true },
+          ) {
+            Text("🚊", fontSize = 28.sp)
+          }
+
           DemoMapControls(
             cameraState,
             styleState,
diff --git a/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/Dp.kt b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/Dp.kt
new file mode 100644
index 000000000..7ad522c78
--- /dev/null
+++ b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/Dp.kt
@@ -0,0 +1,16 @@
+package dev.sargunv.maplibrecompose.material3
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.DpOffset
+
+@Composable
+@ReadOnlyComposable
+internal fun Offset.toDpOffset(): DpOffset =
+  with(LocalDensity.current) { DpOffset(x.toDp(), y.toDp()) }
+
+@Composable
+@ReadOnlyComposable
+internal fun DpOffset.toOffset(): Offset = with(LocalDensity.current) { Offset(x.toPx(), y.toPx()) }
diff --git a/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/Intersection.kt b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/Intersection.kt
new file mode 100644
index 000000000..aa819f812
--- /dev/null
+++ b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/Intersection.kt
@@ -0,0 +1,31 @@
+package dev.sargunv.maplibrecompose.material3
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import kotlin.math.PI
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/**
+ * Given an imaginary line drawn from the center of [area] to [target], returns the point and angle
+ * at which the line intersects with an ellipsis filling the [area].
+ */
+internal fun findEllipsisIntersection(area: Rect, target: Offset): Intersection? {
+  val delta = target - area.center
+  val theta = atan2(delta.y, delta.x) + PI / 2
+  val radius = ellipsisRadius(area.height / 2.0, area.width / 2.0, theta)
+  val ellipsisDelta = Offset((sin(theta) * radius).toFloat(), (-cos(theta) * radius).toFloat())
+  if (delta.getDistanceSquared() < ellipsisDelta.getDistanceSquared()) return null
+
+  return Intersection(ellipsisDelta + area.center, theta)
+}
+
+private fun ellipsisRadius(a: Double, b: Double, angle: Double): Double {
+  val x = sin(angle)
+  val y = cos(angle)
+  return a * b / sqrt(a * a * x * x + b * b * y * y)
+}
+
+internal data class Intersection(val position: Offset, val angle: Double)
diff --git a/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/Modifier.kt b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/Modifier.kt
new file mode 100644
index 000000000..39e2f948e
--- /dev/null
+++ b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/Modifier.kt
@@ -0,0 +1,35 @@
+package dev.sargunv.maplibrecompose.material3
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.layout
+
+/** set padding proportional to the composable's size */
+internal fun Modifier.proportionalPadding(
+  start: Float = 0f,
+  top: Float = 0f,
+  end: Float = 0f,
+  bottom: Float = 0f,
+) = layout { measurable, constraints ->
+  val placeable = measurable.measure(constraints)
+  val width = placeable.width
+  val height = placeable.height
+  val startPad = (start * width).toInt()
+  val endPad = (end * width).toInt()
+  val topPad = (top * height).toInt()
+  val bottomPad = (bottom * height).toInt()
+  layout(width = width + startPad + endPad, height = height + topPad + bottomPad) {
+    placeable.placeRelative(x = startPad, y = topPad)
+  }
+}
+
+/** set padding proportional to the composable's size */
+internal fun Modifier.proportionalPadding(all: Float = 0f) = proportionalPadding(all, all, all, all)
+
+/** set absolute offset proportional to the composable's size */
+internal fun Modifier.proportionalAbsoluteOffset(x: Float = 0f, y: Float = 0f) =
+  layout { measurable, constraints ->
+    val placeable = measurable.measure(constraints)
+    val width = placeable.width
+    val height = placeable.height
+    layout(width, height) { placeable.place(x = (x * width).toInt(), y = (y * height).toInt()) }
+  }
diff --git a/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/PointerPinButton.kt b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/PointerPinButton.kt
new file mode 100644
index 000000000..1e041c3ca
--- /dev/null
+++ b/lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/PointerPinButton.kt
@@ -0,0 +1,166 @@
+package dev.sargunv.maplibrecompose.material3.controls
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.absoluteOffset
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ButtonElevation
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.ElevatedButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Matrix
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.vector.PathParser
+import androidx.compose.ui.graphics.vector.toPath
+import androidx.compose.ui.layout.boundsInParent
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import dev.sargunv.maplibrecompose.compose.CameraState
+import dev.sargunv.maplibrecompose.material3.findEllipsisIntersection
+import dev.sargunv.maplibrecompose.material3.proportionalAbsoluteOffset
+import dev.sargunv.maplibrecompose.material3.proportionalPadding
+import dev.sargunv.maplibrecompose.material3.toDpOffset
+import dev.sargunv.maplibrecompose.material3.toOffset
+import io.github.dellisd.spatialk.geojson.Position
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+/**
+ * An elevated button in the shape of a pointer pin on the edge of an ellipsis drawn inside the
+ * parent layout, pointing towards some [targetPosition] off-screen. Only shown if the
+ * [targetPosition] is outside of the ellipsis.
+ *
+ * Note that this composable should fill the whole area in which the pointer pin button will be
+ * **placed**, i.e. usually the whole map screen.
+ *
+ * @param onClick called when this button is clicked
+ * @param cameraState used to calculate where the given [targetPosition] is in screen coordinates
+ * @param targetPosition position (off-screen) the pin should point at
+ * @param modifier the [Modifier] to be applied to this button
+ * @param enabled controls the enabled state of this button. When `false`, this component will not
+ *   respond to user input, and it will appear visually disabled and disabled to accessibility
+ *   services.
+ * @param colors [ButtonColors] that will be used to resolve the colors for this button in different
+ *   states. See [ButtonDefaults.elevatedButtonColors].
+ * @param elevation [ButtonElevation] used to resolve the elevation for this button in different
+ *   states. This controls the size of the shadow below the button. Additionally, when the container
+ *   color is [ColorScheme.surface], this controls the amount of primary color applied as an
+ *   overlay. See [ButtonDefaults.elevatedButtonElevation].
+ * @param border the border to draw around the container of this button
+ * @param contentPadding the spacing values to apply internally between the container and the
+ *   content
+ * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
+ *   emitting [Interaction]s for this button. You can use this to change the button's appearance or
+ *   preview the button in different states. Note that if `null` is provided, interactions will
+ *   still happen internally.
+ */
+@Composable
+public fun PointerPinButton(
+  cameraState: CameraState,
+  targetPosition: Position,
+  modifier: Modifier = Modifier,
+  onClick: () -> Unit = {},
+  enabled: Boolean = true,
+  colors: ButtonColors = ButtonDefaults.elevatedButtonColors(),
+  elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
+  border: BorderStroke? = null,
+  contentPadding: PaddingValues = PaddingValues(12.dp), // good padding for a 24x24 icon
+  interactionSource: MutableInteractionSource? = null,
+  content: @Composable (BoxScope.() -> Unit),
+) {
+  val target = cameraState.screenLocationFromPosition(targetPosition)?.toOffset() ?: return
+  var area by remember { mutableStateOf<Rect?>(null) }
+
+  Box(modifier = modifier.fillMaxSize().onGloballyPositioned { area = it.boundsInParent() }) {
+    val intersection = remember(target, area) { area?.let { findEllipsisIntersection(it, target) } }
+
+    intersection?.let { (offset, angle) ->
+      val rotation = angle * 180 / PI
+      val dpOffset = offset.toDpOffset()
+
+      val pointerPinShape = remember(rotation) { PointerPinShape(rotation.toFloat()) }
+
+      ElevatedButton(
+        onClick = onClick,
+        modifier =
+          modifier
+            // offsetting it to account for the pointy side of the pin depending on the rotation.
+            // (The tip of the pin should be exactly on the ellipsis outline)
+            .proportionalAbsoluteOffset(
+              x = (-sin(angle) / 2.0 - 0.5).toFloat(),
+              y = (cos(angle) / 2.0 - 0.5).toFloat(),
+            )
+            // offsetting the whole pin to place it correctly within the parent layout
+            .absoluteOffset(dpOffset.x, dpOffset.y),
+        enabled = enabled,
+        shape = pointerPinShape,
+        colors = colors,
+        elevation = elevation,
+        border = border,
+        contentPadding = PaddingValues(0.dp),
+        interactionSource = interactionSource,
+      ) {
+        Box(
+          modifier =
+            Modifier
+              // padding to place the content within the pin correctly, taking into account that the
+              // center of the pointer shape is not the center of to-be-placed icon (due to the
+              // pointy side)
+              .proportionalPadding(PointerPinShape.POINTY_SIZE)
+              .padding(contentPadding)
+        ) {
+          content()
+        }
+      }
+    }
+  }
+}
+
+/** A kind of map-📍 shape, but rotatable */
+private class PointerPinShape(val rotation: Float = 0f) : Shape {
+  override fun createOutline(
+    size: Size,
+    layoutDirection: LayoutDirection,
+    density: Density,
+  ): Outline {
+    val m = Matrix()
+    val halfWidth = size.width / 2
+    val halfHeight = size.height / 2
+    m.translate(halfWidth, halfHeight)
+    m.rotateZ(rotation)
+    m.translate(-halfWidth, -halfHeight)
+    m.scale(x = size.width / PATH_SIZE, y = size.height / PATH_SIZE)
+    val p = PATH.toPath()
+    p.transform(m)
+    return Outline.Generic(p)
+  }
+
+  companion object {
+    const val PATH_SIZE = 76f
+    const val POINTY_SIZE = 14f / 76f
+    val PATH =
+      PathParser()
+        .parsePathString(
+          "M 38,62 C 24.745,62 14,51.255 14,38 14.003,32.6405 15.7995,27.4365 19.1035,23.217 L 38,0 56.914,23.2715 C 60.2005,27.4785 61.99,32.6615 62,38 62,51.255 51.255,62 38,62 Z"
+        )
+        .toNodes()
+  }
+}
diff --git a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/CameraState.kt b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/CameraState.kt
index cb65a485c..f69f7f0df 100644
--- a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/CameraState.kt
+++ b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/CameraState.kt
@@ -53,7 +53,7 @@ public class CameraState(firstPosition: CameraPosition) {
   public var position: CameraPosition
     get() = positionState.value
     set(value) {
-      maybeMap { it.setCameraPosition(value) }
+      mapOrNull()?.setCameraPosition(value)
       positionState.value = value
     }
 
@@ -111,28 +111,26 @@ public class CameraState(firstPosition: CameraPosition) {
     return map as? StandardMaplibreMap ?: error("Desktop not supported yet")
   }
 
-  private fun <T> maybeMap(block: (StandardMaplibreMap) -> T): T? {
-    return map?.let { block(it as? StandardMaplibreMap ?: error("Desktop not supported yet")) }
+  private fun mapOrNull(): StandardMaplibreMap? {
+    if (map == null) return null
+    return requireMap()
   }
 
   /**
    * Returns an offset from the top-left corner of the map composable that corresponds to the given
-   * [position]. This works for positions that are off-screen, too.
-   *
-   * @throws IllegalStateException if the map is not initialized yet. See [awaitInitialized].
+   * [position]. This works for positions that are off-screen, too. Returns `null` if the map is not
+   * initialized yet.
    */
-  public fun screenLocationFromPosition(position: Position): DpOffset {
-    return requireMap().screenLocationFromPosition(position)
+  public fun screenLocationFromPosition(position: Position): DpOffset? {
+    return mapOrNull()?.screenLocationFromPosition(position)
   }
 
   /**
    * Returns a position that corresponds to the given [offset] from the top-left corner of the map
-   * composable.
-   *
-   * @throws IllegalStateException if the map is not initialized yet. See [awaitInitialized].
+   * composable. Returns `null` if the map is not initialized yet.
    */
-  public fun positionFromScreenLocation(offset: DpOffset): Position {
-    return requireMap().positionFromScreenLocation(offset)
+  public fun positionFromScreenLocation(offset: DpOffset): Position? {
+    return mapOrNull()?.positionFromScreenLocation(offset)
   }
 
   /**
@@ -154,7 +152,7 @@ public class CameraState(firstPosition: CameraPosition) {
   ): List<Feature> {
     val predicateOrNull =
       predicate.takeUnless { it == const(true) }?.compile(ExpressionContext.None)
-    return maybeMap { it.queryRenderedFeatures(offset, layerIds, predicateOrNull) } ?: emptyList()
+    return mapOrNull()?.queryRenderedFeatures(offset, layerIds, predicateOrNull) ?: emptyList()
   }
 
   /**
@@ -175,7 +173,7 @@ public class CameraState(firstPosition: CameraPosition) {
   ): List<Feature> {
     val predicateOrNull =
       predicate.takeUnless { it == const(true) }?.compile(ExpressionContext.None)
-    return maybeMap { it.queryRenderedFeatures(rect, layerIds, predicateOrNull) } ?: emptyList()
+    return mapOrNull()?.queryRenderedFeatures(rect, layerIds, predicateOrNull) ?: emptyList()
   }
 
   /**
@@ -183,24 +181,20 @@ public class CameraState(firstPosition: CameraPosition) {
    *
    * Note that the bounding box is always a north-aligned rectangle. I.e. if the map is rotated or
    * tilted, the returned bounding box will always be larger than the actually visible area. See
-   * [queryVisibleRegion]
-   *
-   * @throws IllegalStateException if the map is not initialized yet. See [awaitInitialized].
+   * [queryVisibleRegion]. Returns `null` if the map is not initialized yet.
    */
-  public fun queryVisibleBoundingBox(): BoundingBox {
+  public fun queryVisibleBoundingBox(): BoundingBox? {
     // TODO at some point, this should be refactored to State, just like the camera position
-    return requireMap().getVisibleBoundingBox()
+    return mapOrNull()?.getVisibleBoundingBox()
   }
 
   /**
    * Returns the currently visible area, which is a four-sided polygon spanned by the four points
    * each at one corner of the map composable. If the camera has tilt (pitch), this polygon is a
-   * trapezoid instead of a rectangle.
-   *
-   * @throws IllegalStateException if the map is not initialized yet. See [awaitInitialized].
+   * trapezoid instead of a rectangle. Returns `null` if the map is not initialized yet.
    */
-  public fun queryVisibleRegion(): VisibleRegion {
+  public fun queryVisibleRegion(): VisibleRegion? {
     // TODO at some point, this should be refactored to State, just like the camera position
-    return requireMap().getVisibleRegion()
+    return mapOrNull()?.getVisibleRegion()
   }
 }


Reviews:
user: login: sargunv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T00:44:20Z
user: login: westnordost
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T00:48:13Z
user: login: sargunv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T00:59:01Z
user: login: sargunv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T01:02:10Z
user: login: sargunv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T01:05:59Z
user: login: sargunv
body: approving pending comments
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-26T01:06:33Z
user: login: sargunv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T01:07:49Z
user: login: sargunv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T01:08:21Z
user: login: sargunv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T01:08:47Z
user: login: sargunv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T01:14:35Z
user: login: westnordost
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T09:33:33Z
user: login: westnordost
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T09:36:55Z
user: login: westnordost
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T09:38:38Z
user: login: westnordost
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T09:44:06Z
user: login: westnordost
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T09:44:54Z
user: login: sargunv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T16:22:26Z
user: login: sargunv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T18:04:16Z
user: login: westnordost
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T20:23:31Z
user: login: sargunv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T22:26:55Z
user: login: sargunv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T23:42:48Z

Review comments:
diff_hunk: @@ -93,6 +95,22 @@ object CameraFollowDemo : Demo {
               )
             }
           }
+          // TODO, urgh, this is ugly... why can't this just return null if the map is not available
+          //  yet?? awaitInitialized() doesn't really help, I'm in the middle of the composition
+          //  here after all...
+          val targetPosition =
+            try {
+              cameraState.screenLocationFromPosition(animatedPosition)
+            } catch (_: Exception) {
+              null
+            }
path: demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
user: login: sargunv
body: Can we pass a `Position` as parameter instead of a `DpOffset`? (or, an overload)

this way, any "ugly" map initialization logic here gets encapsulated inside the function
created_at: 2025-05-26T00:44:20Z
author_association: COLLABORATOR
diff_hunk: @@ -93,6 +95,22 @@ object CameraFollowDemo : Demo {
               )
             }
           }
+          // TODO, urgh, this is ugly... why can't this just return null if the map is not available
+          //  yet?? awaitInitialized() doesn't really help, I'm in the middle of the composition
+          //  here after all...
+          val targetPosition =
+            try {
+              cameraState.screenLocationFromPosition(animatedPosition)
+            } catch (_: Exception) {
+              null
+            }
path: demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
user: login: westnordost
body: then we also need to pass the `CameraState`.

What do you think about my suggestion to let that function return simply `null` if the map is not initialized yet?

(Hey, this is kind of the result pattern - that the map is not initialized yet is certainly an expected error, so users should be "forced" to deal with it, by ensuring that screenLocationFromPosition is not null 😺)
created_at: 2025-05-26T00:48:13Z
author_association: COLLABORATOR
diff_hunk: @@ -93,6 +95,22 @@ object CameraFollowDemo : Demo {
               )
             }
           }
+          // TODO, urgh, this is ugly... why can't this just return null if the map is not available
+          //  yet?? awaitInitialized() doesn't really help, I'm in the middle of the composition
+          //  here after all...
+          val targetPosition =
+            try {
+              cameraState.screenLocationFromPosition(animatedPosition)
+            } catch (_: Exception) {
+              null
+            }
path: demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
user: login: sargunv
body: It's probably okay, yeah. I'd prefer something more expressive than `null`, but the result pattern seems excessive here, and Rich Errors aren't available yet, so null is fine for now

Someday, like Kotlin 2.5 or whatever, I'd love to return `DpOffset | MapNotInitializedError` instead of `DpOffset?`
created_at: 2025-05-26T00:59:01Z
author_association: COLLABORATOR
diff_hunk: @@ -93,6 +95,22 @@ object CameraFollowDemo : Demo {
               )
             }
           }
+          // TODO, urgh, this is ugly... why can't this just return null if the map is not available
+          //  yet?? awaitInitialized() doesn't really help, I'm in the middle of the composition
+          //  here after all...
+          val targetPosition =
+            try {
+              cameraState.screenLocationFromPosition(animatedPosition)
+            } catch (_: Exception) {
+              null
+            }
path: demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
user: login: sargunv
body: Though rn we have a mix of patterns with these components: some where we pass state in (attribution button, compass button) and some where we pass in values derived from the state (scale bar, this one).

I personally prefer the pattern of passing state in; it more closely reflects the purpose of the component (take a real-world position and point to which direction it is on the map). But if you think there's value in the pure variants, happy to accept an overload
created_at: 2025-05-26T01:02:10Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,16 @@
+package dev.sargunv.maplibrecompose.material3
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.DpOffset
+
+@Composable
+@ReadOnlyComposable
+internal fun Offset.toDpOffset(): DpOffset =
+  with(LocalDensity.current) { DpOffset(x.toDp(), y.toDp()) }
+
+@Composable
+@ReadOnlyComposable
+internal fun DpOffset.toOffset(): Offset = with(LocalDensity.current) { Offset(x.toPx(), y.toPx()) }
path: lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/Dp.kt
user: login: sargunv
body: I like your pattern of placing utils for particular types in functions named for that type. But as the number grows, should we be relocating these to a `.util` package in the module?

Should we refactor the other utils in the library (in a `util.kt` file iirc) similarly?
created_at: 2025-05-26T01:05:59Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,165 @@
+package dev.sargunv.maplibrecompose.material3.controls
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.absoluteOffset
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ButtonElevation
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.ElevatedButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Matrix
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.vector.PathParser
+import androidx.compose.ui.graphics.vector.toPath
+import androidx.compose.ui.layout.boundsInParent
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import dev.sargunv.maplibrecompose.material3.findEllipsisIntersection
+import dev.sargunv.maplibrecompose.material3.proportionalAbsoluteOffset
+import dev.sargunv.maplibrecompose.material3.proportionalPadding
+import dev.sargunv.maplibrecompose.material3.toDpOffset
+import dev.sargunv.maplibrecompose.material3.toOffset
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+/**
+ * An elevated button in the shape of a pointer pin on the edge of an ellipsis drawn inside the
+ * parent layout, pointing towards some [targetPosition] off-screen. Only shown if the
+ * [targetPosition] is outside of the ellipsis.
+ *
+ * Note that this composable should fill the whole area in which the pointer pin button will be
+ * **placed**, i.e. usually the whole map screen.
+ *
+ * @param onClick called when this button is clicked
+ * @param targetPosition position (off-screen) the pin should point at
+ * @param modifier the [Modifier] to be applied to this button
+ * @param enabled controls the enabled state of this button. When `false`, this component will not
+ *   respond to user input, and it will appear visually disabled and disabled to accessibility
+ *   services.
+ * @param colors [ButtonColors] that will be used to resolve the colors for this button in different
+ *   states. See [ButtonDefaults.elevatedButtonColors].
+ * @param elevation [ButtonElevation] used to resolve the elevation for this button in different
+ *   states. This controls the size of the shadow below the button. Additionally, when the container
+ *   color is [ColorScheme.surface], this controls the amount of primary color applied as an
+ *   overlay. See [ButtonDefaults.elevatedButtonElevation].
+ * @param border the border to draw around the container of this button
+ * @param contentPadding the spacing values to apply internally between the container and the
+ *   content
+ * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
+ *   emitting [Interaction]s for this button. You can use this to change the button's appearance or
+ *   preview the button in different states. Note that if `null` is provided, interactions will
+ *   still happen internally.
+ */
+@Composable
+public fun PointerPinButton(
+  onClick: () -> Unit,
+  targetPosition: DpOffset,
+  modifier: Modifier = Modifier,
+  enabled: Boolean = true,
+  colors: ButtonColors = ButtonDefaults.elevatedButtonColors(),
+  elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
+  border: BorderStroke? = null,
+  contentPadding: PaddingValues = PaddingValues(12.dp),
+  interactionSource: MutableInteractionSource? = null,
+  content: @Composable (BoxScope.() -> Unit),
+) {
+  val target = targetPosition.toOffset()
+  var area by remember { mutableStateOf<Rect?>(null) }
+
+  Box(modifier = modifier.fillMaxSize().onGloballyPositioned { area = it.boundsInParent() }) {
+    val intersection = remember(target, area) { area?.let { findEllipsisIntersection(it, target) } }
path: lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/PointerPinButton.kt
user: login: sargunv
body: how hard is it to change the shape of the intersection area? probably just personal preference but I find it a bit odd the pointer floats away from the corners of the map view. Could this shape be customizable? 
created_at: 2025-05-26T01:07:49Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,165 @@
+package dev.sargunv.maplibrecompose.material3.controls
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.absoluteOffset
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ButtonElevation
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.ElevatedButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Matrix
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.vector.PathParser
+import androidx.compose.ui.graphics.vector.toPath
+import androidx.compose.ui.layout.boundsInParent
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import dev.sargunv.maplibrecompose.material3.findEllipsisIntersection
+import dev.sargunv.maplibrecompose.material3.proportionalAbsoluteOffset
+import dev.sargunv.maplibrecompose.material3.proportionalPadding
+import dev.sargunv.maplibrecompose.material3.toDpOffset
+import dev.sargunv.maplibrecompose.material3.toOffset
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+/**
+ * An elevated button in the shape of a pointer pin on the edge of an ellipsis drawn inside the
+ * parent layout, pointing towards some [targetPosition] off-screen. Only shown if the
+ * [targetPosition] is outside of the ellipsis.
+ *
+ * Note that this composable should fill the whole area in which the pointer pin button will be
+ * **placed**, i.e. usually the whole map screen.
+ *
+ * @param onClick called when this button is clicked
+ * @param targetPosition position (off-screen) the pin should point at
+ * @param modifier the [Modifier] to be applied to this button
+ * @param enabled controls the enabled state of this button. When `false`, this component will not
+ *   respond to user input, and it will appear visually disabled and disabled to accessibility
+ *   services.
+ * @param colors [ButtonColors] that will be used to resolve the colors for this button in different
+ *   states. See [ButtonDefaults.elevatedButtonColors].
+ * @param elevation [ButtonElevation] used to resolve the elevation for this button in different
+ *   states. This controls the size of the shadow below the button. Additionally, when the container
+ *   color is [ColorScheme.surface], this controls the amount of primary color applied as an
+ *   overlay. See [ButtonDefaults.elevatedButtonElevation].
+ * @param border the border to draw around the container of this button
+ * @param contentPadding the spacing values to apply internally between the container and the
+ *   content
+ * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
+ *   emitting [Interaction]s for this button. You can use this to change the button's appearance or
+ *   preview the button in different states. Note that if `null` is provided, interactions will
+ *   still happen internally.
+ */
+@Composable
+public fun PointerPinButton(
+  onClick: () -> Unit,
+  targetPosition: DpOffset,
+  modifier: Modifier = Modifier,
+  enabled: Boolean = true,
+  colors: ButtonColors = ButtonDefaults.elevatedButtonColors(),
+  elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
+  border: BorderStroke? = null,
+  contentPadding: PaddingValues = PaddingValues(12.dp),
+  interactionSource: MutableInteractionSource? = null,
+  content: @Composable (BoxScope.() -> Unit),
+) {
+  val target = targetPosition.toOffset()
+  var area by remember { mutableStateOf<Rect?>(null) }
+
+  Box(modifier = modifier.fillMaxSize().onGloballyPositioned { area = it.boundsInParent() }) {
+    val intersection = remember(target, area) { area?.let { findEllipsisIntersection(it, target) } }
+
+    intersection?.let { (offset, angle) ->
+      val rotation = angle * 180 / PI
+      val dpOffset = offset.toDpOffset()
+
+      val pointerPinShape = remember(rotation) { PointerPinShape(rotation.toFloat()) }
+
+      ElevatedButton(
+        onClick = onClick,
+        modifier =
+          modifier
+            // offsetting it to account for the pointy side of the pin depending on the rotation.
+            // (The
+            // tip of the pin should be exactly on the ellipsis outline)
path: lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/PointerPinButton.kt
user: login: sargunv
body: ```suggestion
            // (The tip of the pin should be exactly on the ellipsis outline)
```
created_at: 2025-05-26T01:08:21Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,165 @@
+package dev.sargunv.maplibrecompose.material3.controls
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.absoluteOffset
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ButtonElevation
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.ElevatedButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Matrix
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.vector.PathParser
+import androidx.compose.ui.graphics.vector.toPath
+import androidx.compose.ui.layout.boundsInParent
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import dev.sargunv.maplibrecompose.material3.findEllipsisIntersection
+import dev.sargunv.maplibrecompose.material3.proportionalAbsoluteOffset
+import dev.sargunv.maplibrecompose.material3.proportionalPadding
+import dev.sargunv.maplibrecompose.material3.toDpOffset
+import dev.sargunv.maplibrecompose.material3.toOffset
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+/**
+ * An elevated button in the shape of a pointer pin on the edge of an ellipsis drawn inside the
+ * parent layout, pointing towards some [targetPosition] off-screen. Only shown if the
+ * [targetPosition] is outside of the ellipsis.
+ *
+ * Note that this composable should fill the whole area in which the pointer pin button will be
+ * **placed**, i.e. usually the whole map screen.
+ *
+ * @param onClick called when this button is clicked
+ * @param targetPosition position (off-screen) the pin should point at
+ * @param modifier the [Modifier] to be applied to this button
+ * @param enabled controls the enabled state of this button. When `false`, this component will not
+ *   respond to user input, and it will appear visually disabled and disabled to accessibility
+ *   services.
+ * @param colors [ButtonColors] that will be used to resolve the colors for this button in different
+ *   states. See [ButtonDefaults.elevatedButtonColors].
+ * @param elevation [ButtonElevation] used to resolve the elevation for this button in different
+ *   states. This controls the size of the shadow below the button. Additionally, when the container
+ *   color is [ColorScheme.surface], this controls the amount of primary color applied as an
+ *   overlay. See [ButtonDefaults.elevatedButtonElevation].
+ * @param border the border to draw around the container of this button
+ * @param contentPadding the spacing values to apply internally between the container and the
+ *   content
+ * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
+ *   emitting [Interaction]s for this button. You can use this to change the button's appearance or
+ *   preview the button in different states. Note that if `null` is provided, interactions will
+ *   still happen internally.
+ */
+@Composable
+public fun PointerPinButton(
+  onClick: () -> Unit,
+  targetPosition: DpOffset,
+  modifier: Modifier = Modifier,
+  enabled: Boolean = true,
+  colors: ButtonColors = ButtonDefaults.elevatedButtonColors(),
+  elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
+  border: BorderStroke? = null,
+  contentPadding: PaddingValues = PaddingValues(12.dp),
+  interactionSource: MutableInteractionSource? = null,
+  content: @Composable (BoxScope.() -> Unit),
+) {
+  val target = targetPosition.toOffset()
+  var area by remember { mutableStateOf<Rect?>(null) }
+
+  Box(modifier = modifier.fillMaxSize().onGloballyPositioned { area = it.boundsInParent() }) {
+    val intersection = remember(target, area) { area?.let { findEllipsisIntersection(it, target) } }
+
+    intersection?.let { (offset, angle) ->
+      val rotation = angle * 180 / PI
+      val dpOffset = offset.toDpOffset()
+
+      val pointerPinShape = remember(rotation) { PointerPinShape(rotation.toFloat()) }
+
+      ElevatedButton(
+        onClick = onClick,
+        modifier =
+          modifier
+            // offsetting it to account for the pointy side of the pin depending on the rotation.
+            // (The
+            // tip of the pin should be exactly on the ellipsis outline)
+            .proportionalAbsoluteOffset(
+              x = (-sin(angle) / 2.0 - 0.5).toFloat(),
+              y = (cos(angle) / 2.0 - 0.5).toFloat(),
+            )
+            // offsetting the whole pin to place it correctly within the parent layout
+            .absoluteOffset(dpOffset.x, dpOffset.y),
+        enabled = enabled,
+        shape = pointerPinShape,
+        colors = colors,
+        elevation = elevation,
+        border = border,
+        contentPadding = PaddingValues(0.dp),
+        interactionSource = interactionSource,
+      ) {
+        Box(
+          modifier =
+            Modifier
+              // padding to place the content within the pin correctly, taking into account that the
+              // center of the pointer shape is not the center of to-be-placed icon (due to the
+              // pointy
+              // side)
path: lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/PointerPinButton.kt
user: login: sargunv
body: ```suggestion
              // pointy side)
```
created_at: 2025-05-26T01:08:47Z
author_association: COLLABORATOR
diff_hunk: @@ -93,6 +95,22 @@ object CameraFollowDemo : Demo {
               )
             }
           }
+          // TODO, urgh, this is ugly... why can't this just return null if the map is not available
+          //  yet?? awaitInitialized() doesn't really help, I'm in the middle of the composition
+          //  here after all...
+          val targetPosition =
+            try {
+              cameraState.screenLocationFromPosition(animatedPosition)
+            } catch (_: Exception) {
+              null
+            }
path: demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
user: login: sargunv
body: oh, and is a recomposition guaranteed when the map is initialized? I think not, and so we'd get null once but never see the actual value until the target position changes or something else triggers recomposition

this is also true of this try/catch based implementation. I think `awaitInitialized` setting some state is necessary. Maybe instead of returning null; some `isInitialized` state?
created_at: 2025-05-26T01:14:35Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,16 @@
+package dev.sargunv.maplibrecompose.material3
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.DpOffset
+
+@Composable
+@ReadOnlyComposable
+internal fun Offset.toDpOffset(): DpOffset =
+  with(LocalDensity.current) { DpOffset(x.toDp(), y.toDp()) }
+
+@Composable
+@ReadOnlyComposable
+internal fun DpOffset.toOffset(): Offset = with(LocalDensity.current) { Offset(x.toPx(), y.toPx()) }
path: lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/Dp.kt
user: login: westnordost
body: I don't mind either way.

My pattern is usually
- <Class name>.kt for extension functions for <Class name>
- SomeNameUtils.kt for a collection of utility functions

(But I would say that should go into a separate PR, if it is done)
created_at: 2025-05-26T09:33:33Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,165 @@
+package dev.sargunv.maplibrecompose.material3.controls
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.absoluteOffset
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ButtonElevation
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.ElevatedButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Matrix
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.vector.PathParser
+import androidx.compose.ui.graphics.vector.toPath
+import androidx.compose.ui.layout.boundsInParent
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import dev.sargunv.maplibrecompose.material3.findEllipsisIntersection
+import dev.sargunv.maplibrecompose.material3.proportionalAbsoluteOffset
+import dev.sargunv.maplibrecompose.material3.proportionalPadding
+import dev.sargunv.maplibrecompose.material3.toDpOffset
+import dev.sargunv.maplibrecompose.material3.toOffset
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+/**
+ * An elevated button in the shape of a pointer pin on the edge of an ellipsis drawn inside the
+ * parent layout, pointing towards some [targetPosition] off-screen. Only shown if the
+ * [targetPosition] is outside of the ellipsis.
+ *
+ * Note that this composable should fill the whole area in which the pointer pin button will be
+ * **placed**, i.e. usually the whole map screen.
+ *
+ * @param onClick called when this button is clicked
+ * @param targetPosition position (off-screen) the pin should point at
+ * @param modifier the [Modifier] to be applied to this button
+ * @param enabled controls the enabled state of this button. When `false`, this component will not
+ *   respond to user input, and it will appear visually disabled and disabled to accessibility
+ *   services.
+ * @param colors [ButtonColors] that will be used to resolve the colors for this button in different
+ *   states. See [ButtonDefaults.elevatedButtonColors].
+ * @param elevation [ButtonElevation] used to resolve the elevation for this button in different
+ *   states. This controls the size of the shadow below the button. Additionally, when the container
+ *   color is [ColorScheme.surface], this controls the amount of primary color applied as an
+ *   overlay. See [ButtonDefaults.elevatedButtonElevation].
+ * @param border the border to draw around the container of this button
+ * @param contentPadding the spacing values to apply internally between the container and the
+ *   content
+ * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
+ *   emitting [Interaction]s for this button. You can use this to change the button's appearance or
+ *   preview the button in different states. Note that if `null` is provided, interactions will
+ *   still happen internally.
+ */
+@Composable
+public fun PointerPinButton(
+  onClick: () -> Unit,
+  targetPosition: DpOffset,
+  modifier: Modifier = Modifier,
+  enabled: Boolean = true,
+  colors: ButtonColors = ButtonDefaults.elevatedButtonColors(),
+  elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
+  border: BorderStroke? = null,
+  contentPadding: PaddingValues = PaddingValues(12.dp),
+  interactionSource: MutableInteractionSource? = null,
+  content: @Composable (BoxScope.() -> Unit),
+) {
+  val target = targetPosition.toOffset()
+  var area by remember { mutableStateOf<Rect?>(null) }
+
+  Box(modifier = modifier.fillMaxSize().onGloballyPositioned { area = it.boundsInParent() }) {
+    val intersection = remember(target, area) { area?.let { findEllipsisIntersection(it, target) } }
path: lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/PointerPinButton.kt
user: login: westnordost
body: kind of hard, I guess... but mostly because I figure calculating the intersection of a line with an ellipsis is so much easier than with e.g. a rounded rectangle.

De hecho, I originally had a much more complicated calculation: Intersection with the rectangle (easy enough - intersection with the closest of 4 line segments) but then also intersection with the bounding box of other HUD elements (also rectangles). In the end, by the way, the ellipsis looks *much* better, IMO.
created_at: 2025-05-26T09:36:54Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,165 @@
+package dev.sargunv.maplibrecompose.material3.controls
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.absoluteOffset
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ButtonElevation
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.ElevatedButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Matrix
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.vector.PathParser
+import androidx.compose.ui.graphics.vector.toPath
+import androidx.compose.ui.layout.boundsInParent
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import dev.sargunv.maplibrecompose.material3.findEllipsisIntersection
+import dev.sargunv.maplibrecompose.material3.proportionalAbsoluteOffset
+import dev.sargunv.maplibrecompose.material3.proportionalPadding
+import dev.sargunv.maplibrecompose.material3.toDpOffset
+import dev.sargunv.maplibrecompose.material3.toOffset
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+/**
+ * An elevated button in the shape of a pointer pin on the edge of an ellipsis drawn inside the
+ * parent layout, pointing towards some [targetPosition] off-screen. Only shown if the
+ * [targetPosition] is outside of the ellipsis.
+ *
+ * Note that this composable should fill the whole area in which the pointer pin button will be
+ * **placed**, i.e. usually the whole map screen.
+ *
+ * @param onClick called when this button is clicked
+ * @param targetPosition position (off-screen) the pin should point at
+ * @param modifier the [Modifier] to be applied to this button
+ * @param enabled controls the enabled state of this button. When `false`, this component will not
+ *   respond to user input, and it will appear visually disabled and disabled to accessibility
+ *   services.
+ * @param colors [ButtonColors] that will be used to resolve the colors for this button in different
+ *   states. See [ButtonDefaults.elevatedButtonColors].
+ * @param elevation [ButtonElevation] used to resolve the elevation for this button in different
+ *   states. This controls the size of the shadow below the button. Additionally, when the container
+ *   color is [ColorScheme.surface], this controls the amount of primary color applied as an
+ *   overlay. See [ButtonDefaults.elevatedButtonElevation].
+ * @param border the border to draw around the container of this button
+ * @param contentPadding the spacing values to apply internally between the container and the
+ *   content
+ * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
+ *   emitting [Interaction]s for this button. You can use this to change the button's appearance or
+ *   preview the button in different states. Note that if `null` is provided, interactions will
+ *   still happen internally.
+ */
+@Composable
+public fun PointerPinButton(
+  onClick: () -> Unit,
+  targetPosition: DpOffset,
+  modifier: Modifier = Modifier,
+  enabled: Boolean = true,
+  colors: ButtonColors = ButtonDefaults.elevatedButtonColors(),
+  elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
+  border: BorderStroke? = null,
+  contentPadding: PaddingValues = PaddingValues(12.dp),
+  interactionSource: MutableInteractionSource? = null,
+  content: @Composable (BoxScope.() -> Unit),
+) {
+  val target = targetPosition.toOffset()
+  var area by remember { mutableStateOf<Rect?>(null) }
+
+  Box(modifier = modifier.fillMaxSize().onGloballyPositioned { area = it.boundsInParent() }) {
+    val intersection = remember(target, area) { area?.let { findEllipsisIntersection(it, target) } }
path: lib/maplibre-compose-material3/src/commonMain/kotlin/dev/sargunv/maplibrecompose/material3/controls/PointerPinButton.kt
user: login: westnordost
body: I guess the most flexible approach could be if the user was able to provide a `Shape` as collision shape. But... uh... well, the calculation would become somewhat complex. I wouldn't be up to implement that. Maybe someone else will implement this in the future, if the need arises.
created_at: 2025-05-26T09:38:38Z
author_association: COLLABORATOR
diff_hunk: @@ -93,6 +95,22 @@ object CameraFollowDemo : Demo {
               )
             }
           }
+          // TODO, urgh, this is ugly... why can't this just return null if the map is not available
+          //  yet?? awaitInitialized() doesn't really help, I'm in the middle of the composition
+          //  here after all...
+          val targetPosition =
+            try {
+              cameraState.screenLocationFromPosition(animatedPosition)
+            } catch (_: Exception) {
+              null
+            }
path: demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
user: login: westnordost
body: I am a fan of passing in as little as possible. But it is true, I cannot really think of a use case where one really wanted to actually pass an offset, rather than a position on the map.

Maybe... if a user just wants to show the direction, so there is no real position, but just a direction? But then, not sure if a pointer pin is the right element to use, design-language-wise 🤔

Yeah, so let's pass in a position, and camera state. We still have the option later to add another overload for passing the "bare" Offset, if the need arises.
created_at: 2025-05-26T09:44:06Z
author_association: COLLABORATOR
diff_hunk: @@ -93,6 +95,22 @@ object CameraFollowDemo : Demo {
               )
             }
           }
+          // TODO, urgh, this is ugly... why can't this just return null if the map is not available
+          //  yet?? awaitInitialized() doesn't really help, I'm in the middle of the composition
+          //  here after all...
+          val targetPosition =
+            try {
+              cameraState.screenLocationFromPosition(animatedPosition)
+            } catch (_: Exception) {
+              null
+            }
path: demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
user: login: westnordost
body: > oh, and is a recomposition guaranteed when the map is initialized? I think not, and so we'd get null once but never see the actual value until the target position changes or something else triggers recomposition

No, and I don't see an obvious way to solve that. Then again, I don't think this is a big issue.

The initialized state is a good idea, though maybe add this as a separate feature? (Separate PR)
created_at: 2025-05-26T09:44:54Z
author_association: COLLABORATOR
diff_hunk: @@ -93,6 +95,22 @@ object CameraFollowDemo : Demo {
               )
             }
           }
+          // TODO, urgh, this is ugly... why can't this just return null if the map is not available
+          //  yet?? awaitInitialized() doesn't really help, I'm in the middle of the composition
+          //  here after all...
+          val targetPosition =
+            try {
+              cameraState.screenLocationFromPosition(animatedPosition)
+            } catch (_: Exception) {
+              null
+            }
path: demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
user: login: sargunv
body: > No, and I don't see an obvious way to solve that. Then again, I don't think this is a big issue.
> 
> The initialized state is a good idea, though maybe add this as a separate feature? (Separate PR)

I think the initialized state would solve it, so would be in scope here 
created_at: 2025-05-26T16:22:26Z
author_association: COLLABORATOR
diff_hunk: @@ -93,6 +95,22 @@ object CameraFollowDemo : Demo {
               )
             }
           }
+          // TODO, urgh, this is ugly... why can't this just return null if the map is not available
+          //  yet?? awaitInitialized() doesn't really help, I'm in the middle of the composition
+          //  here after all...
+          val targetPosition =
+            try {
+              cameraState.screenLocationFromPosition(animatedPosition)
+            } catch (_: Exception) {
+              null
+            }
path: demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
user: login: sargunv
body: Thinking outside the scope of this PR, these imperative API functions could be available inside some Effect instead of on the state directly, like:

```kt
MapCameraEffect(cameraState) { camera ->
  camera.positionToScreenLocation()
}
```

but then, maybe we also want to be able to query them in other cases (on button press?) so then users will save the `camera` to some state, and we're back where we are now (except it's perhaps a more elegant representation of whether the camera is "initialized" yet, if the effect resolves after first initialization).

I think another implementation of a Compose map also does this for imperative style mutation

^ Just a pattern to keep in mind; for now, I think an `isInitialized` state is sufficient for this PR
created_at: 2025-05-26T18:04:16Z
author_association: COLLABORATOR
diff_hunk: @@ -93,6 +95,22 @@ object CameraFollowDemo : Demo {
               )
             }
           }
+          // TODO, urgh, this is ugly... why can't this just return null if the map is not available
+          //  yet?? awaitInitialized() doesn't really help, I'm in the middle of the composition
+          //  here after all...
+          val targetPosition =
+            try {
+              cameraState.screenLocationFromPosition(animatedPosition)
+            } catch (_: Exception) {
+              null
+            }
path: demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
user: login: westnordost
body: Uhh, I lost you there. So, regarding the `isInitialized` thing, would you say this should replace the `awaitInitialized` API? (I am not sure in which situation one would use the `awaitInitialized`... maybe it is anyway also possible to await a state?)
created_at: 2025-05-26T20:23:30Z
author_association: COLLABORATOR
diff_hunk: @@ -93,6 +95,22 @@ object CameraFollowDemo : Demo {
               )
             }
           }
+          // TODO, urgh, this is ugly... why can't this just return null if the map is not available
+          //  yet?? awaitInitialized() doesn't really help, I'm in the middle of the composition
+          //  here after all...
+          val targetPosition =
+            try {
+              cameraState.screenLocationFromPosition(animatedPosition)
+            } catch (_: Exception) {
+              null
+            }
path: demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
user: login: sargunv
body: I'd use awaitInitialized inside LaunchedEffect or similar, and isInitialized for cases like this PR. Though one could also wrap LaunchedEffect with an isInitialized check, so maybe we only need that one. 

If it's possible to await a state, it's valid to replace awaitInitialized with that, but I'm unsure if that's something readily available. 
created_at: 2025-05-26T22:26:55Z
author_association: COLLABORATOR
diff_hunk: @@ -93,6 +95,22 @@ object CameraFollowDemo : Demo {
               )
             }
           }
+          // TODO, urgh, this is ugly... why can't this just return null if the map is not available
+          //  yet?? awaitInitialized() doesn't really help, I'm in the middle of the composition
+          //  here after all...
+          val targetPosition =
+            try {
+              cameraState.screenLocationFromPosition(animatedPosition)
+            } catch (_: Exception) {
+              null
+            }
path: demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/CameraFollowDemo.kt
user: login: sargunv
body: #372 resolves this
created_at: 2025-05-26T23:42:48Z
author_association: COLLABORATOR

PR comments:
user: login: westnordost
created_at: 2025-05-26T00:35:47Z
body: One thing I noticed, other than the glaring TODO:

when I add an `Icon` to the content of the `PointerPinButton` (which is an `ElevatedButton`), the tint is white. White on light gray background. I don't understand, why. This can't be the default in Material3?!
author_association: COLLABORATOR
user: login: sargunv
created_at: 2025-05-26T23:35:57Z
body: * **#372** <a href="https://app.graphite.dev/github/pr/maplibre/maplibre-compose/372?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#364** <a href="https://app.graphite.dev/github/pr/maplibre/maplibre-compose/364?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/maplibre/maplibre-compose/364?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: COLLABORATOR