Surface polish: motion fix, instrument redesign, readout typography (Codex + Claude)
Driven by on-device iteration on a Galaxy S24+ (live ADB install/ screenshot loop), gated by a real-device motion pass at animator scale 1x and frame stats (p95 8ms, 0.85% legacy jank). Motion (Codex): X/Y axes animate in independent concurrent effects. The prior sequential animateTo calls starved Y under ~50Hz retargeting - diagonal movement traced an L and the second axis caught up seconds late. Reduced-motion/system-disabled paths snap both axes. Instrument (Claude, on Codex's base): - Fluorescent yellow-green fluid (matches real spirit-level dye); amber retained for guidance text. - Bubble as a true void: borderless symmetric gradient orb, translucent interior (marks read through it), soft bright refraction band, contact shadow, fluorescent halo, window-style reflection + glint. Velocity-squash experiment tried and removed - shape fidelity beats the flourish. - Bubble (0.12R) nests inside the 1-degree target ring; ring turns lime on VISUAL containment of the drawn bubble (lock label/pulse/ haptic remain on LockDetector truth). - Machined bezel, etched cardinal/45 ticks, per-ring degree labels via the same SurfaceGuidance mapping. dp-scaled strokes replace raw-pixel hairlines; contrast lifted for real screens. - Red center target dot beneath the translucent bubble (Codex). Text hierarchy: guidance suppressed while locked (no correction advice under a 'Flat within' verdict), demoted to titleLarge, and raised into the empty status line's slot. Primary readout optically dead-centered via an invisible leading degree twin; degree symbol .55em at TextDim with cap-height baseline shift (.38 - metrics pair, resize together). 59 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,8 @@ import androidx.compose.ui.unit.sp
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Palette from the concept board (resources/Bubble Level Concepts.dc.html):
|
* Palette from the concept board (resources/Bubble Level Concepts.dc.html):
|
||||||
* deep graphite glass, warm amber spirit fluid, lime for the locked state.
|
* deep graphite glass, warm amber guidance, fluorescent spirit fluid, and lime
|
||||||
|
* for the locked state.
|
||||||
* The design is dark-only in v1 — this is an instrument, not a document.
|
* The design is dark-only in v1 — this is an instrument, not a document.
|
||||||
* Per BRIEF.md, color never carries state alone; labels and numbers always accompany it.
|
* Per BRIEF.md, color never carries state alone; labels and numbers always accompany it.
|
||||||
*/
|
*/
|
||||||
@@ -27,6 +28,16 @@ object LevelColors {
|
|||||||
val AmberHighlight = Color(0xFFFFEEB0)
|
val AmberHighlight = Color(0xFFFFEEB0)
|
||||||
val AmberDeep = Color(0xFFE0961E)
|
val AmberDeep = Color(0xFFE0961E)
|
||||||
|
|
||||||
|
// Fluorescent yellow-green is both familiar from physical spirit levels and
|
||||||
|
// substantially more legible than amber against the graphite vial.
|
||||||
|
val VialLime = Color(0xFFD9FF38)
|
||||||
|
val VialHighlight = Color(0xFFF7FFD5)
|
||||||
|
val VialDeep = Color(0xFF86B800)
|
||||||
|
val VialTarget = Color(0xFFFF6868)
|
||||||
|
|
||||||
|
/** Stronger neutral surface reserved for live numeric instrument panels. */
|
||||||
|
val ReadoutPanel = Color(0xFF242931)
|
||||||
|
|
||||||
val LimeLock = Color(0xFFCBEF5C)
|
val LimeLock = Color(0xFFCBEF5C)
|
||||||
val LimeLockText = Color(0xFFEAFFC2)
|
val LimeLockText = Color(0xFFEAFFC2)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
@@ -33,8 +34,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.BaselineShift
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.em
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.onthelevel.AppContainer
|
import com.onthelevel.AppContainer
|
||||||
@@ -163,7 +171,10 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
val placementOk = reading?.placementOk ?: true
|
val placementOk = reading?.placementOk ?: true
|
||||||
val surfacePresentation = reading?.surfacePresentation
|
val surfacePresentation = reading?.surfacePresentation
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(
|
||||||
|
modifier = Modifier.offset(y = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) {
|
if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) {
|
||||||
val pitch = reading?.stableSurfacePitchDegrees
|
val pitch = reading?.stableSurfacePitchDegrees
|
||||||
val roll = reading?.stableSurfaceRollDegrees
|
val roll = reading?.stableSurfaceRollDegrees
|
||||||
@@ -172,7 +183,8 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—",
|
text = reading?.let { primaryReadout(formatDegrees(it.displayPrimaryDegrees)) }
|
||||||
|
?: AnnotatedString("—"),
|
||||||
style = MaterialTheme.typography.displayLarge,
|
style = MaterialTheme.typography.displayLarge,
|
||||||
color = LevelColors.TextPrimary,
|
color = LevelColors.TextPrimary,
|
||||||
)
|
)
|
||||||
@@ -210,7 +222,14 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
|
|||||||
.height(56.dp),
|
.height(56.dp),
|
||||||
contentAlignment = Alignment.TopCenter,
|
contentAlignment = Alignment.TopCenter,
|
||||||
) {
|
) {
|
||||||
SurfaceAdjustmentInfo(reading, measurementUnits)
|
// Guidance and the status label are mutually exclusive
|
||||||
|
// (guidance renders only while the label line is empty),
|
||||||
|
// so it rises to visually take the label's place.
|
||||||
|
SurfaceAdjustmentInfo(
|
||||||
|
reading,
|
||||||
|
measurementUnits,
|
||||||
|
Modifier.offset(y = (-36).dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,10 +261,18 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
|
|||||||
* strong instruction while the phone is still being placed.
|
* strong instruction while the phone is still being placed.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnits) {
|
private fun SurfaceAdjustmentInfo(
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
reading: LevelReading?,
|
||||||
|
units: MeasurementUnits,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
if (reading == null) return@Column
|
if (reading == null) return@Column
|
||||||
|
|
||||||
|
// A locked surface is flat within tolerance: issuing correction guidance
|
||||||
|
// under the lock line would contradict it and crowd the status stack.
|
||||||
|
if (reading.isLocked) return@Column
|
||||||
|
|
||||||
if (reading.isSettling) {
|
if (reading.isSettling) {
|
||||||
Text(
|
Text(
|
||||||
text = "Settling",
|
text = "Settling",
|
||||||
@@ -259,9 +286,11 @@ private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnit
|
|||||||
val roll = reading.secondaryBDegrees ?: return@Column
|
val roll = reading.secondaryBDegrees ?: return@Column
|
||||||
val highLabel = SurfaceGuidance.from(pitch, roll).highLabel ?: return@Column
|
val highLabel = SurfaceGuidance.from(pitch, roll).highLabel ?: return@Column
|
||||||
|
|
||||||
|
// One rank below the lock/status line (28sp mono): guidance advises, it
|
||||||
|
// doesn't compete.
|
||||||
Text(
|
Text(
|
||||||
text = highLabel,
|
text = highLabel,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = LevelColors.Amber,
|
color = LevelColors.Amber,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
@@ -277,15 +306,26 @@ private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnit
|
|||||||
private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
|
private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
color = LevelColors.Panel,
|
color = LevelColors.ReadoutPanel,
|
||||||
contentColor = LevelColors.TextPrimary,
|
contentColor = LevelColors.TextPrimary,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(
|
||||||
Text(label, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = LevelColors.TextDim,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = value?.let { formatDegrees(it) } ?: "—",
|
text = value?.let { formatDegrees(it) } ?: "—",
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +355,31 @@ private fun statusLine(isCalibrated: Boolean, kind: SensorSource.Kind): String {
|
|||||||
|
|
||||||
internal fun formatDegrees(value: Double): String = String.format(Locale.US, "%.1f°", value)
|
internal fun formatDegrees(value: Double): String = String.format(Locale.US, "%.1f°", value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary readout typography: the degree symbol rendered small and faint keeps the
|
||||||
|
* number elegant without losing the instrument voice — and its invisible leading
|
||||||
|
* twin balances the trailing one, so the digits sit optically dead-center no
|
||||||
|
* matter the glyph widths.
|
||||||
|
*/
|
||||||
|
private fun primaryReadout(text: String): AnnotatedString = buildAnnotatedString {
|
||||||
|
// The shrunken symbol would otherwise sit on the digits' baseline at
|
||||||
|
// mid-height; the baseline shift floats it back up to the cap line where a
|
||||||
|
// degree mark belongs.
|
||||||
|
val degreeStyle = SpanStyle(
|
||||||
|
fontSize = DEGREE_SYMBOL_EM.em,
|
||||||
|
baselineShift = BaselineShift(DEGREE_SYMBOL_SHIFT),
|
||||||
|
)
|
||||||
|
withStyle(degreeStyle.copy(color = Color.Transparent)) { append("°") }
|
||||||
|
append(text.removeSuffix("°"))
|
||||||
|
withStyle(degreeStyle.copy(color = LevelColors.TextDim)) { append("°") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DEGREE_SYMBOL_EM = .55f
|
||||||
|
// No cap-height-alignment primitive exists in Compose; this value is derived from
|
||||||
|
// Roboto's metrics (digit cap height minus the degree glyph's top at the symbol
|
||||||
|
// size above — resize them together), verified against on-device captures.
|
||||||
|
private const val DEGREE_SYMBOL_SHIFT = .38f
|
||||||
|
|
||||||
internal fun formatRiseRun(valueDegrees: Double, units: MeasurementUnits): String = when (units) {
|
internal fun formatRiseRun(valueDegrees: Double, units: MeasurementUnits): String = when (units) {
|
||||||
MeasurementUnits.METRIC ->
|
MeasurementUnits.METRIC ->
|
||||||
String.format(Locale.US, "%.1f mm/m", RiseRun.millimetersPerMeter(valueDegrees))
|
String.format(Locale.US, "%.1f mm/m", RiseRun.millimetersPerMeter(valueDegrees))
|
||||||
|
|||||||
@@ -13,13 +13,34 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.drawscope.withTransform
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.drawText
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import com.onthelevel.core.design.LevelColors
|
import com.onthelevel.core.design.LevelColors
|
||||||
|
import com.onthelevel.core.design.ReadoutFontFamily
|
||||||
import com.onthelevel.core.sensors.SurfaceGuidance
|
import com.onthelevel.core.sensors.SurfaceGuidance
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
/** Glass bullseye whose target and rings are both driven by SurfaceGuidance. */
|
/**
|
||||||
|
* Glass bullseye whose target and rings are both driven by SurfaceGuidance.
|
||||||
|
*
|
||||||
|
* The bubble is a VOID in fluorescent fluid: a perfectly round, borderless
|
||||||
|
* translucent orb — one symmetric gradient whose edge fades out rather than
|
||||||
|
* outlining — grounded by a contact shadow and lit by a small fixed upper-left
|
||||||
|
* reflection. The calibration marks (and the red target dot) stay readable
|
||||||
|
* through it. Every layer serves depth or readability (SURFACE_LEVEL_PLAN.md:
|
||||||
|
* no decorative gloss); alphas are the tuning knobs if anything reads as chrome.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean, reducedMotion: Boolean) {
|
fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean, reducedMotion: Boolean) {
|
||||||
val guidance = SurfaceGuidance.from(pitchDegrees, rollDegrees)
|
val guidance = SurfaceGuidance.from(pitchDegrees, rollDegrees)
|
||||||
@@ -32,12 +53,21 @@ fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean
|
|||||||
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
||||||
}
|
}
|
||||||
val lockPulse = remember { Animatable(0f) }
|
val lockPulse = remember { Animatable(0f) }
|
||||||
LaunchedEffect(targetX, targetY, reducedMotion, systemMotionDisabled) {
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
// These must run independently. animateTo suspends until a spring settles;
|
||||||
|
// sequencing axes here would starve Y under the sensor's ~50 Hz retargeting
|
||||||
|
// and make diagonal movement trace an unnatural L.
|
||||||
|
LaunchedEffect(targetX, reducedMotion, systemMotionDisabled) {
|
||||||
if (reducedMotion || systemMotionDisabled) {
|
if (reducedMotion || systemMotionDisabled) {
|
||||||
animatedX.snapTo(targetX)
|
animatedX.snapTo(targetX)
|
||||||
animatedY.snapTo(targetY)
|
|
||||||
} else {
|
} else {
|
||||||
animatedX.animateTo(targetX, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow))
|
animatedX.animateTo(targetX, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(targetY, reducedMotion, systemMotionDisabled) {
|
||||||
|
if (reducedMotion || systemMotionDisabled) {
|
||||||
|
animatedY.snapTo(targetY)
|
||||||
|
} else {
|
||||||
animatedY.animateTo(targetY, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow))
|
animatedY.animateTo(targetY, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,16 +81,222 @@ fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean
|
|||||||
val radius = size.minDimension / 2f
|
val radius = size.minDimension / 2f
|
||||||
val center = Offset(size.width / 2f, size.height / 2f)
|
val center = Offset(size.width / 2f, size.height / 2f)
|
||||||
val vialRadius = radius * .74f
|
val vialRadius = radius * .74f
|
||||||
|
|
||||||
|
val bubbleCenter = center + Offset(animatedX.value * vialRadius, animatedY.value * vialRadius)
|
||||||
|
// Nested inside the 1° target ring (0.148 × radius): the level-read is the
|
||||||
|
// classic "bubble inside the circle", never the bubble swallowing the mark.
|
||||||
|
val bubbleRadius = radius * BUBBLE_RADIUS_RATIO
|
||||||
|
|
||||||
|
// The target ring highlights on what the EYE sees: the drawn bubble sitting
|
||||||
|
// inside (or tangent to) the circle. Lock truth — label, pulse, haptic —
|
||||||
|
// stays with LockDetector; this accent must never outlive the visual.
|
||||||
|
val targetRingRadius = vialRadius * SurfaceGuidance.ringRadiusRatio(1.0).toFloat()
|
||||||
|
val bubbleInsideTarget =
|
||||||
|
hypot(bubbleCenter.x - center.x, bubbleCenter.y - center.y) + bubbleRadius <=
|
||||||
|
targetRingRadius + 1.dp.toPx()
|
||||||
|
|
||||||
|
drawVialGlass(center, radius, vialRadius)
|
||||||
|
drawCalibrationMarks(center, radius, vialRadius, bubbleInsideTarget, textMeasurer)
|
||||||
|
if (lockPulse.value > 0f) {
|
||||||
|
drawCircle(LevelColors.LimeLock.copy(alpha = .55f * lockPulse.value), vialRadius, center, style = Stroke(3.dp.toPx()))
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBubbleBody(bubbleCenter, bubbleRadius)
|
||||||
|
drawBubbleLight(bubbleCenter, bubbleRadius)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawVialGlass(center: Offset, radius: Float, vialRadius: Float) {
|
||||||
drawCircle(Brush.radialGradient(listOf(Color(0xFF22272E), Color(0xFF090A0C)), center, radius), radius, center)
|
drawCircle(Brush.radialGradient(listOf(Color(0xFF22272E), Color(0xFF090A0C)), center, radius), radius, center)
|
||||||
drawCircle(Color.White.copy(alpha = .08f), radius, center, style = Stroke(1.5f))
|
// Faint sheen from the fixed upper-left light — a depth cue, kept far below gloss.
|
||||||
|
drawCircle(
|
||||||
|
Brush.radialGradient(
|
||||||
|
listOf(Color.White.copy(alpha = .05f), Color.Transparent),
|
||||||
|
center + Offset(-radius * .38f, -radius * .42f),
|
||||||
|
radius * .9f,
|
||||||
|
),
|
||||||
|
radius,
|
||||||
|
center,
|
||||||
|
)
|
||||||
|
// Fluid vignette: the dye reads darker where it meets the vial wall.
|
||||||
|
drawCircle(
|
||||||
|
Brush.radialGradient(
|
||||||
|
colorStops = arrayOf(
|
||||||
|
0f to Color.Transparent,
|
||||||
|
.78f to Color.Transparent,
|
||||||
|
1f to Color.Black.copy(alpha = .32f),
|
||||||
|
),
|
||||||
|
center = center,
|
||||||
|
radius = vialRadius,
|
||||||
|
),
|
||||||
|
vialRadius,
|
||||||
|
center,
|
||||||
|
)
|
||||||
|
// Machined bezel: a shallow metallic annulus, lit from above, instead of a
|
||||||
|
// flat outline — housing depth without adding information noise.
|
||||||
|
val bezelWidth = 5.dp.toPx()
|
||||||
|
drawCircle(
|
||||||
|
Brush.linearGradient(
|
||||||
|
listOf(Color.White.copy(alpha = .20f), Color.White.copy(alpha = .05f)),
|
||||||
|
start = Offset(center.x, center.y - radius),
|
||||||
|
end = Offset(center.x, center.y + radius),
|
||||||
|
),
|
||||||
|
radius - bezelWidth / 2f,
|
||||||
|
center,
|
||||||
|
style = Stroke(bezelWidth),
|
||||||
|
)
|
||||||
|
drawCircle(Color.White.copy(alpha = .14f), radius - bezelWidth, center, style = Stroke(1.dp.toPx()))
|
||||||
|
// Etched bezel ticks: cardinals strong, 45° minors faint — orientation
|
||||||
|
// vocabulary borrowed from real instrument bezels.
|
||||||
|
for (degrees in 0 until 360 step 45) {
|
||||||
|
val isCardinal = degrees % 90 == 0
|
||||||
|
val angle = Math.toRadians(degrees.toDouble())
|
||||||
|
val direction = Offset(cos(angle).toFloat(), sin(angle).toFloat())
|
||||||
|
val outer = radius - 1.dp.toPx()
|
||||||
|
val inner = outer - if (isCardinal) 8.dp.toPx() else 5.dp.toPx()
|
||||||
|
drawLine(
|
||||||
|
Color.White.copy(alpha = if (isCardinal) .45f else .22f),
|
||||||
|
center + direction * inner,
|
||||||
|
center + direction * outer,
|
||||||
|
if (isCardinal) 1.5.dp.toPx() else 1.dp.toPx(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawCalibrationMarks(
|
||||||
|
center: Offset,
|
||||||
|
radius: Float,
|
||||||
|
vialRadius: Float,
|
||||||
|
bubbleInsideTarget: Boolean,
|
||||||
|
textMeasurer: TextMeasurer,
|
||||||
|
) {
|
||||||
|
val labelStyle = TextStyle(
|
||||||
|
fontSize = 9.sp,
|
||||||
|
fontFamily = ReadoutFontFamily,
|
||||||
|
color = Color.White.copy(alpha = .38f),
|
||||||
|
)
|
||||||
SurfaceGuidance.RING_DEGREES.forEach { mark ->
|
SurfaceGuidance.RING_DEGREES.forEach { mark ->
|
||||||
drawCircle(Color.White.copy(alpha = .11f), vialRadius * SurfaceGuidance.ringRadiusRatio(mark).toFloat(), center, style = Stroke(1f))
|
val isTargetRing = mark == 1.0
|
||||||
|
val ringRadius = vialRadius * SurfaceGuidance.ringRadiusRatio(mark).toFloat()
|
||||||
|
val color = if (isTargetRing && bubbleInsideTarget) {
|
||||||
|
// Restrained lime accent, driven by visual containment of the drawn
|
||||||
|
// bubble. The label still carries the lock state in words.
|
||||||
|
LevelColors.LimeLock.copy(alpha = .9f)
|
||||||
|
} else {
|
||||||
|
val alpha = when (mark) {
|
||||||
|
1.0 -> .46f // The target zone needs to be the clearest calibration mark.
|
||||||
|
2.0 -> .36f
|
||||||
|
else -> .27f
|
||||||
}
|
}
|
||||||
drawLine(Color.White.copy(alpha = .10f), Offset(center.x - vialRadius, center.y), Offset(center.x + vialRadius, center.y), 1f)
|
Color.White.copy(alpha = alpha)
|
||||||
drawLine(Color.White.copy(alpha = .10f), Offset(center.x, center.y - vialRadius), Offset(center.x, center.y + vialRadius), 1f)
|
|
||||||
if (lockPulse.value > 0f) drawCircle(LevelColors.LimeLock.copy(alpha = .35f * lockPulse.value), vialRadius, center, style = Stroke(4f))
|
|
||||||
val bubble = center + Offset(animatedX.value * vialRadius, animatedY.value * vialRadius)
|
|
||||||
drawCircle(Brush.radialGradient(listOf(LevelColors.AmberHighlight, LevelColors.Amber, LevelColors.AmberDeep), bubble, radius * .18f), radius * .17f, bubble)
|
|
||||||
drawCircle(Color.White.copy(alpha = .45f), radius * .045f, bubble + Offset(-radius * .05f, -radius * .05f))
|
|
||||||
}
|
}
|
||||||
|
drawCircle(
|
||||||
|
color,
|
||||||
|
ringRadius,
|
||||||
|
center,
|
||||||
|
style = Stroke(if (isTargetRing && bubbleInsideTarget) 2.dp.toPx() else 1.5.dp.toPx()),
|
||||||
|
)
|
||||||
|
// Each ring says what it means: its degree value, etched small on the
|
||||||
|
// lower-right diagonal just outside the ring.
|
||||||
|
val layout = textMeasurer.measure(AnnotatedString("${mark.toInt()}°"), labelStyle)
|
||||||
|
val diagonal = (ringRadius + 5.dp.toPx()) * DIAGONAL_COMPONENT
|
||||||
|
val position = center + Offset(diagonal, diagonal)
|
||||||
|
drawText(
|
||||||
|
layout,
|
||||||
|
topLeft = position - Offset(layout.size.width / 2f, layout.size.height / 2f),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
val crosshairStroke = 1.dp.toPx()
|
||||||
|
drawLine(Color.White.copy(alpha = .32f), Offset(center.x - vialRadius, center.y), Offset(center.x + vialRadius, center.y), crosshairStroke)
|
||||||
|
drawLine(Color.White.copy(alpha = .32f), Offset(center.x, center.y - vialRadius), Offset(center.x, center.y + vialRadius), crosshairStroke)
|
||||||
|
// The exact-center target mark stays beneath the translucent bubble, seen
|
||||||
|
// through the trapped air just as it would be in a real vial.
|
||||||
|
drawCircle(Color.White.copy(alpha = .85f), radius * .030f, center)
|
||||||
|
drawCircle(LevelColors.VialTarget, radius * .020f, center)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A borderless, perfectly round translucent orb: one symmetric gradient, edge fading to nothing. */
|
||||||
|
private fun DrawScope.drawBubbleBody(c: Offset, b: Float) {
|
||||||
|
// Fluorescent halo: the dye glows brightest where the lens bends light past the rim.
|
||||||
|
drawCircle(
|
||||||
|
Brush.radialGradient(
|
||||||
|
listOf(LevelColors.VialLime.copy(alpha = .16f), Color.Transparent),
|
||||||
|
c,
|
||||||
|
b * 1.9f,
|
||||||
|
),
|
||||||
|
b * 1.9f,
|
||||||
|
c,
|
||||||
|
)
|
||||||
|
// Contact shadow: a soft dark ring just outside the rim seats the bubble IN the
|
||||||
|
// fluid instead of floating over the graphics.
|
||||||
|
drawCircle(
|
||||||
|
Brush.radialGradient(
|
||||||
|
colorStops = arrayOf(
|
||||||
|
0f to Color.Transparent,
|
||||||
|
.68f to Color.Transparent,
|
||||||
|
.80f to Color.Black.copy(alpha = .30f),
|
||||||
|
1f to Color.Transparent,
|
||||||
|
),
|
||||||
|
center = c,
|
||||||
|
radius = b * 1.3f,
|
||||||
|
),
|
||||||
|
b * 1.3f,
|
||||||
|
c,
|
||||||
|
)
|
||||||
|
// The orb itself. Centered gradient = perfectly round; the soft bright band
|
||||||
|
// sits evenly inside the rim, and the thin dark contact edge fades out rather
|
||||||
|
// than drawing a border.
|
||||||
|
drawCircle(
|
||||||
|
Brush.radialGradient(
|
||||||
|
colorStops = arrayOf(
|
||||||
|
0f to LevelColors.VialHighlight.copy(alpha = .24f),
|
||||||
|
.52f to LevelColors.VialLime.copy(alpha = .18f),
|
||||||
|
.80f to LevelColors.VialLime.copy(alpha = .36f),
|
||||||
|
.91f to LevelColors.VialHighlight.copy(alpha = .66f),
|
||||||
|
.965f to LevelColors.VialDeep.copy(alpha = .32f),
|
||||||
|
1f to Color.Transparent,
|
||||||
|
),
|
||||||
|
center = c,
|
||||||
|
radius = b * 1.06f,
|
||||||
|
),
|
||||||
|
b * 1.06f,
|
||||||
|
c,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reflection layer, world-oriented: a soft window-style reflection patch at the
|
||||||
|
* upper-left (how glass and soap bubbles actually mirror a light source), a sharp
|
||||||
|
* glint at its heart, and a faint pass-through shimmer on the far rim.
|
||||||
|
*/
|
||||||
|
private fun DrawScope.drawBubbleLight(c: Offset, b: Float) {
|
||||||
|
withTransform({
|
||||||
|
translate(c.x - b * .30f, c.y - b * .32f)
|
||||||
|
rotate(-38f, Offset.Zero)
|
||||||
|
scale(1f, .55f, Offset.Zero)
|
||||||
|
}) {
|
||||||
|
drawCircle(
|
||||||
|
Brush.radialGradient(
|
||||||
|
listOf(Color.White.copy(alpha = .50f), Color.Transparent),
|
||||||
|
Offset.Zero,
|
||||||
|
b * .46f,
|
||||||
|
),
|
||||||
|
b * .46f,
|
||||||
|
Offset.Zero,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
drawCircle(Color.White.copy(alpha = .85f), b * .07f, c + Offset(-b * .30f, -b * .34f))
|
||||||
|
val shimmerCenter = c + Offset(b * .42f, b * .44f)
|
||||||
|
drawCircle(
|
||||||
|
Brush.radialGradient(
|
||||||
|
listOf(Color.White.copy(alpha = .12f), Color.Transparent),
|
||||||
|
shimmerCenter,
|
||||||
|
b * .30f,
|
||||||
|
),
|
||||||
|
b * .30f,
|
||||||
|
shimmerCenter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val BUBBLE_RADIUS_RATIO = .12f
|
||||||
|
private const val DIAGONAL_COMPONENT = .7071f
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body{margin:0;background:radial-gradient(1200px 800px at 50% 20%, #1a1c20 0%, #101114 60%, #0b0c0e 100%)}
|
||||||
|
a{color:#ffb454;text-decoration:none} a:hover{color:#ffc97e}
|
||||||
|
@keyframes ping{0%{transform:scale(0.08);opacity:0.55}75%{transform:scale(1);opacity:0}100%{transform:scale(1);opacity:0}}
|
||||||
|
@keyframes chipring{0%{transform:scale(0.6);opacity:0.9}100%{transform:scale(2.4);opacity:0}}
|
||||||
|
@keyframes breathe{0%,100%{opacity:1}50%{opacity:0.25}}
|
||||||
|
@keyframes bob{0%,100%{transform:translate(0px,0px)}30%{transform:translate(1.4px,-1.1px)}65%{transform:translate(-1.2px,0.9px)}}
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-height:100vh;display:grid;place-items:center;padding:36px 24px">
|
||||||
|
<x-import component-from-global-scope="AndroidDevice" from="./android-frame.jsx" dark="{{ true }}" height="{{ 920 }}" hint-size="428px,936px">
|
||||||
|
<div data-screen-label="Level" style="position:relative;height:100%;display:flex;flex-direction:column;gap:14px;padding:14px 20px 18px;box-sizing:border-box;overflow:hidden;background:radial-gradient(140% 90% at 50% 0%, #121419 0%, #0a0b0d 55%, #060708 100%);font-family:'Archivo',system-ui,sans-serif;color:#e8eaed">
|
||||||
|
<div style="position:absolute;left:-120px;bottom:-120px;width:340px;height:340px;border-radius:50%;background:radial-gradient(circle, rgba(255,164,60,0.14) 0%, rgba(255,164,60,0) 70%);pointer-events:none;opacity:{{ glowOpacity }};transition:opacity 0.6s"></div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:3px">
|
||||||
|
<div style="font-size:14px;font-weight:700;letter-spacing:4px;color:#e8eaed">LEVEL</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
|
<div style="width:5px;height:5px;border-radius:50%;background:#7ed9a0;animation:breathe 2.6s ease-in-out infinite"></div>
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:2px;color:#6f767e">LIVE · FUSED</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#6f767e" stroke-width="1.6" style="cursor:pointer">
|
||||||
|
<circle cx="12" cy="12" r="3.2"></circle>
|
||||||
|
<path d="M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;padding:9px 14px;border:1px solid rgba(255,255,255,0.07);border-radius:12px;background:linear-gradient(180deg, rgba(255,255,255,0.025), rgba(255,255,255,0))">
|
||||||
|
<div style="position:relative;width:8px;height:8px;flex:none">
|
||||||
|
<div style="position:absolute;inset:0;border-radius:50%;background:{{ sonarDotColor }}"></div>
|
||||||
|
<sc-if value="{{ sonarOn }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="position:absolute;inset:-3px;border-radius:50%;border:1px solid #5fd4c4;animation:chipring 2.2s ease-out infinite"></div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;font-weight:600;letter-spacing:2.5px;color:{{ sonarLabelColor }}">SONAR</div>
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace;font-size:12px;color:{{ sonarValueColor }}">{{ sonarText }}</div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<sc-if value="{{ sonarOn }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="display:flex;align-items:center;gap:3px">
|
||||||
|
<div style="width:2px;height:6px;background:#5fd4c4;opacity:0.35;border-radius:1px"></div>
|
||||||
|
<div style="width:2px;height:10px;background:#5fd4c4;opacity:0.6;border-radius:1px"></div>
|
||||||
|
<div style="width:2px;height:14px;background:#5fd4c4;opacity:0.9;border-radius:1px"></div>
|
||||||
|
<div style="width:2px;height:9px;background:#5fd4c4;opacity:0.5;border-radius:1px"></div>
|
||||||
|
<div style="width:2px;height:5px;background:#5fd4c4;opacity:0.3;border-radius:1px"></div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:relative;display:flex;background:#15171b;border:1px solid rgba(255,255,255,0.06);border-radius:999px;padding:3px">
|
||||||
|
<div style="position:absolute;top:3px;bottom:3px;width:calc(50% - 3px);left:{{ thumbLeft }};border-radius:999px;background:linear-gradient(180deg,#2a2d33,#1f2126);border:1px solid rgba(255,255,255,0.1);box-shadow:0 2px 8px rgba(0,0,0,0.5);transition:left 0.25s cubic-bezier(0.3,0.9,0.3,1)"></div>
|
||||||
|
<button onClick="{{ setSurface }}" style="position:relative;flex:1;padding:9px 0;background:none;border:none;border-radius:999px;font-family:'Archivo',sans-serif;font-size:12px;font-weight:600;letter-spacing:1.5px;color:{{ surfTabColor }};cursor:pointer;transition:color 0.25s">SURFACE</button>
|
||||||
|
<button onClick="{{ setEdge }}" style="position:relative;flex:1;padding:9px 0;background:none;border:none;border-radius:999px;font-family:'Archivo',sans-serif;font-size:12px;font-weight:600;letter-spacing:1.5px;color:{{ edgeTabColor }};cursor:pointer;transition:color 0.25s">EDGE</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<sc-if value="{{ isSurface }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div data-screen-label="Surface mode" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:space-evenly;min-height:0">
|
||||||
|
<svg width="308" height="308" viewBox="0 0 360 360" style="flex:none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="dialFace" cx="42%" cy="34%" r="80%">
|
||||||
|
<stop offset="0%" stop-color="#1b1e23"></stop>
|
||||||
|
<stop offset="60%" stop-color="#111318"></stop>
|
||||||
|
<stop offset="100%" stop-color="#0a0b0e"></stop>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="bubbleG" cx="38%" cy="30%" r="75%">
|
||||||
|
<stop offset="0%" stop-color="#eaffb0"></stop>
|
||||||
|
<stop offset="35%" stop-color="#c3e85e"></stop>
|
||||||
|
<stop offset="75%" stop-color="#8ab825"></stop>
|
||||||
|
<stop offset="100%" stop-color="#5f8510"></stop>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="bubbleHalo" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#b7e050" stop-opacity="0.35"></stop>
|
||||||
|
<stop offset="100%" stop-color="#b7e050" stop-opacity="0"></stop>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="180" cy="180" r="172" fill="url(#dialFace)" stroke="rgba(255,255,255,0.1)" stroke-width="1"></circle>
|
||||||
|
<circle cx="180" cy="180" r="164" fill="none" stroke="rgba(255,255,255,0.28)" stroke-width="7" stroke-dasharray="1.4 41.55"></circle>
|
||||||
|
<circle cx="180" cy="180" r="164" fill="none" stroke="rgba(255,255,255,0.12)" stroke-width="5" stroke-dasharray="1 13.35"></circle>
|
||||||
|
<sc-if value="{{ sonarOn }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<g style="transform-origin:180px 180px;animation:ping 4.5s linear infinite">
|
||||||
|
<circle cx="180" cy="180" r="150" fill="none" stroke="#5fd4c4" stroke-width="1" opacity="0.5"></circle>
|
||||||
|
</g>
|
||||||
|
<g style="transform-origin:180px 180px;animation:ping 4.5s linear 2.25s infinite">
|
||||||
|
<circle cx="180" cy="180" r="150" fill="none" stroke="#5fd4c4" stroke-width="1" opacity="0.5"></circle>
|
||||||
|
</g>
|
||||||
|
</sc-if>
|
||||||
|
<circle cx="180" cy="180" r="150" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="1"></circle>
|
||||||
|
<circle cx="180" cy="180" r="100" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="1"></circle>
|
||||||
|
<circle cx="180" cy="180" r="50" fill="none" stroke="rgba(255,255,255,0.14)" stroke-width="1"></circle>
|
||||||
|
<text x="186" y="44" font-family="JetBrains Mono" font-size="10" fill="#5a616a">1.5°</text>
|
||||||
|
<text x="186" y="94" font-family="JetBrains Mono" font-size="10" fill="#5a616a">1°</text>
|
||||||
|
<text x="186" y="144" font-family="JetBrains Mono" font-size="10" fill="#5a616a">0.5°</text>
|
||||||
|
<path d="M180 30 V150 M180 210 V330 M30 180 H150 M210 180 H330" stroke="rgba(255,255,255,0.16)" stroke-width="1"></path>
|
||||||
|
<path d="M78 249 A122 122 0 0 1 60.5 202" fill="none" stroke="#ffb454" stroke-width="2.5" stroke-linecap="round" opacity="{{ dirOpacity }}" style="transition:opacity 0.5s"></path>
|
||||||
|
<g opacity="{{ dirOpacity }}" style="transition:opacity 0.5s">
|
||||||
|
<path d="M96 262 l-9 5 3-10" fill="none" stroke="#ffb454" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
</g>
|
||||||
|
<circle cx="180" cy="180" r="2.5" fill="#ffb454"></circle>
|
||||||
|
<g transform="translate({{ bubbleX }} {{ bubbleY }})" style="transition:transform 0.7s cubic-bezier(0.3,0.9,0.35,1)">
|
||||||
|
<g style="animation:bob 5.5s ease-in-out infinite">
|
||||||
|
<circle cx="180" cy="180" r="46" fill="url(#bubbleHalo)"></circle>
|
||||||
|
<circle cx="163" cy="196" r="3" fill="#b7e050" opacity="0.12"></circle>
|
||||||
|
<circle cx="171" cy="189" r="4.5" fill="#b7e050" opacity="0.22"></circle>
|
||||||
|
<circle cx="180" cy="180" r="21" fill="url(#bubbleG)"></circle>
|
||||||
|
<circle cx="180" cy="180" r="21" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="0.8"></circle>
|
||||||
|
<ellipse cx="173" cy="172" rx="7" ry="4.5" fill="#ffffff" opacity="0.55" transform="rotate(-32 173 172)"></ellipse>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace;font-weight:300;font-size:84px;line-height:1;letter-spacing:-3px;color:#f2f4f6">{{ mainVal }}<span style="font-size:40px;font-weight:400;color:#8a919b;vertical-align:26px;letter-spacing:0">{{ mainUnit }}</span></div>
|
||||||
|
<svg width="240" height="22" viewBox="0 0 240 22">
|
||||||
|
<line x1="10" y1="14" x2="230" y2="14" stroke="rgba(255,255,255,0.1)" stroke-width="4" stroke-dasharray="1 10"></line>
|
||||||
|
<line x1="120" y1="6" x2="120" y2="20" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"></line>
|
||||||
|
<rect x="{{ devX }}" y="4" width="3" height="16" rx="1.5" fill="{{ stateColor }}" style="transition:x 0.7s, fill 0.5s"></rect>
|
||||||
|
</svg>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
<div style="width:5px;height:5px;border-radius:50%;background:{{ stateColor }};transition:background 0.5s"></div>
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:2px;color:#6f767e">{{ subReadout }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ isEdge }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div data-screen-label="Edge mode" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:space-evenly;min-height:0">
|
||||||
|
<svg width="340" height="150" viewBox="0 0 340 150" style="flex:none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="tubeG" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#07080a"></stop>
|
||||||
|
<stop offset="45%" stop-color="#15181d"></stop>
|
||||||
|
<stop offset="100%" stop-color="#0b0c0f"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="8" y="34" width="324" height="62" rx="31" fill="url(#tubeG)" stroke="rgba(255,255,255,0.1)" stroke-width="1"></rect>
|
||||||
|
<rect x="10" y="36" width="320" height="14" rx="7" fill="rgba(255,255,255,0.03)"></rect>
|
||||||
|
<line x1="146" y1="30" x2="146" y2="100" stroke="rgba(255,255,255,0.35)" stroke-width="1.5"></line>
|
||||||
|
<line x1="194" y1="30" x2="194" y2="100" stroke="rgba(255,255,255,0.35)" stroke-width="1.5"></line>
|
||||||
|
<g transform="translate({{ edgeBx }} 0)" style="transition:transform 0.7s cubic-bezier(0.3,0.9,0.35,1)">
|
||||||
|
<ellipse cx="170" cy="65" rx="24" ry="19" fill="url(#bubbleG2)"></ellipse>
|
||||||
|
<ellipse cx="170" cy="65" rx="24" ry="19" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="0.8"></ellipse>
|
||||||
|
<ellipse cx="162" cy="57" rx="8" ry="4" fill="#ffffff" opacity="0.5" transform="rotate(-18 162 57)"></ellipse>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bubbleG2" cx="38%" cy="30%" r="75%">
|
||||||
|
<stop offset="0%" stop-color="#eaffb0"></stop>
|
||||||
|
<stop offset="35%" stop-color="#c3e85e"></stop>
|
||||||
|
<stop offset="75%" stop-color="#8ab825"></stop>
|
||||||
|
<stop offset="100%" stop-color="#5f8510"></stop>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<line x1="20" y1="118" x2="320" y2="118" stroke="rgba(255,255,255,0.12)" stroke-width="6" stroke-dasharray="1 14"></line>
|
||||||
|
<line x1="170" y1="110" x2="170" y2="126" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"></line>
|
||||||
|
<text x="14" y="142" font-family="JetBrains Mono" font-size="10" fill="#5a616a">-2°</text>
|
||||||
|
<text x="166" y="142" font-family="JetBrains Mono" font-size="10" fill="#5a616a">0</text>
|
||||||
|
<text x="312" y="142" font-family="JetBrains Mono" font-size="10" fill="#5a616a">+2°</text>
|
||||||
|
</svg>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:6px">
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace;font-weight:300;font-size:84px;line-height:1;letter-spacing:-3px;color:#f2f4f6">{{ edgeVal }}<span style="font-size:40px;font-weight:400;color:#8a919b;vertical-align:26px;letter-spacing:0">°</span></div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
<div style="width:5px;height:5px;border-radius:50%;background:{{ stateColor }};transition:background 0.5s"></div>
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:2px;color:#6f767e">{{ edgeSubReadout }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||||
|
<div style="background:linear-gradient(180deg,#14161b,#101216);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:13px 16px 11px;display:flex;flex-direction:column;gap:6px">
|
||||||
|
<div style="font-size:9.5px;font-weight:600;letter-spacing:2.5px;color:#6f767e">PITCH</div>
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace;font-size:26px;font-weight:400;color:#e8eaed">{{ pitchText }}</div>
|
||||||
|
<svg width="100%" height="20" viewBox="0 0 130 20" preserveAspectRatio="none">
|
||||||
|
<line x1="0" y1="10" x2="130" y2="10" stroke="rgba(255,255,255,0.08)" stroke-width="1"></line>
|
||||||
|
<g style="transform-origin:65px 10px;transform:rotate({{ pitchRot }}deg);transition:transform 0.7s">
|
||||||
|
<line x1="20" y1="10" x2="110" y2="10" stroke="{{ stateColor }}" stroke-width="1.5"></line>
|
||||||
|
</g>
|
||||||
|
<circle cx="65" cy="10" r="2" fill="{{ stateColor }}"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style="background:linear-gradient(180deg,#14161b,#101216);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:13px 16px 11px;display:flex;flex-direction:column;gap:6px">
|
||||||
|
<div style="font-size:9.5px;font-weight:600;letter-spacing:2.5px;color:#6f767e">ROLL</div>
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace;font-size:26px;font-weight:400;color:#e8eaed">{{ rollText }}</div>
|
||||||
|
<svg width="100%" height="20" viewBox="0 0 130 20" preserveAspectRatio="none">
|
||||||
|
<line x1="0" y1="10" x2="130" y2="10" stroke="rgba(255,255,255,0.08)" stroke-width="1"></line>
|
||||||
|
<g style="transform-origin:65px 10px;transform:rotate({{ rollRot }}deg);transition:transform 0.7s">
|
||||||
|
<line x1="20" y1="10" x2="110" y2="10" stroke="{{ stateColor }}" stroke-width="1.5"></line>
|
||||||
|
</g>
|
||||||
|
<circle cx="65" cy="10" r="2" fill="{{ stateColor }}"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-import>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":520,"height":1000},"leveled":{"editor":"boolean","default":false,"tsType":"boolean","section":"State"},"sonarOn":{"editor":"boolean","default":true,"tsType":"boolean","section":"Sonar"},"units":{"editor":"enum","options":["degrees","mm/m","percent"],"default":"degrees","tsType":"string","section":"Readout"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = { mode: 'surface' };
|
||||||
|
renderVals() {
|
||||||
|
const leveled = this.props.leveled ?? false;
|
||||||
|
const sonarOn = this.props.sonarOn ?? true;
|
||||||
|
const units = this.props.units ?? 'degrees';
|
||||||
|
const mode = this.state.mode;
|
||||||
|
const pitch = leveled ? 0 : -0.6, roll = leveled ? 0 : -0.3;
|
||||||
|
const stateColor = leveled ? '#7ed9a0' : '#ffb454';
|
||||||
|
const fmt = (v) => (v > 0 ? '+' : v < 0 ? '-' : '') + Math.abs(v).toFixed(1) + '\u00b0';
|
||||||
|
let mainVal, mainUnit;
|
||||||
|
if (units === 'mm/m') { mainVal = leveled ? '0.0' : '10.5'; mainUnit = ' mm/m'; }
|
||||||
|
else if (units === 'percent') { mainVal = leveled ? '0.0' : '1.2'; mainUnit = '%'; }
|
||||||
|
else { mainVal = leveled ? '0.0' : '0.7'; mainUnit = '\u00b0'; }
|
||||||
|
return {
|
||||||
|
isSurface: mode === 'surface', isEdge: mode === 'edge',
|
||||||
|
setSurface: () => this.setState({ mode: 'surface' }),
|
||||||
|
setEdge: () => this.setState({ mode: 'edge' }),
|
||||||
|
thumbLeft: mode === 'surface' ? '3px' : 'calc(50%)',
|
||||||
|
surfTabColor: mode === 'surface' ? '#e8eaed' : '#6f767e',
|
||||||
|
edgeTabColor: mode === 'edge' ? '#e8eaed' : '#6f767e',
|
||||||
|
sonarOn,
|
||||||
|
sonarDotColor: sonarOn ? '#5fd4c4' : '#3a3f45',
|
||||||
|
sonarLabelColor: sonarOn ? '#9ba3ac' : '#4d535a',
|
||||||
|
sonarValueColor: sonarOn ? '#5fd4c4' : '#4d535a',
|
||||||
|
sonarText: sonarOn ? '0.42 m' : 'OFF',
|
||||||
|
glowOpacity: leveled ? 0 : 1,
|
||||||
|
dirOpacity: leveled ? 0 : 0.9,
|
||||||
|
bubbleX: roll * 100, bubbleY: -pitch * 100,
|
||||||
|
devX: 118.5 + pitch * 60,
|
||||||
|
stateColor,
|
||||||
|
mainVal, mainUnit,
|
||||||
|
subReadout: leveled ? 'LEVEL \u00b7 HOLD' : '10.5 MM/M \u00b7 HIGH \u2199',
|
||||||
|
edgeVal: leveled ? '0.0' : '0.3',
|
||||||
|
edgeSubReadout: leveled ? 'LEVEL \u00b7 HOLD' : '5.2 MM/M \u00b7 HIGH LEFT',
|
||||||
|
edgeBx: roll * 120,
|
||||||
|
pitchText: fmt(pitch), rollText: fmt(roll),
|
||||||
|
pitchRot: pitch * 12, rollRot: roll * 12,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user