745 lines
21 KiB
Dart
745 lines
21 KiB
Dart
import 'dart:math' as math;
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/scheduler.dart';
|
|
import 'package:rra_app/pages/map/vector/mapbox_vector_tile_cache.dart';
|
|
import 'package:rra_app/pages/map/vector/mvt_parser.dart';
|
|
import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart';
|
|
|
|
// How many ancestor levels to walk when looking for a fallback image.
|
|
const int _kMaxAncestorLookup = 4;
|
|
const double _kTileResolutionScale = 2.0;
|
|
|
|
class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
|
TilePyramidPainter({
|
|
required this.tileUrlTemplate,
|
|
required this.tileSize,
|
|
required this.minTileZoom,
|
|
required this.maxTileZoom,
|
|
required this.tileOverscan,
|
|
});
|
|
|
|
final String tileUrlTemplate;
|
|
final double tileSize;
|
|
final int minTileZoom;
|
|
final int maxTileZoom;
|
|
final int tileOverscan;
|
|
|
|
// Viewport state — updated by the map page each frame.
|
|
double mapWidth = 0;
|
|
double mapHeight = 0;
|
|
double zoom = 0;
|
|
double centerWorldX = 0;
|
|
double centerWorldY = 0;
|
|
int activeTileZoom = 0;
|
|
int? fallbackTileZoom;
|
|
double devicePixelRatio = 1;
|
|
bool interactionActive = false;
|
|
|
|
// Colour filters applied when drawing each tile (contrast + saturation combined).
|
|
static const double _contrast = 1.20;
|
|
static const double _saturation = 1.12;
|
|
late final ui.ColorFilter _tileColorFilter = ui.ColorFilter.matrix(
|
|
_multiplyMatrices(
|
|
_contrastMatrix(_contrast),
|
|
_saturationMatrix(_saturation),
|
|
),
|
|
);
|
|
|
|
void markDirty() => notifyListeners();
|
|
|
|
String _tileUrl(int z, int x, int y) => tileUrlTemplate
|
|
.replaceAll('{z}', '$z')
|
|
.replaceAll('{x}', '$x')
|
|
.replaceAll('{y}', '$y');
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
if (mapWidth == 0 || mapHeight == 0) return;
|
|
|
|
final fallbackZ = fallbackTileZoom;
|
|
if (fallbackZ != null && fallbackZ != activeTileZoom) {
|
|
_drawTileLayer(canvas, fallbackZ, queueTiles: false);
|
|
}
|
|
|
|
_drawTileLayer(canvas, activeTileZoom, queueTiles: true);
|
|
}
|
|
|
|
void _drawTileLayer(Canvas canvas, int z, {required bool queueTiles}) {
|
|
final zDelta = z - activeTileZoom;
|
|
final factor = math.pow(2, zoom - z).toDouble();
|
|
final worldScale = math.pow(2, zDelta).toDouble();
|
|
final centerX = centerWorldX * worldScale;
|
|
final centerY = centerWorldY * worldScale;
|
|
final maxIndex = 1 << z;
|
|
|
|
final minWX = centerX - (mapWidth / 2) / factor;
|
|
final minWY = centerY - (mapHeight / 2) / factor;
|
|
final maxWX = centerX + (mapWidth / 2) / factor;
|
|
final maxWY = centerY + (mapHeight / 2) / factor;
|
|
|
|
final minTX = (minWX / tileSize).floor() - tileOverscan;
|
|
final maxTX = (maxWX / tileSize).floor() + tileOverscan;
|
|
final minTY = (minWY / tileSize).floor() - tileOverscan;
|
|
final maxTY = (maxWY / tileSize).floor() + tileOverscan;
|
|
|
|
if (queueTiles) {
|
|
final allowed = _allowedQueuedTileHashes(
|
|
z,
|
|
minTX,
|
|
maxTX,
|
|
minTY,
|
|
maxTY,
|
|
maxIndex,
|
|
);
|
|
TilePyramidCache.setQueueFilter(
|
|
(qz, qx, qy) => allowed.contains(Object.hash(qz, qx, qy)),
|
|
);
|
|
}
|
|
|
|
for (var tx = minTX; tx <= maxTX; tx++) {
|
|
for (var ty = minTY; ty <= maxTY; ty++) {
|
|
if (ty < 0 || ty >= maxIndex) continue;
|
|
|
|
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
|
|
|
|
final left = (tx * tileSize - minWX) * factor;
|
|
final top = (ty * tileSize - minWY) * factor;
|
|
final displaySize = tileSize * factor;
|
|
|
|
final dst = Rect.fromLTWH(left, top, displaySize, displaySize);
|
|
|
|
_drawTile(canvas, z, wrappedX, ty, dst);
|
|
}
|
|
}
|
|
|
|
// Queue rasterisation for visible tiles when permitted.
|
|
if (queueTiles) {
|
|
_queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex);
|
|
|
|
// Pre-warm tiles just outside the viewport so panning feels instant.
|
|
if (!interactionActive) {
|
|
const guard = 2;
|
|
_queueVisibleTiles(
|
|
z,
|
|
minTX - guard,
|
|
maxTX + guard,
|
|
minTY - guard,
|
|
maxTY + guard,
|
|
maxIndex,
|
|
skipIfAlreadyQueued: true,
|
|
visMinTX: minTX,
|
|
visMaxTX: maxTX,
|
|
visMinTY: minTY,
|
|
visMaxTY: maxTY,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Set<int> _allowedQueuedTileHashes(
|
|
int z,
|
|
int minTX,
|
|
int maxTX,
|
|
int minTY,
|
|
int maxTY,
|
|
int maxIndex,
|
|
) {
|
|
final hashes = <int>{};
|
|
|
|
for (var tx = minTX; tx <= maxTX; tx++) {
|
|
for (var ty = minTY; ty <= maxTY; ty++) {
|
|
if (ty < 0 || ty >= maxIndex) continue;
|
|
|
|
final wx = ((tx % maxIndex) + maxIndex) % maxIndex;
|
|
hashes.add(Object.hash(z, wx, ty));
|
|
}
|
|
}
|
|
|
|
if (!interactionActive) {
|
|
const guard = 2;
|
|
final guardMinTX = minTX - guard;
|
|
final guardMaxTX = maxTX + guard;
|
|
final guardMinTY = minTY - guard;
|
|
final guardMaxTY = maxTY + guard;
|
|
|
|
for (var tx = guardMinTX; tx <= guardMaxTX; tx++) {
|
|
for (var ty = guardMinTY; ty <= guardMaxTY; ty++) {
|
|
if (ty < 0 || ty >= maxIndex) continue;
|
|
if (tx >= minTX && tx <= maxTX && ty >= minTY && ty <= maxTY) {
|
|
continue;
|
|
}
|
|
|
|
final wx = ((tx % maxIndex) + maxIndex) % maxIndex;
|
|
hashes.add(Object.hash(z, wx, ty));
|
|
}
|
|
}
|
|
}
|
|
|
|
return hashes;
|
|
}
|
|
|
|
void _drawTile(Canvas canvas, int z, int x, int y, Rect dst) {
|
|
final zoomBucket = z;
|
|
|
|
// Try native level first.
|
|
final native = TilePyramidCache.peek(z, x, y);
|
|
if (native != null && native.zoomBucket == zoomBucket) {
|
|
_blitImage(
|
|
canvas,
|
|
native.image,
|
|
Rect.fromLTWH(
|
|
0,
|
|
0,
|
|
native.image.width.toDouble(),
|
|
native.image.height.toDouble(),
|
|
),
|
|
dst,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Walk up the pyramid for an ancestor fallback.
|
|
for (int dz = 1; dz <= _kMaxAncestorLookup; dz++) {
|
|
final az = z - dz;
|
|
if (az < minTileZoom) break;
|
|
|
|
final subdivision = 1 << dz;
|
|
final ancestorX = x >> dz;
|
|
final ancestorY = y >> dz;
|
|
final ancestor = TilePyramidCache.peek(az, ancestorX, ancestorY);
|
|
if (ancestor != null) {
|
|
// Compute which sub-region of the ancestor image corresponds to this tile.
|
|
final fracX = (x - ancestorX * subdivision).toDouble() / subdivision;
|
|
final fracY = (y - ancestorY * subdivision).toDouble() / subdivision;
|
|
final srcW = ancestor.image.width / subdivision;
|
|
final srcH = ancestor.image.height / subdivision;
|
|
final src = Rect.fromLTWH(
|
|
fracX * ancestor.image.width,
|
|
fracY * ancestor.image.height,
|
|
srcW,
|
|
srcH,
|
|
);
|
|
|
|
_blitImage(canvas, ancestor.image, src, dst);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Queue native image if not already cached/in-flight.
|
|
if ((native == null || native.zoomBucket != zoomBucket) &&
|
|
!TilePyramidCache.isInFlight(z, x, y) &&
|
|
!interactionActive) {
|
|
_queueTile(z, x, y, zoomBucket);
|
|
}
|
|
}
|
|
|
|
void _blitImage(Canvas canvas, ui.Image image, Rect src, Rect dst) {
|
|
final paint = Paint()
|
|
..filterQuality = FilterQuality.high
|
|
..colorFilter = _tileColorFilter;
|
|
|
|
canvas.drawImageRect(image, src, dst, paint);
|
|
}
|
|
|
|
void _queueTile(int z, int x, int y, int zoomBucket) {
|
|
final url = _tileUrl(z, x, y);
|
|
final rasterPx = (tileSize * devicePixelRatio * _kTileResolutionScale)
|
|
.round();
|
|
final currentZoom = zoom;
|
|
|
|
TilePyramidCache.enqueue(
|
|
z,
|
|
x,
|
|
y,
|
|
zoomBucket,
|
|
() async {
|
|
final tile = await MapboxVectorTileCache.getOrFetch(url);
|
|
if (tile == null || tile.layers.isEmpty) return null;
|
|
|
|
return _rasterize(tile, zoom: currentZoom, rasterPx: rasterPx);
|
|
},
|
|
() {
|
|
// Image ready — ask Flutter to redraw.
|
|
SchedulerBinding.instance.scheduleFrame();
|
|
notifyListeners();
|
|
},
|
|
);
|
|
}
|
|
|
|
void _queueVisibleTiles(
|
|
int z,
|
|
int minTX,
|
|
int maxTX,
|
|
int minTY,
|
|
int maxTY,
|
|
int maxIndex, {
|
|
bool skipIfAlreadyQueued = false,
|
|
int visMinTX = 0,
|
|
int visMaxTX = 0,
|
|
int visMinTY = 0,
|
|
int visMaxTY = 0,
|
|
}) {
|
|
for (var tx = minTX; tx <= maxTX; tx++) {
|
|
for (var ty = minTY; ty <= maxTY; ty++) {
|
|
if (ty < 0 || ty >= maxIndex) continue;
|
|
|
|
// When pre-warming the guard ring, skip tiles in the core visible area
|
|
// (they're already handled by the first call).
|
|
if (skipIfAlreadyQueued &&
|
|
tx >= visMinTX &&
|
|
tx <= visMaxTX &&
|
|
ty >= visMinTY &&
|
|
ty <= visMaxTY) {
|
|
continue;
|
|
}
|
|
|
|
final wx = ((tx % maxIndex) + maxIndex) % maxIndex;
|
|
final cached = TilePyramidCache.peek(z, wx, ty);
|
|
if (cached != null && cached.zoomBucket == z) continue;
|
|
if (TilePyramidCache.isInFlight(z, wx, ty)) continue;
|
|
_queueTile(z, wx, ty, z);
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Rasterisation for the cached tile images.
|
|
// -------------------------------------------------------------------------
|
|
|
|
void _drawTileContents(
|
|
Canvas canvas,
|
|
Size sz,
|
|
MvtTile tile, {
|
|
required double zoom,
|
|
}) {
|
|
final paint = Paint()..isAntiAlias = true;
|
|
paint.color = const Color(0xFFE6EAF1);
|
|
canvas.drawRect(Offset.zero & sz, paint);
|
|
|
|
_drawPolygons(
|
|
canvas,
|
|
sz,
|
|
tile,
|
|
const <String>{'landuse'},
|
|
const Color(0xFFCCDCBF),
|
|
zoom: zoom,
|
|
);
|
|
_drawPolygons(
|
|
canvas,
|
|
sz,
|
|
tile,
|
|
const <String>{'park'},
|
|
const Color(0xFF97D585),
|
|
zoom: zoom,
|
|
);
|
|
_drawPolygons(
|
|
canvas,
|
|
sz,
|
|
tile,
|
|
const <String>{'water'},
|
|
const Color(0xFF68B5EC),
|
|
zoom: zoom,
|
|
);
|
|
_drawPolygons(
|
|
canvas,
|
|
sz,
|
|
tile,
|
|
const <String>{'building'},
|
|
const Color(0xFFCCD3DF),
|
|
zoom: zoom,
|
|
strokeColor: const Color(0xFF8E9CB4),
|
|
strokeBaseWidth: 0.56,
|
|
);
|
|
|
|
_drawRoads(canvas, sz, tile, zoom: zoom);
|
|
_drawLabels(canvas, sz, tile, zoom: zoom);
|
|
}
|
|
|
|
Future<ui.Image?> _rasterize(
|
|
MvtTile tile, {
|
|
required double zoom,
|
|
required int rasterPx,
|
|
}) async {
|
|
if (rasterPx <= 0) return null;
|
|
|
|
final recorder = ui.PictureRecorder();
|
|
final sz = Size(rasterPx.toDouble(), rasterPx.toDouble());
|
|
_drawTileContents(Canvas(recorder), sz, tile, zoom: zoom);
|
|
|
|
final pic = recorder.endRecording();
|
|
try {
|
|
return await pic.toImage(rasterPx, rasterPx);
|
|
} finally {
|
|
pic.dispose();
|
|
}
|
|
}
|
|
|
|
void _drawPolygons(
|
|
Canvas canvas,
|
|
Size size,
|
|
MvtTile tile,
|
|
Set<String> layerNames,
|
|
Color color, {
|
|
required double zoom,
|
|
Color? strokeColor,
|
|
double strokeBaseWidth = 0.0,
|
|
}) {
|
|
final fill = Paint()
|
|
..color = color
|
|
..style = PaintingStyle.fill
|
|
..isAntiAlias = true;
|
|
|
|
final stroke = strokeColor == null
|
|
? null
|
|
: (Paint()
|
|
..color = strokeColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth =
|
|
(strokeBaseWidth + (zoom - 15.0).clamp(0.0, 8.0) * 0.06).clamp(
|
|
0.44,
|
|
1.18,
|
|
)
|
|
..strokeJoin = StrokeJoin.round
|
|
..strokeCap = StrokeCap.round
|
|
..isAntiAlias = true);
|
|
|
|
for (final entry in tile.layers.entries) {
|
|
if (!layerNames.contains(entry.key)) continue;
|
|
final layer = entry.value;
|
|
final scale = size.width / layer.extent;
|
|
|
|
for (final feature in layer.features) {
|
|
if (feature.type != MvtGeometryType.polygon) continue;
|
|
for (final ring in feature.geometry) {
|
|
if (ring.length < 3) continue;
|
|
final path = Path()
|
|
..moveTo(ring.first.dx * scale, ring.first.dy * scale);
|
|
for (var i = 1; i < ring.length; i++) {
|
|
path.lineTo(ring[i].dx * scale, ring[i].dy * scale);
|
|
}
|
|
path.close();
|
|
canvas.drawPath(path, fill);
|
|
if (stroke != null) canvas.drawPath(path, stroke);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void _drawRoads(
|
|
Canvas canvas,
|
|
Size size,
|
|
MvtTile tile, {
|
|
required double zoom,
|
|
}) {
|
|
final roadLayer = tile.layers['road'];
|
|
if (roadLayer == null) return;
|
|
|
|
final scale = size.width / roadLayer.extent;
|
|
|
|
for (final feature in roadLayer.features) {
|
|
if (feature.type != MvtGeometryType.lineString) continue;
|
|
|
|
final roadClass =
|
|
(feature.properties['class'] ?? feature.properties['type'] ?? '')
|
|
.toString()
|
|
.toLowerCase();
|
|
|
|
final isPathLike =
|
|
roadClass.contains('path') ||
|
|
roadClass.contains('footway') ||
|
|
roadClass.contains('steps') ||
|
|
roadClass.contains('cycleway') ||
|
|
roadClass.contains('track') ||
|
|
roadClass.contains('bridleway');
|
|
if (isPathLike && zoom < 15) continue;
|
|
|
|
final isMotorway = roadClass.contains('motorway');
|
|
final isTrunkPrimary =
|
|
roadClass.contains('trunk') || roadClass.contains('primary');
|
|
final isSecondary =
|
|
roadClass.contains('secondary') || roadClass.contains('tertiary');
|
|
final isLink = roadClass.contains('link');
|
|
|
|
var baseWidth = 0.92;
|
|
var growth = 0.24;
|
|
var casingColor = const Color(0xFF9BA7BE);
|
|
var fillColor = const Color(0xFFD8DFEC);
|
|
|
|
if (isMotorway) {
|
|
baseWidth = 1.60;
|
|
growth = 0.42;
|
|
casingColor = const Color(0xFF5D6F92);
|
|
fillColor = const Color(0xFF98AFCF);
|
|
} else if (isTrunkPrimary) {
|
|
baseWidth = 1.35;
|
|
growth = 0.34;
|
|
casingColor = const Color(0xFF7080A0);
|
|
fillColor = const Color(0xFFAFC0D8);
|
|
} else if (isSecondary) {
|
|
baseWidth = 1.06;
|
|
growth = 0.28;
|
|
casingColor = const Color(0xFF8694B0);
|
|
fillColor = const Color(0xFFC5D1E3);
|
|
}
|
|
|
|
if (isLink) baseWidth *= 0.82;
|
|
|
|
final zoomBoost = (zoom - 11).clamp(0.0, 10.0);
|
|
final width = (baseWidth + zoomBoost * growth).clamp(0.60, 7.20);
|
|
|
|
final casing = Paint()
|
|
..color = casingColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = width + (isMotorway ? 1.15 : 0.95)
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.round
|
|
..isAntiAlias = true;
|
|
|
|
final road = Paint()
|
|
..color = fillColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = width
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.round
|
|
..isAntiAlias = true;
|
|
|
|
for (final line in feature.geometry) {
|
|
if (line.length < 2) continue;
|
|
final path = Path()
|
|
..moveTo(line.first.dx * scale, line.first.dy * scale);
|
|
for (var i = 1; i < line.length; i++) {
|
|
path.lineTo(line[i].dx * scale, line[i].dy * scale);
|
|
}
|
|
canvas.drawPath(path, casing);
|
|
canvas.drawPath(path, road);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _drawLabels(
|
|
Canvas canvas,
|
|
Size size,
|
|
MvtTile tile, {
|
|
required double zoom,
|
|
}) {
|
|
if (zoom < 12) return;
|
|
|
|
final occupiedRects = <Rect>[];
|
|
final seenTexts = <String>{};
|
|
|
|
void drawFromLayer(
|
|
String layerName,
|
|
Color color,
|
|
double fontSize, {
|
|
bool pointsOnly = true,
|
|
FontWeight fontWeight = FontWeight.w500,
|
|
required double minZoom,
|
|
int maxLabels = 9999,
|
|
double minSpacing = 10,
|
|
Set<String>? allowedClasses,
|
|
}) {
|
|
if (zoom < minZoom) return;
|
|
final layer = tile.layers[layerName];
|
|
if (layer == null) return;
|
|
final scale = size.width / layer.extent;
|
|
final candidates = <_LabelCandidate>[];
|
|
|
|
for (final feature in layer.features) {
|
|
if (pointsOnly && feature.type != MvtGeometryType.point) continue;
|
|
final featureClass =
|
|
(feature.properties['class'] ?? feature.properties['type'] ?? '')
|
|
.toString()
|
|
.toLowerCase();
|
|
if (allowedClasses != null &&
|
|
allowedClasses.isNotEmpty &&
|
|
!allowedClasses.contains(featureClass)) {
|
|
continue;
|
|
}
|
|
|
|
final text =
|
|
(feature.properties['name_en'] ?? feature.properties['name'] ?? '')
|
|
.toString()
|
|
.trim();
|
|
if (text.isEmpty || text.length > 26) continue;
|
|
|
|
final anchor =
|
|
feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty
|
|
? feature.geometry.first.first
|
|
: null;
|
|
if (anchor == null) continue;
|
|
|
|
final scalerank = _toRank(feature.properties['scalerank']) ?? 99;
|
|
final localrank = _toRank(feature.properties['localrank']) ?? 99;
|
|
candidates.add(
|
|
_LabelCandidate(
|
|
text: text,
|
|
anchor: anchor,
|
|
rank: scalerank * 100 + localrank,
|
|
),
|
|
);
|
|
}
|
|
|
|
candidates.sort((a, b) {
|
|
final r = a.rank.compareTo(b.rank);
|
|
return r != 0 ? r : a.text.length.compareTo(b.text.length);
|
|
});
|
|
|
|
var drawn = 0;
|
|
for (final c in candidates) {
|
|
if (drawn >= maxLabels) break;
|
|
if (seenTexts.contains(c.text.toLowerCase())) continue;
|
|
|
|
final tp = TextPainter(
|
|
text: TextSpan(
|
|
text: c.text,
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: fontSize,
|
|
fontWeight: fontWeight,
|
|
shadows: const <Shadow>[
|
|
Shadow(color: Color(0xF7FFFFFF), blurRadius: 1.4),
|
|
],
|
|
height: 1.0,
|
|
),
|
|
),
|
|
textDirection: TextDirection.ltr,
|
|
maxLines: 1,
|
|
ellipsis: '',
|
|
)..layout();
|
|
|
|
final x = c.anchor.dx * scale - tp.width / 2;
|
|
final y = c.anchor.dy * scale - tp.height / 2;
|
|
|
|
if (x < -tp.width ||
|
|
y < -tp.height ||
|
|
x > size.width ||
|
|
y > size.height) {
|
|
continue;
|
|
}
|
|
|
|
final labelRect = Rect.fromLTWH(
|
|
x,
|
|
y,
|
|
tp.width,
|
|
tp.height,
|
|
).inflate(minSpacing);
|
|
if (occupiedRects.any((r) => r.overlaps(labelRect))) continue;
|
|
|
|
tp.paint(canvas, Offset(x, y));
|
|
occupiedRects.add(labelRect);
|
|
seenTexts.add(c.text.toLowerCase());
|
|
drawn++;
|
|
}
|
|
}
|
|
|
|
drawFromLayer(
|
|
'place_label',
|
|
const Color(0xFF1F2636),
|
|
11.2,
|
|
fontWeight: FontWeight.w600,
|
|
minZoom: 12.5,
|
|
maxLabels: zoom >= 15 ? 5 : 2,
|
|
minSpacing: zoom >= 15 ? 24 : 30,
|
|
allowedClasses: const <String>{
|
|
'city',
|
|
'town',
|
|
'suburb',
|
|
'neighbourhood',
|
|
'borough',
|
|
},
|
|
);
|
|
}
|
|
|
|
int? _toRank(Object? value) {
|
|
if (value is int) return value;
|
|
if (value is double) return value.toInt();
|
|
if (value is String) return int.tryParse(value);
|
|
return null;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// ColorFilter matrices
|
|
// -------------------------------------------------------------------------
|
|
|
|
static List<double> _contrastMatrix(double contrast) {
|
|
final c = contrast.clamp(0.0, 3.0);
|
|
final t = 128.0 * (1.0 - c);
|
|
return <double>[c, 0, 0, 0, t, 0, c, 0, 0, t, 0, 0, c, 0, t, 0, 0, 0, 1, 0];
|
|
}
|
|
|
|
static List<double> _saturationMatrix(double saturation) {
|
|
final s = saturation.clamp(0.0, 2.0);
|
|
const rw = 0.213;
|
|
const gw = 0.715;
|
|
const bw = 0.072;
|
|
final a = 1 - s;
|
|
return <double>[
|
|
a * rw + s,
|
|
a * gw,
|
|
a * bw,
|
|
0,
|
|
0,
|
|
a * rw,
|
|
a * gw + s,
|
|
a * bw,
|
|
0,
|
|
0,
|
|
a * rw,
|
|
a * gw,
|
|
a * bw + s,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
1,
|
|
0,
|
|
];
|
|
}
|
|
|
|
// 4x5 colour matrix multiply: result = a * b (both are row-major 4x5).
|
|
static List<double> _multiplyMatrices(List<double> a, List<double> b) {
|
|
final out = List<double>.filled(20, 0);
|
|
for (int row = 0; row < 4; row++) {
|
|
for (int col = 0; col < 5; col++) {
|
|
double v = 0;
|
|
for (int k = 0; k < 4; k++) {
|
|
v += a[row * 5 + k] * b[k * 5 + col];
|
|
}
|
|
// translation column — just add when col == 4
|
|
if (col == 4) v += a[row * 5 + 4];
|
|
out[row * 5 + col] = v;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// CustomPainter boilerplate
|
|
// -------------------------------------------------------------------------
|
|
|
|
@override
|
|
bool shouldRepaint(covariant TilePyramidPainter oldDelegate) => true;
|
|
|
|
@override
|
|
bool shouldRebuildSemantics(covariant CustomPainter oldDelegate) => false;
|
|
|
|
@override
|
|
SemanticsBuilderCallback? get semanticsBuilder => null;
|
|
|
|
@override
|
|
bool hitTest(Offset position) => false;
|
|
}
|
|
|
|
class _LabelCandidate {
|
|
const _LabelCandidate({
|
|
required this.text,
|
|
required this.anchor,
|
|
required this.rank,
|
|
});
|
|
final String text;
|
|
final Offset anchor;
|
|
final int rank;
|
|
}
|