diff --git a/app/src/main/java/com/grigowashere/loratester/LoraApp.java b/app/src/main/java/com/grigowashere/loratester/LoraApp.java index a9d4a37..a7fe8cd 100644 --- a/app/src/main/java/com/grigowashere/loratester/LoraApp.java +++ b/app/src/main/java/com/grigowashere/loratester/LoraApp.java @@ -6,6 +6,7 @@ import com.grigowashere.loratester.api.ServerApi; import com.grigowashere.loratester.location.LocationTracker; import com.grigowashere.loratester.net.NetworkMonitor; import com.grigowashere.loratester.track.TrackRecorder; +import com.grigowashere.loratester.ui.LinkLossAlerter; import org.mapsforge.map.android.graphics.AndroidGraphicFactory; @@ -18,15 +19,18 @@ public class LoraApp extends Application { private PeerStatsCache peerStatsCache; private CommandPoller commandPoller; private LocationTracker locationTracker; + private LinkLossAlerter linkLossAlerter; @Override public void onCreate() { super.onCreate(); AndroidGraphicFactory.createInstance(this); settingsRepository = new SettingsRepository(this); + linkLossAlerter = new LinkLossAlerter(this, settingsRepository); networkMonitor = new NetworkMonitor(this); networkMonitor.start(); telemetryUploader = new TelemetryUploader(this, settingsRepository, networkMonitor); + telemetryUploader.setLinkLossAlerter(linkLossAlerter); peerStatsCache = new PeerStatsCache(); ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl()); String deviceId = settingsRepository.getOrCreateDeviceId(); @@ -79,6 +83,10 @@ public class LoraApp extends Application { return commandPoller; } + public LinkLossAlerter getLinkLossAlerter() { + return linkLossAlerter; + } + public synchronized void startLocationUpdates() { if (locationTracker == null) { locationTracker = new LocationTracker(this, (lat, lon, alt) -> { diff --git a/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java b/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java index 232ee28..26070ad 100644 --- a/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java +++ b/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java @@ -14,6 +14,7 @@ public class SettingsRepository { private static final String KEY_TELNET_ENABLED = "telnet_enabled"; private static final String KEY_DEVICE_ID = "device_id"; private static final String KEY_DEVICE_LABEL = "device_label"; + private static final String KEY_LINK_LOSS_ALERT = "link_loss_alert_enabled"; public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru"; private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634"; @@ -119,4 +120,12 @@ public class SettingsRepository { prefs.edit().putString(KEY_DEVICE_LABEL, label.trim()).apply(); } } + + public boolean isLinkLossAlertEnabled() { + return prefs.getBoolean(KEY_LINK_LOSS_ALERT, false); + } + + public void setLinkLossAlertEnabled(boolean enabled) { + prefs.edit().putBoolean(KEY_LINK_LOSS_ALERT, enabled).apply(); + } } diff --git a/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java b/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java index 34092cb..07ae8f7 100644 --- a/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java +++ b/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java @@ -11,11 +11,13 @@ import com.grigowashere.loratester.api.TelemetryPayload; import com.grigowashere.loratester.api.UploadQueue; import com.grigowashere.loratester.net.NetworkMonitor; import com.grigowashere.loratester.location.GeoUtils; +import com.grigowashere.loratester.model.RadioSnapshot; import com.grigowashere.loratester.telnet.AtCommandFormatter; import com.grigowashere.loratester.telnet.RadioMacroBuilder; import com.grigowashere.loratester.telnet.StatsExtractor; import com.grigowashere.loratester.telnet.TelnetClient; import com.grigowashere.loratester.telnet.TelnetFrameParser; +import com.grigowashere.loratester.ui.LinkLossAlerter; import java.nio.charset.StandardCharsets; import java.util.Arrays; @@ -69,6 +71,7 @@ public class TelemetryUploader implements TelnetClient.Listener { private volatile StatsExtractor.ExtractedStats lastStats; private volatile long lastStatsAtMs; private StatsListener statsListener; + private LinkLossAlerter linkLossAlerter; private final UploadQueue uploadQueue; private final NetworkMonitor networkMonitor; @@ -265,6 +268,10 @@ public class TelemetryUploader implements TelnetClient.Listener { } } + public void setLinkLossAlerter(LinkLossAlerter alerter) { + this.linkLossAlerter = alerter; + } + public StatsExtractor.ExtractedStats getLastStats() { return lastStats; } @@ -284,6 +291,14 @@ public class TelemetryUploader implements TelnetClient.Listener { if (listener != null) { mainHandler.post(() -> listener.onStatsUpdated(stats)); } + LinkLossAlerter alerter = linkLossAlerter; + if (alerter != null) { + RadioSnapshot snap = RadioSnapshot.fromExtracted(stats); + if (StatsExtractor.ROLE_RX.equals(snap.role) && snap.rxQualityPercent != null) { + Double quality = snap.rxQualityPercent; + mainHandler.post(() -> alerter.onQualitySample(quality)); + } + } TelemetryPayload payload = new TelemetryPayload( settings.getOrCreateDeviceId(), phoneLabel(), diff --git a/app/src/main/java/com/grigowashere/loratester/ui/LinkLossAlerter.java b/app/src/main/java/com/grigowashere/loratester/ui/LinkLossAlerter.java new file mode 100644 index 0000000..8a7d0f7 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/LinkLossAlerter.java @@ -0,0 +1,92 @@ +package com.grigowashere.loratester.ui; + +import android.content.Context; +import android.media.AudioManager; +import android.media.ToneGenerator; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.os.VibratorManager; + +import com.grigowashere.loratester.SettingsRepository; + +/** One-shot audio + vibration when RX quality drops to 0. */ +public final class LinkLossAlerter { + + private static final int VIBRATE_MS = 300; + private static final int TONE_MS = 400; + + private final SettingsRepository settings; + private final Context appContext; + private Double lastQuality; + + public LinkLossAlerter(Context context, SettingsRepository settings) { + this.appContext = context.getApplicationContext(); + this.settings = settings; + } + + public synchronized void onQualitySample(Double quality) { + if (!settings.isLinkLossAlertEnabled()) { + lastQuality = quality; + return; + } + if (quality != null && quality > 0) { + lastQuality = quality; + return; + } + if (quality != null && quality == 0 + && (lastQuality == null || lastQuality > 0)) { + fireAlert(); + } + lastQuality = quality; + } + + private void fireAlert() { + vibrate(); + playTone(); + } + + private void vibrate() { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + VibratorManager vm = (VibratorManager) appContext.getSystemService( + Context.VIBRATOR_MANAGER_SERVICE); + if (vm != null) { + Vibrator v = vm.getDefaultVibrator(); + if (v != null && v.hasVibrator()) { + v.vibrate(VibrationEffect.createOneShot( + VIBRATE_MS, + VibrationEffect.DEFAULT_AMPLITUDE + )); + } + } + } else { + Vibrator v = (Vibrator) appContext.getSystemService(Context.VIBRATOR_SERVICE); + if (v != null && v.hasVibrator()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + v.vibrate(VibrationEffect.createOneShot( + VIBRATE_MS, + VibrationEffect.DEFAULT_AMPLITUDE + )); + } else { + v.vibrate(VIBRATE_MS); + } + } + } + } catch (Exception ignored) { + // ignore missing vibrator + } + } + + private void playTone() { + try { + ToneGenerator tone = new ToneGenerator( + AudioManager.STREAM_NOTIFICATION, + ToneGenerator.MAX_VOLUME / 2 + ); + tone.startTone(ToneGenerator.TONE_PROP_BEEP, TONE_MS); + } catch (Exception ignored) { + // ignore missing audio + } + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java index d084d1a..8d3af9b 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java @@ -3,6 +3,8 @@ package com.grigowashere.loratester.ui; import android.content.Context; import android.graphics.PorterDuff; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; @@ -81,6 +83,7 @@ public class MapFragment extends Fragment { private static final int TILE_SIZE_PX = 256; private static final long DEVICE_POLL_MS = 5000; + private static final long SELF_POSITION_MS = 1000; /** Ignore GPS jitter smaller than ~11 m. */ private static final double POSITION_EPS = 0.0001; private static final float USER_PAN_THRESHOLD_PX = 12f; @@ -88,6 +91,7 @@ public class MapFragment extends Fragment { private static final int ARGB_TX = 0xFFE94560; private static final int ARGB_RX = 0xFF4FC3F7; private static final int ARGB_TRACK = 0xFF00FF88; + private static final int ARGB_SELF = 0xFF00FF88; private static final int ARGB_HILL = 0xFFFFC107; private static final int HILL_SEARCH_RADIUS_M = 5000; private static final long LORA_STATS_FRESH_MS = 120_000; @@ -104,6 +108,7 @@ public class MapFragment extends Fragment { private FragmentPollHelper pollHelper; private TelemetryUploader uploader; + private LinkLossAlerter linkLossAlerter; private TrackRecorder trackRecorder; private CommandPoller commandPoller; private MapView mapView; @@ -147,6 +152,8 @@ public class MapFragment extends Fragment { private Bitmap bitmapRx; private Bitmap bitmapTrackPoint; private Bitmap bitmapHill; + private Bitmap bitmapSelf; + private Marker selfMarker; private Marker hillMarker; private Polyline hillPathLine; private boolean hillActive; @@ -164,12 +171,15 @@ public class MapFragment extends Fragment { private float touchDownX; private float touchDownY; private Runnable pendingFitRunnable; + private final Handler selfPositionHandler = new Handler(Looper.getMainLooper()); + private Runnable selfPositionTick; @Override public void onAttach(@NonNull Context context) { super.onAttach(context); LoraApp app = (LoraApp) context.getApplicationContext(); uploader = app.getTelemetryUploader(); + linkLossAlerter = app.getLinkLossAlerter(); trackRecorder = app.getTrackRecorder(); commandPoller = app.getCommandPoller(); networkMonitor = app.getNetworkMonitor(); @@ -401,10 +411,11 @@ public class MapFragment extends Fragment { position.setZoomLevel((byte) 12); } - bitmapTx = MapsforgeBitmaps.dot(ARGB_TX, 20); - bitmapRx = MapsforgeBitmaps.dot(ARGB_RX, 20); - bitmapTrackPoint = MapsforgeBitmaps.dot(ARGB_TRACK, 12); - bitmapHill = MapsforgeBitmaps.dot(ARGB_HILL, 22); + bitmapTx = MapsforgeBitmaps.dot(ARGB_TX, 26); + bitmapRx = MapsforgeBitmaps.dot(ARGB_RX, 26); + bitmapTrackPoint = MapsforgeBitmaps.dot(ARGB_TRACK, 16); + bitmapHill = MapsforgeBitmaps.dot(ARGB_HILL, 28); + bitmapSelf = MapsforgeBitmaps.dot(ARGB_SELF, 24); mapInitialized = true; } @@ -419,6 +430,7 @@ public class MapFragment extends Fragment { if (pollHelper != null) { pollHelper.start(0); } + startSelfPositionTick(); if (trackRecorder != null && btnTrack != null) { setupTrackRecorderListener(); } @@ -429,6 +441,7 @@ public class MapFragment extends Fragment { public void onPause() { mapResumed = false; saveCameraState(); + stopSelfPositionTick(); if (pollHelper != null) { pollHelper.stop(); } @@ -443,6 +456,7 @@ public class MapFragment extends Fragment { mapResumed = false; mapInitialized = false; cancelPendingFit(); + stopSelfPositionTick(); if (pollHelper != null) { pollHelper.stop(); } @@ -454,6 +468,7 @@ public class MapFragment extends Fragment { trackRecorder.setListener(null); } removeAllDeviceMarkers(); + removeSelfMarker(); clearTrackLayers(); clearLiveTrackLayers(); clearHillLayers(); @@ -473,6 +488,7 @@ public class MapFragment extends Fragment { bitmapRx = null; bitmapTrackPoint = null; bitmapHill = null; + bitmapSelf = null; iconServer = null; iconLora = null; btnFindHill = null; @@ -899,6 +915,64 @@ public class MapFragment extends Fragment { } } + private void removeSelfMarker() { + if (selfMarker != null && mapView != null) { + mapView.getLayerManager().getLayers().remove(selfMarker); + } + selfMarker = null; + } + + private void startSelfPositionTick() { + stopSelfPositionTick(); + selfPositionTick = new Runnable() { + @Override + public void run() { + if (!mapResumed) { + return; + } + tickSelfPosition(); + selfPositionHandler.postDelayed(this, SELF_POSITION_MS); + } + }; + selfPositionHandler.post(selfPositionTick); + } + + private void stopSelfPositionTick() { + if (selfPositionTick != null) { + selfPositionHandler.removeCallbacks(selfPositionTick); + selfPositionTick = null; + } + } + + private void tickSelfPosition() { + updateSelfPositionMarker(); + updateRxQuality(); + if (linkLossAlerter != null) { + linkLossAlerter.onQualitySample(resolveRxQualityPercent()); + } + } + + private void updateSelfPositionMarker() { + if (!isMapReady() || uploader == null || bitmapSelf == null) { + return; + } + if (!uploader.hasGpsFix()) { + removeSelfMarker(); + return; + } + LatLong pos = new LatLong(uploader.getGpsLat(), uploader.getGpsLon()); + if (selfMarker == null) { + selfMarker = new Marker(pos, bitmapSelf, 0, 0); + mapView.getLayerManager().getLayers().add(selfMarker); + requestMapInvalidate(); + return; + } + if (!samePosition(selfMarker.getLatLong(), pos)) { + selfMarker.setLatLong(pos); + requestMapInvalidate(); + } + } + private Bitmap roleBitmap(String role) { return StatsExtractor.ROLE_RX.equals(role) ? bitmapRx : bitmapTx; } @@ -1450,6 +1524,8 @@ public class MapFragment extends Fragment { int onMap = 0; List boundsPoints = new ArrayList<>(); Set seen = new HashSet<>(); + String myId = uploader != null ? uploader.getDeviceId() : null; + boolean hideSelfOnServer = uploader != null && uploader.hasGpsFix(); for (DeviceInfo d : devices) { if (StatsExtractor.ROLE_TX.equals(d.role)) { @@ -1457,6 +1533,13 @@ public class MapFragment extends Fragment { } else if (StatsExtractor.ROLE_RX.equals(d.role)) { rxCount++; } + if (hideSelfOnServer && myId != null && myId.equals(d.device_id)) { + Marker existing = deviceMarkers.remove(d.device_id); + if (existing != null) { + mapView.getLayerManager().getLayers().remove(existing); + } + continue; + } if (!GeoUtils.isValidCoordinate(d.lat, d.lon)) { continue; } diff --git a/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java index 2408681..867566a 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java @@ -46,6 +46,7 @@ public class SettingsFragment extends Fragment { TextInputEditText editRange = view.findViewById(R.id.editRangeRegex); TextInputEditText editDeviceLabel = view.findViewById(R.id.editDeviceLabel); SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet); + SwitchMaterial switchLinkLossAlert = view.findViewById(R.id.switchLinkLossAlert); Button batteryBtn = view.findViewById(R.id.btnBatteryOptimization); TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel); Button save = view.findViewById(R.id.btnSaveSettings); @@ -60,6 +61,7 @@ public class SettingsFragment extends Fragment { editDeviceLabel.setText(savedLabel); } switchTelnet.setChecked(settings.isTelnetEnabled()); + switchLinkLossAlert.setChecked(settings.isLinkLossAlertEnabled()); deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId())); batteryBtn.setOnClickListener(v -> { @@ -82,6 +84,7 @@ public class SettingsFragment extends Fragment { settings.setRangeRegex(textOf(editRange, SettingsRepository.DEFAULT_RANGE_REGEX)); settings.setDeviceLabel(textOf(editDeviceLabel, "")); settings.setTelnetEnabled(switchTelnet.isChecked()); + settings.setLinkLossAlertEnabled(switchLinkLossAlert.isChecked()); uploader.refreshApi(); uploader.registerPresence(); if (switchTelnet.isChecked()) { diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml index 10848a6..082d8b8 100644 --- a/app/src/main/res/layout/fragment_map.xml +++ b/app/src/main/res/layout/fragment_map.xml @@ -16,7 +16,7 @@ android:layout_gravity="top|start" android:layout_marginStart="8dp" android:layout_marginTop="8dp" - android:layout_marginEnd="56dp" + android:layout_marginEnd="64dp" android:background="@drawable/bg_map_panel" android:elevation="6dp" android:orientation="vertical" @@ -33,8 +33,8 @@ @@ -48,8 +48,8 @@ @@ -102,7 +102,7 @@ @@ -125,8 +125,8 @@ @@ -134,8 +134,8 @@ @@ -143,8 +143,8 @@ @@ -152,8 +152,8 @@ @@ -161,8 +161,8 @@ @@ -174,7 +174,7 @@ android:layout_height="wrap_content" android:layout_gravity="end|top" android:layout_marginTop="8dp" - android:layout_marginEnd="56dp" + android:layout_marginEnd="64dp" android:background="@drawable/bg_map_panel" android:elevation="6dp" android:scrollbars="none" diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index a873543..208da23 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -93,6 +93,20 @@ android:layout_marginTop="16dp" android:text="@string/telnet_enabled" /> + + + + RSSI regex Range regex Подключить telnet + Сигнал при потере связи + Один короткий звук и вибрация, когда RX Quality падает до 0 ID устройства: %1$s Имя на карте (realme, OPPO…) Сохранить diff --git a/server/static/index.html b/server/static/index.html index 0ea3061..ac5eb74 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -332,7 +332,7 @@
-
RX Quality vs расстояние TX↔RX
+
RX Quality и плотность точек vs расстояние TX↔RX
diff --git a/server/static/quality-viz.js b/server/static/quality-viz.js index 53e4099..6250707 100644 --- a/server/static/quality-viz.js +++ b/server/static/quality-viz.js @@ -2,6 +2,61 @@ (function (global) { 'use strict'; + function niceMeterStep(span) { + if (span <= 50) return 10; + if (span <= 150) return 25; + if (span <= 400) return 50; + return 100; + } + + function niceMeterTicks(minD, maxD) { + const span = Math.max(maxD - minD, 1); + const step = niceMeterStep(span); + const ticks = []; + const start = Math.ceil(minD / step) * step; + for (let d = start; d <= maxD + step * 0.01; d += step) { + if (d >= minD - step * 0.01 && d <= maxD + step * 0.01) { + ticks.push(d); + } + } + if (!ticks.length || ticks[0] > minD + 0.5) { + ticks.unshift(minD); + } + if (ticks[ticks.length - 1] < maxD - 0.5) { + ticks.push(maxD); + } + return ticks; + } + + function drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span, w) { + const ticks = niceMeterTicks(minD, minD + span); + const minLabelGap = 28; + const maxLabels = Math.max(2, Math.floor(plotW / minLabelGap)); + const labelEvery = ticks.length > maxLabels + ? Math.ceil(ticks.length / maxLabels) + : 1; + + ctx.strokeStyle = '#2a2a3a'; + ctx.lineWidth = 1; + ticks.forEach((d, i) => { + const x = margin.l + ((d - minD) / span) * plotW; + ctx.beginPath(); + ctx.moveTo(x, margin.t); + ctx.lineTo(x, margin.t + plotH); + ctx.stroke(); + + if (i % labelEvery === 0 || i === ticks.length - 1) { + const label = `${Math.round(d)}m`; + ctx.fillStyle = '#888'; + ctx.font = '9px system-ui'; + const tw = ctx.measureText(label).width; + let lx = x - tw / 2; + lx = Math.max(margin.l, Math.min(lx, margin.l + plotW - tw)); + ctx.fillText(label, lx, h - 4); + } + }); + } + function drawQualityDistChart(canvas, samples, qualityColor, highlightDist) { if (!canvas) return; const ctx = canvas.getContext('2d'); @@ -23,7 +78,7 @@ const minD = Math.min(...dists); const maxD = Math.max(...dists); const span = Math.max(maxD - minD, 1); - const binCount = Math.min(20, Math.max(5, Math.ceil(span / 10))); + const binCount = Math.min(40, Math.max(8, Math.ceil(span / 5))); const binW = span / binCount; const bins = Array.from({ length: binCount }, (_, i) => ({ min: minD + i * binW, @@ -37,53 +92,73 @@ bins[idx].qualities.push(s.quality); } - const margin = { l: 36, r: 8, t: 16, b: 22 }; + const margin = { l: 36, r: 8, t: 16, b: 30 }; const plotW = w - margin.l - margin.r; const plotH = h - margin.t - margin.b; + const distBandH = Math.round(plotH * 0.25); + const qualBandH = plotH - distBandH - 4; + const qualTop = margin.t; + const distTop = margin.t + qualBandH + 4; + const axisY = margin.t + plotH; ctx.strokeStyle = '#333'; ctx.beginPath(); - ctx.moveTo(margin.l, margin.t); - ctx.lineTo(margin.l, margin.t + plotH); - ctx.lineTo(margin.l + plotW, margin.t + plotH); + ctx.moveTo(margin.l, qualTop); + ctx.lineTo(margin.l, axisY); + ctx.lineTo(margin.l + plotW, axisY); ctx.stroke(); ctx.fillStyle = '#888'; ctx.font = '9px system-ui'; - ctx.fillText('0%', 2, margin.t + plotH); - ctx.fillText('100%', 2, margin.t + 8); - ctx.fillText(`${Math.round(minD)}m`, margin.l, h - 2); - ctx.fillText(`${Math.round(maxD)}m`, margin.l + plotW - 24, h - 2); - ctx.fillStyle = '#ccc'; - ctx.font = '10px system-ui'; - ctx.fillText('RX Quality vs расстояние', margin.l, margin.t - 4); + ctx.fillText('0%', 2, distTop - 2); + ctx.fillText('100%', 2, qualTop + 8); + ctx.fillStyle = '#666'; + ctx.fillText('точки', 2, distTop + distBandH - 2); + + drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span, w); + + const barW = plotW / binCount; + let maxCount = 1; + bins.forEach(b => { + if (b.qualities.length > maxCount) maxCount = b.qualities.length; + }); - const barW = plotW / binCount * 0.75; bins.forEach((b, i) => { - if (!b.qualities.length) return; - const avg = b.qualities.reduce((a, v) => a + v, 0) / b.qualities.length; - const cx = margin.l + (i + 0.5) / binCount * plotW; - const barH = (avg / 100) * plotH; - const x = cx - barW / 2; - const y = margin.t + plotH - barH; - const col = qualityColor ? qualityColor(avg) : '#888'; + const x = margin.l + i / binCount * plotW; + const count = b.qualities.length; const highlight = highlightDist != null && highlightDist >= b.min && highlightDist < b.max; - ctx.fillStyle = col; - ctx.globalAlpha = highlight ? 1 : 0.75; - ctx.fillRect(x, y, barW, barH); - ctx.globalAlpha = 1; - if (highlight) { - ctx.strokeStyle = '#fff'; - ctx.lineWidth = 1.5; - ctx.strokeRect(x, y, barW, barH); + + if (count === 0) { + ctx.fillStyle = 'rgba(255,255,255,0.08)'; + ctx.fillRect(x, distTop + distBandH - 1, barW, 1); + } else { + const distBarH = (count / maxCount) * distBandH; + const intensity = 0.35 + 0.65 * (count / maxCount); + ctx.fillStyle = `rgba(74, 85, 104, ${intensity})`; + ctx.fillRect(x, distTop + distBandH - distBarH, barW, distBarH); + + const avg = b.qualities.reduce((a, v) => a + v, 0) / count; + const qualBarH = (avg / 100) * qualBandH; + const y = qualTop + qualBandH - qualBarH; + const col = qualityColor ? qualityColor(avg) : '#888'; + ctx.fillStyle = col; + ctx.globalAlpha = highlight ? 1 : 0.75; + ctx.fillRect(x, y, barW, qualBarH); + ctx.globalAlpha = 1; + if (highlight) { + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1.5; + ctx.strokeRect(x, y, barW, qualBarH); + ctx.strokeRect(x, distTop + distBandH - distBarH, barW, distBarH); + } } }); ctx.fillStyle = 'rgba(255,255,255,0.25)'; samples.forEach(s => { const x = margin.l + ((s.distM - minD) / span) * plotW; - const y = margin.t + plotH - (s.quality / 100) * plotH; + const y = qualTop + qualBandH - (s.quality / 100) * qualBandH; ctx.beginPath(); ctx.arc(x, y, 1.5, 0, Math.PI * 2); ctx.fill();