Skip to content

Commit

Permalink
Merge pull request #76 from TimPushkin/clustering
Browse files Browse the repository at this point in the history
Marker clustering
  • Loading branch information
TimPushkin authored Oct 31, 2023
2 parents a3d2494 + aee2403 commit 5ddcc10
Show file tree
Hide file tree
Showing 16 changed files with 403 additions and 108 deletions.
44 changes: 44 additions & 0 deletions app/src/main/java/ru/spbu/depnav/data/composite/MarkerWithText.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* DepNav -- department navigator.
* Copyright (C) 2022 Timofei Pushkin
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

package ru.spbu.depnav.data.composite

import ru.spbu.depnav.data.model.Marker
import ru.spbu.depnav.data.model.MarkerText

/** [Marker] with its corresponding [MarkerText]. */
data class MarkerWithText(val marker: Marker, val text: MarkerText) {
init {
require(marker.id == text.markerId) {
"Marker ID ${marker.id} != marker text's marker ID ${text.markerId}"
}
}

/** ID of this marker as a string. It is parsed by the marker clustering code. */
val extendedId by lazy {
if (marker.type == Marker.MarkerType.ROOM) {
"${marker.id}$ID_DIVIDER${text.title}"
} else {
"${marker.id}"
}
}

companion object {
val ID_DIVIDER = ':'
}
}
5 changes: 0 additions & 5 deletions app/src/main/java/ru/spbu/depnav/data/model/Marker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ package ru.spbu.depnav.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Ignore
import androidx.room.PrimaryKey

