Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions mobile/lib/services/brush_overlay.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import 'package:maplibre_gl/maplibre_gl.dart';

/// Render-side contract used by `BrushController`. Tests substitute a fake
/// so the controller's state logic can be exercised without a live
/// MapLibre surface.
abstract class BrushOverlaySurface {
bool get isAttached;
Future<void> setPreview(Map<String, dynamic> geometry, String colorHex);
Future<void> clear();
Future<void> detach();
}

/// Semi-transparent fill of the in-progress brush stroke. Mirrors the
/// `brush-preview` source + fill layer in web/src/lib/brush.svelte.js.
class BrushOverlay implements BrushOverlaySurface {
BrushOverlay._(this._controller);

static const String sourceId = 'brush-preview';
static const String fillLayerId = 'brush-preview-fill';

static const Map<int, String> _colors = {
-7: '#c0392b',
-3: '#e74c3c',
-1: '#f1948a',
0: '#6b7280',
1: '#76d7c4',
3: '#1abc9c',
7: '#0e6655',
};
static const String _fallbackColor = '#6b7280';

/// Get the hex color for a given rating value, or the fallback gray if not found.
static String colorFor(int value) => _colors[value] ?? _fallbackColor;

final MapLibreMapController _controller;
bool _attached = false;
String? _lastColorHex;

@override
bool get isAttached => _attached;

/// Create and attach the brush preview source and fill layer to the current style.
///
/// [belowLayerId] allows layering the brush preview below other overlays.
/// Pass `null` to add at the top of the style. Returns an attached overlay
/// ready to receive geometry updates via [setPreview].
static Future<BrushOverlay> attach(
MapLibreMapController controller, {
String? belowLayerId,
}) async {
final overlay = BrushOverlay._(controller);

await controller.addGeoJsonSource(
sourceId,
const {'type': 'FeatureCollection', 'features': []},
);
await controller.addFillLayer(
sourceId,
fillLayerId,
const FillLayerProperties(
fillColor: _fallbackColor,
fillOpacity: 0.3,
),
belowLayerId: belowLayerId,
enableInteraction: false,
);
overlay._attached = true;
return overlay;
}

/// Update the preview geometry and color, caching the color to avoid redundant layer updates.
///
/// Since [setPreview] is called on every pointer-move event, the geometry always
/// changes but the color usually stays the same within a stroke. This method skips
/// [setLayerProperties] calls when the color hex hasn't changed.
@override
Future<void> setPreview(
Map<String, dynamic> geometry,
String colorHex,
) async {
if (!_attached) return;
await _controller.setGeoJsonSource(sourceId, {
'type': 'FeatureCollection',
'features': [
{'type': 'Feature', 'properties': {}, 'geometry': geometry},
],
});
if (colorHex != _lastColorHex) {
await _controller.setLayerProperties(
fillLayerId,
FillLayerProperties(fillColor: colorHex, fillOpacity: 0.3),
);
_lastColorHex = colorHex;
}
}

/// Clear all rendered geometry without removing the source or layer.
///
/// The preview will be hidden until the next [setPreview] call.
@override
Future<void> clear() async {
if (!_attached) return;
await _controller.setGeoJsonSource(sourceId, const {
'type': 'FeatureCollection',
'features': [],
});
}

/// Remove the fill layer and source from the style. Safe to call multiple times.
///
/// This should be called during screen disposal to clean up all resources.
@override
Future<void> detach() async {
if (!_attached) return;
_attached = false;
try {
await _controller.removeLayer(fillLayerId);
} catch (_) {}
try {
await _controller.removeSource(sourceId);
} catch (_) {}
}
}
53 changes: 53 additions & 0 deletions mobile/test/services/brush_overlay_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:beebeebike/services/brush_overlay.dart';

class _FakeSurface implements BrushOverlaySurface {
@override
bool isAttached = true;
Map<String, dynamic>? lastGeometry;
String? lastColor;
bool cleared = false;
bool detached = false;

@override
Future<void> setPreview(
Map<String, dynamic> geometry, String colorHex) async {
lastGeometry = geometry;
lastColor = colorHex;
}

@override
Future<void> clear() async => cleared = true;

@override
Future<void> detach() async {
detached = true;
isAttached = false;
}
}

void main() {
test('colorFor returns expected web-parity hex per rating', () {
expect(BrushOverlay.colorFor(-7), '#c0392b');
expect(BrushOverlay.colorFor(-3), '#e74c3c');
expect(BrushOverlay.colorFor(-1), '#f1948a');
expect(BrushOverlay.colorFor(0), '#6b7280');
expect(BrushOverlay.colorFor(1), '#76d7c4');
expect(BrushOverlay.colorFor(3), '#1abc9c');
expect(BrushOverlay.colorFor(7), '#0e6655');
});

test('BrushOverlaySurface contract is usable via a fake', () async {
final fake = _FakeSurface();
await fake.setPreview(
const {'type': 'Polygon', 'coordinates': []},
'#1abc9c',
);
expect(fake.lastColor, '#1abc9c');
await fake.clear();
expect(fake.cleared, isTrue);
await fake.detach();
expect(fake.detached, isTrue);
expect(fake.isAttached, isFalse);
});
}
4 changes: 3 additions & 1 deletion web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app { width: 100%; height: 100%; }
body {
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-family: "Manrope Variable", Manrope, -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
color: #14272F;
background: #EEF0F2;
}
button, input, textarea, select {
font: inherit;
Expand Down
20 changes: 20 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"vitest": "^3.2.4"
},
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"@turf/buffer": "^7.3.4",
"@turf/helpers": "^7.3.4",
"@versatiles/style": "^5.10.2",
Expand Down
32 changes: 23 additions & 9 deletions web/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -158,23 +158,37 @@
}
.user-bar {
pointer-events: auto;
background: white; padding: 8px 16px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
display: flex; gap: 12px; align-items: center; font-size: 14px;
background: var(--panel);
padding: 8px 16px;
border-radius: var(--radius-chip);
box-shadow: var(--shadow-panel);
display: flex;
gap: 12px;
align-items: center;
font: 600 14px/1.3 var(--font-sans);
color: var(--ink);
white-space: nowrap;
}
.user-bar button {
background: none; border: none; color: #2563eb; cursor: pointer;
background: none;
border: none;
color: var(--brand);
cursor: pointer;
font: 600 14px/1.3 var(--font-sans);
padding: 0;
}
.user-bar button:hover {
color: var(--ink);
}

@media (max-width: 640px) {
.top-panel {
right: 12px;
flex-direction: column;
gap: 0;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
background: var(--panel);
border-radius: var(--radius-ctrl);
box-shadow: var(--shadow-panel);
pointer-events: auto;
}
.search-stack {
Expand All @@ -189,8 +203,8 @@
font-size: 12px;
background: none;
box-shadow: none;
border-radius: 0 0 8px 8px;
border-top: 1px solid #f0f0f0;
border-radius: 0 0 var(--radius-ctrl) var(--radius-ctrl);
border-top: 1px solid var(--divider);
}
}
</style>
Loading
Loading