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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docker/postgres/init/01-postgis-init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ BEGIN
CREATE INDEX IF NOT EXISTS idx_lockers_location_geog
ON public.lockers
USING GIST (location);

CREATE INDEX IF NOT EXISTS idx_lockers_latitude_longitude
ON public.lockers (latitude, longitude);
END;
$$;

Expand Down
3 changes: 3 additions & 0 deletions scripts/db/01-postgis.sql
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ BEGIN
CREATE INDEX IF NOT EXISTS idx_lockers_location_geog
ON public.lockers
USING GIST (location);

CREATE INDEX IF NOT EXISTS idx_lockers_latitude_longitude
ON public.lockers (latitude, longitude);
END;
$$;;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,12 @@ public static void validate(double latitude, double longitude) {
throw new BusinessException(ErrorCode.INVALID_LOCATION_RANGE);
}
}

public static void validateBounds(double swLat, double swLng, double neLat, double neLng) {
validate(swLat, swLng);
validate(neLat, neLng);
if (swLat > neLat || swLng > neLng) {
throw new BusinessException(ErrorCode.INVALID_LOCATION_RANGE);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.zimdugo.locker.application.pin;

import com.zimdugo.locker.application.result.GeoBoundsUtils;
import com.zimdugo.locker.application.result.LockerBoundsResult;
import com.zimdugo.locker.application.result.pin.LockerPinItemResult;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;

@Component
public class LockerPinClusterer {

private static final double CLUSTER_ZOOM_THRESHOLD = 15.0;
private static final int WEB_MERCATOR_TILE_SIZE_PX = 256;
private static final int CLUSTER_CELL_SIZE_PX = 128;
private static final double EARTH_RADIUS_METERS = 6_378_137.0;
private static final double EARTH_CIRCUMFERENCE_METERS = 2 * Math.PI * EARTH_RADIUS_METERS;
private static final double WEB_MERCATOR_LATITUDE_OFFSET_RADIANS = Math.PI / 4.0;

public List<LockerPinItemResult> cluster(List<LockerPinItemResult> pins, double zoomLevel) {
if (pins.isEmpty() || zoomLevel >= CLUSTER_ZOOM_THRESHOLD) {
return pins;
}

int cellSizeMeters = cellSizeMetersFor(zoomLevel);
Map<CellKey, List<LockerPinItemResult>> pinsByCell = groupByCell(pins, cellSizeMeters);
List<LockerPinItemResult> clusters = new ArrayList<>(pinsByCell.size());
for (List<LockerPinItemResult> cellPins : pinsByCell.values()) {
if (cellPins.size() == 1) {
clusters.add(cellPins.getFirst());
continue;
}
clusters.add(toCluster(cellPins));
}
return clusters;
}

int cellSizeMetersFor(double zoomLevel) {
double scale = Math.pow(2, Math.floor(zoomLevel));
double metersPerPixel = EARTH_CIRCUMFERENCE_METERS / (WEB_MERCATOR_TILE_SIZE_PX * scale);
return (int) Math.round(CLUSTER_CELL_SIZE_PX * metersPerPixel);
}

private Map<CellKey, List<LockerPinItemResult>> groupByCell(List<LockerPinItemResult> pins, int cellSizeMeters) {
Map<CellKey, List<LockerPinItemResult>> pinsByCell = new LinkedHashMap<>();
for (LockerPinItemResult pin : pins) {
CellKey key = toCellKey(pin.latitude(), pin.longitude(), cellSizeMeters);
pinsByCell.computeIfAbsent(key, ignored -> new ArrayList<>()).add(pin);
}
return pinsByCell;
}

private CellKey toCellKey(double latitude, double longitude, int cellSizeMeters) {
return new CellKey(
(long) Math.floor(longitudeToX(longitude) / cellSizeMeters),
(long) Math.floor(latitudeToY(latitude) / cellSizeMeters)
);
}

private double longitudeToX(double longitude) {
return Math.toRadians(longitude) * EARTH_RADIUS_METERS;
}

private double latitudeToY(double latitude) {
return Math.log(Math.tan(
WEB_MERCATOR_LATITUDE_OFFSET_RADIANS + Math.toRadians(latitude) / 2
)) * EARTH_RADIUS_METERS;
}

private LockerPinItemResult toCluster(List<LockerPinItemResult> pins) {
double latitudeSum = 0d;
double longitudeSum = 0d;
for (LockerPinItemResult pin : pins) {
latitudeSum += pin.latitude();
longitudeSum += pin.longitude();
}

LockerBoundsResult bounds = GeoBoundsUtils.from(
pins,
LockerPinItemResult::latitude,
LockerPinItemResult::longitude
).orElseThrow();

return LockerPinItemResult.cluster(
latitudeSum / pins.size(),
longitudeSum / pins.size(),
pins.size(),
bounds
);
}

private record CellKey(long x, long y) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.zimdugo.locker.application.pin;

public record LockerPinQuery(
double swLat,
double swLng,
double neLat,
double neLng,
double zoomLevel
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,25 @@ public class LockerPinQueryService {

private final NearbyLockerPlaceReader nearbyLockerPlaceReader;
private final LockerPinAssembler lockerPinAssembler;
private final LockerPinClusterer lockerPinClusterer;
private final FavoriteLockerReader favoriteLockerReader;

public LockerPinResult getPins(Long userId, double latitude, double longitude, int radiusMeters) {
LocationValidator.validate(latitude, longitude);
public LockerPinResult getPins(Long userId, LockerPinQuery query) {
LocationValidator.validateBounds(query.swLat(), query.swLng(), query.neLat(), query.neLng());

List<NearbyLocker> nearbyLockers = nearbyLockerPlaceReader.findNearby(latitude, longitude, radiusMeters);
List<NearbyLocker> nearbyLockers = nearbyLockerPlaceReader.findWithinBounds(
query.swLat(),
query.swLng(),
query.neLat(),
query.neLng()
);
if (nearbyLockers.isEmpty()) {
return LockerPinResult.empty();
}

Set<Long> favoriteLockerIds = resolveFavoriteLockerIds(userId, nearbyLockers);
List<LockerPinItemResult> pins = lockerPinAssembler.assemble(nearbyLockers, favoriteLockerIds);
return LockerPinResult.of(pins);
return LockerPinResult.of(lockerPinClusterer.cluster(pins, query.zoomLevel()));
}

private Set<Long> resolveFavoriteLockerIds(Long userId, List<NearbyLocker> lockers) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,62 @@
package com.zimdugo.locker.application.result.pin;

import com.zimdugo.locker.application.result.LockerBoundsResult;

public record LockerPinItemResult(
LockerPinType pinType,
Long placeId,
Long lockerId,
double latitude,
double longitude,
Boolean isFavorite,
Integer lockerCount
Integer lockerCount,
Integer pinCount,
LockerBoundsResult bounds
) {
public static LockerPinItemResult place(Long placeId, double latitude, double longitude, int count) {
return new LockerPinItemResult(LockerPinType.PLACE, placeId, null, latitude, longitude, null, count);
return new LockerPinItemResult(
LockerPinType.PLACE,
placeId,
null,
latitude,
longitude,
null,
count,
null,
null
);
}

public static LockerPinItemResult locker(Long lockerId, double latitude, double longitude, boolean isFavorite) {
return new LockerPinItemResult(LockerPinType.LOCKER, null, lockerId, latitude, longitude, isFavorite, null);
return new LockerPinItemResult(
LockerPinType.LOCKER,
null,
lockerId,
latitude,
longitude,
isFavorite,
null,
null,
null
);
}

public static LockerPinItemResult cluster(
double latitude,
double longitude,
int pinCount,
LockerBoundsResult bounds
) {
return new LockerPinItemResult(
LockerPinType.CLUSTER,
null,
null,
latitude,
longitude,
null,
null,
pinCount,
bounds
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

public enum LockerPinType {
PLACE,
LOCKER
LOCKER,
CLUSTER
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
import java.util.List;

public interface NearbyLockerPlaceReader {
List<NearbyLocker> findNearby(double latitude, double longitude, int radiusMeters);
List<NearbyLocker> findWithinBounds(double swLat, double swLng, double neLat, double neLng);
}
21 changes: 3 additions & 18 deletions src/main/java/com/zimdugo/locker/entrypoint/LockerApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.zimdugo.core.response.RestResponse;
import com.zimdugo.locker.entrypoint.dto.request.keyword.LockerKeywordRequest;
import com.zimdugo.locker.entrypoint.dto.request.place.PlaceLockerRequest;
import com.zimdugo.locker.entrypoint.dto.request.pin.LockerPinRequest;
import com.zimdugo.locker.entrypoint.dto.response.detail.LockerDetailResponse;
import com.zimdugo.locker.entrypoint.dto.response.keyword.LockerKeywordResponse;
import com.zimdugo.locker.entrypoint.dto.response.pin.LockerPinResponse;
Expand All @@ -20,8 +21,6 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
Expand Down Expand Up @@ -52,7 +51,7 @@ ResponseEntity<RestResponse<LockerDetailResponse>> getLockerDetail(

@Operation(
summary = "지도 핀 조회",
description = "현재 좌표 기준 반경 내 핀 목록을 반환한다. 같은 장소의 보관함이 1개면 LOCKER, 2개 이상이면 PLACE 핀으로 반환한다."
description = "현재 지도 화면 영역 내 핀 목록을 반환한다. 줌 레벨 15 이상은 LOCKER/PLACE, 14 이하는 격자 클러스터링을 적용한다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공"),
Expand All @@ -62,21 +61,7 @@ ResponseEntity<RestResponse<LockerDetailResponse>> getLockerDetail(
@GetMapping("/lockers/pin")
ResponseEntity<RestResponse<LockerPinResponse>> getPins(
@NullableCurrentUser Long userId,
@RequestParam("lat")
@Parameter(description = "사용자 위도", example = "37.498095")
@Schema(minimum = "-90", maximum = "90")
@DecimalMin(value = "-90.0")
@DecimalMax(value = "90.0") double latitude,
@RequestParam("lng")
@Parameter(description = "사용자 경도", example = "127.027610")
@Schema(minimum = "-180", maximum = "180")
@DecimalMin(value = "-180.0")
@DecimalMax(value = "180.0") double longitude,
@RequestParam(name = "radius", defaultValue = "500")
@Parameter(description = "조회 반경(m)", example = "500")
@Schema(minimum = "1", maximum = "7000")
@Min(1)
@Max(7000) int radiusMeters
@ParameterObject @Valid LockerPinRequest request
);

@Operation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.zimdugo.locker.application.result.seo.LockerSeoResult;
import com.zimdugo.locker.entrypoint.dto.request.keyword.LockerKeywordRequest;
import com.zimdugo.locker.entrypoint.dto.request.place.PlaceLockerRequest;
import com.zimdugo.locker.entrypoint.dto.request.pin.LockerPinRequest;
import com.zimdugo.locker.entrypoint.dto.response.detail.LockerDetailResponse;
import com.zimdugo.locker.entrypoint.dto.response.keyword.LockerKeywordResponse;
import com.zimdugo.locker.entrypoint.dto.response.pin.LockerPinResponse;
Expand Down Expand Up @@ -55,11 +56,9 @@ public ResponseEntity<RestResponse<LockerDetailResponse>> getLockerDetail(
@Override
public ResponseEntity<RestResponse<LockerPinResponse>> getPins(
@NullableCurrentUser Long userId,
double latitude,
double longitude,
int radiusMeters
LockerPinRequest request
) {
LockerPinResult result = lockerPinQueryService.getPins(userId, latitude, longitude, radiusMeters);
LockerPinResult result = lockerPinQueryService.getPins(userId, request.toQuery());
return ResponseEntity.ok(RestResponse.of(SuccessCode.OK, LockerPinResponse.from(result)));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.zimdugo.locker.entrypoint.dto.request.pin;

import com.zimdugo.locker.application.pin.LockerPinQuery;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;

public record LockerPinRequest(
@Parameter(description = "지도 남서쪽 위도", example = "37.490000")
@Schema(minimum = "-90", maximum = "90")
@DecimalMin(value = "-90.0")
@DecimalMax(value = "90.0")
double swLat,

@Parameter(description = "지도 남서쪽 경도", example = "127.020000")
@Schema(minimum = "-180", maximum = "180")
@DecimalMin(value = "-180.0")
@DecimalMax(value = "180.0")
double swLng,

@Parameter(description = "지도 북동쪽 위도", example = "37.510000")
@Schema(minimum = "-90", maximum = "90")
@DecimalMin(value = "-90.0")
@DecimalMax(value = "90.0")
double neLat,

@Parameter(description = "지도 북동쪽 경도", example = "127.040000")
@Schema(minimum = "-180", maximum = "180")
@DecimalMin(value = "-180.0")
@DecimalMax(value = "180.0")
double neLng,

@Parameter(description = "네이버 지도 줌 레벨. 15 이상은 상세 핀, 14 이하는 클러스터 적용", example = "14")
@Schema(minimum = "0", maximum = "21")
@DecimalMin(value = "0.0")
@DecimalMax(value = "21.0")
double zoom
) {
public LockerPinQuery toQuery() {
return new LockerPinQuery(swLat, swLng, neLat, neLng, zoom);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.zimdugo.locker.entrypoint.dto.response.pin;

import com.zimdugo.locker.application.result.pin.LockerPinItemResult;
import com.zimdugo.locker.entrypoint.dto.response.LockerBoundsResponse;

public record LockerPinItemResponse(
LockerPinTypeResponse pinType,
Expand All @@ -9,7 +10,9 @@ public record LockerPinItemResponse(
double latitude,
double longitude,
Boolean isFavorite,
Integer lockerCount
Integer lockerCount,
Integer pinCount,
LockerBoundsResponse bounds
) {
public static LockerPinItemResponse from(LockerPinItemResult item) {
return new LockerPinItemResponse(
Expand All @@ -19,7 +22,9 @@ public static LockerPinItemResponse from(LockerPinItemResult item) {
item.latitude(),
item.longitude(),
item.isFavorite(),
item.lockerCount()
item.lockerCount(),
item.pinCount(),
LockerBoundsResponse.from(item.bounds())
);
}
}
Loading
Loading