Created
July 30, 2026 16:14
-
-
Save Narayan-Dhingra/9cb8f2b3c50e0ff52512e96c4e773851 to your computer and use it in GitHub Desktop.
iOS Mapbox WKWebView Bridge in Compose Multiplatform
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package app.snookalook.mobile.bridges | |
| import app.snookalook.mobile.util.ErrorReporter | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.DisposableEffect | |
| import androidx.compose.runtime.LaunchedEffect | |
| import androidx.compose.runtime.collectAsState | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.rememberUpdatedState | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.viewinterop.UIKitView | |
| import app.snookalook.mobile.di.MapboxConfig | |
| import app.snookalook.mobile.model.Center | |
| import app.snookalook.mobile.model.GeoPoint | |
| import app.snookalook.mobile.model.lat | |
| import app.snookalook.mobile.model.lng | |
| import app.snookalook.mobile.state.appearance.AppearanceState | |
| import app.snookalook.mobile.state.appearance.AppearanceStore | |
| import app.snookalook.mobile.state.home.MapCameraRequestChannel | |
| import app.snookalook.mobile.state.home.MapIntroSession | |
| import app.snookalook.mobile.state.home.MapPoseSession | |
| import app.snookalook.mobile.ui.theme.isEffectivelyLight | |
| import app.snookalook.mobile.util.UaeGeo | |
| import kotlinx.cinterop.ExperimentalForeignApi | |
| import kotlinx.coroutines.delay | |
| import kotlinx.coroutines.flow.MutableStateFlow | |
| import kotlinx.coroutines.flow.first | |
| import org.koin.mp.KoinPlatform | |
| import platform.CoreGraphics.CGRectMake | |
| import platform.Foundation.NSURL | |
| import platform.WebKit.WKScriptMessage | |
| import platform.WebKit.WKScriptMessageHandlerProtocol | |
| import platform.WebKit.WKUserContentController | |
| import platform.WebKit.WKWebView | |
| import platform.WebKit.WKWebViewConfiguration | |
| import platform.UIKit.UIScrollViewContentInsetAdjustmentBehavior | |
| import platform.darwin.NSObject | |
| @OptIn(ExperimentalForeignApi::class) | |
| @Composable | |
| actual fun SnookMap( | |
| modifier: Modifier, | |
| centers: List<Center>, | |
| userLocation: Pair<Double, Double>?, | |
| isOutsideUae: Boolean, | |
| onCenterSelected: (Center) -> Unit, | |
| ) { | |
| val centersById = remember(centers) { centers.associateBy { it.id } } | |
| val appearanceStore: AppearanceStore? = remember { | |
| runCatching { KoinPlatform.getKoin().get<AppearanceStore>() }.getOrNull() | |
| } | |
| val appearanceState = appearanceStore?.state?.collectAsState()?.value ?: AppearanceState() | |
| val isLight = appearanceState.selected.isEffectivelyLight() | |
| // Stable HTML, keyed only on isLight (initial theme). Centers injected | |
| // via JS post-load so async-arriving centers do NOT trigger HTML reload | |
| // mid-arc. Theme flips routed through snookSetStyle JS. | |
| val html = remember(isLight) { buildMapHtml(isLight, MapPoseSession.last) } | |
| val readyFlow = remember { MutableStateFlow(false) } | |
| var webViewRef by remember { mutableStateOf<WKWebView?>(null) } | |
| val currentUserLoc by rememberUpdatedState(userLocation) | |
| val currentIsOutsideUae by rememberUpdatedState(isOutsideUae) | |
| // Inject / refresh markers when centers change. Map state preserved. | |
| LaunchedEffect(centers) { | |
| readyFlow.first { it } | |
| val webView = webViewRef ?: return@LaunchedEffect | |
| val markersJson = buildMarkersJson(centers) | |
| webView.evaluateJavaScript("snookSetMarkers($markersJson);", null) | |
| } | |
| // 3-flow camera choreography, port of Android MapViewBridge.android.kt | |
| // LaunchedEffect(Unit) state machine (PR #40 815e971). Beats + timings | |
| // match exactly so iOS feels identical to Android. | |
| // | |
| // Flow 3 outside-early (GPS settled outside): snap user@z6.5 → | |
| // UAE overview → Dubai cluster | |
| // Flow 1 inside-granted: UAE → cluster → flyTo user@z14 | |
| // Flow 2 no-permission: UAE → cluster → await grant → branch | |
| // Flow 3 outside-late: UAE → cluster → user@z6.5 → UAE → cluster | |
| // | |
| // Codex P2 (PR #130, Android parity): hasPlayed flips at intro START | |
| // (re-entry snap semantics), so it cannot gate user camera requests — | |
| // mid-arc picks would fly then get stomped by the still-running intro | |
| // coroutine. introComplete flips only at each choreography terminal. | |
| val introComplete = remember { mutableStateOf(MapIntroSession.hasPlayed) } | |
| LaunchedEffect(Unit) { | |
| readyFlow.first { it } | |
| val webView = webViewRef ?: return@LaunchedEffect | |
| // Session-singleton gate, intro plays once per launch. Re-entry | |
| // snaps to final pose. Founder pen 2026-06-06. | |
| if (MapIntroSession.hasPlayed) { | |
| val loc = currentUserLoc | |
| if (loc != null) { | |
| val (lat, lng) = loc | |
| val zoom = if (currentIsOutsideUae) 6.5 else 14.0 | |
| webView.evaluateJavaScript("snookSetUserDot($lat, $lng);", null) | |
| webView.evaluateJavaScript("snookSnapUser($lat, $lng, $zoom);", null) | |
| MapPoseSession.set(lat, lng, zoom) | |
| } else { | |
| webView.evaluateJavaScript("snookDubaiCluster(0);", null) | |
| MapPoseSession.set(55.30, 25.20, 10.5) | |
| } | |
| introComplete.value = true | |
| return@LaunchedEffect | |
| } | |
| MapIntroSession.markPlayed() | |
| // GPS settle window, granted permission usually fixes <500ms on | |
| // real device. Outside-UAE detect at compose time skips late arc. | |
| var settled = 0 | |
| while ((currentUserLoc == null || !currentIsOutsideUae) && settled < 1200) { | |
| delay(150L) | |
| settled += 150 | |
| } | |
| // Outside-UAE branch with GPS settled: user FIRST (founder spec | |
| // 2026-05-29, outside-UAE user must see themselves first). | |
| val startLoc = currentUserLoc | |
| if (startLoc != null && currentIsOutsideUae) { | |
| val (lat, lng) = startLoc | |
| webView.evaluateJavaScript("snookSetUserDot($lat, $lng);", null) | |
| webView.evaluateJavaScript("snookSnapUser($lat, $lng, 6.5);", null) | |
| MapPoseSession.set(lat, lng, 6.5) | |
| delay(1500L) | |
| webView.evaluateJavaScript("snookUaeOverview(2500);", null) | |
| MapPoseSession.set(54.0, 24.5, 6.0) | |
| delay(2700L) | |
| webView.evaluateJavaScript("snookDubaiCluster(2200);", null) | |
| MapPoseSession.set(55.30, 25.20, 10.5) | |
| introComplete.value = true | |
| return@LaunchedEffect | |
| } | |
| // Beat 1: UAE overview pause, see all 7 emirates. | |
| delay(1100L) | |
| // Beat 2: flyTo Dubai cluster, frames all 5 green balls. | |
| webView.evaluateJavaScript("snookDubaiCluster(2200);", null) | |
| MapPoseSession.set(55.30, 25.20, 10.5) | |
| delay(2400L) | |
| // Beat 3: await GPS fix. | |
| while (currentUserLoc == null) { | |
| delay(300L) | |
| } | |
| val loc = currentUserLoc ?: return@LaunchedEffect | |
| val (lat, lng) = loc | |
| val outside = currentIsOutsideUae | |
| if (!outside) { | |
| // Flow 1 + Flow 2 granted-inside, flyTo user@z14, final pose. | |
| val insideUae = UaeGeo.boundsContain(GeoPoint(lat, lng)) | |
| if (insideUae) { | |
| webView.evaluateJavaScript("snookSetUserDot($lat, $lng);", null) | |
| webView.evaluateJavaScript("snookFlyUser($lat, $lng, 14, 2000);", null) | |
| MapPoseSession.set(lat, lng, 14.0) | |
| } | |
| } else { | |
| // GPS revealed outside-UAE late, user → UAE → cluster arc. | |
| webView.evaluateJavaScript("snookSetUserDot($lat, $lng);", null) | |
| webView.evaluateJavaScript("snookFlyUser($lat, $lng, 6.5, 2500);", null) | |
| MapPoseSession.set(lat, lng, 6.5) | |
| delay(2700L) | |
| webView.evaluateJavaScript("snookUaeOverview(2500);", null) | |
| MapPoseSession.set(54.0, 24.5, 6.0) | |
| delay(2700L) | |
| webView.evaluateJavaScript("snookDubaiCluster(2200);", null) | |
| MapPoseSession.set(55.30, 25.20, 10.5) | |
| } | |
| introComplete.value = true | |
| } | |
| // User-driven camera animations (PickLocation pin, RefreshLocation | |
| // fresh GPS). Side-channel from HomeStore.Label.AnimateMapCameraTo → | |
| // HomeScreen forwards to MapCameraRequestChannel. Gated on intro | |
| // completion so the choreography is not raced; pre-intro picks are | |
| // dropped (rare, acceptable, founder pen "fly after intro"). | |
| LaunchedEffect(Unit) { | |
| readyFlow.first { it } | |
| MapCameraRequestChannel.requests.collect { target -> | |
| if (!introComplete.value) return@collect | |
| val webView = webViewRef ?: return@collect | |
| // Move the gold user-dot to the target FIRST so it slides in with | |
| // the camera rather than appearing only after flyTo lands. Mapbox | |
| // GL keeps the marker pinned to (lng, lat) during flyTo, so the | |
| // pulsing ring is on-screen throughout the animation. | |
| webView.evaluateJavaScript( | |
| "snookSetUserDot(${target.lat}, ${target.lng});", | |
| null, | |
| ) | |
| webView.evaluateJavaScript( | |
| "snookFlyUser(${target.lat}, ${target.lng}, ${target.zoom}, ${target.durationMs});", | |
| null, | |
| ) | |
| MapPoseSession.set(target.lat, target.lng, target.zoom) | |
| } | |
| } | |
| // Theme flip, re-style without rebuilding WebView. | |
| DisposableEffect(isLight) { | |
| val styleUrl = if (isLight) "mapbox://styles/mapbox/light-v11" else "mapbox://styles/mapbox/dark-v11" | |
| webViewRef?.evaluateJavaScript("snookSetStyle('$styleUrl');", null) | |
| onDispose {} | |
| } | |
| UIKitView( | |
| modifier = modifier, | |
| factory = { | |
| val messageHandler = object : NSObject(), WKScriptMessageHandlerProtocol { | |
| override fun userContentController( | |
| userContentController: WKUserContentController, | |
| didReceiveScriptMessage: WKScriptMessage, | |
| ) { | |
| val body = didReceiveScriptMessage.body as? String ?: return | |
| if (body == "__ready__") { | |
| readyFlow.value = true | |
| return | |
| } | |
| if (body.startsWith("__diag__:")) { | |
| ErrorReporter.breadcrumb("map", "SnookMap[diag] ${body.removePrefix("__diag__:")}") | |
| return | |
| } | |
| val center = centersById[body] | |
| if (center != null) { | |
| onCenterSelected(center) | |
| } else { | |
| ErrorReporter.breadcrumb("map", "SnookMap: tap on unknown centerId=$body") | |
| } | |
| } | |
| } | |
| val config = WKWebViewConfiguration().apply { | |
| userContentController.addScriptMessageHandler( | |
| messageHandler, | |
| name = "snookBridge", | |
| ) | |
| } | |
| WKWebView(frame = CGRectMake(0.0, 0.0, 0.0, 0.0), configuration = config).apply { | |
| setOpaque(false) | |
| // Match dark map bg so first 1-2s tile-load gap shows brand-dark | |
| // rather than black. | |
| scrollView.setScrollEnabled(false) | |
| scrollView.setBounces(false) | |
| scrollView.contentInsetAdjustmentBehavior = | |
| UIScrollViewContentInsetAdjustmentBehavior.UIScrollViewContentInsetAdjustmentNever | |
| // baseURL fixed to mapbox host so document.origin is well-defined. | |
| // null baseURL produced "null" origin → mapbox-gl-js Marker.addTo | |
| // appended elements but did NOT apply position:absolute / | |
| // transform, markers block-flow stacked vertically regardless | |
| // of lng/lat. Founder visual 2026-06-22 ~17:31Z confirmed bug | |
| // persisted after CSS-inline fix `34f2591`. | |
| loadHTMLString(html, baseURL = NSURL.URLWithString("https://api.mapbox.com/")) | |
| webViewRef = this | |
| } | |
| }, | |
| update = { webView -> | |
| // No reload, HTML stable. Centers / theme / user dot all updated | |
| // via JS calls in side effects above. | |
| webViewRef = webView | |
| }, | |
| ) | |
| } | |
| private fun buildMarkersJson(centers: List<Center>): String { | |
| val sb = StringBuilder("[") | |
| centers.forEachIndexed { i, c -> | |
| if (i > 0) sb.append(",") | |
| val safeName = c.name.replace("\\", "\\\\").replace("\"", "\\\"") | |
| val safeId = c.id.replace("\\", "\\\\").replace("\"", "\\\"") | |
| sb.append("{\"id\":\"").append(safeId) | |
| .append("\",\"name\":\"").append(safeName) | |
| .append("\",\"lat\":").append(c.lat) | |
| .append(",\"lng\":").append(c.lng) | |
| .append("}") | |
| } | |
| sb.append("]") | |
| return sb.toString() | |
| } | |
| private fun buildMapHtml(isLight: Boolean, initialPose: MapPoseSession.Pose? = null): String { | |
| val token = MapboxConfig.PUBLIC_TOKEN | |
| val styleUrl = if (isLight) "mapbox://styles/mapbox/light-v11" else "mapbox://styles/mapbox/dark-v11" | |
| val bodyBg = if (isLight) "#F4F1EA" else "#0E1530" | |
| val initLng = initialPose?.lng ?: 54.0 | |
| val initLat = initialPose?.lat ?: 24.5 | |
| val initZoom = initialPose?.zoom ?: 6.0 | |
| return """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> | |
| <script src="https://api.mapbox.com/mapbox-gl-js/v3.4.0/mapbox-gl.js"></script> | |
| <link href="https://api.mapbox.com/mapbox-gl-js/v3.4.0/mapbox-gl.css" rel="stylesheet"> | |
| <style> | |
| html, body { margin: 0; padding: 0; background: $bodyBg; } | |
| #map { position: absolute; top: 0; bottom: 0; width: 100%; background: $bodyBg; } | |
| /* Critical mapbox-gl marker/canvas/popup rules inlined, fallback | |
| * when remote mapbox-gl.css fails to apply in WKWebView (null | |
| * baseURL + 0x0 init race observed on iOS). Without these the | |
| * .mapboxgl-marker wrapper has no position:absolute and the | |
| * transform3d() translates mapbox-gl applies on pan/zoom have | |
| * no positioning anchor, markers block-flow stack at top of | |
| * #map regardless of camera. Mirrors Android's projected-overlay | |
| * fidelity by ensuring DOM markers actually pin to lng/lat. */ | |
| .mapboxgl-map { overflow: hidden; position: relative; -webkit-tap-highlight-color: rgba(0,0,0,0); } | |
| .mapboxgl-canvas-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } | |
| .mapboxgl-canvas { position: absolute; left: 0; top: 0; } | |
| .mapboxgl-marker { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| will-change: transform; | |
| opacity: 1; | |
| pointer-events: auto; | |
| } | |
| .mapboxgl-popup { position: absolute; top: 0; left: 0; display: flex; will-change: transform; pointer-events: none; } | |
| .mapboxgl-popup-content { position: relative; background: #1a1a1a; border-radius: 8px; padding: 10px 14px; pointer-events: auto; } | |
| .mapboxgl-popup-anchor-bottom { flex-direction: column; align-items: center; } | |
| .mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip { align-self: center; border-top-color: #1a1a1a; } | |
| .mapboxgl-popup-tip { width: 0; height: 0; border: 6px solid transparent; } | |
| /* Center marker, green snooker ball, radial gradient + specular */ | |
| .snook-center { | |
| width: 22px; | |
| height: 22px; | |
| border-radius: 50%; | |
| background: radial-gradient(circle at 35% 30%, #3DAA6E 0%, #0B3D2E 100%); | |
| box-shadow: 0 2px 6px rgba(0,0,0,0.45); | |
| cursor: pointer; | |
| position: relative; | |
| } | |
| .snook-center::after { | |
| content: ''; | |
| position: absolute; | |
| top: 3px; left: 4px; | |
| width: 6px; height: 6px; | |
| border-radius: 50%; | |
| background: rgba(255,255,255,0.55); | |
| filter: blur(1px); | |
| } | |
| /* User marker, gold ball + pulsing ring */ | |
| .snook-user { | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 50%; | |
| background: radial-gradient(circle at 35% 30%, #F2D060 0%, #B8922A 100%); | |
| box-shadow: 0 2px 6px rgba(0,0,0,0.5); | |
| position: relative; | |
| } | |
| .snook-user::before { | |
| content: ''; | |
| position: absolute; | |
| top: -9px; left: -9px; | |
| width: 36px; height: 36px; | |
| border-radius: 50%; | |
| background: rgba(212,175,55,0.55); | |
| animation: snookPulse 1.4s linear infinite; | |
| pointer-events: none; | |
| } | |
| .snook-user::after { | |
| content: ''; | |
| position: absolute; | |
| top: 3px; left: 4px; | |
| width: 5px; height: 5px; | |
| border-radius: 50%; | |
| background: rgba(255,255,255,0.7); | |
| filter: blur(0.6px); | |
| } | |
| @keyframes snookPulse { | |
| 0% { transform: scale(0.6); opacity: 0.65; } | |
| 100% { transform: scale(1.8); opacity: 0; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="map"></div> | |
| <script> | |
| mapboxgl.accessToken = '$token'; | |
| // Initial pose = UAE overview, matching Android setCamera(uaeOverviewCam) | |
| // at MapView construct so Beat 1 has somewhere to come FROM. | |
| var map = new mapboxgl.Map({ | |
| container: 'map', | |
| style: '$styleUrl', | |
| center: [$initLng, $initLat], | |
| zoom: $initZoom, | |
| attributionControl: false | |
| }); | |
| // Choreography JS helpers, driven from Kotlin LaunchedEffect via | |
| // WKWebView.evaluateJavaScript. All durations in ms. | |
| window.snookUaeOverview = function(duration) { | |
| map.flyTo({ | |
| center: [54.0, 24.5], | |
| zoom: 6.0, | |
| duration: duration || 2500, | |
| essential: true | |
| }); | |
| }; | |
| window.snookDubaiCluster = function(duration) { | |
| map.flyTo({ | |
| center: [55.30, 25.20], | |
| zoom: 10.5, | |
| duration: duration || 2200, | |
| essential: true | |
| }); | |
| }; | |
| window.snookFlyUser = function(lat, lng, zoom, duration) { | |
| map.flyTo({ | |
| center: [lng, lat], | |
| zoom: zoom, | |
| duration: duration || 2000, | |
| essential: true | |
| }); | |
| }; | |
| window.snookSnapUser = function(lat, lng, zoom) { | |
| map.jumpTo({ | |
| center: [lng, lat], | |
| zoom: zoom | |
| }); | |
| }; | |
| // Center markers, Kotlin drives via snookSetMarkers([{id,name,lat,lng}]). | |
| // Replaces all existing center markers (idempotent). | |
| window.__snookCenterMarkers = []; | |
| window.snookSetMarkers = function(centers) { | |
| // Force canvas re-size in case WKWebView frame was 0x0 at | |
| // mapbox-gl construct (UIKitView CGRectMake(0,0,0,0) init → | |
| // Compose layout sets real frame after, mapbox-gl needs to be | |
| // told). Without this, map.project([lng,lat]) returns garbage. | |
| try { map.resize(); } catch (e) {} | |
| for (var i = 0; i < window.__snookCenterMarkers.length; i++) { | |
| window.__snookCenterMarkers[i].remove(); | |
| } | |
| window.__snookCenterMarkers = []; | |
| for (var j = 0; j < centers.length; j++) { | |
| (function(c) { | |
| var el = document.createElement('div'); | |
| el.className = 'snook-center'; | |
| el.addEventListener('click', function() { | |
| window.webkit.messageHandlers.snookBridge.postMessage(c.id); | |
| }); | |
| var popup = new mapboxgl.Popup({ offset: 18, closeButton: false }) | |
| .setHTML('<div style="color:#C8A96E;font-family:sans-serif;font-size:13px;font-weight:600;">' + c.name + '</div>'); | |
| var m = new mapboxgl.Marker(el) | |
| .setLngLat([c.lng, c.lat]) | |
| .setPopup(popup) | |
| .addTo(map); | |
| // Belt-and-suspenders: explicit inline-style positioning | |
| // in case mapbox-gl Marker.addTo did not apply | |
| // position:absolute (observed iOS WKWebView regression | |
| //, block-flow stacking, founder visual 2026-06-22). | |
| // Inline style has higher specificity than CSS rules. | |
| el.style.position = 'absolute'; | |
| el.style.top = '0'; | |
| el.style.left = '0'; | |
| el.style.willChange = 'transform'; | |
| window.__snookCenterMarkers.push(m); | |
| })(centers[j]); | |
| } | |
| // Force one update tick, mapbox-gl re-projects markers on | |
| // any camera event. Trigger a no-op jumpTo to current center | |
| // to re-compute transforms after potential resize change. | |
| try { | |
| var c = map.getCenter(); | |
| map.jumpTo({ center: [c.lng, c.lat], zoom: map.getZoom() }); | |
| } catch (e) {} | |
| // Diagnostic, report computed style of first marker so we | |
| // can verify position:absolute + non-zero transform in device | |
| // logs. Strip after fix confirmed. | |
| setTimeout(function() { | |
| try { | |
| var first = document.querySelector('.snook-center'); | |
| if (!first) { | |
| window.webkit.messageHandlers.snookBridge.postMessage('__diag__:no-snook-center-in-DOM'); | |
| return; | |
| } | |
| var cs = window.getComputedStyle(first); | |
| var parent = first.parentElement; | |
| var canvasSize = ''; | |
| try { | |
| var cv = map.getCanvas(); | |
| canvasSize = cv.width + 'x' + cv.height + ' client=' + cv.clientWidth + 'x' + cv.clientHeight; | |
| } catch (e) { canvasSize = 'err:' + e.message; } | |
| var diag = { | |
| n: document.querySelectorAll('.snook-center').length, | |
| pos: cs.position, | |
| tform: cs.transform, | |
| parent: parent ? parent.className : 'null', | |
| inlinePos: first.style.position, | |
| canvas: canvasSize | |
| }; | |
| window.webkit.messageHandlers.snookBridge.postMessage('__diag__:' + JSON.stringify(diag)); | |
| } catch (e) { | |
| window.webkit.messageHandlers.snookBridge.postMessage('__diag__:err:' + e.message); | |
| } | |
| }, 300); | |
| }; | |
| // User-dot marker, Kotlin drives create/update via snookSetUserDot. | |
| // Mirrors Android's animated gold "3" ball (pulsing ring CSS keyframe). | |
| window.__snookUserMarker = null; | |
| window.snookSetUserDot = function(lat, lng) { | |
| if (window.__snookUserMarker) { | |
| window.__snookUserMarker.setLngLat([lng, lat]); | |
| return; | |
| } | |
| var userEl = document.createElement('div'); | |
| userEl.className = 'snook-user'; | |
| window.__snookUserMarker = new mapboxgl.Marker(userEl) | |
| .setLngLat([lng, lat]) | |
| .addTo(map); | |
| }; | |
| // Theme flip, Kotlin DisposableEffect(isLight) calls this; map | |
| // state preserved (markers, camera). | |
| window.snookSetStyle = function(styleUrl) { | |
| map.setStyle(styleUrl); | |
| }; | |
| // Legacy snookSetUser, kept for back-compat callers; now routes | |
| // through snookSetUserDot + snookFlyUser. | |
| window.snookSetUser = function(lat, lng) { | |
| window.snookSetUserDot(lat, lng); | |
| window.snookFlyUser(lat, lng, 14, 1000); | |
| }; | |
| map.on('load', function() { | |
| // Signal Kotlin LaunchedEffect that choreography + setMarkers can fire. | |
| window.webkit.messageHandlers.snookBridge.postMessage('__ready__'); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """.trimIndent() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment