Skip to content

Commit

Permalink
feat: Scrollable x axis (#1445)
Browse files Browse the repository at this point in the history
* Started horizontal scrolling for DeviceMetrics. Drawing lines based on the TimeFrame and setting the dp.

* Wrote YAxisLabels(), it will replace the Y labels portion of the ChartOverlay(). The composable works for either side of the graph.

* Wrote HorizontalLinesOverlay(), it will replace the horizontal lines portion of the ChartOverlay().

* Updated the data points to use their actual x values.

* Based the width of the scrollable graph on time.

* Added a date label to the TimeAxisOverlay.
  • Loading branch information
Robert-0410 authored Dec 1, 2024
1 parent 3c581f8 commit b3f4929
Show file tree
Hide file tree
Showing 5 changed files with 325 additions and 85 deletions.
44 changes: 44 additions & 0 deletions app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import android.app.Application
import android.content.SharedPreferences
import android.net.Uri
import androidx.annotation.StringRes
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
Expand Down Expand Up @@ -120,6 +122,48 @@ enum class TimeFrame(
} else {
System.currentTimeMillis() / 1000 - this.seconds
}

/**
* The time interval to draw the vertical lines representing
* time on the x-axis.
*
* @return seconds epoch seconds
*/
fun lineInterval(): Long {
return when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal,
FORTY_EIGHT_HOURS.ordinal ->
TimeUnit.HOURS.toSeconds(1)
ONE_WEEK.ordinal,
TWO_WEEKS.ordinal ->
TimeUnit.DAYS.toSeconds(1)
else ->
TimeUnit.DAYS.toSeconds(7)
}
}

/**
* Calculates the needed [Dp] depending on the amount of time being plotted.
*
* @param time in seconds
*/
fun dp(screenWidth: Int, time: Long): Dp {

val timePerScreen = when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal,
FORTY_EIGHT_HOURS.ordinal ->
TimeUnit.HOURS.toSeconds(1)
ONE_WEEK.ordinal,
TWO_WEEKS.ordinal ->
TimeUnit.DAYS.toSeconds(1)
else ->
TimeUnit.DAYS.toSeconds(7)
}

val multiplier = time / timePerScreen
val dp = (screenWidth * multiplier).toInt().dp
return dp.takeIf { it != 0.dp } ?: screenWidth.dp
}
}

private fun MeshPacket.hasValidSignal(): Boolean =
Expand Down
169 changes: 161 additions & 8 deletions app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,21 +59,25 @@ import androidx.compose.ui.unit.sp
import com.geeksville.mesh.R
import com.geeksville.mesh.ui.components.CommonCharts.LINE_LIMIT
import com.geeksville.mesh.ui.components.CommonCharts.TEXT_PAINT_ALPHA
import com.geeksville.mesh.ui.components.CommonCharts.TIME_FORMAT
import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.components.CommonCharts.LEFT_LABEL_SPACING
import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC
import java.text.DateFormat

object CommonCharts {
val TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
val DATE_TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
const val X_AXIS_SPACING = 8f
const val LEFT_LABEL_SPACING = 36
const val MS_PER_SEC = 1000.0f
const val MS_PER_SEC = 1000L
const val LINE_LIMIT = 4
const val TEXT_PAINT_ALPHA = 192
}

private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
private const val LINE_ON = 10f
private const val LINE_OFF = 20f
private const val DATE_Y = 32f

data class LegendData(val nameRes: Int, val color: Color, val isLine: Boolean = false)

Expand All @@ -100,6 +104,7 @@ fun ChartHeader(amount: Int) {
* @param lineColors A list of 5 `Color`s for the chart lines, 0 being the lowest line on the chart.
* @param leaveSpace When true the lines will leave space for Y labels on the left side of the graph.
*/
@Deprecated("Will soon be replaced with YAxisLabels() and HorizontalLines()", level = DeprecationLevel.WARNING)
@Composable
fun ChartOverlay(
modifier: Modifier,
Expand Down Expand Up @@ -160,29 +165,177 @@ fun ChartOverlay(
}
}

/**
* Draws chart lines with respect to the Y-axis range; defined by (`maxValue` - `minValue`).
*
* @param lineColors A list of 5 `Color`s for the chart lines, 0 being the lowest line on the chart.
*/
@Composable
fun HorizontalLinesOverlay(
modifier: Modifier,
lineColors: List<Color>,
minValue: Float,
maxValue: Float,
) {
val range = maxValue - minValue
val verticalSpacing = range / LINE_LIMIT
Canvas(modifier = modifier) {

val lineStart = 0f
val height = size.height
val width = size.width

/* Horizontal Lines */
var lineY = minValue
for (i in 0..LINE_LIMIT) {
val ratio = (lineY - minValue) / range
val y = height - (ratio * height)
drawLine(
start = Offset(lineStart, y),
end = Offset(width, y),
color = lineColors[i],
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f)
)
lineY += verticalSpacing
}
}
}

/**
* Draws labels on the Y-axis with respect to the range. Defined by (`maxValue` - `minValue`).
*/
@Composable
fun YAxisLabels(
modifier: Modifier,
labelColor: Color,
minValue: Float,
maxValue: Float,
) {
val range = maxValue - minValue
val verticalSpacing = range / LINE_LIMIT
val density = LocalDensity.current
Canvas(modifier = modifier) {

val height = size.height

/* Y Labels */
val textPaint = Paint().apply {
color = labelColor.toArgb()
textAlign = Paint.Align.LEFT
textSize = density.run { 12.dp.toPx() }
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
alpha = TEXT_PAINT_ALPHA
}

drawContext.canvas.nativeCanvas.apply {
var label = minValue
for (i in 0..LINE_LIMIT) {
val ratio = (label - minValue) / range
val y = height - (ratio * height)
drawText(
"${label.toInt()}",
0f,
y + 4.dp.toPx(),
textPaint
)
label += verticalSpacing
}
}
}
}

/**
* Draws the vertical lines to help the user relate the plotted data within a time frame.
*/
@Composable
fun TimeAxisOverlay(
modifier: Modifier,
oldest: Int,
newest: Int,
timeInterval: Long
) {

val range = newest - oldest
val density = LocalDensity.current
val lineColor = MaterialTheme.colors.onSurface
Canvas(modifier = modifier) {

val height = size.height
val width = size.width - 28.dp.toPx()

/* Cut out the time remaining in order to place the lines on the dot. */
val timeRemaining = oldest % timeInterval
var current = oldest.toLong()
current -= timeRemaining
current += timeInterval

val textPaint = Paint().apply {
color = lineColor.toArgb()
textAlign = Paint.Align.LEFT
textSize = density.run { 12.dp.toPx() }
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
alpha = TEXT_PAINT_ALPHA
}

/* Vertical Lines with labels */
drawContext.canvas.nativeCanvas.apply {
while (current <= newest) {
val ratio = (current - oldest).toFloat() / range
val x = (ratio * width)
drawLine(
start = Offset(x, 0f),
end = Offset(x, height),
color = lineColor,
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f)
)

/* Time */
drawText(
TIME_FORMAT.format(current * MS_PER_SEC),
x,
0f,
textPaint
)
/* Date */
drawText(
DATE_FORMAT.format(current * MS_PER_SEC),
x,
DATE_Y,
textPaint
)
current += timeInterval
}
}
}
}

/**
* Draws the `oldest` and `newest` times for the respective telemetry data.
* Expects time in milliseconds
* Expects time in seconds.
*/
@Composable
fun TimeLabels(
oldest: Float,
newest: Float
oldest: Int,
newest: Int
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = TIME_FORMAT.format(oldest),
text = DATE_TIME_FORMAT.format(oldest * MS_PER_SEC),
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = 12.sp,
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = TIME_FORMAT.format(newest),
text = DATE_TIME_FORMAT.format(newest * MS_PER_SEC),
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = 12.sp
Expand Down
Loading

0 comments on commit b3f4929

Please sign in to comment.