/** Displayable marker. */
Expand Down Expand Up @@ -52,10 +51,6 @@ data class Marker(
/** Y coordinate of this marker. */
val y: Double
) {
/** ID of this marker as a string. */
@Ignore
val idStr = id.toString()

/** Type of an object represented by a [Marker]. */
enum class MarkerType {
/** Building entrance. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
package ru.spbu.depnav.data.repository

import android.util.Log
import ru.spbu.depnav.data.composite.MarkerWithText
import ru.spbu.depnav.data.db.MarkerWithTextDao
import ru.spbu.depnav.data.model.Marker
import ru.spbu.depnav.data.model.MarkerText
Expand All @@ -42,24 +43,24 @@ class MarkerWithTextRepo(
constructor(dao: MarkerWithTextDao) : this(dao, Bm25())

/** Loads a [Marker] by its ID and its corresponding [MarkerText] on the current language. */
suspend fun loadById(id: Int): Pair<Marker, MarkerText> {
suspend fun loadById(id: Int): MarkerWithText {
val language = MarkerText.LanguageId.getCurrent()
val (marker, markerTexts) = dao.loadById(id, language).entries.firstOrNull()
?: throw IllegalArgumentException("No markers with ID $id")
val (marker, markerTexts) = checkNotNull(dao.loadById(id, language).entries.firstOrNull()) {
"No markers with ID $id"
}
val markerText = markerTexts.squeezedFor(marker, language)
return marker to markerText
return MarkerWithText(marker, markerText)
}

/**
* Loads all [Markers][Marker] from the specified map and floor with their corresponding
* [MarkerTexts][MarkerText] on the current language.
* Loads [Marker]s from the specified map and floor with their corresponding [MarkerText]s on
* the current language.
*/
suspend fun loadByFloor(mapName: String, floor: Int): Map<Marker, MarkerText> {
suspend fun loadByFloor(mapName: String, floor: Int): List<MarkerWithText> {
val language = MarkerText.LanguageId.getCurrent()
val markersWithTexts = dao.loadByFloor(mapName, floor, language)
return markersWithTexts.entries.associate { (marker, markerTexts) ->
val markerText = markerTexts.squeezedFor(marker, language)
marker to markerText
return markersWithTexts.entries.map { (marker, texts) ->
MarkerWithText(marker, text = texts.squeezedFor(marker, language))
}
}

Expand All @@ -70,11 +71,11 @@ class MarkerWithTextRepo(
}

/**
* Loads [Markers][Marker] from the specified map with their corresponding
* [MarkerTexts][MarkerText] on the current language so that the text satisfies the specified
* query. The results are sorted first by relevance, then alphabetically.
* Loads [Marker]s from the specified map with their corresponding [MarkerText]s on the current
* language so that the text satisfies the specified query. The results are sorted first by
* relevance, then alphabetically.
*/
suspend fun loadByQuery(mapName: String, query: String): Map<Marker, MarkerText> {
suspend fun loadByQuery(mapName: String, query: String): List<MarkerWithText> {
val language = MarkerText.LanguageId.getCurrent()
val tokenized = query.tokenized()

Expand All @@ -97,10 +98,9 @@ class MarkerWithTextRepo(
.thenByDescending { it.first.title }
.thenByDescending { it.first.description }
)
.associate { (markerText, markers, _) ->
val marker = markers.firstOrNull()
checkNotNull(marker) { "$markerText has no associated marker" }
marker to markerText
.map { (markerText, markers, _) ->
val marker = checkNotNull(markers.firstOrNull()) { "No marker for $markerText" }
MarkerWithText(marker, markerText)
}
}

Expand Down
5 changes: 2 additions & 3 deletions app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import ru.spbu.depnav.R
import ru.spbu.depnav.data.model.Marker
import ru.spbu.depnav.data.model.MarkerText
import ru.spbu.depnav.data.composite.MarkerWithText
import ru.spbu.depnav.ui.theme.DEFAULT_PADDING

// These are basically copied from SearchBar implementation
Expand Down Expand Up @@ -87,7 +86,7 @@ fun MapSearchBar(
onQueryChange: (String) -> Unit,
active: Boolean,
onActiveChange: (Boolean) -> Unit,
results: Map<Marker, MarkerText>,
results: List<MarkerWithText>,
onResultClick: (Int) -> Unit,
onInfoClick: () -> Unit,
onSettingsClick: () -> Unit,
Expand Down
144 changes: 144 additions & 0 deletions app/src/main/java/ru/spbu/depnav/ui/component/MarkerCluster.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* DepNav -- department navigator.
* Copyright (C) 2022 Timofei Pushkin
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

package ru.spbu.depnav.ui.component

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.coerceAtMost
import androidx.compose.ui.unit.dp
import ru.spbu.depnav.R
import ru.spbu.depnav.data.composite.MarkerWithText
import ru.spbu.depnav.data.model.Marker
import ru.spbu.depnav.ui.theme.DEFAULT_PADDING

/** Maximum size a marker cluster composable can have in each dimension. */
val MAX_MARKER_CLUSTER_VIEW_SIZE = 50.dp

/** Multiple markers clustered together. */
@Composable
fun MarkersCluster(
markerIds: List<String>,
type: Marker.MarkerType,
modifier: Modifier = Modifier
) = when (type) {
Marker.MarkerType.ROOM -> RoomMarkersCluster(markerIds, modifier)
Marker.MarkerType.ENTRANCE ->
NonRoomMarkersCluster(painterResource(R.drawable.mrk_entrance), modifier)
Marker.MarkerType.STAIRS_UP, Marker.MarkerType.STAIRS_DOWN, Marker.MarkerType.STAIRS_BOTH ->
NonRoomMarkersCluster(painterResource(R.drawable.mrk_stairs), modifier)
Marker.MarkerType.ELEVATOR ->
NonRoomMarkersCluster(painterResource(R.drawable.mrk_elevator), modifier)
Marker.MarkerType.WC_MAN, Marker.MarkerType.WC_WOMAN, Marker.MarkerType.WC ->
NonRoomMarkersCluster(painterResource(R.drawable.mrk_wc), modifier)
Marker.MarkerType.OTHER ->
NonRoomMarkersCluster(painterResource(R.drawable.mrk_other), modifier)
}

@Composable
private fun RoomMarkersCluster(markerIds: List<String>, modifier: Modifier = Modifier) {
val title = rememberSaveable {
val commonPrefix = markerIds.fold(markerTitleFromExtendedId(markerIds.first())) { acc, s ->
val title = markerTitleFromExtendedId(s)
acc.commonPrefixWith(title)
}
"$commonPrefix"
}

Surface(
modifier = Modifier
.sizeIn(
maxWidth = MAX_MARKER_CLUSTER_VIEW_SIZE,
maxHeight = MAX_MARKER_CLUSTER_VIEW_SIZE
)
.then(modifier),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.onBackground
) {
Box(contentAlignment = Alignment.Center) {
Text(
title,
modifier = Modifier.padding(DEFAULT_PADDING / 4),
overflow = TextOverflow.Ellipsis
)
}
}
}

private fun markerTitleFromExtendedId(extendedId: String) =
extendedId.substringAfter(MarkerWithText.ID_DIVIDER, "")

@Composable
private fun NonRoomMarkersCluster(painter: Painter, modifier: Modifier = Modifier) {
Surface(
modifier = Modifier
.sizeIn(
maxWidth = MARKER_ICON_SIZE.coerceAtMost(MAX_MARKER_CLUSTER_VIEW_SIZE),
maxHeight = MARKER_ICON_SIZE.coerceAtMost(MAX_MARKER_CLUSTER_VIEW_SIZE)
)
.then(modifier),
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.onBackground
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painter,
contentDescription = stringResource(R.string.label_non_room_cluster),
modifier = Modifier
.size(MARKER_ICON_SIZE) // Will be shrunk by the surface because of the padding
.padding(DEFAULT_PADDING / 4)
)
}
}
}

@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun RoomMarkersClusterPreview() {
MarkersCluster(
markerIds = listOf(
"1${MarkerWithText.ID_DIVIDER}1234",
"2${MarkerWithText.ID_DIVIDER}1235",
"3${MarkerWithText.ID_DIVIDER}1236"
),
type = Marker.MarkerType.ROOM
)
}

@Preview
@Composable
@Suppress("UnusedPrivateMember")
private fun NonRoomMarkersClusterPreview() {
MarkersCluster(markerIds = listOf("1", "2"), type = Marker.MarkerType.WC)
}
9 changes: 5 additions & 4 deletions app/src/main/java/ru/spbu/depnav/ui/component/MarkerView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ import ru.spbu.depnav.data.model.Marker
import ru.spbu.depnav.data.model.Marker.MarkerType
import ru.spbu.depnav.ui.theme.DepNavTheme

private val ICON_SIZE = 20.dp
/** Size of marker icon in each dimension. */
val MARKER_ICON_SIZE = 20.dp

/** Visual representation of a [Marker]. */
@Composable
fun MarkerView(
title: String,
title: String?,
type: MarkerType,
isClosed: Boolean,
modifier: Modifier = Modifier,
Expand All @@ -58,7 +59,7 @@ fun MarkerView(
)
} else {
RoomName(
name = title,
name = requireNotNull(title) { "Room markers must have titles" },
lineTrough = isClosed,
modifier = modifier
)
Expand Down Expand Up @@ -130,7 +131,7 @@ private fun MarkerIcon(
painter = painter,
contentDescription = contentDescription,
modifier = Modifier
.size(ICON_SIZE)
.size(MARKER_ICON_SIZE)
.alpha(if (faded) 0.38f else 1f)
.then(modifier)
)
Expand Down
12 changes: 9 additions & 3 deletions app/src/main/java/ru/spbu/depnav/ui/component/SearchResults.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,21 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import ru.spbu.depnav.R
import ru.spbu.depnav.data.composite.MarkerWithText
import ru.spbu.depnav.data.model.Marker
import ru.spbu.depnav.data.model.MarkerText
import ru.spbu.depnav.ui.theme.DEFAULT_PADDING
import ru.spbu.depnav.ui.theme.DepNavTheme

/** Column with clickable information about markers that were found by the search. */
/**
* Column with clickable information about markers that were found by the search.
*
* @param markersWithTexts results sorted by importance: elements listed first will be displayed on
* top.
*/
@Composable
fun SearchResults(
markersWithTexts: Map<Marker, MarkerText>,
markersWithTexts: List<MarkerWithText>,
isHistory: Boolean,
onScroll: (onTop: Boolean) -> Unit,
onResultClick: (Int) -> Unit,
Expand All @@ -71,7 +77,7 @@ fun SearchResults(
contentPadding = PaddingValues(top = DEFAULT_PADDING / 2),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(markersWithTexts.toList().asReversed()) { (marker, markerText) ->
items(markersWithTexts.asReversed()) { (marker, markerText) ->
if (markerText.title == null) return@items

SearchResult(
Expand Down
Loading

0 comments on commit 5ddcc10

Please sign in to comment.