From 7e6527d7676404985c3ee1b0411a43866a92e6cc Mon Sep 17 00:00:00 2001 From: jiahui-yang-2157 Date: Wed, 18 Mar 2026 17:51:37 +0000 Subject: [PATCH 01/35] update indoor map calibration and related files --- .../data/remote/FloorplanApiClient.java | 3 + .../PositionMe/mapmatching/CandidatePose.java | 38 + .../mapmatching/CorrectionType.java | 11 + .../mapmatching/MapGeometryUtils.java | 293 +++ .../mapmatching/MapMatchingInput.java | 70 + .../mapmatching/MapMatchingResult.java | 77 + .../mapmatching/MapMatchingService.java | 261 ++ .../PositionMe/mapmatching/MotionDelta.java | 35 + .../mapmatching/VerticalTransitionHint.java | 29 + .../fragment/StartLocationFragment.java | 208 +- .../fragment/TrajectoryMapFragment.java | 2111 +++++++++++++---- .../PositionMe/utils/IndoorMapManager.java | 467 ++-- .../res/layout/fragment_startlocation.xml | 30 +- .../res/layout/fragment_trajectory_map.xml | 262 +- secrets.properties | 6 +- 15 files changed, 3207 insertions(+), 694 deletions(-) create mode 100644 app/src/main/java/com/openpositioning/PositionMe/mapmatching/CandidatePose.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/mapmatching/CorrectionType.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapGeometryUtils.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapMatchingInput.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapMatchingResult.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapMatchingService.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/mapmatching/MotionDelta.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/mapmatching/VerticalTransitionHint.java diff --git a/app/src/main/java/com/openpositioning/PositionMe/data/remote/FloorplanApiClient.java b/app/src/main/java/com/openpositioning/PositionMe/data/remote/FloorplanApiClient.java index cadb6037..cb7f5874 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/data/remote/FloorplanApiClient.java +++ b/app/src/main/java/com/openpositioning/PositionMe/data/remote/FloorplanApiClient.java @@ -192,6 +192,9 @@ public interface FloorplanCallback { */ public void requestFloorplan(double lat, double lon, List macs, FloorplanCallback callback) { + Log.d(TAG, "userKey length = " + (BuildConfig.OPENPOSITIONING_API_KEY == null ? -1 : BuildConfig.OPENPOSITIONING_API_KEY.length())); + Log.d(TAG, "masterKey = " + BuildConfig.OPENPOSITIONING_MASTER_KEY); + String url = BASE_URL + userKey + "?key=" + masterKey; // Build JSON request body diff --git a/app/src/main/java/com/openpositioning/PositionMe/mapmatching/CandidatePose.java b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/CandidatePose.java new file mode 100644 index 00000000..fd9d4ef3 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/CandidatePose.java @@ -0,0 +1,38 @@ +package com.openpositioning.PositionMe.mapmatching; + +import com.google.android.gms.maps.model.LatLng; + +/** + * 表示一个候选位置状态。 + * 后续可用于表示 fusion 输出的位置、map matching 修正后的位置等。 + */ +public class CandidatePose { + + private final LatLng latLng; + private final int floor; + private final long timestampMs; + private final String sourceType; + + public CandidatePose(LatLng latLng, int floor, long timestampMs, String sourceType) { + this.latLng = latLng; + this.floor = floor; + this.timestampMs = timestampMs; + this.sourceType = sourceType; + } + + public LatLng getLatLng() { + return latLng; + } + + public int getFloor() { + return floor; + } + + public long getTimestampMs() { + return timestampMs; + } + + public String getSourceType() { + return sourceType; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/mapmatching/CorrectionType.java b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/CorrectionType.java new file mode 100644 index 00000000..74e5c312 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/CorrectionType.java @@ -0,0 +1,11 @@ +package com.openpositioning.PositionMe.mapmatching; + +/** + * 表示 map matching 修正的原因。 + */ +public enum CorrectionType { + NONE, + THROUGH_WALL, + INVALID_FLOOR_CHANGE, + SNAP_TO_VALID_AREA +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapGeometryUtils.java b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapGeometryUtils.java new file mode 100644 index 00000000..150cf473 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapGeometryUtils.java @@ -0,0 +1,293 @@ +package com.openpositioning.PositionMe.mapmatching; + +import com.google.android.gms.maps.model.LatLng; +import com.openpositioning.PositionMe.data.remote.FloorplanApiClient; + +import java.util.List; + +/** + * 提供 3.2 Map Matching 所需的基础几何判断工具。 + * + * 当前第一版实现: + * 1. 判断一段轨迹是否与 wall 要素相交 + * 2. 判断当前位置是否靠近 stairs + * 3. 判断当前位置是否靠近 lift + * + * 注意: + * 这里先实现“够用、稳定、易懂”的版本, + * 后面如果需要再继续增强。 + */ +public class MapGeometryUtils { + + // 默认邻近阈值(米) + // 楼梯邻近阈值可以稍大一点 + private static final double STAIRS_PROXIMITY_THRESHOLD_METERS = 4.0; + + // 电梯邻近阈值相对更紧一些 + private static final double LIFT_PROXIMITY_THRESHOLD_METERS = 2.5; + /** + * 判断从 start 到 end 的轨迹是否穿过当前楼层中的 wall。 + */ + public static boolean crossesWall(LatLng start, + LatLng end, + FloorplanApiClient.FloorShapes floorShapes) { + if (start == null || end == null || floorShapes == null || floorShapes.getFeatures() == null) { + return false; + } + + for (FloorplanApiClient.MapShapeFeature feature : floorShapes.getFeatures()) { + if (!"wall".equalsIgnoreCase(feature.getIndoorType())) { + continue; + } + + if (intersectsFeature(start, end, feature)) { + return true; + } + } + + return false; + } + + /** + * 判断当前位置是否靠近楼梯。 + */ + /** + * 判断当前位置是否靠近楼梯。 + * 楼梯区域通常范围稍大,因此阈值放宽一些。 + */ + public static boolean isNearStairs(LatLng point, + FloorplanApiClient.FloorShapes floorShapes) { + return isNearIndoorType( + point, + floorShapes, + "stairs", + STAIRS_PROXIMITY_THRESHOLD_METERS + ); + } + + /** + * 判断当前位置是否靠近电梯。 + * 电梯区域通常更集中,因此阈值稍微收紧。 + */ + public static boolean isNearLift(LatLng point, + FloorplanApiClient.FloorShapes floorShapes) { + return isNearIndoorType( + point, + floorShapes, + "lift", + LIFT_PROXIMITY_THRESHOLD_METERS + ); + } + + /** + * 更通用的“是否靠近某类 indoor feature”判断。 + */ + public static boolean isNearIndoorType(LatLng point, + FloorplanApiClient.FloorShapes floorShapes, + String indoorType, + double thresholdMeters) { + if (point == null || floorShapes == null || floorShapes.getFeatures() == null) { + return false; + } + + for (FloorplanApiClient.MapShapeFeature feature : floorShapes.getFeatures()) { + if (!indoorType.equalsIgnoreCase(feature.getIndoorType())) { + continue; + } + + List> parts = feature.getParts(); + if (parts == null) continue; + + for (List part : parts) { + if (part == null || part.isEmpty()) continue; + + // 如果是面/封闭区域,点在里面也算 near + if (part.size() >= 3 && isPointInPolygon(point, part)) { + return true; + } + + // 否则判断点到边/线段的最小距离 + if (isPointNearPolyline(point, part, thresholdMeters)) { + return true; + } + } + } + + return false; + } + + // ========================================================= + // 内部工具方法 + // ========================================================= + + /** + * 判断轨迹线段是否与某个 feature 的边界/线段相交。 + */ + private static boolean intersectsFeature(LatLng start, + LatLng end, + FloorplanApiClient.MapShapeFeature feature) { + List> parts = feature.getParts(); + if (parts == null) return false; + + for (List part : parts) { + if (part == null || part.size() < 2) continue; + + for (int i = 0; i < part.size() - 1; i++) { + LatLng a = part.get(i); + LatLng b = part.get(i + 1); + + if (segmentsIntersect(start, end, a, b)) { + return true; + } + } + + // 如果是 polygon ring,最后一个点和第一个点也需要闭合判断 + if (part.size() >= 3) { + LatLng last = part.get(part.size() - 1); + LatLng first = part.get(0); + if (segmentsIntersect(start, end, last, first)) { + return true; + } + } + } + + return false; + } + + /** + * 判断一个点是否靠近一条 polyline / polygon 边界。 + */ + private static boolean isPointNearPolyline(LatLng point, + List polyline, + double thresholdMeters) { + if (polyline == null || polyline.size() < 2) return false; + + for (int i = 0; i < polyline.size() - 1; i++) { + LatLng a = polyline.get(i); + LatLng b = polyline.get(i + 1); + + double distance = distancePointToSegmentMeters(point, a, b); + if (distance <= thresholdMeters) { + return true; + } + } + + // 如果像 polygon ring,也检查闭合边 + if (polyline.size() >= 3) { + double distance = distancePointToSegmentMeters( + point, + polyline.get(polyline.size() - 1), + polyline.get(0) + ); + return distance <= thresholdMeters; + } + + return false; + } + + /** + * 点是否在 polygon 内。 + * 采用简单射线法。 + */ + private static boolean isPointInPolygon(LatLng point, List polygon) { + if (point == null || polygon == null || polygon.size() < 3) return false; + + boolean inside = false; + double x = point.longitude; + double y = point.latitude; + + for (int i = 0, j = polygon.size() - 1; i < polygon.size(); j = i++) { + double xi = polygon.get(i).longitude; + double yi = polygon.get(i).latitude; + double xj = polygon.get(j).longitude; + double yj = polygon.get(j).latitude; + + boolean intersect = ((yi > y) != (yj > y)) + && (x < (xj - xi) * (y - yi) / ((yj - yi) + 1e-12) + xi); + + if (intersect) inside = !inside; + } + + return inside; + } + + /** + * 判断两条线段是否相交。 + * 这里采用平面近似,对室内小范围足够。 + */ + private static boolean segmentsIntersect(LatLng p1, LatLng p2, LatLng q1, LatLng q2) { + int o1 = orientation(p1, p2, q1); + int o2 = orientation(p1, p2, q2); + int o3 = orientation(q1, q2, p1); + int o4 = orientation(q1, q2, p2); + + if (o1 != o2 && o3 != o4) return true; + + // 共线特殊情况 + if (o1 == 0 && onSegment(p1, q1, p2)) return true; + if (o2 == 0 && onSegment(p1, q2, p2)) return true; + if (o3 == 0 && onSegment(q1, p1, q2)) return true; + if (o4 == 0 && onSegment(q1, p2, q2)) return true; + + return false; + } + + private static int orientation(LatLng a, LatLng b, LatLng c) { + double value = (b.latitude - a.latitude) * (c.longitude - b.longitude) + - (b.longitude - a.longitude) * (c.latitude - b.latitude); + + if (Math.abs(value) < 1e-12) return 0; + return (value > 0) ? 1 : 2; + } + + private static boolean onSegment(LatLng a, LatLng b, LatLng c) { + return b.longitude <= Math.max(a.longitude, c.longitude) + && b.longitude >= Math.min(a.longitude, c.longitude) + && b.latitude <= Math.max(a.latitude, c.latitude) + && b.latitude >= Math.min(a.latitude, c.latitude); + } + + /** + * 计算点到线段的最短距离(米)。 + * 使用局部平面近似(经纬度转米),对室内小范围足够。 + */ + private static double distancePointToSegmentMeters(LatLng p, LatLng a, LatLng b) { + double[] pxy = toLocalMeters(p, p); + double[] axy = toLocalMeters(a, p); + double[] bxy = toLocalMeters(b, p); + + double px = pxy[0], py = pxy[1]; + double ax = axy[0], ay = axy[1]; + double bx = bxy[0], by = bxy[1]; + + double dx = bx - ax; + double dy = by - ay; + + if (dx == 0 && dy == 0) { + return Math.hypot(px - ax, py - ay); + } + + double t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy); + t = Math.max(0, Math.min(1, t)); + + double closestX = ax + t * dx; + double closestY = ay + t * dy; + + return Math.hypot(px - closestX, py - closestY); + } + + /** + * 把经纬度转换为以 reference 为原点的局部米坐标。 + * 返回 [xMeters, yMeters] + */ + private static double[] toLocalMeters(LatLng point, LatLng reference) { + double latRad = Math.toRadians(reference.latitude); + double metersPerDegLat = 111320.0; + double metersPerDegLon = 111320.0 * Math.cos(latRad); + + double x = (point.longitude - reference.longitude) * metersPerDegLon; + double y = (point.latitude - reference.latitude) * metersPerDegLat; + + return new double[]{x, y}; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapMatchingInput.java b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapMatchingInput.java new file mode 100644 index 00000000..8e578ef9 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapMatchingInput.java @@ -0,0 +1,70 @@ +package com.openpositioning.PositionMe.mapmatching; + +import androidx.annotation.Nullable; + +import com.openpositioning.PositionMe.data.remote.FloorplanApiClient; + +/** + * 表示一次 map matching 所需的完整输入。 + * 当前先打包位置、位移、高度变化、当前楼层地图和当前建筑信息。 + */ +public class MapMatchingInput { + + private final CandidatePose previousPose; + private final CandidatePose currentCandidatePose; + private final MotionDelta motionDelta; + private final VerticalTransitionHint verticalHint; + private final FloorplanApiClient.FloorShapes activeFloorShapes; + private final String activeBuildingId; + + public MapMatchingInput( + @Nullable CandidatePose previousPose, + CandidatePose currentCandidatePose, + @Nullable MotionDelta motionDelta, + @Nullable VerticalTransitionHint verticalHint, + @Nullable FloorplanApiClient.FloorShapes activeFloorShapes, + @Nullable String activeBuildingId + ) { + this.previousPose = previousPose; + this.currentCandidatePose = currentCandidatePose; + this.motionDelta = motionDelta; + this.verticalHint = verticalHint; + this.activeFloorShapes = activeFloorShapes; + this.activeBuildingId = activeBuildingId; + } + + @Nullable + public CandidatePose getPreviousPose() { + return previousPose; + } + + public CandidatePose getCurrentCandidatePose() { + return currentCandidatePose; + } + + @Nullable + public MotionDelta getMotionDelta() { + return motionDelta; + } + + @Nullable + public VerticalTransitionHint getVerticalHint() { + return verticalHint; + } + + @Nullable + public FloorplanApiClient.FloorShapes getActiveFloorShapes() { + return activeFloorShapes; + } + + @Nullable + public String getActiveBuildingId() { + return activeBuildingId; + } + + public boolean hasActiveFloorMap() { + return activeFloorShapes != null + && activeFloorShapes.getFeatures() != null + && !activeFloorShapes.getFeatures().isEmpty(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapMatchingResult.java b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapMatchingResult.java new file mode 100644 index 00000000..2028ea2e --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapMatchingResult.java @@ -0,0 +1,77 @@ +package com.openpositioning.PositionMe.mapmatching; + +import com.google.android.gms.maps.model.LatLng; + +/** + * 表示一次地图匹配后的输出结果。 + */ +public class MapMatchingResult { + + private final boolean validPosition; + private final boolean crossedWall; + private final boolean nearStairs; + private final boolean nearLift; + private final boolean floorChangeAllowed; + private final LatLng correctedLatLng; + private final int correctedFloor; + private final CorrectionType correctionType; + private final String debugReason; + + public MapMatchingResult( + boolean validPosition, + boolean crossedWall, + boolean nearStairs, + boolean nearLift, + boolean floorChangeAllowed, + LatLng correctedLatLng, + int correctedFloor, + CorrectionType correctionType, + String debugReason + ) { + this.validPosition = validPosition; + this.crossedWall = crossedWall; + this.nearStairs = nearStairs; + this.nearLift = nearLift; + this.floorChangeAllowed = floorChangeAllowed; + this.correctedLatLng = correctedLatLng; + this.correctedFloor = correctedFloor; + this.correctionType = correctionType; + this.debugReason = debugReason; + } + + public boolean isValidPosition() { + return validPosition; + } + + public boolean isCrossedWall() { + return crossedWall; + } + + public boolean isNearStairs() { + return nearStairs; + } + + public boolean isNearLift() { + return nearLift; + } + + public boolean isFloorChangeAllowed() { + return floorChangeAllowed; + } + + public LatLng getCorrectedLatLng() { + return correctedLatLng; + } + + public int getCorrectedFloor() { + return correctedFloor; + } + + public CorrectionType getCorrectionType() { + return correctionType; + } + + public String getDebugReason() { + return debugReason; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapMatchingService.java b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapMatchingService.java new file mode 100644 index 00000000..80b931f8 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MapMatchingService.java @@ -0,0 +1,261 @@ +package com.openpositioning.PositionMe.mapmatching; + +import androidx.annotation.NonNull; + +import com.google.android.gms.maps.model.LatLng; +import com.openpositioning.PositionMe.data.remote.FloorplanApiClient; + +/** + * 3.2 Map Matching 的核心服务类。 + * + * 当前这一版开始真正使用地图几何约束: + * 1. 检测穿墙 + * 2. 判断是否靠近 stairs / lift + * 3. 用高度变化 + 地图位置约束楼层切换 + * + * 这是一个“先求稳”的第一版: + * - 穿墙时先回退到上一点 + * - 非法换层时先保持上一楼层 + * - 不做复杂投影修正,先保证行为正确、容易调试 + */ +public class MapMatchingService { + // 水平位移较小时,更像 lift;较大时,更像 stairs + private static final double MAX_LIFT_HORIZONTAL_DISPLACEMENT_METERS = 1.5; + // 位移太小时,不做穿墙检测,避免传感器抖动带来误判 + private static final double MIN_DISPLACEMENT_FOR_WALL_CHECK_METERS = 0.3; + + /** + * 对一次候选位置执行 map matching。 + */ + @NonNull + public MapMatchingResult match(@NonNull MapMatchingInput input) { + CandidatePose currentPose = input.getCurrentCandidatePose(); + CandidatePose previousPose = input.getPreviousPose(); + FloorplanApiClient.FloorShapes floorShapes = input.getActiveFloorShapes(); + + LatLng candidateLatLng = currentPose.getLatLng(); + LatLng correctedLatLng = candidateLatLng; + int correctedFloor = currentPose.getFloor(); + + boolean nearStairs = false; + boolean nearLift = false; + boolean crossedWall = false; + boolean invalidFloorChange = false; + boolean floorChangeAllowed = isFloorChangeAllowed(input); + + CorrectionType correctionType = CorrectionType.NONE; + String debugReason = "Candidate pose accepted."; + boolean validPosition = true; + + // ========================================================= + // 【情况 1:当前没有可用地图】 + // 不做地图约束,直接透传 + // ========================================================= + if (!input.hasActiveFloorMap() || floorShapes == null) { + return new MapMatchingResult( + true, + false, + false, + false, + floorChangeAllowed, + candidateLatLng, + currentPose.getFloor(), + CorrectionType.NONE, + "No active floor map. Pass through candidate pose." + ); + } + + // ========================================================= + // 【新增:判断当前位置是否靠近 stairs / lift】 + // ========================================================= + nearStairs = MapGeometryUtils.isNearStairs(candidateLatLng, floorShapes); + nearLift = MapGeometryUtils.isNearLift(candidateLatLng, floorShapes); + // ========================================================= + // 【新增:给当前垂直变化一个更易读的解释】 + // 仅用于 debugReason,不改变主逻辑 + // ========================================================= + MotionDelta motionDelta = input.getMotionDelta(); + String transitionDescription = describeVerticalTransition( + nearStairs, + nearLift, + motionDelta + ); + + if (input.getVerticalHint() != null && input.getVerticalHint().isHeightChanged()) { + debugReason = "Height changed; " + transitionDescription + "."; + } else { + debugReason = "Candidate pose accepted; " + transitionDescription + "."; + } + // ========================================================= + // 【新增:判断轨迹是否穿墙】 + // 如果穿墙,第一版先回退到上一点 + // ========================================================= + if (previousPose != null && previousPose.getLatLng() != null) { + boolean shouldCheckWall = true; + + MotionDelta wallCheckMotionDelta = input.getMotionDelta(); + if (wallCheckMotionDelta != null) { + shouldCheckWall = + wallCheckMotionDelta.getStepDistance() >= MIN_DISPLACEMENT_FOR_WALL_CHECK_METERS; + } + + if (shouldCheckWall) { + crossedWall = MapGeometryUtils.crossesWall( + previousPose.getLatLng(), + candidateLatLng, + floorShapes + ); + + if (crossedWall) { + correctedLatLng = previousPose.getLatLng(); + correctedFloor = previousPose.getFloor(); + correctionType = CorrectionType.THROUGH_WALL; + debugReason = "Trajectory crossed wall. Reverted to previous pose."; + validPosition = false; + } + } else { + debugReason = "Step distance too small for wall check. Candidate pose accepted."; + } + } + + // ========================================================= + // 【新增:楼层变化约束】 + // 如果当前候选楼层和上一时刻不同,但不满足换层条件, + // 第一版先保持上一楼层 + // ========================================================= + if (previousPose != null + && currentPose.getFloor() != previousPose.getFloor() + && !floorChangeAllowed) { + + invalidFloorChange = true; + correctedFloor = previousPose.getFloor(); + + if (correctionType == CorrectionType.NONE) { + correctionType = CorrectionType.INVALID_FLOOR_CHANGE; + debugReason = "Floor change rejected: no valid height change or not near stairs/lift."; + } + + validPosition = false; + } + + return new MapMatchingResult( + validPosition, + crossedWall, + nearStairs, + nearLift, + floorChangeAllowed, + correctedLatLng, + correctedFloor, + correctionType, + debugReason + ); + } + + /** + * 判断当前是否允许换层。 + * + * 当前这一版规则: + * 1. 必须有 verticalHint + * 2. 必须检测到明显高度变化 + * 3. 如果没有可用地图,为了不阻断原逻辑,先允许 + * 4. 如果有地图,则必须 near stairs 或 near lift + * 5. 如果有 motionDelta,则进一步用 stepDistance 区分 stairs / lift: + * - nearLift only -> 需要较小水平位移 + * - nearStairs only -> 需要较大水平位移 + * - 如果两者都 near,则先允许(第一版不强行细分) + */ + public boolean isFloorChangeAllowed(@NonNull MapMatchingInput input) { + VerticalTransitionHint verticalHint = input.getVerticalHint(); + + if (verticalHint == null) { + return false; + } + + if (!verticalHint.isHeightChanged()) { + return false; + } + + // 没有地图时先不阻断现有逻辑 + if (!input.hasActiveFloorMap()) { + return true; + } + + FloorplanApiClient.FloorShapes floorShapes = input.getActiveFloorShapes(); + if (floorShapes == null) { + return true; + } + + LatLng currentPoint = input.getCurrentCandidatePose().getLatLng(); + + boolean nearStairs = MapGeometryUtils.isNearStairs(currentPoint, floorShapes); + boolean nearLift = MapGeometryUtils.isNearLift(currentPoint, floorShapes); + + // 不在楼梯/电梯附近,不允许换层 + if (!nearStairs && !nearLift) { + return false; + } + + MotionDelta motionDelta = input.getMotionDelta(); + + // 没有运动增量时,保持上一版的宽松策略 + if (motionDelta == null) { + return nearStairs || nearLift; + } + + double stepDistance = motionDelta.getStepDistance(); + boolean likelyLiftMotion = stepDistance <= MAX_LIFT_HORIZONTAL_DISPLACEMENT_METERS; + + // 只靠近 lift:要求水平位移小,更像乘电梯 + if (nearLift && !nearStairs) { + return likelyLiftMotion; + } + + // 只靠近 stairs:要求水平位移较大,更像走楼梯 + if (nearStairs && !nearLift) { + return !likelyLiftMotion; + } + + // 两者都靠近时,第一版先允许 + return true; + } + + /** + * 根据当前位置和水平位移,给一次垂直变化做一个简单解释: + * 更像 stairs / 更像 lift / 不确定。 + * + * 这个方法当前只用于调试说明,不改变主逻辑。 + */ + private String describeVerticalTransition(boolean nearStairs, + boolean nearLift, + MotionDelta motionDelta) { + if (!nearStairs && !nearLift) { + return "not near stairs or lift"; + } + + if (motionDelta == null) { + if (nearStairs && nearLift) return "near both stairs and lift"; + if (nearStairs) return "near stairs"; + return "near lift"; + } + + double stepDistance = motionDelta.getStepDistance(); + boolean likelyLiftMotion = stepDistance <= MAX_LIFT_HORIZONTAL_DISPLACEMENT_METERS; + + if (nearLift && !nearStairs) { + return likelyLiftMotion + ? "likely lift transition" + : "lift nearby but horizontal movement looks too large"; + } + + if (nearStairs && !nearLift) { + return likelyLiftMotion + ? "stairs nearby but horizontal movement looks too small" + : "likely stairs transition"; + } + + // 两者都 near 时,先给一个保守描述 + return likelyLiftMotion + ? "near both stairs and lift, motion slightly favors lift" + : "near both stairs and lift, motion slightly favors stairs"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MotionDelta.java b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MotionDelta.java new file mode 100644 index 00000000..1ec4d15f --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/MotionDelta.java @@ -0,0 +1,35 @@ +package com.openpositioning.PositionMe.mapmatching; + +/** + * 表示相邻两次位置更新之间的运动增量。 + */ +public class MotionDelta { + + private final double deltaX; + private final double deltaY; + private final double stepDistance; + private final double headingDeg; + + public MotionDelta(double deltaX, double deltaY, double stepDistance, double headingDeg) { + this.deltaX = deltaX; + this.deltaY = deltaY; + this.stepDistance = stepDistance; + this.headingDeg = headingDeg; + } + + public double getDeltaX() { + return deltaX; + } + + public double getDeltaY() { + return deltaY; + } + + public double getStepDistance() { + return stepDistance; + } + + public double getHeadingDeg() { + return headingDeg; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/mapmatching/VerticalTransitionHint.java b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/VerticalTransitionHint.java new file mode 100644 index 00000000..3a718a19 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/mapmatching/VerticalTransitionHint.java @@ -0,0 +1,29 @@ +package com.openpositioning.PositionMe.mapmatching; + +/** + * 表示由高度/气压估计得到的楼层变化提示。 + */ +public class VerticalTransitionHint { + + private final double currentElevation; + private final double deltaHeight; + private final boolean heightChanged; + + public VerticalTransitionHint(double currentElevation, double deltaHeight, boolean heightChanged) { + this.currentElevation = currentElevation; + this.deltaHeight = deltaHeight; + this.heightChanged = heightChanged; + } + + public double getCurrentElevation() { + return currentElevation; + } + + public double getDeltaHeight() { + return deltaHeight; + } + + public boolean isHeightChanged() { + return heightChanged; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/StartLocationFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/StartLocationFragment.java index 0951e85a..644b0a1f 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/StartLocationFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/StartLocationFragment.java @@ -37,6 +37,10 @@ import java.util.List; import java.util.Map; +import android.os.Handler; +import android.os.Looper; +import android.widget.Toast; + /** * Fragment for selecting the start location before recording begins. * Displays a Google Map with building outlines fetched from the floorplan API. @@ -49,6 +53,17 @@ * @see FloorplanApiClient the API client for fetching building data */ public class StartLocationFragment extends Fragment { + // --- 新增变量 --- + // --- 新增变量 --- + private Button btnFindIndoorMap; + private Button btnFindActualMap; + private boolean useActualMap = false; // 核心状态:判断显示哪种地图 + private com.google.android.gms.maps.model.GroundOverlay realMapOverlay; + + private final Handler retryHandler = new Handler(Looper.getMainLooper()); + private int requestRetryCount = 0; + private static final int MAX_REQUEST_RETRIES = 10; + private static final long RETRY_DELAY_MS = 2000; private static final String TAG = "StartLocationFragment"; @@ -78,6 +93,9 @@ public class StartLocationFragment extends Fragment { private final List previewPolygons = new ArrayList<>(); private final List previewPolylines = new ArrayList<>(); + // 【新增这一行】:用于存储白色蒙版以便清除 + private Polygon whiteMaskPolygon; + // Building outline colours (ARGB) private static final int FILL_COLOR_DEFAULT = Color.argb(60, 33, 150, 243); private static final int STROKE_COLOR_DEFAULT = Color.argb(200, 33, 150, 243); @@ -126,12 +144,63 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, public void onMapReady(GoogleMap googleMap) { mMap = googleMap; setupMap(); - requestBuildingData(); + //requestBuildingData(); } }); return rootView; } + /** + * 清理所有地图上的覆盖物(矢量图、白板、真实贴图) + */ + private void resetMapOverlays() { + for (Polygon p : previewPolygons) p.remove(); + for (Polyline p : previewPolylines) p.remove(); + previewPolygons.clear(); + previewPolylines.clear(); + + if (whiteMaskPolygon != null) { + whiteMaskPolygon.remove(); + whiteMaskPolygon = null; + } + + if (realMapOverlay != null) { + realMapOverlay.remove(); + realMapOverlay = null; + } + } + private void requestBuildingDataWhenReady() { + float[] gnss = sensorFusion.getGNSSLatitude(false); + startPosition[0] = gnss[0]; + startPosition[1] = gnss[1]; + + boolean gnssReady = !(startPosition[0] == 0f && startPosition[1] == 0f); + + if (!gnssReady) { + if (requestRetryCount < MAX_REQUEST_RETRIES) { + requestRetryCount++; + Log.d(TAG, "GNSS not ready, retry floorplan request: " + requestRetryCount); + retryHandler.postDelayed(this::requestBuildingDataWhenReady, RETRY_DELAY_MS); + } else { + Log.w(TAG, "GNSS still not ready after retries, requesting floorplan anyway"); + requestBuildingData(); + return; + } + + } + + LatLng current = new LatLng(startPosition[0], startPosition[1]); + + if (startMarker != null) { + startMarker.setPosition(current); + } + + if (mMap != null) { + mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(current, zoom)); + } + + requestBuildingData(); + } /** * Configures the Google Map with initial settings, draggable marker, and listeners. @@ -161,6 +230,32 @@ public void onMarkerDragStart(Marker marker) {} public void onMarkerDragEnd(Marker marker) { startPosition[0] = (float) marker.getPosition().latitude; startPosition[1] = (float) marker.getPosition().longitude; + + floorplanBuildingMap.clear(); + + for (Polygon p : buildingPolygons) { + p.remove(); + } + buildingPolygons.clear(); + + for (Polygon p : previewPolygons) { + p.remove(); + } + previewPolygons.clear(); + + for (Polyline p : previewPolylines) { + p.remove(); + } + previewPolylines.clear(); +// 【新增这段】:清除上一层的白色蒙版 + if (whiteMaskPolygon != null) { + whiteMaskPolygon.remove(); + whiteMaskPolygon = null; + } + selectedPolygon = null; + selectedBuildingId = null; + + requestBuildingData(); } @Override @@ -310,15 +405,53 @@ private void onBuildingSelected(String buildingName, Polygon polygon) { // Zoom to the building mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(center, 20f)); - // Show floor plan overlay for the selected building - showFloorPlanOverlay(buildingName); + // ================= 【核心逻辑修改】 ================= + // 点击建筑后,先清理屏幕上已有的图层 + resetMapOverlays(); - // Update UI with building name - updateBuildingInfoDisplay(buildingName); + // 根据当前用户选中的模式,展示不同的地图 + if (useActualMap) { + updateRealMapOverlay(buildingName, true); // 只有点击蓝框后,才贴上 drawable 里的真实图片 + } else { + showFloorPlanOverlay(buildingName); // 只有点击蓝框后,才弹出白板并画上黑色矢量线 + } + // ================================================== - Log.d(TAG, "Building selected: " + buildingName); + Log.d(TAG, "Building selected: " + buildingName + ", Mode: " + (useActualMap ? "Actual" : "Vector")); } + /** + * 【新增方法】:用于在地图上覆盖你 drawable 文件夹中的实际室内地图图片 + */ + private void updateRealMapOverlay(String buildingName, boolean show) { + // 先移除旧的图片蒙版 + if (realMapOverlay != null) { + realMapOverlay.remove(); + realMapOverlay = null; + } + + if (!show || buildingName == null) return; + + int drawableResId = 0; + LatLngBounds bounds = null; + // 根据建筑名称匹配图片和对应的真实经纬度边界 + if (buildingName.equals("nucleus_building")) { + drawableResId = R.drawable.nucleus1; + // 使用项目中已有的 BuildingPolygon 边界数据 + bounds = new LatLngBounds( + com.openpositioning.PositionMe.utils.BuildingPolygon.NUCLEUS_SW, + com.openpositioning.PositionMe.utils.BuildingPolygon.NUCLEUS_NE); + } + + if (drawableResId != 0 && bounds != null) { + // 将实际图片贴在地图上 + realMapOverlay = mMap.addGroundOverlay(new com.google.android.gms.maps.model.GroundOverlayOptions() + .image(com.google.android.gms.maps.model.BitmapDescriptorFactory.fromResource(drawableResId)) + .positionFromBounds(bounds) + // zIndex设为 15,确保它显示在白色半透明蒙版(zIndex=10)上方,如果希望在黑线之上可改大 + .zIndex(15)); + } + } /** * Shows a vector floor plan preview for the selected building using the * map_shapes data from the API. Draws the ground floor shapes (walls, rooms). @@ -333,6 +466,18 @@ private void showFloorPlanOverlay(String buildingName) { previewPolygons.clear(); previewPolylines.clear(); + // 【新增这段】:清除旧蒙版并绘制全新的全屏高透明度白色蒙版 + if (whiteMaskPolygon != null) { + whiteMaskPolygon.remove(); + whiteMaskPolygon = null; + } + PolygonOptions whiteMask = new PolygonOptions() + .add(new LatLng(85, -180), new LatLng(85, 180), new LatLng(-85, 180), new LatLng(-85, -180)) + .fillColor(0xD9FFFFFF) // 约 85% 透明度的白色 + .strokeWidth(0) + .zIndex(5); // 蒙版在底层 (层级5) + whiteMaskPolygon = mMap.addPolygon(whiteMask); + FloorplanApiClient.BuildingInfo building = floorplanBuildingMap.get(buildingName); if (building == null) return; @@ -357,7 +502,8 @@ private void showFloorPlanOverlay(String buildingName) { .addAll(ring) .strokeColor(getPreviewStrokeColor(indoorType)) .strokeWidth(2f) - .fillColor(getPreviewFillColor(indoorType))); + .fillColor(getPreviewFillColor(indoorType)) + .zIndex(10)); // 【新增】:层级设为10,确保浮在白板上方 previewPolygons.add(p); } } else if ("MultiLineString".equals(geoType) @@ -367,20 +513,21 @@ private void showFloorPlanOverlay(String buildingName) { Polyline pl = mMap.addPolyline(new PolylineOptions() .addAll(line) .color(getPreviewStrokeColor(indoorType)) - .width(3f)); + .width(4f) // 【修改】:稍微加粗至4f使其更明显 + .zIndex(10)); // 【新增】:层级设为10,确保浮在白板上方 previewPolylines.add(pl); } } } } - /** * Returns the stroke colour for a preview indoor feature. */ private int getPreviewStrokeColor(String indoorType) { - if ("wall".equals(indoorType)) return Color.argb(200, 80, 80, 80); - if ("room".equals(indoorType)) return Color.argb(180, 33, 150, 243); - return Color.argb(150, 100, 100, 100); + // 【修改】:全部换为更深的黑/深灰色 + if ("wall".equals(indoorType)) return Color.argb(255, 34, 34, 34); + if ("room".equals(indoorType)) return Color.argb(255, 60, 60, 60); + return Color.argb(255, 50, 50, 50); } /** @@ -445,19 +592,37 @@ private LatLng computePolygonCenter(Polygon polygon) { return new LatLng(latSum / count, lonSum / count); } - /** - * {@inheritDoc} - * Sets up button click listeners and view references after the view is created. - */ @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); this.button = view.findViewById(R.id.startLocationDone); this.instructionText = view.findViewById(R.id.correctionInfoView); - this.buildingInfoCard = view.findViewById(R.id.buildingInfoCard); - this.buildingNameText = view.findViewById(R.id.buildingNameText); + // 绑定两个 Find 按钮 + this.btnFindIndoorMap = view.findViewById(R.id.btnFindIndoorMap); + this.btnFindActualMap = view.findViewById(R.id.btnFindActualMap); + + if (this.btnFindIndoorMap != null) { + this.btnFindIndoorMap.setOnClickListener(v -> { + useActualMap = false; // 模式设为:矢量地图 + resetMapOverlays(); // 清理之前的图层 + requestBuildingData(); // 请求画蓝框 + Toast.makeText(getContext(), "模式:矢量地图。请点击蓝色建筑", Toast.LENGTH_SHORT).show(); + }); + } + + if (this.btnFindActualMap != null) { + this.btnFindActualMap.setOnClickListener(v -> { + useActualMap = true; // 模式设为:真实贴图 + resetMapOverlays(); // 清理之前的图层 + requestBuildingData(); // 请求画蓝框 + Toast.makeText(getContext(), "模式:实际地图。请点击蓝色建筑", Toast.LENGTH_SHORT).show(); + }); + } + + // 下方保留你原本的 this.button.setOnClickListener (Start/Set Location) 逻辑... + // 下方保留你原本的 this.button.setOnClickListener (Start/Set Location) 逻辑 this.button.setOnClickListener(v -> { float chosenLat = startPosition[0]; float chosenLon = startPosition[1]; @@ -483,4 +648,11 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat } }); } + @Override + public void onDestroyView() { + super.onDestroyView(); + retryHandler.removeCallbacksAndMessages(null); + } + } + diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java index 479ea51b..343c2553 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java @@ -1,5 +1,8 @@ package com.openpositioning.PositionMe.presentation.fragment; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.BitmapFactory; import android.graphics.Color; import android.os.Bundle; import android.os.Handler; @@ -14,276 +17,1373 @@ import android.widget.Button; import android.widget.Spinner; import android.widget.TextView; -import com.google.android.material.switchmaterial.SwitchMaterial; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; -import com.google.android.gms.maps.OnMapReadyCallback; +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.SupportMapFragment; +import com.google.android.gms.maps.model.BitmapDescriptorFactory; +import com.google.android.gms.maps.model.GroundOverlay; +import com.google.android.gms.maps.model.GroundOverlayOptions; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.LatLngBounds; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import com.google.android.gms.maps.model.Polygon; +import com.google.android.gms.maps.model.PolygonOptions; +import com.google.android.gms.maps.model.Polyline; +import com.google.android.gms.maps.model.PolylineOptions; +import com.google.android.material.progressindicator.CircularProgressIndicator; +import com.google.android.material.switchmaterial.SwitchMaterial; import com.openpositioning.PositionMe.R; +import com.openpositioning.PositionMe.data.remote.FloorplanApiClient; import com.openpositioning.PositionMe.sensors.SensorFusion; +import com.openpositioning.PositionMe.utils.BuildingPolygon; import com.openpositioning.PositionMe.utils.IndoorMapManager; import com.openpositioning.PositionMe.utils.UtilFunctions; -import com.google.android.gms.maps.CameraUpdateFactory; -import com.google.android.gms.maps.GoogleMap; -import com.google.android.gms.maps.SupportMapFragment; -import com.google.android.gms.maps.model.*; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; +public class TrajectoryMapFragment extends Fragment { -/** - * A fragment responsible for displaying a trajectory map using Google Maps. - *

- * The TrajectoryMapFragment provides a map interface for visualizing movement trajectories, - * GNSS tracking, and indoor mapping. It manages map settings, user interactions, and real-time - * updates to user location and GNSS markers. - *

- * Key Features: - * - Displays a Google Map with support for different map types (Hybrid, Normal, Satellite). - * - Tracks and visualizes user movement using polylines. - * - Supports GNSS position updates and visual representation. - * - Includes indoor mapping with floor selection and auto-floor adjustments. - * - Allows user interaction through map controls and UI elements. - * - * @see com.openpositioning.PositionMe.presentation.activity.RecordingActivity The activity hosting this fragment. - * @see com.openpositioning.PositionMe.utils.IndoorMapManager Utility for managing indoor map overlays. - * @see com.openpositioning.PositionMe.utils.UtilFunctions Utility functions for UI and graphics handling. - * - * @author Mate Stodulka - */ + private static final String TAG = "TrajectoryMapFragment"; + private static final long AUTO_FLOOR_DEBOUNCE_MS = 3000; + private static final long AUTO_FLOOR_CHECK_INTERVAL_MS = 1000; + private static final String CALIBRATION_PREFS_NAME = "actual_map_calibration"; + private static final float CALIBRATION_SHIFT_STEP = 0.005f; + private static final float CALIBRATION_SCALE_STEP = 0.005f; + + private enum HorizontalAnchor { + LEFT, + RIGHT, + CENTER + } -public class TrajectoryMapFragment extends Fragment { + private static final class ActualMapAlignmentConfig { + final HorizontalAnchor horizontalAnchor; + final double northInsetRatio; + final double southInsetRatio; + final double horizontalInsetRatio; + final double widthScale; + final double topVisibleInsetRatio; + final double bottomVisibleInsetRatio; + final double rightVisibleInsetRatio; + final double leftVisibleInsetRatio; + + ActualMapAlignmentConfig(HorizontalAnchor horizontalAnchor, + double northInsetRatio, + double southInsetRatio, + double horizontalInsetRatio, + double widthScale, + double topVisibleInsetRatio, + double bottomVisibleInsetRatio, + double rightVisibleInsetRatio, + double leftVisibleInsetRatio) { + this.horizontalAnchor = horizontalAnchor; + this.northInsetRatio = northInsetRatio; + this.southInsetRatio = southInsetRatio; + this.horizontalInsetRatio = horizontalInsetRatio; + this.widthScale = widthScale; + this.topVisibleInsetRatio = topVisibleInsetRatio; + this.bottomVisibleInsetRatio = bottomVisibleInsetRatio; + this.rightVisibleInsetRatio = rightVisibleInsetRatio; + this.leftVisibleInsetRatio = leftVisibleInsetRatio; + } + } - private GoogleMap gMap; // Google Maps instance - private LatLng currentLocation; // Stores the user's current location - private Marker orientationMarker; // Marker representing user's heading - private Marker gnssMarker; // GNSS position marker - // Keep test point markers so they can be cleared when recording ends - private final List testPointMarkers = new ArrayList<>(); + private static final class DrawableContentInsets { + final double leftFraction; + final double topFraction; + final double rightFraction; + final double bottomFraction; + + DrawableContentInsets(double leftFraction, + double topFraction, + double rightFraction, + double bottomFraction) { + this.leftFraction = clamp01(leftFraction); + this.topFraction = clamp01(topFraction); + this.rightFraction = clamp01(rightFraction); + this.bottomFraction = clamp01(bottomFraction); + } - private Polyline polyline; // Polyline representing user's movement path - private boolean isRed = true; // Tracks whether the polyline color is red - private boolean isGnssOn = false; // Tracks if GNSS tracking is enabled + double contentWidthFraction() { + return Math.max(0.01d, 1d - leftFraction - rightFraction); + } - private Polyline gnssPolyline; // Polyline for GNSS path - private LatLng lastGnssLocation = null; // Stores the last GNSS location + double contentHeightFraction() { + return Math.max(0.01d, 1d - topFraction - bottomFraction); + } + } + + private static final class OverlayCalibration { + final float shiftLatRatio; + final float shiftLngRatio; + final float widthScale; + final float heightScale; + + OverlayCalibration(float shiftLatRatio, float shiftLngRatio, float widthScale, float heightScale) { + this.shiftLatRatio = shiftLatRatio; + this.shiftLngRatio = shiftLngRatio; + this.widthScale = widthScale; + this.heightScale = heightScale; + } - private LatLng pendingCameraPosition = null; // Stores pending camera movement - private boolean hasPendingCameraMove = false; // Tracks if camera needs to move + static OverlayCalibration identity() { + return new OverlayCalibration(0f, 0f, 1f, 1f); + } + } - private IndoorMapManager indoorMapManager; // Manages indoor mapping + private Button btnFindIndoorMap; + private Button btnFindActualMap; + private TextView selectedVenueText; + private TextView calibrationTargetText; + private TextView calibrationValueText; + private CircularProgressIndicator indoorLoadingIndicator; + + private View calibrationPanel; + private Button btnToggleAdjustMap; + private Button btnCalibrationTarget; + private Button btnCalibrateUp; + private Button btnCalibrateDown; + private Button btnCalibrateLeft; + private Button btnCalibrateRight; + private Button btnWidthMinus; + private Button btnWidthPlus; + private Button btnHeightMinus; + private Button btnHeightPlus; + private Button btnSaveCalibration; + private Button btnResetCalibration; + + private String calibrationTargetBuildingKey = ""; + + private boolean indoorMapVisible = false; + private boolean actualMapVisible = false; + private boolean hasFetchedNearbyBuildings = false; + private boolean hasAttemptedInitialBuildingFetch = false; + private boolean isIndoorRequestInFlight = false; + private int currentFloorIndex = 0; + + private final List realMapOverlays = new ArrayList<>(); + + private GoogleMap gMap; + private LatLng currentLocation; + private Marker orientationMarker; + private Marker gnssMarker; + private final List testPointMarkers = new ArrayList<>(); + private Polyline polyline; + private boolean isRed = true; + private boolean isGnssOn = false; + private Polyline gnssPolyline; + private LatLng lastGnssLocation = null; + private LatLng pendingCameraPosition = null; + private boolean hasPendingCameraMove = false; + + private IndoorMapManager indoorMapManager; private SensorFusion sensorFusion; + private final List floorplanPolygons = new ArrayList<>(); + private final Map polygonToBuilding = new HashMap<>(); + private final List lastFetchedBuildings = new ArrayList<>(); + private FloorplanApiClient.BuildingInfo selectedFloorplanBuilding; + private Polygon selectedFloorplanPolygon; + private final FloorplanApiClient floorplanApiClient = new FloorplanApiClient(); - // Auto-floor state - private static final String TAG = "TrajectoryMapFragment"; - private static final long AUTO_FLOOR_DEBOUNCE_MS = 3000; - private static final long AUTO_FLOOR_CHECK_INTERVAL_MS = 1000; private Handler autoFloorHandler; private Runnable autoFloorTask; private int lastCandidateFloor = Integer.MIN_VALUE; private long lastCandidateTime = 0; - // UI private Spinner switchMapSpinner; - private SwitchMaterial gnssSwitch; private SwitchMaterial autoFloorSwitch; - private com.google.android.material.floatingactionbutton.FloatingActionButton floorUpButton, floorDownButton; private TextView floorLabel; private Button switchColorButton; - private Polygon buildingPolygon; + public TrajectoryMapFragment() { + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_trajectory_map, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + switchMapSpinner = view.findViewById(R.id.mapSwitchSpinner); + gnssSwitch = view.findViewById(R.id.gnssSwitch); + autoFloorSwitch = view.findViewById(R.id.autoFloor); + floorUpButton = view.findViewById(R.id.floorUpButton); + floorDownButton = view.findViewById(R.id.floorDownButton); + floorLabel = view.findViewById(R.id.floorLabel); + switchColorButton = view.findViewById(R.id.lineColorButton); + + btnFindIndoorMap = view.findViewById(R.id.btnFindIndoorMap); + btnFindActualMap = view.findViewById(R.id.btnFindActualMap); + btnToggleAdjustMap = view.findViewById(R.id.btnToggleAdjustMap); + btnCalibrationTarget = view.findViewById(R.id.btnCalibrationTarget); + btnCalibrateUp = view.findViewById(R.id.btnCalibrateUp); + btnCalibrateDown = view.findViewById(R.id.btnCalibrateDown); + btnCalibrateLeft = view.findViewById(R.id.btnCalibrateLeft); + btnCalibrateRight = view.findViewById(R.id.btnCalibrateRight); + btnWidthMinus = view.findViewById(R.id.btnWidthMinus); + btnWidthPlus = view.findViewById(R.id.btnWidthPlus); + btnHeightMinus = view.findViewById(R.id.btnHeightMinus); + btnHeightPlus = view.findViewById(R.id.btnHeightPlus); + btnSaveCalibration = view.findViewById(R.id.btnSaveCalibration); + btnResetCalibration = view.findViewById(R.id.btnResetCalibration); + calibrationPanel = view.findViewById(R.id.calibrationPanel); + selectedVenueText = view.findViewById(R.id.selectedVenueText); + calibrationTargetText = view.findViewById(R.id.calibrationTargetText); + calibrationValueText = view.findViewById(R.id.calibrationValueText); + indoorLoadingIndicator = view.findViewById(R.id.indoorLoadingIndicator); + + if (indoorLoadingIndicator != null) { + indoorLoadingIndicator.setVisibility(View.GONE); + } + if (selectedVenueText != null) { + selectedVenueText.setText("Tap a blue building outline to select a building"); + } + if (calibrationPanel != null) { + calibrationPanel.setVisibility(View.GONE); + } + if (btnToggleAdjustMap != null) { + btnToggleAdjustMap.setVisibility(View.GONE); + } + setFloorControlsVisibility(View.GONE); + + SupportMapFragment mapFragment = (SupportMapFragment) getChildFragmentManager().findFragmentById(R.id.trajectoryMap); + if (mapFragment != null) { + mapFragment.getMapAsync(googleMap -> { + gMap = googleMap; + initMapSettings(gMap); + if (hasPendingCameraMove && pendingCameraPosition != null) { + gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(pendingCameraPosition, 19f)); + hasPendingCameraMove = false; + pendingCameraPosition = null; + } + restoreCachedBuildingsIfAny(); + maybeFetchNearbyBuildingsOnFirstLocation(); + }); + } + + initMapTypeSpinner(); + + gnssSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + isGnssOn = isChecked; + if (!isChecked && gnssMarker != null) { + gnssMarker.remove(); + gnssMarker = null; + } + }); + + switchColorButton.setOnClickListener(v -> { + if (polyline != null) { + if (isRed) { + switchColorButton.setBackgroundColor(Color.BLACK); + polyline.setColor(Color.BLACK); + isRed = false; + } else { + switchColorButton.setBackgroundColor(Color.RED); + polyline.setColor(Color.RED); + isRed = true; + } + } + }); + + setupCalibrationControls(); + + sensorFusion = SensorFusion.getInstance(); + autoFloorSwitch.setOnCheckedChangeListener((compoundButton, isChecked) -> { + if (isChecked) { + startAutoFloor(); + } else { + stopAutoFloor(); + } + }); + + floorUpButton.setOnClickListener(v -> { + autoFloorSwitch.setChecked(false); + if (selectedFloorplanBuilding != null) { + int nextFloorIndex = getAdjacentFloorIndex(selectedFloorplanBuilding, currentFloorIndex, true); + if (nextFloorIndex != currentFloorIndex) { + setFloor(nextFloorIndex); + } + } + }); + + floorDownButton.setOnClickListener(v -> { + autoFloorSwitch.setChecked(false); + if (selectedFloorplanBuilding != null) { + int nextFloorIndex = getAdjacentFloorIndex(selectedFloorplanBuilding, currentFloorIndex, false); + if (nextFloorIndex != currentFloorIndex) { + setFloor(nextFloorIndex); + } + } + }); + + if (btnFindIndoorMap != null) { + btnFindIndoorMap.setOnClickListener(v -> { + if (selectedFloorplanBuilding == null) { + if (lastFetchedBuildings.isEmpty()) { + requestNearbyIndoorMaps(true); + } else if (selectedVenueText != null) { + selectedVenueText.setText("Tap a blue building outline to select a building first"); + } + return; + } + + actualMapVisible = false; + indoorMapVisible = true; + if (indoorMapManager != null) { + indoorMapManager.setSelectedBuilding(selectedFloorplanBuilding); + } + setFloor(currentFloorIndex); + + if (selectedVenueText != null) { + selectedVenueText.setText("Showing indoor map for " + prettyBuildingName(selectedFloorplanBuilding.getName())); + } + updateCalibrationUi(); + }); + } + + if (btnFindActualMap != null) { + btnFindActualMap.setOnClickListener(v -> { + if (selectedFloorplanBuilding == null) { + if (lastFetchedBuildings.isEmpty()) { + requestNearbyIndoorMaps(true); + } else if (selectedVenueText != null) { + selectedVenueText.setText("Tap a blue building outline to select a building first"); + } + return; + } + + indoorMapVisible = true; + actualMapVisible = !actualMapVisible; + if (indoorMapManager != null) { + indoorMapManager.setSelectedBuilding(selectedFloorplanBuilding); + } + setFloor(currentFloorIndex); + + if (selectedVenueText != null) { + if (actualMapVisible) { + selectedVenueText.setText("Displaying actual map for " + prettyBuildingName(selectedFloorplanBuilding.getName())); + } else { + selectedVenueText.setText("Actual map hidden for " + prettyBuildingName(selectedFloorplanBuilding.getName())); + } + } + updateCalibrationUi(); + }); + } + } + + private void maybeFetchNearbyBuildingsOnFirstLocation() { + if (gMap == null || currentLocation == null || hasAttemptedInitialBuildingFetch || isIndoorRequestInFlight || hasFetchedNearbyBuildings) { + return; + } + hasAttemptedInitialBuildingFetch = true; + requestNearbyIndoorMaps(false); + } + + private void setFloor(int newFloorIndex) { + if (selectedFloorplanBuilding == null || indoorMapManager == null) { + return; + } + + int maxFloor = selectedFloorplanBuilding.getFloorShapesList().size() - 1; + currentFloorIndex = Math.max(0, Math.min(newFloorIndex, maxFloor)); + refreshSelectedPolygonAppearance(); + + if (!indoorMapVisible) { + indoorMapManager.clearIndoorMap(); + updateFloorLabel(); + return; + } + + resetMapOverlays(); + indoorMapManager.setVectorBaseplateEnabled(!actualMapVisible); + if (actualMapVisible) { + updateRealMapOverlay(selectedFloorplanBuilding.getName(), currentFloorIndex, true); + } + + indoorMapManager.setCurrentFloor(currentFloorIndex, false); + setFloorControlsVisibility(View.VISIBLE); + updateFloorLabel(); + updateCalibrationUi(); + } + + + + + private void updateRealMapOverlay(String buildingName, int floorIndex, boolean show) { + if (!show || gMap == null || selectedFloorplanBuilding == null) { + return; + } + + String selectedBuildingKey = resolveKnownBuildingKey(selectedFloorplanBuilding, buildingName); + String selectedFloorDisplayName = normalizeFloorLabel(getFloorDisplayName(selectedFloorplanBuilding, floorIndex)); + addActualMapOverlayForBuilding(selectedFloorplanBuilding, selectedBuildingKey, selectedFloorDisplayName, floorIndex); + + if (shouldShowLinkedLibraryAndNucleus(selectedBuildingKey)) { + String linkedBuildingKey = "library".equals(selectedBuildingKey) ? "nucleus_building" : "library"; + FloorplanApiClient.BuildingInfo linkedBuilding = findBuildingByKnownKey(linkedBuildingKey); + int linkedFloorIndex = linkedBuilding != null + ? findMatchingFloorIndex(linkedBuilding, selectedFloorDisplayName, floorIndex) + : resolveFallbackFloorIndexForKey(linkedBuildingKey, selectedFloorDisplayName, floorIndex); + addActualMapOverlayForBuilding(linkedBuilding, linkedBuildingKey, selectedFloorDisplayName, linkedFloorIndex); + } + } + + private void addActualMapOverlayForBuilding(FloorplanApiClient.BuildingInfo building, + String buildingKey, + String requestedFloorDisplayName, + int requestedFloorIndex) { + if (gMap == null) { + return; + } + + String normalizedBuildingKey = normalizeBuildingKey(buildingKey); + String requestedCanonicalFloor = canonicalFloorLabel(requestedFloorDisplayName); + if ("library".equals(normalizedBuildingKey) && "LG".equals(requestedCanonicalFloor)) { + return; + } + + int resolvedFloorIndex = building != null + ? findMatchingFloorIndex(building, requestedFloorDisplayName, requestedFloorIndex) + : resolveFallbackFloorIndexForKey(normalizedBuildingKey, requestedFloorDisplayName, requestedFloorIndex); + String resolvedFloorDisplayName = building != null + ? normalizeFloorLabel(getFloorDisplayName(building, resolvedFloorIndex)) + : normalizeFloorLabel(requestedFloorDisplayName); + String drawableFloorDisplayName = "library".equals(normalizedBuildingKey) + ? requestedCanonicalFloor + : resolvedFloorDisplayName; + int drawableResId = resolveActualMapDrawable(normalizedBuildingKey, drawableFloorDisplayName, resolvedFloorIndex); + LatLngBounds bounds = computeActualMapBounds(building, normalizedBuildingKey, drawableResId, drawableFloorDisplayName); + + if (drawableResId != 0 && bounds != null) { + GroundOverlay overlay = gMap.addGroundOverlay(new GroundOverlayOptions() + .image(BitmapDescriptorFactory.fromResource(drawableResId)) + .positionFromBounds(bounds) + .zIndex(5f)); + if (overlay != null) { + realMapOverlays.add(overlay); + } + } + } + + private int resolveFallbackFloorIndexForKey(String buildingKey, String requestedFloorDisplayName, int requestedFloorIndex) { + String normalizedKey = normalizeBuildingKey(buildingKey); + String canonicalFloor = canonicalFloorLabel(requestedFloorDisplayName); + + if ("nucleus_building".equals(normalizedKey)) { + switch (canonicalFloor) { + case "LG": + return 0; + case "G": + return 1; + case "1": + return 2; + case "2": + return 3; + case "3": + return 4; + default: + return Math.max(0, Math.min(requestedFloorIndex, 4)); + } + } + + if ("library".equals(normalizedKey)) { + switch (canonicalFloor) { + case "G": + return 0; + case "1": + return 1; + case "2": + return 2; + case "3": + return 3; + default: + return Math.max(0, Math.min(requestedFloorIndex, 3)); + } + } + + return Math.max(0, requestedFloorIndex); + } + + private boolean shouldShowLinkedLibraryAndNucleus(String buildingKey) { + return "library".equals(buildingKey) || "nucleus_building".equals(buildingKey); + } + + private FloorplanApiClient.BuildingInfo findBuildingByKnownKey(String knownKey) { + if (knownKey == null || knownKey.isEmpty()) { + return null; + } + + if (selectedFloorplanBuilding != null) { + String selectedKey = resolveKnownBuildingKey(selectedFloorplanBuilding, selectedFloorplanBuilding.getName()); + if (knownKey.equals(selectedKey)) { + return selectedFloorplanBuilding; + } + } + + for (FloorplanApiClient.BuildingInfo building : lastFetchedBuildings) { + String candidateKey = resolveKnownBuildingKey(building, building != null ? building.getName() : null); + if (knownKey.equals(candidateKey)) { + return building; + } + } + + List cachedBuildings = SensorFusion.getInstance().getFloorplanBuildings(); + if (cachedBuildings != null) { + for (FloorplanApiClient.BuildingInfo building : cachedBuildings) { + String candidateKey = resolveKnownBuildingKey(building, building != null ? building.getName() : null); + if (knownKey.equals(candidateKey)) { + return building; + } + } + } + + return null; + } + + private int findMatchingFloorIndex(FloorplanApiClient.BuildingInfo building, + String requestedFloorDisplayName, + int fallbackFloorIndex) { + if (building == null || building.getFloorShapesList() == null || building.getFloorShapesList().isEmpty()) { + return 0; + } + + String normalizedRequestedFloor = normalizeFloorLabel(requestedFloorDisplayName); + if (!normalizedRequestedFloor.isEmpty()) { + for (int i = 0; i < building.getFloorShapesList().size(); i++) { + String candidateLabel = normalizeFloorLabel(building.getFloorShapesList().get(i).getDisplayName()); + if (areEquivalentFloorLabels(normalizedRequestedFloor, candidateLabel)) { + return i; + } + } + } + + return Math.max(0, Math.min(fallbackFloorIndex, building.getFloorShapesList().size() - 1)); + } + + private boolean areEquivalentFloorLabels(String requestedFloorLabel, String candidateFloorLabel) { + if (requestedFloorLabel == null || candidateFloorLabel == null) { + return false; + } + if (requestedFloorLabel.equals(candidateFloorLabel)) { + return true; + } + + String requested = canonicalFloorLabel(requestedFloorLabel); + String candidate = canonicalFloorLabel(candidateFloorLabel); + return !requested.isEmpty() && requested.equals(candidate); + } + + private String canonicalFloorLabel(String floorLabel) { + String normalized = normalizeFloorLabel(floorLabel); + switch (normalized) { + case "LG": + case "LOWERGROUND": + case "LOWERG": + case "B1": + case "BASEMENT1": + return "LG"; + case "G": + case "GF": + case "GROUND": + case "GROUNDFLOOR": + case "0": + return "G"; + case "1": + case "F1": + case "FIRST": + case "FIRSTFLOOR": + return "1"; + case "2": + case "F2": + case "SECOND": + case "SECONDFLOOR": + return "2"; + case "3": + case "F3": + case "THIRD": + case "THIRDFLOOR": + return "3"; + default: + return normalized; + } + } + + private int resolveActualMapDrawable(String buildingName, String floorDisplayName, int floorIndex) { + buildingName = normalizeBuildingKey(buildingName); + String canonicalFloor = canonicalFloorLabel(floorDisplayName); + if ("nucleus_building".equals(buildingName)) { + if ("LG".equals(canonicalFloor)) { + return R.drawable.nucleuslg; + } + if ("G".equals(canonicalFloor)) { + return R.drawable.nucleusg; + } + if ("1".equals(canonicalFloor)) { + return R.drawable.nucleus1; + } + if ("2".equals(canonicalFloor)) { + return R.drawable.nucleus2; + } + if ("3".equals(canonicalFloor)) { + return R.drawable.nucleus3; + } + + switch (floorIndex) { + case 0: + return R.drawable.nucleuslg; + case 1: + return R.drawable.nucleusg; + case 2: + return R.drawable.nucleus1; + case 3: + return R.drawable.nucleus2; + case 4: + return R.drawable.nucleus3; + default: + return R.drawable.nucleusg; + } + } + + if ("library".equals(buildingName)) { + if ("LG".equals(canonicalFloor)) { + return 0; + } + if ("G".equals(canonicalFloor)) { + return R.drawable.libraryg; + } + if ("1".equals(canonicalFloor)) { + return R.drawable.library1; + } + if ("2".equals(canonicalFloor)) { + return R.drawable.library2; + } + if ("3".equals(canonicalFloor)) { + return R.drawable.library3; + } + + switch (floorIndex) { + case 0: + return 0; + case 1: + return R.drawable.libraryg; + case 2: + return R.drawable.library1; + case 3: + return R.drawable.library2; + case 4: + return R.drawable.library3; + default: + return R.drawable.libraryg; + } + } + + return 0; + } + + private LatLngBounds computeActualMapBounds(FloorplanApiClient.BuildingInfo building, String buildingName, int drawableResId, String floorDisplayName) { + buildingName = normalizeBuildingKey(buildingName); + ActualMapAlignmentConfig config = getActualMapAlignmentConfig(buildingName); + + LatLngBounds bounds = null; + if ("library".equals(buildingName)) { + bounds = computeFixedActualMapBounds(buildingName, drawableResId); + } else if (building != null) { + bounds = computeThreeEdgeAlignedBounds(building, drawableResId, config); + } else { + bounds = computeFixedActualMapBounds(buildingName, drawableResId); + } + + if (bounds == null) { + bounds = getFallbackBuildingBounds(buildingName); + } + if (bounds != null) { + bounds = applySavedCalibration(bounds, buildingName, floorDisplayName); + } + return bounds; + } + + private LatLngBounds computeFixedActualMapBounds(String buildingName, int drawableResId) { + buildingName = normalizeBuildingKey(buildingName); + if ("library".equals(buildingName)) { + double widthScale = getLibraryFixedWidthScale(drawableResId); + return buildRightAnchoredRectBounds( + BuildingPolygon.LIBRARY_SW, + BuildingPolygon.LIBRARY_NE, + widthScale, + 1.0, + 0.008, + 0.0); + } + return null; + } + + private double getLibraryFixedWidthScale(int drawableResId) { + return 1.000; + } + + private LatLngBounds buildRightAnchoredRectBounds(LatLng southWest, + LatLng northEast, + double widthScale, + double heightScale, + double eastShiftRatio, + double northShiftRatio) { + if (southWest == null || northEast == null) { + return null; + } + + double rectWidth = northEast.longitude - southWest.longitude; + double rectHeight = northEast.latitude - southWest.latitude; + if (rectWidth <= 0d || rectHeight <= 0d) { + return null; + } + + double overlayWidth = rectWidth * widthScale; + double overlayHeight = rectHeight * heightScale; + double east = northEast.longitude + rectWidth * eastShiftRatio; + double west = east - overlayWidth; + double north = northEast.latitude + rectHeight * northShiftRatio; + double south = north - overlayHeight; + return new LatLngBounds(new LatLng(south, west), new LatLng(north, east)); + } + + private LatLngBounds computeThreeEdgeAlignedBounds(FloorplanApiClient.BuildingInfo building, + int drawableResId, + ActualMapAlignmentConfig config) { + if (building == null || config == null) { + return null; + } + + List outline = building.getOutlinePolygon(); + if (outline == null || outline.size() < 3) { + return null; + } + + double minLat = Double.POSITIVE_INFINITY; + double maxLat = Double.NEGATIVE_INFINITY; + double minLng = Double.POSITIVE_INFINITY; + double maxLng = Double.NEGATIVE_INFINITY; + for (LatLng point : outline) { + if (point == null) { + continue; + } + minLat = Math.min(minLat, point.latitude); + maxLat = Math.max(maxLat, point.latitude); + minLng = Math.min(minLng, point.longitude); + maxLng = Math.max(maxLng, point.longitude); + } + + if (!Double.isFinite(minLat) || !Double.isFinite(maxLat) + || !Double.isFinite(minLng) || !Double.isFinite(maxLng) + || maxLat <= minLat || maxLng <= minLng) { + return null; + } + + double latSpan = maxLat - minLat; + double lngSpan = maxLng - minLng; + double visibleNorth = maxLat - latSpan * config.northInsetRatio; + double visibleSouth = minLat + latSpan * config.southInsetRatio; + if (visibleSouth >= visibleNorth) { + return null; + } + + double fullAspectRatio = getDrawableAspectRatio(drawableResId); + if (fullAspectRatio <= 0d) { + return null; + } + + DrawableContentInsets contentInsets = getDrawableContentInsets(drawableResId, config); + double visibleContentHeightFraction = contentInsets.contentHeightFraction(); + double visibleContentWidthFraction = contentInsets.contentWidthFraction(); + if (visibleContentHeightFraction <= 0d || visibleContentWidthFraction <= 0d) { + return null; + } + + double visibleHeightDeg = visibleNorth - visibleSouth; + double totalHeightDeg = visibleHeightDeg / visibleContentHeightFraction; + double overlayNorth = visibleNorth + totalHeightDeg * contentInsets.topFraction; + double overlaySouth = overlayNorth - totalHeightDeg; + + double midLatitudeRadians = Math.toRadians((visibleNorth + visibleSouth) / 2.0); + double metersPerDegreeLng = Math.max(111320.0 * Math.cos(midLatitudeRadians), 1.0); + double totalHeightMeters = totalHeightDeg * 111320.0; + double totalWidthMeters = (totalHeightMeters / fullAspectRatio) * config.widthScale; + double totalWidthLng = totalWidthMeters / metersPerDegreeLng; + double visibleContentWidthLng = totalWidthLng * visibleContentWidthFraction; + + double visibleEast; + double visibleWest; + switch (config.horizontalAnchor) { + case RIGHT: + visibleEast = maxLng - lngSpan * config.horizontalInsetRatio; + visibleWest = visibleEast - visibleContentWidthLng; + break; + case LEFT: + visibleWest = minLng + lngSpan * config.horizontalInsetRatio; + visibleEast = visibleWest + visibleContentWidthLng; + break; + case CENTER: + default: + double centerLng = ((minLng + maxLng) / 2.0) + (lngSpan * (config.leftVisibleInsetRatio - config.rightVisibleInsetRatio) * 0.5); + visibleWest = centerLng - visibleContentWidthLng / 2.0; + visibleEast = centerLng + visibleContentWidthLng / 2.0; + break; + } + + double overlayWest = visibleWest - totalWidthLng * contentInsets.leftFraction; + double overlayEast = overlayWest + totalWidthLng; + return new LatLngBounds(new LatLng(overlaySouth, overlayWest), new LatLng(overlayNorth, overlayEast)); + } + + private DrawableContentInsets getDrawableContentInsets(int drawableResId, ActualMapAlignmentConfig config) { + BitmapFactory.Options bounds = new BitmapFactory.Options(); + bounds.inJustDecodeBounds = true; + BitmapFactory.decodeResource(getResources(), drawableResId, bounds); + int width = bounds.outWidth; + int height = bounds.outHeight; + if (width <= 0 || height <= 0) { + return new DrawableContentInsets(config.leftVisibleInsetRatio, config.topVisibleInsetRatio, + config.rightVisibleInsetRatio, config.bottomVisibleInsetRatio); + } + return new DrawableContentInsets(config.leftVisibleInsetRatio, config.topVisibleInsetRatio, + config.rightVisibleInsetRatio, config.bottomVisibleInsetRatio); + } + + private static double clamp01(double value) { + return Math.max(0d, Math.min(1d, value)); + } + + private double getDrawableAspectRatio(int drawableResId) { + try { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeResource(getResources(), drawableResId, options); + if (options.outWidth > 0 && options.outHeight > 0) { + return (double) options.outHeight / (double) options.outWidth; + } + } catch (Exception ignored) { + } + return 1d; + } + + private LatLngBounds getFallbackBuildingBounds(String buildingName) { + buildingName = normalizeBuildingKey(buildingName); + if ("nucleus_building".equals(buildingName)) { + return new LatLngBounds(BuildingPolygon.NUCLEUS_SW, BuildingPolygon.NUCLEUS_NE); + } + if ("library".equals(buildingName)) { + return new LatLngBounds(BuildingPolygon.LIBRARY_SW, BuildingPolygon.LIBRARY_NE); + } + if ("murchison_house".equals(buildingName)) { + return new LatLngBounds(BuildingPolygon.MURCHISON_SW, BuildingPolygon.MURCHISON_NE); + } + return null; + } + + private ActualMapAlignmentConfig getActualMapAlignmentConfig(String buildingName) { + buildingName = normalizeBuildingKey(buildingName); + if ("nucleus_building".equals(buildingName)) { + return new ActualMapAlignmentConfig(HorizontalAnchor.RIGHT, + 0.0, 0.0, 0.0, + 0.99, + 0.0, 0.0, 0.0, 0.0); + } + if ("library".equals(buildingName)) { + return new ActualMapAlignmentConfig(HorizontalAnchor.RIGHT, + 0.0, 0.0, 0.0, + 0.985, + 0.0, 0.0, 0.0, 0.0); + } + if ("murchison_house".equals(buildingName)) { + return new ActualMapAlignmentConfig(HorizontalAnchor.CENTER, + 0.0, 0.0, 0.0, + 1.0, + 0.0, 0.0, 0.0, 0.0); + } + return new ActualMapAlignmentConfig(HorizontalAnchor.CENTER, + 0.0, 0.0, 0.0, + 1.0, + 0.0, 0.0, 0.0, 0.0); + } + + private String normalizeFloorLabel(String floorDisplayName) { + if (floorDisplayName == null) { + return ""; + } + return floorDisplayName.trim().toUpperCase().replace(" ", ""); + } + + private String normalizeBuildingKey(String buildingName) { + if (buildingName == null) { + return ""; + } + String key = buildingName.trim().toLowerCase(); + if (key.contains("nucleus") || key.contains("nuclear")) { + return "nucleus_building"; + } + if (key.contains("library") || key.contains("kenneth") || key.contains("murray")) { + return "library"; + } + if (key.contains("murchison")) { + return "murchison_house"; + } + return key; + } + + private String resolveKnownBuildingKey(FloorplanApiClient.BuildingInfo building, String buildingName) { + String normalized = normalizeBuildingKey(buildingName); + if ("nucleus_building".equals(normalized) || "library".equals(normalized) || "murchison_house".equals(normalized)) { + return normalized; + } + + LatLng center = building != null ? building.getCenter() : null; + if (center != null) { + if (BuildingPolygon.inLibrary(center)) { + return "library"; + } + if (BuildingPolygon.inNucleus(center)) { + return "nucleus_building"; + } + if (BuildingPolygon.inMurchison(center)) { + return "murchison_house"; + } + } + + List outline = building != null ? building.getOutlinePolygon() : null; + if (outline != null && !outline.isEmpty()) { + LatLng centroid = computeOutlineCentroid(outline); + if (centroid != null) { + if (BuildingPolygon.inLibrary(centroid)) { + return "library"; + } + if (BuildingPolygon.inNucleus(centroid)) { + return "nucleus_building"; + } + if (BuildingPolygon.inMurchison(centroid)) { + return "murchison_house"; + } + } + } + + return normalized; + } + + private LatLng computeOutlineCentroid(List outline) { + if (outline == null || outline.isEmpty()) { + return null; + } + + double latSum = 0d; + double lngSum = 0d; + int count = 0; + for (LatLng point : outline) { + if (point == null) { + continue; + } + latSum += point.latitude; + lngSum += point.longitude; + count++; + } + if (count == 0) { + return null; + } + return new LatLng(latSum / count, lngSum / count); + } + + private void onFloorplanBuildingSelected(FloorplanApiClient.BuildingInfo building, Polygon polygon) { + if (selectedFloorplanPolygon != null) { + selectedFloorplanPolygon.setFillColor(Color.argb(50, 33, 150, 243)); + selectedFloorplanPolygon.setStrokeColor(Color.argb(220, 33, 150, 243)); + selectedFloorplanPolygon.setZIndex(1f); + } + + selectedFloorplanPolygon = polygon; + selectedFloorplanBuilding = building; + indoorMapVisible = false; + actualMapVisible = false; + currentFloorIndex = getDefaultFloorIndex(building); + + if (polygon != null) { + polygon.setZIndex(1f); + } + refreshSelectedPolygonAppearance(); + + SensorFusion.getInstance().setSelectedBuildingId(building.getName()); + + resetMapOverlays(); + if (indoorMapManager != null) { + indoorMapManager.clearIndoorMap(); + } + setFloorControlsVisibility(View.GONE); + + if (selectedVenueText != null) { + selectedVenueText.setText("Selected: " + prettyBuildingName(building.getName()) + ". Tap Find Indoor Maps."); + } + calibrationTargetBuildingKey = resolveKnownBuildingKey(building, building.getName()); + updateCalibrationUi(); + + LatLng center = building.getCenter(); + if (center != null && !(center.latitude == 0 && center.longitude == 0)) { + gMap.animateCamera(CameraUpdateFactory.newLatLngZoom(center, 20f)); + } + } + + private int getDefaultFloorIndex(FloorplanApiClient.BuildingInfo building) { + if (building == null || building.getFloorShapesList() == null || building.getFloorShapesList().isEmpty()) { + return 0; + } + + for (int i = 0; i < building.getFloorShapesList().size(); i++) { + String displayName = building.getFloorShapesList().get(i).getDisplayName(); + if ("G".equals(canonicalFloorLabel(displayName))) { + return i; + } + } + + List orderedFloorIndices = getOrderedFloorIndices(building); + return orderedFloorIndices.isEmpty() ? 0 : orderedFloorIndices.get(0); + } + + private String getFloorDisplayName(FloorplanApiClient.BuildingInfo building, int floorIndex) { + if (building == null || building.getFloorShapesList() == null + || floorIndex < 0 || floorIndex >= building.getFloorShapesList().size()) { + return ""; + } + String displayName = building.getFloorShapesList().get(floorIndex).getDisplayName(); + return displayName == null ? "" : displayName.trim().toUpperCase(); + } + + private int getAdjacentFloorIndex(FloorplanApiClient.BuildingInfo building, int currentIndex, boolean moveUp) { + List orderedFloorIndices = getOrderedFloorIndices(building); + if (orderedFloorIndices.isEmpty()) { + return currentIndex; + } + + int currentOrderedPosition = orderedFloorIndices.indexOf(currentIndex); + if (currentOrderedPosition < 0) { + currentOrderedPosition = 0; + for (int i = 0; i < orderedFloorIndices.size(); i++) { + if (orderedFloorIndices.get(i) >= currentIndex) { + currentOrderedPosition = i; + break; + } + } + } + + int nextOrderedPosition = moveUp ? currentOrderedPosition + 1 : currentOrderedPosition - 1; + if (nextOrderedPosition < 0 || nextOrderedPosition >= orderedFloorIndices.size()) { + return currentIndex; + } + return orderedFloorIndices.get(nextOrderedPosition); + } + + private List getOrderedFloorIndices(FloorplanApiClient.BuildingInfo building) { + List ordered = new ArrayList<>(); + if (building == null || building.getFloorShapesList() == null || building.getFloorShapesList().isEmpty()) { + return ordered; + } + + List fallback = new ArrayList<>(); + for (int i = 0; i < building.getFloorShapesList().size(); i++) { + fallback.add(i); + } + + String[] desiredOrder = new String[]{"LG", "G", "1", "2", "3"}; + for (String desiredFloor : desiredOrder) { + for (int i = 0; i < building.getFloorShapesList().size(); i++) { + if (ordered.contains(i)) { + continue; + } + String candidateFloor = canonicalFloorLabel(building.getFloorShapesList().get(i).getDisplayName()); + if (desiredFloor.equals(candidateFloor)) { + ordered.add(i); + break; + } + } + } + + for (Integer index : fallback) { + if (!ordered.contains(index)) { + ordered.add(index); + } + } + return ordered; + } + + private String formatFloorLabelForUi(String rawFloorLabel) { + String canonicalFloor = canonicalFloorLabel(rawFloorLabel); + switch (canonicalFloor) { + case "LG": + return "LG"; + case "G": + return "G"; + case "1": + return "F1"; + case "2": + return "F2"; + case "3": + return "F3"; + default: + return rawFloorLabel == null ? "" : rawFloorLabel; + } + } + + private void refreshSelectedPolygonAppearance() { + if (selectedFloorplanPolygon == null) { + return; + } + + if (indoorMapVisible) { + selectedFloorplanPolygon.setFillColor(Color.argb(10, 33, 150, 243)); + selectedFloorplanPolygon.setStrokeColor(Color.argb(180, 33, 150, 243)); + selectedFloorplanPolygon.setStrokeWidth(3f); + selectedFloorplanPolygon.setZIndex(1f); + } else { + selectedFloorplanPolygon.setFillColor(Color.argb(100, 33, 150, 243)); + selectedFloorplanPolygon.setStrokeColor(Color.argb(255, 25, 118, 210)); + selectedFloorplanPolygon.setStrokeWidth(5f); + selectedFloorplanPolygon.setZIndex(1f); + } + } + + private void resetMapOverlays() { + for (GroundOverlay overlay : realMapOverlays) { + if (overlay != null) { + overlay.remove(); + } + } + realMapOverlays.clear(); + } + + private void restoreCachedBuildingsIfAny() { + List cached = SensorFusion.getInstance().getFloorplanBuildings(); + if (cached != null && !cached.isEmpty()) { + drawFloorplanBuildings(cached); + lastFetchedBuildings.clear(); + lastFetchedBuildings.addAll(cached); + hasFetchedNearbyBuildings = true; + if (selectedVenueText != null) { + selectedVenueText.setText("Tap a blue building outline to select a building"); + } + } + } - public TrajectoryMapFragment() { - // Required empty public constructor + private void setIndoorLoading(boolean loading) { + isIndoorRequestInFlight = loading; + if (indoorLoadingIndicator != null) { + indoorLoadingIndicator.setVisibility(loading ? View.VISIBLE : View.GONE); + } + if (btnFindIndoorMap != null) { + btnFindIndoorMap.setEnabled(!loading); + btnFindIndoorMap.setAlpha(loading ? 0.6f : 1f); + } + if (btnFindActualMap != null) { + btnFindActualMap.setEnabled(!loading); + btnFindActualMap.setAlpha(loading ? 0.6f : 1f); + } + if (btnToggleAdjustMap != null) { + btnToggleAdjustMap.setEnabled(!loading); + btnToggleAdjustMap.setAlpha(loading ? 0.6f : 1f); + } } - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, - @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - // Inflate the separate layout containing map + map-related UI - return inflater.inflate(R.layout.fragment_trajectory_map, container, false); + private List getObservedMacs() { + List macs = new ArrayList<>(); + List wifiList = SensorFusion.getInstance().getWifiList(); + if (wifiList != null) { + for (com.openpositioning.PositionMe.sensors.Wifi wifi : wifiList) { + String mac = wifi.getBssidString(); + if (mac != null && !mac.isEmpty()) { + macs.add(mac); + } + } + } + return macs; } - @Override - public void onViewCreated(@NonNull View view, - @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); + private void requestNearbyIndoorMaps(boolean userInitiated) { + if (gMap == null) { + return; + } - // Grab references to UI controls - switchMapSpinner = view.findViewById(R.id.mapSwitchSpinner); - gnssSwitch = view.findViewById(R.id.gnssSwitch); - autoFloorSwitch = view.findViewById(R.id.autoFloor); - floorUpButton = view.findViewById(R.id.floorUpButton); - floorDownButton = view.findViewById(R.id.floorDownButton); - floorLabel = view.findViewById(R.id.floorLabel); - switchColorButton = view.findViewById(R.id.lineColorButton); + LatLng center = currentLocation; + if (center == null && orientationMarker != null) { + center = orientationMarker.getPosition(); + } + if (center == null) { + float[] gnss = SensorFusion.getInstance().getGNSSLatitude(false); + if (!(gnss[0] == 0f && gnss[1] == 0f)) { + center = new LatLng(gnss[0], gnss[1]); + } + } - // Setup floor up/down UI hidden initially until we know there's an indoor map - setFloorControlsVisibility(View.GONE); + if (center == null) { + if (userInitiated && selectedVenueText != null) { + selectedVenueText.setText("Location not ready yet. Please wait."); + } + return; + } - // Initialize the map asynchronously - SupportMapFragment mapFragment = (SupportMapFragment) - getChildFragmentManager().findFragmentById(R.id.trajectoryMap); - if (mapFragment != null) { - mapFragment.getMapAsync(new OnMapReadyCallback() { - @Override - public void onMapReady(@NonNull GoogleMap googleMap) { - // Assign the provided googleMap to your field variable - gMap = googleMap; - // Initialize map settings with the now non-null gMap - initMapSettings(gMap); - - // If we had a pending camera move, apply it now - if (hasPendingCameraMove && pendingCameraPosition != null) { - gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(pendingCameraPosition, 19f)); - hasPendingCameraMove = false; - pendingCameraPosition = null; + if (userInitiated && selectedVenueText != null) { + selectedVenueText.setText("Requesting nearby indoor maps..."); + } + setIndoorLoading(true); + + floorplanApiClient.requestFloorplan(center.latitude, center.longitude, getObservedMacs(), + new FloorplanApiClient.FloorplanCallback() { + @Override + public void onSuccess(List buildings) { + if (!isAdded() || gMap == null) { + return; + } + setIndoorLoading(false); + hasFetchedNearbyBuildings = buildings != null && !buildings.isEmpty(); + + lastFetchedBuildings.clear(); + if (buildings != null) { + lastFetchedBuildings.addAll(buildings); + } + + SensorFusion.getInstance().setFloorplanBuildings(buildings); + drawFloorplanBuildings(buildings); + + if (selectedVenueText != null) { + if (buildings == null || buildings.isEmpty()) { + selectedVenueText.setText("No nearby buildings found."); + } else { + selectedVenueText.setText("Tap a blue building outline to select a building"); + } + } } - drawBuildingPolygon(); - - Log.d("TrajectoryMapFragment", "onMapReady: Map is ready!"); + @Override + public void onFailure(String error) { + if (!isAdded()) { + return; + } + setIndoorLoading(false); + if (userInitiated && selectedVenueText != null) { + selectedVenueText.setText("Request failed: " + error); + } + } + }); + } + private void drawFloorplanBuildings(List buildings) { + for (Polygon p : floorplanPolygons) { + p.remove(); + } + floorplanPolygons.clear(); + polygonToBuilding.clear(); - } - }); + if (buildings == null || buildings.isEmpty() || gMap == null) { + return; } - // Map type spinner setup - initMapTypeSpinner(); + LatLngBounds.Builder boundsBuilder = new LatLngBounds.Builder(); + boolean hasAnyPoint = false; - // GNSS Switch - gnssSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { - isGnssOn = isChecked; - if (!isChecked && gnssMarker != null) { - gnssMarker.remove(); - gnssMarker = null; + for (FloorplanApiClient.BuildingInfo building : buildings) { + List outline = building.getOutlinePolygon(); + if (outline == null || outline.size() < 3) { + continue; } - }); - // Color switch - switchColorButton.setOnClickListener(v -> { - if (polyline != null) { - if (isRed) { - switchColorButton.setBackgroundColor(Color.BLACK); - polyline.setColor(Color.BLACK); - isRed = false; - } else { - switchColorButton.setBackgroundColor(Color.RED); - polyline.setColor(Color.RED); - isRed = true; - } - } - }); + Polygon polygon = gMap.addPolygon(new PolygonOptions() + .addAll(outline) + .strokeColor(Color.argb(220, 33, 150, 243)) + .strokeWidth(5f) + .fillColor(Color.argb(50, 33, 150, 243)) + .clickable(true) + .zIndex(1f)); - // Auto-floor toggle: start/stop periodic floor evaluation - sensorFusion = SensorFusion.getInstance(); - autoFloorSwitch.setOnCheckedChangeListener((compoundButton, isChecked) -> { - if (isChecked) { - startAutoFloor(); - } else { - stopAutoFloor(); - } - }); + floorplanPolygons.add(polygon); + polygonToBuilding.put(polygon, building); - floorUpButton.setOnClickListener(v -> { - // If user manually changes floor, turn off auto floor - autoFloorSwitch.setChecked(false); - if (indoorMapManager != null) { - indoorMapManager.increaseFloor(); - updateFloorLabel(); + for (LatLng point : outline) { + boundsBuilder.include(point); + hasAnyPoint = true; } - }); + } - floorDownButton.setOnClickListener(v -> { - autoFloorSwitch.setChecked(false); - if (indoorMapManager != null) { - indoorMapManager.decreaseFloor(); - updateFloorLabel(); + if (hasAnyPoint) { + try { + gMap.animateCamera(CameraUpdateFactory.newLatLngBounds(boundsBuilder.build(), 100)); + } catch (Exception ignored) { } - }); + } } - /** - * Initialize the map settings with the provided GoogleMap instance. - *

- * The method sets basic map settings, initializes the indoor map manager, - * and creates an empty polyline for user movement tracking. - * The method also initializes the GNSS polyline for tracking GNSS path. - * The method sets the map type to Hybrid and initializes the map with these settings. - * - * @param map - */ - private void initMapSettings(GoogleMap map) { - // Basic map settings map.getUiSettings().setCompassEnabled(true); map.getUiSettings().setTiltGesturesEnabled(true); map.getUiSettings().setRotateGesturesEnabled(true); map.getUiSettings().setScrollGesturesEnabled(true); map.setMapType(GoogleMap.MAP_TYPE_HYBRID); - // Initialize indoor manager indoorMapManager = new IndoorMapManager(map); - // Initialize an empty polyline - polyline = map.addPolyline(new PolylineOptions() - .color(Color.RED) - .width(5f) - .add() // start empty - ); + map.setOnPolygonClickListener(polygon -> { + FloorplanApiClient.BuildingInfo building = polygonToBuilding.get(polygon); + if (building != null) { + onFloorplanBuildingSelected(building, polygon); + } + }); - // GNSS path in blue - gnssPolyline = map.addPolyline(new PolylineOptions() - .color(Color.BLUE) - .width(5f) - .add() // start empty - ); + polyline = map.addPolyline(new PolylineOptions().color(Color.RED).width(5f).add()); + gnssPolyline = map.addPolyline(new PolylineOptions().color(Color.BLUE).width(5f).add()); } + private String prettyBuildingName(String raw) { + if (raw == null || raw.isEmpty()) { + return ""; + } + String[] parts = raw.split("_"); + StringBuilder sb = new StringBuilder(); + for (String p : parts) { + if (p.isEmpty()) { + continue; + } + if (sb.length() > 0) { + sb.append(" "); + } + sb.append(Character.toUpperCase(p.charAt(0))); + if (p.length() > 1) { + sb.append(p.substring(1)); + } + } + return sb.toString(); + } - /** - * Initialize the map type spinner with the available map types. - *

- * The spinner allows the user to switch between different map types - * (e.g. Hybrid, Normal, Satellite) to customize their map view. - * The spinner is populated with the available map types and listens - * for user selection to update the map accordingly. - * The map type is updated directly on the GoogleMap instance. - *

- * Note: The spinner is initialized with the default map type (Hybrid). - * The map type is updated on user selection. - *

- *

- * @see com.google.android.gms.maps.GoogleMap The GoogleMap instance to update map type. - */ private void initMapTypeSpinner() { - if (switchMapSpinner == null) return; - String[] maps = new String[]{ - getString(R.string.hybrid), - getString(R.string.normal), - getString(R.string.satellite) - }; - ArrayAdapter adapter = new ArrayAdapter<>( - requireContext(), - android.R.layout.simple_spinner_dropdown_item, - maps - ); + if (switchMapSpinner == null) { + return; + } + String[] maps = new String[]{getString(R.string.hybrid), getString(R.string.normal), getString(R.string.satellite)}; + ArrayAdapter adapter = new ArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_dropdown_item, maps); switchMapSpinner.setAdapter(adapter); - switchMapSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override - public void onItemSelected(AdapterView parent, View view, - int position, long id) { - if (gMap == null) return; - switch (position){ + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (gMap == null) { + return; + } + switch (position) { case 0: gMap.setMapType(GoogleMap.MAP_TYPE_HYBRID); break; @@ -295,144 +1395,86 @@ public void onItemSelected(AdapterView parent, View view, break; } } + @Override - public void onNothingSelected(AdapterView parent) {} + public void onNothingSelected(AdapterView parent) { + } }); } - /** - * Update the user's current location on the map, create or move orientation marker, - * and append to polyline if the user actually moved. - * - * @param newLocation The new location to plot. - * @param orientation The user’s heading (e.g. from sensor fusion). - */ public void updateUserLocation(@NonNull LatLng newLocation, float orientation) { - if (gMap == null) return; - - // Keep track of current location + if (gMap == null) { + return; + } LatLng oldLocation = this.currentLocation; this.currentLocation = newLocation; - // If no marker, create it + boolean shouldFollowCamera = !(indoorMapVisible || actualMapVisible); if (orientationMarker == null) { orientationMarker = gMap.addMarker(new MarkerOptions() .position(newLocation) .flat(true) .title("Current Position") - .icon(BitmapDescriptorFactory.fromBitmap( - UtilFunctions.getBitmapFromVector(requireContext(), - R.drawable.ic_baseline_navigation_24))) - ); - gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(newLocation, 19f)); + .icon(BitmapDescriptorFactory.fromBitmap(UtilFunctions.getBitmapFromVector(requireContext(), R.drawable.ic_baseline_navigation_24)))); + if (shouldFollowCamera) { + gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(newLocation, 19f)); + } } else { - // Update marker position + orientation orientationMarker.setPosition(newLocation); orientationMarker.setRotation(orientation); - // Move camera a bit - gMap.moveCamera(CameraUpdateFactory.newLatLng(newLocation)); + if (shouldFollowCamera) { + gMap.moveCamera(CameraUpdateFactory.newLatLng(newLocation)); + } } - // Extend polyline if movement occurred - /*if (oldLocation != null && !oldLocation.equals(newLocation) && polyline != null) { - List points = new ArrayList<>(polyline.getPoints()); - points.add(newLocation); - polyline.setPoints(points); - }*/ - // Extend polyline if (polyline != null) { List points = new ArrayList<>(polyline.getPoints()); - - // First position fix: add the first polyline point - if (oldLocation == null) { - points.add(newLocation); - polyline.setPoints(points); - } else if (!oldLocation.equals(newLocation)) { - // Subsequent movement: append a new polyline point + if (oldLocation == null || !oldLocation.equals(newLocation)) { points.add(newLocation); polyline.setPoints(points); } } - - // Update indoor map overlay if (indoorMapManager != null) { indoorMapManager.setCurrentLocation(newLocation); - setFloorControlsVisibility(indoorMapManager.getIsIndoorMapSet() ? View.VISIBLE : View.GONE); } - } - + maybeFetchNearbyBuildingsOnFirstLocation(); + } - /** - * Set the initial camera position for the map. - *

- * The method sets the initial camera position for the map when it is first loaded. - * If the map is already ready, the camera is moved immediately. - * If the map is not ready, the camera position is stored until the map is ready. - * The method also tracks if there is a pending camera move. - *

- * @param startLocation The initial camera position to set. - */ public void setInitialCameraPosition(@NonNull LatLng startLocation) { - // If the map is already ready, move camera immediately if (gMap != null) { gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(startLocation, 19f)); } else { - // Otherwise, store it until onMapReady pendingCameraPosition = startLocation; hasPendingCameraMove = true; } } - - /** - * Get the current user location on the map. - * @return The current user location as a LatLng object. - */ public LatLng getCurrentLocation() { return currentLocation; } - /** - * Add a numbered test point marker on the map. - * Called by RecordingFragment when user presses the "Test Point" button. - */ public void addTestPointMarker(int index, long timestampMs, @NonNull LatLng position) { - if (gMap == null) return; - - Marker m = gMap.addMarker(new MarkerOptions() - .position(position) - .title("TP " + index) - .snippet("t=" + timestampMs)); - + if (gMap == null) { + return; + } + Marker m = gMap.addMarker(new MarkerOptions().position(position).title("TP " + index).snippet("t=" + timestampMs)); if (m != null) { - m.showInfoWindow(); // Show TP index immediately + m.showInfoWindow(); testPointMarkers.add(m); } } - - /** - * Called when we want to set or update the GNSS marker position - */ public void updateGNSS(@NonNull LatLng gnssLocation) { - if (gMap == null) return; - if (!isGnssOn) return; - + if (gMap == null || !isGnssOn) { + return; + } if (gnssMarker == null) { - // Create the GNSS marker for the first time - gnssMarker = gMap.addMarker(new MarkerOptions() - .position(gnssLocation) - .title("GNSS Position") - .icon(BitmapDescriptorFactory - .defaultMarker(BitmapDescriptorFactory.HUE_AZURE))); + gnssMarker = gMap.addMarker(new MarkerOptions().position(gnssLocation).title("GNSS Position").icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE))); lastGnssLocation = gnssLocation; } else { - // Move existing GNSS marker gnssMarker.setPosition(gnssLocation); - - // Add a segment to the blue GNSS line, if this is a new location if (lastGnssLocation != null && !lastGnssLocation.equals(gnssLocation)) { List gnssPoints = new ArrayList<>(gnssPolyline.getPoints()); gnssPoints.add(gnssLocation); @@ -442,10 +1484,6 @@ public void updateGNSS(@NonNull LatLng gnssLocation) { } } - - /** - * Remove GNSS marker if user toggles it off - */ public void clearGNSS() { if (gnssMarker != null) { gnssMarker.remove(); @@ -453,13 +1491,315 @@ public void clearGNSS() { } } - /** - * Whether user is currently showing GNSS or not - */ public boolean isGnssEnabled() { return isGnssOn; } + private void setupCalibrationControls() { + if (btnToggleAdjustMap != null) { + btnToggleAdjustMap.setOnClickListener(v -> { + if (!actualMapVisible) { + if (selectedVenueText != null) { + selectedVenueText.setText("Show actual maps first, then adjust them"); + } + return; + } + if (calibrationPanel != null) { + calibrationPanel.setVisibility(calibrationPanel.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE); + } + updateCalibrationUi(); + }); + } + + if (btnCalibrationTarget != null) { + btnCalibrationTarget.setOnClickListener(v -> { + cycleCalibrationTarget(); + updateCalibrationUi(); + }); + } + + bindCalibrationButton(btnCalibrateUp, 0f, CALIBRATION_SHIFT_STEP, 0f, 0f); + bindCalibrationButton(btnCalibrateDown, 0f, -CALIBRATION_SHIFT_STEP, 0f, 0f); + bindCalibrationButton(btnCalibrateLeft, -CALIBRATION_SHIFT_STEP, 0f, 0f, 0f); + bindCalibrationButton(btnCalibrateRight, CALIBRATION_SHIFT_STEP, 0f, 0f, 0f); + bindCalibrationButton(btnWidthMinus, 0f, 0f, -CALIBRATION_SCALE_STEP, 0f); + bindCalibrationButton(btnWidthPlus, 0f, 0f, CALIBRATION_SCALE_STEP, 0f); + bindCalibrationButton(btnHeightMinus, 0f, 0f, 0f, -CALIBRATION_SCALE_STEP); + bindCalibrationButton(btnHeightPlus, 0f, 0f, 0f, CALIBRATION_SCALE_STEP); + + if (btnSaveCalibration != null) { + btnSaveCalibration.setOnClickListener(v -> { + String targetKey = getEffectiveCalibrationTargetBuildingKey(); + if (targetKey.isEmpty()) { + return; + } + String floorKey = getCurrentCalibrationFloorKey(targetKey); + OverlayCalibration calibration = loadOverlayCalibration(targetKey, floorKey); + logCalibration(targetKey, floorKey, calibration, "saved"); + if (selectedVenueText != null) { + selectedVenueText.setText("Saved calibration for " + prettyBuildingName(targetKey) + " " + floorKey); + } + updateCalibrationUi(); + }); + } + + if (btnResetCalibration != null) { + btnResetCalibration.setOnClickListener(v -> { + String targetKey = getEffectiveCalibrationTargetBuildingKey(); + if (targetKey.isEmpty()) { + return; + } + String floorKey = getCurrentCalibrationFloorKey(targetKey); + OverlayCalibration defaults = getHardcodedCalibrationDefault(targetKey, floorKey); + saveOverlayCalibration(targetKey, floorKey, defaults); + if (actualMapVisible) { + setFloor(currentFloorIndex); + } + if (selectedVenueText != null) { + selectedVenueText.setText("Reset calibration for " + prettyBuildingName(targetKey) + " " + floorKey); + } + logCalibration(targetKey, floorKey, defaults, "reset"); + updateCalibrationUi(); + }); + } + } + + private void bindCalibrationButton(Button button, float shiftLngDelta, float shiftLatDelta, float widthDelta, float heightDelta) { + if (button == null) { + return; + } + button.setOnClickListener(v -> applyCalibrationDelta(shiftLngDelta, shiftLatDelta, widthDelta, heightDelta)); + } + + private void applyCalibrationDelta(float shiftLngDelta, float shiftLatDelta, float widthDelta, float heightDelta) { + String targetKey = getEffectiveCalibrationTargetBuildingKey(); + if (targetKey.isEmpty()) { + if (selectedVenueText != null) { + selectedVenueText.setText("Select a visible actual map to adjust"); + } + return; + } + + String floorKey = getCurrentCalibrationFloorKey(targetKey); + OverlayCalibration current = loadOverlayCalibration(targetKey, floorKey); + OverlayCalibration updated = new OverlayCalibration( + current.shiftLatRatio + shiftLatDelta, + current.shiftLngRatio + shiftLngDelta, + Math.max(0.50f, current.widthScale + widthDelta), + Math.max(0.50f, current.heightScale + heightDelta) + ); + saveOverlayCalibration(targetKey, floorKey, updated); + logCalibration(targetKey, floorKey, updated, "updated"); + if (actualMapVisible) { + setFloor(currentFloorIndex); + } + updateCalibrationUi(); + } + + private LatLngBounds applySavedCalibration(LatLngBounds baseBounds, String buildingKey, String floorDisplayName) { + if (baseBounds == null) { + return null; + } + OverlayCalibration calibration = loadOverlayCalibration(buildingKey, floorDisplayName); + double baseNorth = baseBounds.northeast.latitude; + double baseSouth = baseBounds.southwest.latitude; + double baseEast = baseBounds.northeast.longitude; + double baseWest = baseBounds.southwest.longitude; + double latSpan = baseNorth - baseSouth; + double lngSpan = baseEast - baseWest; + if (latSpan <= 0d || lngSpan <= 0d) { + return baseBounds; + } + + double centerLat = ((baseNorth + baseSouth) / 2d) + latSpan * calibration.shiftLatRatio; + double centerLng = ((baseEast + baseWest) / 2d) + lngSpan * calibration.shiftLngRatio; + double adjustedLatSpan = latSpan * calibration.heightScale; + double adjustedLngSpan = lngSpan * calibration.widthScale; + + double north = centerLat + adjustedLatSpan / 2d; + double south = centerLat - adjustedLatSpan / 2d; + double east = centerLng + adjustedLngSpan / 2d; + double west = centerLng - adjustedLngSpan / 2d; + return new LatLngBounds(new LatLng(south, west), new LatLng(north, east)); + } + + private SharedPreferences getCalibrationPrefs() { + Context context = getContext(); + if (context == null) { + return null; + } + return context.getSharedPreferences(CALIBRATION_PREFS_NAME, Context.MODE_PRIVATE); + } + + private OverlayCalibration loadOverlayCalibration(String buildingKey, String floorDisplayName) { + OverlayCalibration defaults = getHardcodedCalibrationDefault(buildingKey, floorDisplayName); + SharedPreferences prefs = getCalibrationPrefs(); + if (prefs == null) { + return defaults; + } + String baseKey = getCalibrationPrefBaseKey(buildingKey, floorDisplayName); + return new OverlayCalibration( + prefs.getFloat(baseKey + "_shift_lat", defaults.shiftLatRatio), + prefs.getFloat(baseKey + "_shift_lng", defaults.shiftLngRatio), + prefs.getFloat(baseKey + "_width", defaults.widthScale), + prefs.getFloat(baseKey + "_height", defaults.heightScale) + ); + } + + private void saveOverlayCalibration(String buildingKey, String floorDisplayName, OverlayCalibration calibration) { + SharedPreferences prefs = getCalibrationPrefs(); + if (prefs == null) { + return; + } + String baseKey = getCalibrationPrefBaseKey(buildingKey, floorDisplayName); + prefs.edit() + .putFloat(baseKey + "_shift_lat", calibration.shiftLatRatio) + .putFloat(baseKey + "_shift_lng", calibration.shiftLngRatio) + .putFloat(baseKey + "_width", calibration.widthScale) + .putFloat(baseKey + "_height", calibration.heightScale) + .apply(); + } + + private String getCalibrationPrefBaseKey(String buildingKey, String floorDisplayName) { + String normalizedBuilding = normalizeBuildingKey(buildingKey); + String normalizedFloor = canonicalFloorLabel(floorDisplayName); + return normalizedBuilding + "__" + normalizedFloor; + } + + private OverlayCalibration getHardcodedCalibrationDefault(String buildingKey, String floorDisplayName) { + String normalizedBuilding = normalizeBuildingKey(buildingKey); + String normalizedFloor = canonicalFloorLabel(floorDisplayName); + + if ("nucleus_building".equals(normalizedBuilding)) { + switch (normalizedFloor) { + case "LG": + return new OverlayCalibration(0.012f, -0.022f, 0.967f, 0.986f); + case "G": + return new OverlayCalibration(0.029f, -0.052f, 0.958f, 0.942f); + case "1": + return new OverlayCalibration(0.004f, 0.000f, 1.000f, 0.981f); + case "2": + return new OverlayCalibration(0.005f, -0.005f, 1.000f, 1.000f); + case "3": + return new OverlayCalibration(0.005f, 0.005f, 1.015f, 0.990f); + default: + return OverlayCalibration.identity(); + } + } + + if ("library".equals(normalizedBuilding)) { + switch (normalizedFloor) { + case "G": + return new OverlayCalibration(-0.072f, 0.018f, 0.965f, 1.098f); + case "1": + return new OverlayCalibration(-0.053f, 0.019f, 0.945f, 1.050f); + case "2": + return new OverlayCalibration(-0.075f, 0.025f, 0.950f, 1.070f); + case "3": + return new OverlayCalibration(-0.070f, 0.025f, 0.960f, 1.065f); + default: + return OverlayCalibration.identity(); + } + } + + return OverlayCalibration.identity(); + } + + private void logCalibration(String buildingKey, String floorKey, OverlayCalibration calibration, String event) { + Log.d(TAG, String.format(Locale.US, + "MAP_CALIBRATION %s building=%s floor=%s shiftLat=%.5f shiftLng=%.5f widthScale=%.5f heightScale=%.5f", + event, normalizeBuildingKey(buildingKey), canonicalFloorLabel(floorKey), + calibration.shiftLatRatio, calibration.shiftLngRatio, calibration.widthScale, calibration.heightScale)); + } + + private List getAvailableCalibrationTargets() { + List targets = new ArrayList<>(); + if (!actualMapVisible || selectedFloorplanBuilding == null) { + return targets; + } + + String selectedKey = resolveKnownBuildingKey(selectedFloorplanBuilding, selectedFloorplanBuilding.getName()); + String selectedFloorLabel = canonicalFloorLabel(getFloorDisplayName(selectedFloorplanBuilding, currentFloorIndex)); + + if (resolveActualMapDrawable(selectedKey, selectedFloorLabel, currentFloorIndex) != 0) { + targets.add(selectedKey); + } + + if (shouldShowLinkedLibraryAndNucleus(selectedKey)) { + String linkedKey = "library".equals(selectedKey) ? "nucleus_building" : "library"; + int linkedIndex = resolveFallbackFloorIndexForKey(linkedKey, selectedFloorLabel, currentFloorIndex); + if (resolveActualMapDrawable(linkedKey, selectedFloorLabel, linkedIndex) != 0 && !targets.contains(linkedKey)) { + targets.add(linkedKey); + } + } + return targets; + } + + private void cycleCalibrationTarget() { + List targets = getAvailableCalibrationTargets(); + if (targets.isEmpty()) { + calibrationTargetBuildingKey = ""; + return; + } + int currentIndex = targets.indexOf(getEffectiveCalibrationTargetBuildingKey()); + if (currentIndex < 0) { + calibrationTargetBuildingKey = targets.get(0); + return; + } + calibrationTargetBuildingKey = targets.get((currentIndex + 1) % targets.size()); + } + + private String getEffectiveCalibrationTargetBuildingKey() { + List targets = getAvailableCalibrationTargets(); + if (targets.isEmpty()) { + return ""; + } + if (targets.contains(calibrationTargetBuildingKey)) { + return calibrationTargetBuildingKey; + } + calibrationTargetBuildingKey = targets.get(0); + return calibrationTargetBuildingKey; + } + + private String getCurrentCalibrationFloorKey(String buildingKey) { + String selectedFloorLabel = selectedFloorplanBuilding == null ? "" : getFloorDisplayName(selectedFloorplanBuilding, currentFloorIndex); + String canonicalFloor = canonicalFloorLabel(selectedFloorLabel); + if ("library".equals(normalizeBuildingKey(buildingKey)) && "LG".equals(canonicalFloor)) { + return "LG"; + } + return canonicalFloor; + } + + private void updateCalibrationUi() { + boolean canAdjust = actualMapVisible && selectedFloorplanBuilding != null; + if (btnToggleAdjustMap != null) { + btnToggleAdjustMap.setVisibility(canAdjust ? View.VISIBLE : View.GONE); + btnToggleAdjustMap.setText((calibrationPanel != null && calibrationPanel.getVisibility() == View.VISIBLE) ? "Hide Adjust Map" : "Adjust Map"); + } + if (!canAdjust) { + if (calibrationPanel != null) { + calibrationPanel.setVisibility(View.GONE); + } + return; + } + + String targetKey = getEffectiveCalibrationTargetBuildingKey(); + String floorKey = getCurrentCalibrationFloorKey(targetKey); + OverlayCalibration calibration = loadOverlayCalibration(targetKey, floorKey); + + if (calibrationTargetText != null) { + calibrationTargetText.setText("Target: " + prettyBuildingName(targetKey) + " / " + floorKey); + } + if (calibrationValueText != null) { + calibrationValueText.setText(String.format(Locale.US, + "x=%.3f y=%.3f w=%.3f h=%.3f", + calibration.shiftLngRatio, calibration.shiftLatRatio, calibration.widthScale, calibration.heightScale)); + } + if (btnCalibrationTarget != null) { + btnCalibrationTarget.setVisibility(getAvailableCalibrationTargets().size() > 1 ? View.VISIBLE : View.GONE); + } + } + private void setFloorControlsVisibility(int visibility) { floorUpButton.setVisibility(visibility); floorDownButton.setVisibility(visibility); @@ -470,13 +1810,11 @@ private void setFloorControlsVisibility(int visibility) { } } - /** - * Updates the floor label text to reflect the current floor display name. - */ private void updateFloorLabel() { - if (floorLabel != null && indoorMapManager != null) { - floorLabel.setText(indoorMapManager.getCurrentFloorDisplayName()); + if (floorLabel != null && indoorMapManager != null && indoorMapManager.getIsIndoorMapSet()) { + floorLabel.setText(formatFloorLabelForUi(indoorMapManager.getCurrentFloorDisplayName())); } + updateCalibrationUi(); } public void clearMapAndReset() { @@ -501,135 +1839,51 @@ public void clearMapAndReset() { gnssMarker = null; } lastGnssLocation = null; - currentLocation = null; + currentLocation = null; - // Clear test point markers for (Marker m : testPointMarkers) { m.remove(); } testPointMarkers.clear(); - - // Re-create empty polylines with your chosen colors if (gMap != null) { - polyline = gMap.addPolyline(new PolylineOptions() - .color(Color.RED) - .width(5f) - .add()); - gnssPolyline = gMap.addPolyline(new PolylineOptions() - .color(Color.BLUE) - .width(5f) - .add()); - } - } - - /** - * Draw the building polygon on the map - *

- * The method draws a polygon representing the building on the map. - * The polygon is drawn with specific vertices and colors to represent - * different buildings or areas on the map. - * The method removes the old polygon if it exists and adds the new polygon - * to the map with the specified options. - * The method logs the number of vertices in the polygon for debugging. - *

- * - * Note: The method uses hard-coded vertices for the building polygon. - * - *

- * - * See: {@link com.google.android.gms.maps.model.PolygonOptions} The options for the new polygon. - */ - private void drawBuildingPolygon() { - if (gMap == null) { - Log.e("TrajectoryMapFragment", "GoogleMap is not ready"); - return; + polyline = gMap.addPolyline(new PolylineOptions().color(Color.RED).width(5f).add()); + gnssPolyline = gMap.addPolyline(new PolylineOptions().color(Color.BLUE).width(5f).add()); } - - // nuclear building polygon vertices - LatLng nucleus1 = new LatLng(55.92279538827796, -3.174612147506538); - LatLng nucleus2 = new LatLng(55.92278121423647, -3.174107900816096); - LatLng nucleus3 = new LatLng(55.92288405733954, -3.173843694667146); - LatLng nucleus4 = new LatLng(55.92331786793876, -3.173832892645086); - LatLng nucleus5 = new LatLng(55.923337194112555, -3.1746284301397387); - - - // nkml building polygon vertices - LatLng nkml1 = new LatLng(55.9230343434213, -3.1751847990731954); - LatLng nkml2 = new LatLng(55.923032840563366, -3.174777103346131); - LatLng nkml4 = new LatLng(55.92280139974615, -3.175195527934348); - LatLng nkml3 = new LatLng(55.922793885410734, -3.1747958788136867); - - LatLng fjb1 = new LatLng(55.92269205199916, -3.1729563477188774);//left top - LatLng fjb2 = new LatLng(55.922822801570994, -3.172594249522305); - LatLng fjb3 = new LatLng(55.92223512226413, -3.171921917547244); - LatLng fjb4 = new LatLng(55.9221071265519, -3.1722813131202097); - - LatLng faraday1 = new LatLng(55.92242866264128, -3.1719553662011815); - LatLng faraday2 = new LatLng(55.9224966752294, -3.1717846714743474); - LatLng faraday3 = new LatLng(55.922271383074154, -3.1715191463437162); - LatLng faraday4 = new LatLng(55.92220124468304, -3.171705013935158); - - - - PolygonOptions buildingPolygonOptions = new PolygonOptions() - .add(nucleus1, nucleus2, nucleus3, nucleus4, nucleus5) - .strokeColor(Color.RED) // Red border - .strokeWidth(10f) // Border width - //.fillColor(Color.argb(50, 255, 0, 0)) // Semi-transparent red fill - .zIndex(1); // Set a higher zIndex to ensure it appears above other overlays - - // Options for the new polygon - PolygonOptions buildingPolygonOptions2 = new PolygonOptions() - .add(nkml1, nkml2, nkml3, nkml4, nkml1) - .strokeColor(Color.BLUE) // Blue border - .strokeWidth(10f) // Border width - // .fillColor(Color.argb(50, 0, 0, 255)) // Semi-transparent blue fill - .zIndex(1); // Set a higher zIndex to ensure it appears above other overlays - - PolygonOptions buildingPolygonOptions3 = new PolygonOptions() - .add(fjb1, fjb2, fjb3, fjb4, fjb1) - .strokeColor(Color.GREEN) // Green border - .strokeWidth(10f) // Border width - //.fillColor(Color.argb(50, 0, 255, 0)) // Semi-transparent green fill - .zIndex(1); // Set a higher zIndex to ensure it appears above other overlays - - PolygonOptions buildingPolygonOptions4 = new PolygonOptions() - .add(faraday1, faraday2, faraday3, faraday4, faraday1) - .strokeColor(Color.YELLOW) // Yellow border - .strokeWidth(10f) // Border width - //.fillColor(Color.argb(50, 255, 255, 0)) // Semi-transparent yellow fill - .zIndex(1); // Set a higher zIndex to ensure it appears above other overlays - - - // Remove the old polygon if it exists - if (buildingPolygon != null) { - buildingPolygon.remove(); + for (Polygon p : floorplanPolygons) { + p.remove(); } - - // Add the polygon to the map - buildingPolygon = gMap.addPolygon(buildingPolygonOptions); - gMap.addPolygon(buildingPolygonOptions2); - gMap.addPolygon(buildingPolygonOptions3); - gMap.addPolygon(buildingPolygonOptions4); - Log.d(TAG, "Building polygon added, vertex count: " + buildingPolygon.getPoints().size()); + floorplanPolygons.clear(); + polygonToBuilding.clear(); + lastFetchedBuildings.clear(); + selectedFloorplanPolygon = null; + selectedFloorplanBuilding = null; + indoorMapVisible = false; + actualMapVisible = false; + hasFetchedNearbyBuildings = false; + hasAttemptedInitialBuildingFetch = false; + + resetMapOverlays(); + if (indoorMapManager != null) { + indoorMapManager.clearIndoorMap(); + } + if (selectedVenueText != null) { + selectedVenueText.setText("Tap a blue building outline to select a building"); + } + calibrationTargetBuildingKey = ""; + updateCalibrationUi(); + if (indoorLoadingIndicator != null) { + indoorLoadingIndicator.setVisibility(View.GONE); + } + setFloorControlsVisibility(View.GONE); } - //region Auto-floor logic - - /** - * Starts the periodic auto-floor evaluation task. Checks every second - * and applies floor changes only after the debounce window (3 seconds - * of consistent readings). - */ private void startAutoFloor() { if (autoFloorHandler == null) { autoFloorHandler = new Handler(Looper.getMainLooper()); } lastCandidateFloor = Integer.MIN_VALUE; lastCandidateTime = 0; - - // Immediately jump to the best-guess floor (skip debounce on first toggle) applyImmediateFloor(); autoFloorTask = new Runnable() { @@ -640,84 +1894,61 @@ public void run() { } }; autoFloorHandler.post(autoFloorTask); - Log.d(TAG, "Auto-floor started"); } - /** - * Applies the best-guess floor immediately without debounce. - * Called once when auto-floor is first toggled on, so the user - * sees an instant correction after manually browsing wrong floors. - */ private void applyImmediateFloor() { - if (sensorFusion == null || indoorMapManager == null) return; - if (!indoorMapManager.getIsIndoorMapSet()) return; - + if (sensorFusion == null || indoorMapManager == null || !indoorMapManager.getIsIndoorMapSet()) { + return; + } int candidateFloor; if (sensorFusion.getLatLngWifiPositioning() != null) { candidateFloor = sensorFusion.getWifiFloor(); } else { float elevation = sensorFusion.getElevation(); float floorHeight = indoorMapManager.getFloorHeight(); - if (floorHeight <= 0) return; + if (floorHeight <= 0) { + return; + } candidateFloor = Math.round(elevation / floorHeight); } - - indoorMapManager.setCurrentFloor(candidateFloor, true); - updateFloorLabel(); - // Seed the debounce state so subsequent checks don't re-trigger immediately + setFloor(indoorMapManager.logicalFloorToIndex(candidateFloor)); lastCandidateFloor = candidateFloor; lastCandidateTime = SystemClock.elapsedRealtime(); } - /** - * Stops the periodic auto-floor evaluation and resets debounce state. - */ private void stopAutoFloor() { if (autoFloorHandler != null && autoFloorTask != null) { autoFloorHandler.removeCallbacks(autoFloorTask); } lastCandidateFloor = Integer.MIN_VALUE; lastCandidateTime = 0; - Log.d(TAG, "Auto-floor stopped"); } - /** - * Evaluates the current floor using WiFi positioning (priority) or - * barometric elevation (fallback). Applies a 3-second debounce window - * to prevent jittery floor switching. - */ private void evaluateAutoFloor() { - if (sensorFusion == null || indoorMapManager == null) return; - if (!indoorMapManager.getIsIndoorMapSet()) return; - + if (sensorFusion == null || indoorMapManager == null || !indoorMapManager.getIsIndoorMapSet()) { + return; + } int candidateFloor; - - // Priority 1: WiFi-based floor (only if WiFi positioning has returned data) if (sensorFusion.getLatLngWifiPositioning() != null) { candidateFloor = sensorFusion.getWifiFloor(); } else { - // Fallback: barometric elevation estimate float elevation = sensorFusion.getElevation(); float floorHeight = indoorMapManager.getFloorHeight(); - if (floorHeight <= 0) return; + if (floorHeight <= 0) { + return; + } candidateFloor = Math.round(elevation / floorHeight); } - // Debounce: require the same floor reading for AUTO_FLOOR_DEBOUNCE_MS long now = SystemClock.elapsedRealtime(); if (candidateFloor != lastCandidateFloor) { lastCandidateFloor = candidateFloor; lastCandidateTime = now; return; } - if (now - lastCandidateTime >= AUTO_FLOOR_DEBOUNCE_MS) { - indoorMapManager.setCurrentFloor(candidateFloor, true); - updateFloorLabel(); - // Reset timer so we don't keep re-applying the same floor + setFloor(indoorMapManager.logicalFloorToIndex(candidateFloor)); lastCandidateTime = now; } } - - //endregion } diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorMapManager.java b/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorMapManager.java index f8058603..38ac1fb0 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorMapManager.java +++ b/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorMapManager.java @@ -15,187 +15,254 @@ import java.util.ArrayList; import java.util.List; -/** - * Manages indoor floor map display for all supported buildings - * (Nucleus, Library, Murchison). Uses vector shape data from the floorplan API - * to dynamically draw walls, rooms, and other indoor features on the Google Map. - * Provides unified floor indexing, floor switching, and building detection. - * - * @see BuildingPolygon Describes the bounds of buildings and the methods to check if a point is - * within a building - * @see FloorplanApiClient.FloorShapes Per-floor vector shape data - */ public class IndoorMapManager { private static final String TAG = "IndoorMapManager"; - /** Building identifiers for tracking which building the user is in. */ public static final int BUILDING_NONE = 0; public static final int BUILDING_NUCLEUS = 1; public static final int BUILDING_LIBRARY = 2; public static final int BUILDING_MURCHISON = 3; - private GoogleMap gMap; + private final GoogleMap gMap; private LatLng currentLocation; private boolean isIndoorMapSet = false; - private int currentFloor; + private int currentFloor = 0; private int currentBuilding = BUILDING_NONE; private float floorHeight; - // Vector shapes currently drawn on the map (cleared on floor switch or exit) private final List drawnPolygons = new ArrayList<>(); private final List drawnPolylines = new ArrayList<>(); - - // Per-floor vector shape data for the current building private List currentFloorShapes; + private boolean vectorBaseplateEnabled = true; - // Average floor heights per building (meters), used for barometric auto-floor public static final float NUCLEUS_FLOOR_HEIGHT = 4.2F; public static final float LIBRARY_FLOOR_HEIGHT = 3.6F; public static final float MURCHISON_FLOOR_HEIGHT = 4.0F; - // Colours for different indoor feature types - private static final int WALL_STROKE = Color.argb(200, 80, 80, 80); - private static final int ROOM_STROKE = Color.argb(180, 33, 150, 243); - private static final int ROOM_FILL = Color.argb(40, 33, 150, 243); - private static final int DEFAULT_STROKE = Color.argb(150, 100, 100, 100); - - /** - * Constructor to set the map instance. - * - * @param map the map on which the indoor floor map shapes are drawn - */ + private static final int WALL_STROKE = Color.argb(255, 34, 34, 34); + private static final int ROOM_STROKE = Color.argb(255, 60, 60, 60); + private static final int ROOM_FILL = Color.argb(40, 0, 0, 0); + private static final int DEFAULT_STROKE = Color.argb(255, 50, 50, 50); + public IndoorMapManager(GoogleMap map) { this.gMap = map; } - /** - * Updates the current location of the user and displays the indoor map - * if the user is in a building with indoor maps available. - * - * @param currentLocation new location of user - */ public void setCurrentLocation(LatLng currentLocation) { this.currentLocation = currentLocation; - setBuildingOverlay(); } - /** - * Returns the current building's floor height. - * - * @return the floor height of the current building the user is in - */ public float getFloorHeight() { return floorHeight; } - /** - * Returns whether an indoor floor map is currently being displayed. - * - * @return true if an indoor map is visible to the user, false otherwise - */ public boolean getIsIndoorMapSet() { return isIndoorMapSet; } - /** - * Returns the identifier of the building the user is currently in. - * - * @return one of {@link #BUILDING_NONE}, {@link #BUILDING_NUCLEUS}, - * {@link #BUILDING_LIBRARY}, or {@link #BUILDING_MURCHISON} - */ public int getCurrentBuilding() { return currentBuilding; } - /** - * Returns the current floor index being displayed. - * - * @return the current floor index in the active building's floor list - */ public int getCurrentFloor() { return currentFloor; } - /** - * Returns the display name for the current floor (e.g. "LG", "G", "1"). - * Falls back to the numeric index if no display name is available. - * - * @return human-readable floor label - */ public String getCurrentFloorDisplayName() { if (currentFloorShapes != null && currentFloor >= 0 && currentFloor < currentFloorShapes.size()) { - return currentFloorShapes.get(currentFloor).getDisplayName(); + String displayName = currentFloorShapes.get(currentFloor).getDisplayName(); + if (displayName == null || displayName.isEmpty()) { + return String.valueOf(currentFloor); + } + return formatFloorLabelForDisplay(displayName); } return String.valueOf(currentFloor); } - /** - * Returns the auto-floor bias for the current building. Buildings with a - * lower-ground floor at index 0 need a +1 bias so that WiFi/barometric - * floor 0 (ground) maps to the correct floor index. - * - * @return the floor index offset for auto-floor conversion - */ public int getAutoFloorBias() { switch (currentBuilding) { case BUILDING_NUCLEUS: case BUILDING_MURCHISON: - return 1; // LG at index 0, so G = index 1 + return 1; + case BUILDING_LIBRARY: + default: + return 0; + } + } + + public int logicalFloorToIndex(int logicalFloor) { + String targetFloorLabel; + if (logicalFloor <= -1) { + targetFloorLabel = "LG"; + } else if (logicalFloor == 0) { + targetFloorLabel = "G"; + } else { + targetFloorLabel = String.valueOf(logicalFloor); + } + + int matchingIndex = findFloorIndexByCanonicalLabel(targetFloorLabel); + if (matchingIndex >= 0) { + return matchingIndex; + } + + return clampFloorIndex(logicalFloor + getAutoFloorBias()); + } + + public int clampFloorIndex(int floorIndex) { + if (currentFloorShapes == null || currentFloorShapes.isEmpty()) { + return 0; + } + return Math.max(0, Math.min(floorIndex, currentFloorShapes.size() - 1)); + } + + public void setVectorBaseplateEnabled(boolean enabled) { + if (this.vectorBaseplateEnabled != enabled) { + this.vectorBaseplateEnabled = enabled; + if (isIndoorMapSet && currentFloorShapes != null && currentFloor >= 0 && currentFloor < currentFloorShapes.size()) { + drawFloorShapes(currentFloor); + } + } + } + + public void setSelectedBuilding(FloorplanApiClient.BuildingInfo building) { + clearDrawnShapes(); + + if (building == null) { + clearIndoorMap(); + return; + } + + currentBuilding = resolveBuildingType(building.getName()); + currentFloorShapes = building.getFloorShapesList(); + currentFloor = -1; + isIndoorMapSet = currentFloorShapes != null && !currentFloorShapes.isEmpty(); + + switch (currentBuilding) { + case BUILDING_NUCLEUS: + floorHeight = NUCLEUS_FLOOR_HEIGHT; + break; case BUILDING_LIBRARY: + floorHeight = LIBRARY_FLOOR_HEIGHT; + break; + case BUILDING_MURCHISON: + floorHeight = MURCHISON_FLOOR_HEIGHT; + break; default: - return 0; // G at index 0 + floorHeight = 0f; + break; } } - /** - * Sets the floor to display. When called from auto-floor, the floor number - * is a logical floor (0=G, -1=LG, 1=Floor 1, etc.) and the building bias - * is applied. When called manually, the floor number is the direct index. - * - * @param newFloor the floor the user is at - * @param autoFloor true if called by auto-floor feature - */ + public void clearIndoorMap() { + clearDrawnShapes(); + isIndoorMapSet = false; + currentBuilding = BUILDING_NONE; + currentFloor = 0; + currentFloorShapes = null; + floorHeight = 0f; + } + public void setCurrentFloor(int newFloor, boolean autoFloor) { - if (currentFloorShapes == null || currentFloorShapes.isEmpty()) return; + if (currentFloorShapes == null || currentFloorShapes.isEmpty()) { + return; + } if (autoFloor) { - newFloor += getAutoFloorBias(); + newFloor = logicalFloorToIndex(newFloor); + } else { + newFloor = clampFloorIndex(newFloor); } - if (newFloor >= 0 && newFloor < currentFloorShapes.size() - && newFloor != this.currentFloor) { + if (newFloor != this.currentFloor || (drawnPolygons.isEmpty() && drawnPolylines.isEmpty())) { this.currentFloor = newFloor; drawFloorShapes(newFloor); } } - /** - * Increments the current floor and changes to a higher floor's map - * (if a higher floor exists). - */ public void increaseFloor() { - this.setCurrentFloor(currentFloor + 1, false); + setCurrentFloor(currentFloor + 1, false); } - /** - * Decrements the current floor and changes to the lower floor's map - * (if a lower floor exists). - */ public void decreaseFloor() { - this.setCurrentFloor(currentFloor - 1, false); + setCurrentFloor(currentFloor - 1, false); + } + + + private int findFloorIndexByCanonicalLabel(String targetFloorLabel) { + if (currentFloorShapes == null || currentFloorShapes.isEmpty()) { + return -1; + } + + String canonicalTarget = canonicalFloorLabel(targetFloorLabel); + for (int i = 0; i < currentFloorShapes.size(); i++) { + String candidateDisplayName = currentFloorShapes.get(i).getDisplayName(); + if (canonicalTarget.equals(canonicalFloorLabel(candidateDisplayName))) { + return i; + } + } + return -1; + } + + private String formatFloorLabelForDisplay(String rawFloorLabel) { + String canonicalFloor = canonicalFloorLabel(rawFloorLabel); + switch (canonicalFloor) { + case "LG": + return "LG"; + case "G": + return "G"; + case "1": + return "F1"; + case "2": + return "F2"; + case "3": + return "F3"; + default: + return rawFloorLabel == null ? "" : rawFloorLabel; + } + } + + private String canonicalFloorLabel(String rawFloorLabel) { + if (rawFloorLabel == null) { + return ""; + } + + String normalized = rawFloorLabel.trim().toUpperCase().replace(" ", ""); + switch (normalized) { + case "LG": + case "LOWERGROUND": + case "LOWERG": + case "B1": + case "BASEMENT1": + return "LG"; + case "G": + case "GF": + case "GROUND": + case "GROUNDFLOOR": + case "0": + return "G"; + case "1": + case "F1": + case "FIRST": + case "FIRSTFLOOR": + return "1"; + case "2": + case "F2": + case "SECOND": + case "SECONDFLOOR": + return "2"; + case "3": + case "F3": + case "THIRD": + case "THIRDFLOOR": + return "3"; + default: + return normalized; + } } - /** - * Sets the map overlay for the building if the user's current location is - * inside a building and the overlay is not already set. Removes the overlay - * if the user leaves all buildings. - * - *

Detection priority: floorplan API real polygon outlines first, - * then legacy hard-coded rectangular boundaries as fallback.

- */ private void setBuildingOverlay() { try { int detected = detectCurrentBuilding(); @@ -225,7 +292,6 @@ private void setBuildingOverlay() { return; } - // Load floor shapes from cached API data FloorplanApiClient.BuildingInfo building = SensorFusion.getInstance().getFloorplanBuilding(apiName); if (building != null) { @@ -238,28 +304,19 @@ private void setBuildingOverlay() { } } else if (!inAnyBuilding && isIndoorMapSet) { - clearDrawnShapes(); - isIndoorMapSet = false; - currentBuilding = BUILDING_NONE; - currentFloor = 0; - currentFloorShapes = null; + clearIndoorMap(); } } catch (Exception ex) { - Log.e(TAG, "Error with overlay: " + ex.toString()); + Log.e(TAG, "Error with overlay: " + ex); } } - /** - * Draws all vector shapes for the given floor index on the Google Map. - * Clears any previously drawn shapes before drawing the new floor. - * - * @param floorIndex the floor index (0-based, matching FloorShapes list order) - */ private void drawFloorShapes(int floorIndex) { clearDrawnShapes(); - if (currentFloorShapes == null || floorIndex < 0 - || floorIndex >= currentFloorShapes.size()) return; + if (currentFloorShapes == null || floorIndex < 0 || floorIndex >= currentFloorShapes.size()) { + return; + } FloorplanApiClient.FloorShapes floor = currentFloorShapes.get(floorIndex); for (FloorplanApiClient.MapShapeFeature feature : floor.getFeatures()) { @@ -268,133 +325,155 @@ private void drawFloorShapes(int floorIndex) { if ("MultiPolygon".equals(geoType) || "Polygon".equals(geoType)) { for (List ring : feature.getParts()) { - if (ring.size() < 3) continue; + if (ring.size() < 3) { + continue; + } + if (vectorBaseplateEnabled) { + Polygon underlay = gMap.addPolygon(new PolygonOptions() + .addAll(ring) + .strokeColor(Color.argb(145, 255, 255, 255)) + .strokeWidth(9f) + .fillColor(Color.argb(125, 255, 255, 255)) + .zIndex(6f)); + drawnPolygons.add(underlay); + } Polygon p = gMap.addPolygon(new PolygonOptions() .addAll(ring) .strokeColor(getStrokeColor(indoorType)) .strokeWidth(5f) - .fillColor(getFillColor(indoorType))); + .fillColor(getFillColor(indoorType)) + .zIndex(10f)); drawnPolygons.add(p); } - } else if ("MultiLineString".equals(geoType) - || "LineString".equals(geoType)) { + } else if ("MultiLineString".equals(geoType) || "LineString".equals(geoType)) { for (List line : feature.getParts()) { - if (line.size() < 2) continue; + if (line.size() < 2) { + continue; + } + if (vectorBaseplateEnabled) { + Polyline underlay = gMap.addPolyline(new PolylineOptions() + .addAll(line) + .color(Color.argb(170, 255, 255, 255)) + .width(10f) + .zIndex(6f)); + drawnPolylines.add(underlay); + } Polyline pl = gMap.addPolyline(new PolylineOptions() .addAll(line) .color(getStrokeColor(indoorType)) - .width(6f)); + .width(6f) + .zIndex(10f)); drawnPolylines.add(pl); } } } } - /** - * Removes all vector shapes currently drawn on the map. - */ private void clearDrawnShapes() { - for (Polygon p : drawnPolygons) p.remove(); - for (Polyline p : drawnPolylines) p.remove(); + for (Polygon p : drawnPolygons) { + p.remove(); + } + for (Polyline p : drawnPolylines) { + p.remove(); + } drawnPolygons.clear(); drawnPolylines.clear(); } - /** - * Returns the stroke colour for a given indoor feature type. - * - * @param indoorType the indoor_type property value - * @return ARGB colour value - */ private int getStrokeColor(String indoorType) { - if ("wall".equals(indoorType)) return WALL_STROKE; - if ("room".equals(indoorType)) return ROOM_STROKE; + if ("wall".equals(indoorType)) { + return WALL_STROKE; + } + if ("room".equals(indoorType)) { + return ROOM_STROKE; + } return DEFAULT_STROKE; } - /** - * Returns the fill colour for a given indoor feature type. - * - * @param indoorType the indoor_type property value - * @return ARGB colour value - */ private int getFillColor(String indoorType) { - if ("room".equals(indoorType)) return ROOM_FILL; + if ("room".equals(indoorType)) { + return ROOM_FILL; + } return Color.TRANSPARENT; } - /** - * Detects which building the user is currently in. - * Checks floorplan API outline polygons first; falls back to legacy - * hard-coded rectangular boundaries if no API match is found. - * - * @return building type constant, or {@link #BUILDING_NONE} - */ private int detectCurrentBuilding() { - // Phase 1: API real polygon outlines - List apiBuildings = - SensorFusion.getInstance().getFloorplanBuildings(); - for (FloorplanApiClient.BuildingInfo building : apiBuildings) { - List outline = building.getOutlinePolygon(); - if (outline != null && outline.size() >= 3 - && BuildingPolygon.pointInPolygon(currentLocation, outline)) { - int type = resolveBuildingType(building.getName()); - if (type != BUILDING_NONE) return type; + List apiBuildings = SensorFusion.getInstance().getFloorplanBuildings(); + + if (apiBuildings != null) { + for (FloorplanApiClient.BuildingInfo building : apiBuildings) { + List outline = building.getOutlinePolygon(); + if (outline != null && outline.size() >= 3 + && BuildingPolygon.pointInPolygon(currentLocation, outline)) { + int type = resolveBuildingType(building.getName()); + if (type != BUILDING_NONE) { + return type; + } + } } } - // Phase 2: legacy hard-coded fallback - if (BuildingPolygon.inNucleus(currentLocation)) return BUILDING_NUCLEUS; - if (BuildingPolygon.inLibrary(currentLocation)) return BUILDING_LIBRARY; - if (BuildingPolygon.inMurchison(currentLocation)) return BUILDING_MURCHISON; + if (BuildingPolygon.inNucleus(currentLocation)) { + return BUILDING_NUCLEUS; + } + if (BuildingPolygon.inLibrary(currentLocation)) { + return BUILDING_LIBRARY; + } + if (BuildingPolygon.inMurchison(currentLocation)) { + return BUILDING_MURCHISON; + } return BUILDING_NONE; } - /** - * Maps a floorplan API building name to a building type constant. - * - * @param apiName building name from API (e.g. "nucleus_building") - * @return building type constant, or {@link #BUILDING_NONE} if unrecognised - */ private int resolveBuildingType(String apiName) { - if (apiName == null) return BUILDING_NONE; + if (apiName == null) { + return BUILDING_NONE; + } switch (apiName) { - case "nucleus_building": return BUILDING_NUCLEUS; - case "murchison_house": return BUILDING_MURCHISON; - case "library": return BUILDING_LIBRARY; - default: return BUILDING_NONE; + case "nucleus_building": + return BUILDING_NUCLEUS; + case "murchison_house": + return BUILDING_MURCHISON; + case "library": + return BUILDING_LIBRARY; + default: + return BUILDING_NONE; } } - /** - * Draws green polyline indicators around all buildings with available - * indoor floor maps. Uses floorplan API outlines when available, - * falls back to legacy hard-coded polygons otherwise. - */ public void setIndicationOfIndoorMap() { - List apiBuildings = - SensorFusion.getInstance().getFloorplanBuildings(); + List apiBuildings = SensorFusion.getInstance().getFloorplanBuildings(); - boolean nucleusDrawn = false, libraryDrawn = false, murchisonDrawn = false; + boolean nucleusDrawn = false; + boolean libraryDrawn = false; + boolean murchisonDrawn = false; - // Phase 1: draw API outlines - for (FloorplanApiClient.BuildingInfo building : apiBuildings) { - List outline = building.getOutlinePolygon(); - if (outline == null || outline.size() < 3) continue; + if (apiBuildings != null) { + for (FloorplanApiClient.BuildingInfo building : apiBuildings) { + List outline = building.getOutlinePolygon(); + if (outline == null || outline.size() < 3) { + continue; + } - List closed = new ArrayList<>(outline); - closed.add(closed.get(0)); - gMap.addPolyline(new PolylineOptions().color(Color.GREEN).addAll(closed)); + List closed = new ArrayList<>(outline); + closed.add(closed.get(0)); + gMap.addPolyline(new PolylineOptions().color(Color.GREEN).addAll(closed)); - switch (building.getName()) { - case "nucleus_building": nucleusDrawn = true; break; - case "library": libraryDrawn = true; break; - case "murchison_house": murchisonDrawn = true; break; + switch (building.getName()) { + case "nucleus_building": + nucleusDrawn = true; + break; + case "library": + libraryDrawn = true; + break; + case "murchison_house": + murchisonDrawn = true; + break; + } } } - // Phase 2: fallback for buildings not covered by API if (!nucleusDrawn) { List pts = new ArrayList<>(BuildingPolygon.NUCLEUS_POLYGON); pts.add(pts.get(0)); diff --git a/app/src/main/res/layout/fragment_startlocation.xml b/app/src/main/res/layout/fragment_startlocation.xml index fa9b0931..343a13df 100644 --- a/app/src/main/res/layout/fragment_startlocation.xml +++ b/app/src/main/res/layout/fragment_startlocation.xml @@ -20,6 +20,33 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + +