11package com.eried.eucplanet.ui.dashboard
22
3+ import androidx.compose.animation.AnimatedVisibility
4+ import androidx.compose.animation.core.animateFloatAsState
5+ import androidx.compose.animation.core.tween
6+ import androidx.compose.animation.fadeIn
7+ import androidx.compose.animation.fadeOut
38import androidx.compose.foundation.Canvas
49import androidx.compose.foundation.background
10+ import androidx.compose.foundation.clickable
511import androidx.compose.foundation.gestures.awaitEachGesture
612import androidx.compose.foundation.gestures.awaitFirstDown
13+ import androidx.compose.foundation.gestures.detectTapGestures
714import androidx.compose.foundation.layout.Arrangement
815import androidx.compose.foundation.layout.Box
916import androidx.compose.foundation.layout.Column
@@ -13,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxSize
1320import androidx.compose.foundation.layout.fillMaxWidth
1421import androidx.compose.foundation.layout.height
1522import androidx.compose.foundation.layout.padding
23+ import androidx.compose.foundation.layout.size
1624import androidx.compose.foundation.rememberScrollState
1725import androidx.compose.foundation.shape.RoundedCornerShape
1826import androidx.compose.foundation.verticalScroll
@@ -146,7 +154,9 @@ fun MetricDetailScreen(
146154 Spacer (Modifier .height(8 .dp))
147155
148156 if (samples.size >= 2 ) {
149- val windowSamples = samples.takeLast(300 )
157+ // History is already time-windowed to 5 min in WheelRepository,
158+ // so the slice here is just defensive — no need to cap by count.
159+ val windowSamples = samples
150160 val values = windowSamples.map { it.value }
151161 val min = values.min()
152162 val max = values.max()
@@ -278,6 +288,32 @@ private fun MetricGraph(
278288 var frozenSamples by remember { mutableStateOf<List <MetricSample >? > (null ) }
279289 val latestSamples = rememberUpdatedState(samples)
280290
291+ // Zoom state. zoomTarget = 1f means "see the full window"; 2f means
292+ // "see half the time range centered on panFractionTarget". The animated
293+ // values drive rendering so resets snap smoothly back to 1.0 over ~250ms.
294+ var zoomTarget by remember { mutableStateOf(1f ) }
295+ var panFractionTarget by remember { mutableStateOf(0.5f ) }
296+ val zoom by animateFloatAsState(
297+ targetValue = zoomTarget,
298+ animationSpec = tween(250 ),
299+ label = " graphZoom"
300+ )
301+ val panFraction by animateFloatAsState(
302+ targetValue = panFractionTarget,
303+ animationSpec = tween(250 ),
304+ label = " graphPan"
305+ )
306+ val isZoomed = zoomTarget > 1.001f
307+
308+ // While zoomed we keep the snapshot frozen so live samples don't push the
309+ // user's zoomed-in moment off the right edge. Reset both together.
310+ val resetZoom = {
311+ zoomTarget = 1f
312+ panFractionTarget = 0.5f
313+ frozenSamples = null
314+ touchX = null
315+ }
316+
281317 val displaySamples = frozenSamples ? : samples
282318
283319 Box (
@@ -289,32 +325,97 @@ private fun MetricGraph(
289325 modifier = Modifier
290326 .fillMaxSize()
291327 .padding(start = 44 .dp, bottom = 28 .dp, top = 12 .dp, end = 12 .dp)
328+ // Double-tap anywhere resets zoom + unfreezes live data. Runs
329+ // on its own pointerInput so it composes cleanly with the
330+ // scrub / pinch handler below — tap detection only fires on
331+ // quick taps so it doesn't shadow press-and-hold scrubbing.
332+ .pointerInput(Unit ) {
333+ detectTapGestures(onDoubleTap = { resetZoom() })
334+ }
292335 .pointerInput(Unit ) {
293336 awaitEachGesture {
294- val down = awaitFirstDown(requireUnconsumed = false )
295- // Snapshot current samples so the graph stops sliding while held
296- frozenSamples = latestSamples.value
297- touchX = down.position.x
298- down.consume()
337+ awaitFirstDown(requireUnconsumed = false )
338+ // Snapshot current samples; if we're not already
339+ // zoomed, this also freezes the live-slide.
340+ if (frozenSamples == null ) frozenSamples = latestSamples.value
341+
342+ // Pinch tracking. We capture the time-fraction under
343+ // the pinch midpoint at gesture start so the zoom
344+ // anchors to where the user's fingers are, instead
345+ // of always centering on the middle of the window.
346+ var initialPinchDist: Float? = null
347+ var initialZoom = zoomTarget
348+ var anchorTimeFrac = 0.5f
349+ var pinchFracStart = 0.5f
350+
299351 while (true ) {
300352 val ev = awaitPointerEvent()
301- val change = ev.changes.firstOrNull() ? : break
302- if (! change.pressed) {
353+ val pressed = ev.changes.filter { it.pressed }
354+ if (pressed.isEmpty()) break
355+
356+ if (pressed.size >= 2 ) {
357+ // Two-finger pinch. Hide tooltip and start
358+ // (or continue) a zoom session.
303359 touchX = null
304- frozenSamples = null
305- break
360+ val p1 = pressed[0 ].position
361+ val p2 = pressed[1 ].position
362+ val dist = (p1 - p2).getDistance()
363+ if (initialPinchDist == null ) {
364+ initialPinchDist = dist
365+ initialZoom = zoomTarget
366+ pinchFracStart = ((p1.x + p2.x) / 2f / size.width)
367+ .coerceIn(0f , 1f )
368+ val visStart = panFractionTarget - 0.5f / zoomTarget
369+ val visWidth = 1f / zoomTarget
370+ anchorTimeFrac = (visStart + pinchFracStart * visWidth)
371+ .coerceIn(0f , 1f )
372+ } else {
373+ val scale = dist / initialPinchDist!!
374+ val newZoom = (initialZoom * scale).coerceIn(1f , 20f )
375+ zoomTarget = newZoom
376+ val newPan = anchorTimeFrac +
377+ (0.5f - pinchFracStart) / newZoom
378+ val halfVis = 0.5f / newZoom
379+ panFractionTarget = newPan.coerceIn(halfVis, 1f - halfVis)
380+ }
381+ } else {
382+ // Single finger — scrub for tooltip.
383+ initialPinchDist = null
384+ touchX = pressed[0 ].position.x
306385 }
307- touchX = change.position.x
308- change.consume()
386+ ev.changes.forEach { it.consume() }
309387 }
388+
389+ // All fingers released. Drop tooltip; unfreeze the
390+ // sample list only if we're NOT still zoomed in.
391+ touchX = null
392+ if (zoomTarget <= 1.001f ) frozenSamples = null
310393 }
311394 }
312395 ) {
313396 if (displaySamples.size < 2 ) return @Canvas
314397 val w = size.width
315398 val h = size.height
316399
317- val values = displaySamples.map { it.value }
400+ // Apply zoom: pick the slice of displaySamples in the visible
401+ // time window. The window has width 1/zoom and is centered on
402+ // panFraction (both in [0..1] of the full snapshot's time range).
403+ val fullStart = displaySamples.first().timestampMs
404+ val fullEnd = displaySamples.last().timestampMs
405+ val fullSpanMs = (fullEnd - fullStart).coerceAtLeast(1 )
406+ val halfVis = 0.5f / zoom
407+ val visStartFrac = (panFraction - halfVis).coerceIn(0f , 1f - 2 * halfVis)
408+ val visEndFrac = (visStartFrac + 2 * halfVis).coerceAtMost(1f )
409+ val visStartMs = fullStart + (visStartFrac * fullSpanMs).toLong()
410+ val visEndMs = fullStart + (visEndFrac * fullSpanMs).toLong()
411+ val visibleSamples = if (zoom <= 1.001f ) {
412+ displaySamples
413+ } else {
414+ displaySamples.filter { it.timestampMs in visStartMs.. visEndMs }
415+ .ifEmpty { displaySamples }
416+ }
417+
418+ val values = visibleSamples.map { it.value }
318419 val bounds = boundsFor(values.min(), values.max())
319420 val graphMin = bounds.min
320421 val graphRange = bounds.range
@@ -331,9 +432,9 @@ private fun MetricGraph(
331432 drawText(measured, topLeft = Offset (- measured.size.width - 4f , y - measured.size.height / 2f ))
332433 }
333434
334- // Time axis labels
335- val startTime = displaySamples .first().timestampMs
336- val endTime = displaySamples .last().timestampMs
435+ // Time axis labels — reflect the currently-visible zoom window.
436+ val startTime = visibleSamples .first().timestampMs
437+ val endTime = visibleSamples .last().timestampMs
337438 val totalSec = ((endTime - startTime) / 1000 ).toInt().coerceAtLeast(1 )
338439 val timeSteps = if (totalSec > 300 ) 5 else if (totalSec > 60 ) 4 else 3
339440 for (i in 0 .. timeSteps) {
@@ -345,10 +446,10 @@ private fun MetricGraph(
345446 drawText(measured, topLeft = Offset (x - measured.size.width / 2f , h + 4f ))
346447 }
347448
348- // Data line
449+ // Data line — only samples in the visible window are drawn.
349450 val timeRange = (endTime - startTime).coerceAtLeast(1 )
350451 val path = androidx.compose.ui.graphics.Path ()
351- displaySamples .forEachIndexed { idx, sample ->
452+ visibleSamples .forEachIndexed { idx, sample ->
352453 val x = ((sample.timestampMs - startTime).toFloat() / timeRange) * w
353454 val y = h - ((sample.value - graphMin) / graphRange) * h
354455 if (idx == 0 ) path.moveTo(x, y) else path.lineTo(x, y)
@@ -371,12 +472,12 @@ private fun MetricGraph(
371472
372473 // Find bracketing samples for interpolation
373474 var leftIdx = 0
374- for (i in displaySamples .indices) {
375- if (displaySamples [i].timestampMs <= targetMs) leftIdx = i else break
475+ for (i in visibleSamples .indices) {
476+ if (visibleSamples [i].timestampMs <= targetMs) leftIdx = i else break
376477 }
377- val rightIdx = (leftIdx + 1 ).coerceAtMost(displaySamples .size - 1 )
378- val left = displaySamples [leftIdx]
379- val right = displaySamples [rightIdx]
478+ val rightIdx = (leftIdx + 1 ).coerceAtMost(visibleSamples .size - 1 )
479+ val left = visibleSamples [leftIdx]
480+ val right = visibleSamples [rightIdx]
380481 val span = (right.timestampMs - left.timestampMs).coerceAtLeast(1 )
381482 val frac = ((targetMs - left.timestampMs).toFloat() / span).coerceIn(0f , 1f )
382483 val interpValue = left.value + (right.value - left.value) * frac
@@ -405,6 +506,33 @@ private fun MetricGraph(
405506 drawText(measured, topLeft = Offset (boxX + padX, boxY + padY))
406507 }
407508 }
509+
510+ // ▶ Live chip — only shown while a zoom is active. Tap to snap back
511+ // and resume live sliding. Doubles as the "you are paused" indicator
512+ // so the user has an obvious way out of the inspection mode.
513+ AnimatedVisibility (
514+ visible = isZoomed,
515+ enter = fadeIn(tween(150 )),
516+ exit = fadeOut(tween(150 )),
517+ modifier = Modifier
518+ .align(Alignment .TopEnd )
519+ .padding(top = 8 .dp, end = 8 .dp)
520+ ) {
521+ Box (
522+ modifier = Modifier
523+ .clip(RoundedCornerShape (14 .dp))
524+ .background(color.copy(alpha = 0.85f ))
525+ .clickable { resetZoom() }
526+ .padding(horizontal = 10 .dp, vertical = 4 .dp)
527+ ) {
528+ Text (
529+ text = " Reset" ,
530+ fontSize = 11 .sp,
531+ fontWeight = FontWeight .Medium ,
532+ color = Color .Black
533+ )
534+ }
535+ }
408536 }
409537}
410538
0 commit comments