add tile caching and rendering for Mapbox vector tiles with custom painter
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart';
|
||||
import 'package:rra_app/pages/map/vector/mvt_parser.dart';
|
||||
|
||||
class MapboxVectorTileCache {
|
||||
MapboxVectorTileCache._();
|
||||
|
||||
static final MvtParser _parser = const MvtParser();
|
||||
static const Set<String> _styleLayerWhitelist = <String>{
|
||||
'water',
|
||||
'landuse',
|
||||
'park',
|
||||
'building',
|
||||
'road',
|
||||
'road_label',
|
||||
'place_label',
|
||||
'poi_label',
|
||||
};
|
||||
static const int _maxEntries = 220;
|
||||
static final LinkedHashMap<String, MvtTile> _cache =
|
||||
LinkedHashMap<String, MvtTile>();
|
||||
static final Map<String, Future<MvtTile?>> _inFlight =
|
||||
<String, Future<MvtTile?>>{};
|
||||
|
||||
static MvtTile? peek(String url) {
|
||||
final cached = _cache.remove(url);
|
||||
if (cached != null) {
|
||||
_cache[url] = cached;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
static Future<MvtTile?> getOrFetch(String url) {
|
||||
final cached = peek(url);
|
||||
if (cached != null) return Future<MvtTile?>.value(cached);
|
||||
|
||||
final inFlight = _inFlight[url];
|
||||
if (inFlight != null) return inFlight;
|
||||
|
||||
final future = _fetch(url);
|
||||
_inFlight[url] = future;
|
||||
future.whenComplete(() => _inFlight.remove(url));
|
||||
return future;
|
||||
}
|
||||
|
||||
static Future<MvtTile?> _fetch(String url) async {
|
||||
final bytes = await HiveTileCache.getOrFetch(url);
|
||||
if (bytes == null || bytes.isEmpty) return null;
|
||||
final tile = _parse(bytes);
|
||||
if (tile == null) return null;
|
||||
_cache[url] = tile;
|
||||
_trim();
|
||||
return tile;
|
||||
}
|
||||
|
||||
static MvtTile? _parse(Uint8List bytes) {
|
||||
try {
|
||||
return _parser.parse(bytes, layerNames: _styleLayerWhitelist);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void _trim() {
|
||||
while (_cache.length > _maxEntries) {
|
||||
_cache.remove(_cache.keys.first);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui';
|
||||
|
||||
enum MvtGeometryType { unknown, point, lineString, polygon }
|
||||
|
||||
class MvtTile {
|
||||
const MvtTile({required this.layers});
|
||||
|
||||
final Map<String, MvtLayer> layers;
|
||||
}
|
||||
|
||||
class MvtLayer {
|
||||
const MvtLayer({
|
||||
required this.name,
|
||||
required this.extent,
|
||||
required this.features,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final int extent;
|
||||
final List<MvtFeature> features;
|
||||
}
|
||||
|
||||
class MvtFeature {
|
||||
const MvtFeature({
|
||||
required this.type,
|
||||
required this.properties,
|
||||
required this.geometry,
|
||||
});
|
||||
|
||||
final MvtGeometryType type;
|
||||
final Map<String, Object?> properties;
|
||||
final List<List<Offset>> geometry;
|
||||
}
|
||||
|
||||
class MvtParser {
|
||||
const MvtParser();
|
||||
|
||||
MvtTile parse(Uint8List bytes, {Set<String>? layerNames}) {
|
||||
final reader = _PbfReader(bytes);
|
||||
final layers = <String, MvtLayer>{};
|
||||
|
||||
while (!reader.isAtEnd) {
|
||||
final tag = reader.readVarint();
|
||||
final field = tag >> 3;
|
||||
final wire = tag & 0x07;
|
||||
if (field == 3 && wire == 2) {
|
||||
final layerBytes = reader.readBytes();
|
||||
final layer = _parseLayer(layerBytes);
|
||||
if (layer == null) continue;
|
||||
if (layerNames != null && !layerNames.contains(layer.name)) continue;
|
||||
layers[layer.name] = layer;
|
||||
} else {
|
||||
reader.skipWireType(wire);
|
||||
}
|
||||
}
|
||||
|
||||
return MvtTile(layers: layers);
|
||||
}
|
||||
|
||||
MvtLayer? _parseLayer(Uint8List bytes) {
|
||||
final reader = _PbfReader(bytes);
|
||||
String name = '';
|
||||
var extent = 4096;
|
||||
final rawFeatures = <_RawFeature>[];
|
||||
final keys = <String>[];
|
||||
final values = <Object?>[];
|
||||
|
||||
while (!reader.isAtEnd) {
|
||||
final tag = reader.readVarint();
|
||||
final field = tag >> 3;
|
||||
final wire = tag & 0x07;
|
||||
switch (field) {
|
||||
case 1:
|
||||
if (wire == 2) {
|
||||
name = reader.readString();
|
||||
} else {
|
||||
reader.skipWireType(wire);
|
||||
}
|
||||
case 2:
|
||||
if (wire == 2) {
|
||||
rawFeatures.add(_parseRawFeature(reader.readBytes()));
|
||||
} else {
|
||||
reader.skipWireType(wire);
|
||||
}
|
||||
case 3:
|
||||
if (wire == 2) {
|
||||
keys.add(reader.readString());
|
||||
} else {
|
||||
reader.skipWireType(wire);
|
||||
}
|
||||
case 4:
|
||||
if (wire == 2) {
|
||||
values.add(_parseValue(reader.readBytes()));
|
||||
} else {
|
||||
reader.skipWireType(wire);
|
||||
}
|
||||
case 5:
|
||||
if (wire == 0) {
|
||||
extent = reader.readVarint();
|
||||
} else {
|
||||
reader.skipWireType(wire);
|
||||
}
|
||||
default:
|
||||
reader.skipWireType(wire);
|
||||
}
|
||||
}
|
||||
|
||||
if (name.isEmpty) return null;
|
||||
|
||||
final features = <MvtFeature>[];
|
||||
for (final raw in rawFeatures) {
|
||||
final properties = <String, Object?>{};
|
||||
for (var i = 0; i + 1 < raw.tags.length; i += 2) {
|
||||
final keyIndex = raw.tags[i];
|
||||
final valueIndex = raw.tags[i + 1];
|
||||
if (keyIndex < 0 || keyIndex >= keys.length) continue;
|
||||
if (valueIndex < 0 || valueIndex >= values.length) continue;
|
||||
properties[keys[keyIndex]] = values[valueIndex];
|
||||
}
|
||||
|
||||
features.add(
|
||||
MvtFeature(
|
||||
type: _decodeFeatureType(raw.type),
|
||||
properties: properties,
|
||||
geometry: _decodeGeometry(raw.geometry, _decodeFeatureType(raw.type)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return MvtLayer(name: name, extent: extent, features: features);
|
||||
}
|
||||
|
||||
_RawFeature _parseRawFeature(Uint8List bytes) {
|
||||
final reader = _PbfReader(bytes);
|
||||
var type = 0;
|
||||
var geometry = <int>[];
|
||||
final tags = <int>[];
|
||||
|
||||
while (!reader.isAtEnd) {
|
||||
final tag = reader.readVarint();
|
||||
final field = tag >> 3;
|
||||
final wire = tag & 0x07;
|
||||
switch (field) {
|
||||
case 2:
|
||||
if (wire == 2) {
|
||||
tags.addAll(reader.readPackedVarints());
|
||||
} else {
|
||||
reader.skipWireType(wire);
|
||||
}
|
||||
case 3:
|
||||
if (wire == 0) {
|
||||
type = reader.readVarint();
|
||||
} else {
|
||||
reader.skipWireType(wire);
|
||||
}
|
||||
case 4:
|
||||
if (wire == 2) {
|
||||
geometry = reader.readPackedVarints();
|
||||
} else {
|
||||
reader.skipWireType(wire);
|
||||
}
|
||||
default:
|
||||
reader.skipWireType(wire);
|
||||
}
|
||||
}
|
||||
|
||||
return _RawFeature(type: type, tags: tags, geometry: geometry);
|
||||
}
|
||||
|
||||
Object? _parseValue(Uint8List bytes) {
|
||||
final reader = _PbfReader(bytes);
|
||||
while (!reader.isAtEnd) {
|
||||
final tag = reader.readVarint();
|
||||
final field = tag >> 3;
|
||||
final wire = tag & 0x07;
|
||||
switch (field) {
|
||||
case 1:
|
||||
if (wire == 2) return reader.readString();
|
||||
case 2:
|
||||
if (wire == 5) return reader.readFloat32();
|
||||
case 3:
|
||||
if (wire == 1) return reader.readFloat64();
|
||||
case 4:
|
||||
if (wire == 0) return reader.readVarint();
|
||||
case 5:
|
||||
if (wire == 0) return reader.readVarint();
|
||||
case 6:
|
||||
if (wire == 0) return _decodeZigZag(reader.readVarint());
|
||||
case 7:
|
||||
if (wire == 0) return reader.readVarint() != 0;
|
||||
}
|
||||
reader.skipWireType(wire);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
MvtGeometryType _decodeFeatureType(int rawType) {
|
||||
switch (rawType) {
|
||||
case 1:
|
||||
return MvtGeometryType.point;
|
||||
case 2:
|
||||
return MvtGeometryType.lineString;
|
||||
case 3:
|
||||
return MvtGeometryType.polygon;
|
||||
default:
|
||||
return MvtGeometryType.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
List<List<Offset>> _decodeGeometry(List<int> commands, MvtGeometryType type) {
|
||||
final geometries = <List<Offset>>[];
|
||||
var cursorX = 0;
|
||||
var cursorY = 0;
|
||||
var i = 0;
|
||||
List<Offset>? current;
|
||||
|
||||
while (i < commands.length) {
|
||||
final commandInteger = commands[i++];
|
||||
final commandId = commandInteger & 0x7;
|
||||
final count = commandInteger >> 3;
|
||||
|
||||
if (commandId == 1) {
|
||||
for (var c = 0; c < count; c++) {
|
||||
if (i + 1 >= commands.length) break;
|
||||
cursorX += _decodeZigZag(commands[i++]);
|
||||
cursorY += _decodeZigZag(commands[i++]);
|
||||
final point = Offset(cursorX.toDouble(), cursorY.toDouble());
|
||||
if (type == MvtGeometryType.point) {
|
||||
geometries.add(<Offset>[point]);
|
||||
current = null;
|
||||
} else {
|
||||
current = <Offset>[point];
|
||||
geometries.add(current);
|
||||
}
|
||||
}
|
||||
} else if (commandId == 2) {
|
||||
if (current == null) {
|
||||
current = <Offset>[];
|
||||
geometries.add(current);
|
||||
}
|
||||
for (var c = 0; c < count; c++) {
|
||||
if (i + 1 >= commands.length) break;
|
||||
cursorX += _decodeZigZag(commands[i++]);
|
||||
cursorY += _decodeZigZag(commands[i++]);
|
||||
current.add(Offset(cursorX.toDouble(), cursorY.toDouble()));
|
||||
}
|
||||
} else if (commandId == 7) {
|
||||
if (type == MvtGeometryType.polygon &&
|
||||
current != null &&
|
||||
current.isNotEmpty &&
|
||||
current.first != current.last) {
|
||||
current.add(current.first);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return geometries.where((path) => path.isNotEmpty).toList(growable: false);
|
||||
}
|
||||
|
||||
int _decodeZigZag(int n) => (n >> 1) ^ (-(n & 1));
|
||||
}
|
||||
|
||||
class _RawFeature {
|
||||
const _RawFeature({
|
||||
required this.type,
|
||||
required this.tags,
|
||||
required this.geometry,
|
||||
});
|
||||
|
||||
final int type;
|
||||
final List<int> tags;
|
||||
final List<int> geometry;
|
||||
}
|
||||
|
||||
class _PbfReader {
|
||||
_PbfReader(Uint8List bytes)
|
||||
: _bytes = bytes,
|
||||
_data = ByteData.sublistView(bytes);
|
||||
|
||||
final Uint8List _bytes;
|
||||
final ByteData _data;
|
||||
int _offset = 0;
|
||||
|
||||
bool get isAtEnd => _offset >= _bytes.length;
|
||||
|
||||
int readVarint() {
|
||||
var shift = 0;
|
||||
var result = 0;
|
||||
while (true) {
|
||||
if (_offset >= _bytes.length) {
|
||||
throw const FormatException('Unexpected EOF while reading varint');
|
||||
}
|
||||
final byte = _bytes[_offset++];
|
||||
result |= (byte & 0x7f) << shift;
|
||||
if ((byte & 0x80) == 0) break;
|
||||
shift += 7;
|
||||
if (shift > 63) {
|
||||
throw const FormatException('Varint too long');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Uint8List readBytes() {
|
||||
final length = readVarint();
|
||||
if (length < 0 || _offset + length > _bytes.length) {
|
||||
throw const FormatException('Invalid length-delimited field');
|
||||
}
|
||||
final bytes = Uint8List.sublistView(_bytes, _offset, _offset + length);
|
||||
_offset += length;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
String readString() {
|
||||
final bytes = readBytes();
|
||||
return String.fromCharCodes(bytes);
|
||||
}
|
||||
|
||||
double readFloat32() {
|
||||
final value = _data.getFloat32(_offset, Endian.little);
|
||||
_offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
double readFloat64() {
|
||||
final value = _data.getFloat64(_offset, Endian.little);
|
||||
_offset += 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
List<int> readPackedVarints() {
|
||||
final bytes = readBytes();
|
||||
final inner = _PbfReader(bytes);
|
||||
final values = <int>[];
|
||||
while (!inner.isAtEnd) {
|
||||
values.add(inner.readVarint());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
void skipWireType(int wireType) {
|
||||
switch (wireType) {
|
||||
case 0:
|
||||
readVarint();
|
||||
case 1:
|
||||
_offset += 8;
|
||||
case 2:
|
||||
final length = readVarint();
|
||||
_offset += length;
|
||||
case 5:
|
||||
_offset += 4;
|
||||
default:
|
||||
throw FormatException('Unsupported wire type: $wireType');
|
||||
}
|
||||
if (_offset > _bytes.length) {
|
||||
throw const FormatException('Unexpected EOF while skipping field');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
class _TileCoord {
|
||||
const _TileCoord(this.z, this.x, this.y);
|
||||
|
||||
final int z;
|
||||
final int x;
|
||||
final int y;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is _TileCoord && other.z == z && other.x == x && other.y == y;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(z, x, y);
|
||||
}
|
||||
|
||||
class CachedTile {
|
||||
CachedTile(this.image, this.zoomBucket);
|
||||
|
||||
final ui.Image image;
|
||||
final int zoomBucket;
|
||||
}
|
||||
|
||||
// Static tile pyramid image cache. Stores rasterised ui.Image per (z, x, y).
|
||||
// Each entry also records the zoom bucket it was rendered at — callers can
|
||||
// check whether a re-render is warranted, but the old image stays as a
|
||||
// fallback until the new one arrives.
|
||||
//
|
||||
// Ancestor lookup (for fallback blitting) just calls peek() at a coarser z.
|
||||
class TilePyramidCache {
|
||||
TilePyramidCache._();
|
||||
|
||||
static const int _maxEntries = 384;
|
||||
static const int _maxApproxBytes = 256 * 1024 * 1024;
|
||||
|
||||
// Cap concurrent renders to avoid saturating the main isolate with
|
||||
// synchronous canvas recording. I/O (network/Hive) is still concurrent
|
||||
// up to this limit.
|
||||
static const int _maxConcurrent = 4;
|
||||
|
||||
static final LinkedHashMap<_TileCoord, CachedTile> _cache =
|
||||
LinkedHashMap<_TileCoord, CachedTile>();
|
||||
|
||||
static final Set<_TileCoord> _inFlight = <_TileCoord>{};
|
||||
static final Queue<_Queued> _queue = Queue<_Queued>();
|
||||
static int _active = 0;
|
||||
static bool Function(int z, int x, int y)? _queueFilter;
|
||||
|
||||
static int _approxBytes = 0;
|
||||
|
||||
static CachedTile? peek(int z, int x, int y) {
|
||||
final coord = _TileCoord(z, x, y);
|
||||
final tile = _cache.remove(coord);
|
||||
if (tile == null) return null;
|
||||
_cache[coord] = tile; // promote to MRU
|
||||
return tile;
|
||||
}
|
||||
|
||||
static bool isInFlight(int z, int x, int y) =>
|
||||
_inFlight.contains(_TileCoord(z, x, y));
|
||||
|
||||
// Queue a render for (z, x, y). No-op if already in flight.
|
||||
// render() produces the image; onReady() is called after it's stored.
|
||||
static void enqueue(
|
||||
int z,
|
||||
int x,
|
||||
int y,
|
||||
int zoomBucket,
|
||||
Future<ui.Image?> Function() render,
|
||||
void Function() onReady,
|
||||
) {
|
||||
final coord = _TileCoord(z, x, y);
|
||||
if (_inFlight.contains(coord)) return;
|
||||
final filter = _queueFilter;
|
||||
if (filter != null && !filter(z, x, y)) return;
|
||||
_inFlight.add(coord);
|
||||
|
||||
_queue.add(
|
||||
_Queued(
|
||||
coord: coord,
|
||||
zoomBucket: zoomBucket,
|
||||
render: render,
|
||||
onReady: onReady,
|
||||
),
|
||||
);
|
||||
_drain();
|
||||
}
|
||||
|
||||
static void setQueueFilter(bool Function(int z, int x, int y)? filter) {
|
||||
_queueFilter = filter;
|
||||
_pruneQueue();
|
||||
}
|
||||
|
||||
static void _drain() {
|
||||
while (_active < _maxConcurrent && _queue.isNotEmpty) {
|
||||
_active++;
|
||||
_run(_queue.removeFirst());
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _run(_Queued item) async {
|
||||
try {
|
||||
final image = await item.render();
|
||||
_inFlight.remove(item.coord);
|
||||
if (image == null) return;
|
||||
|
||||
final filter = _queueFilter;
|
||||
if (filter != null && !filter(item.coord.z, item.coord.x, item.coord.y)) {
|
||||
image.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final existing = _cache.remove(item.coord);
|
||||
if (existing != null) {
|
||||
_approxBytes -= _estimateBytes(existing.image);
|
||||
existing.image.dispose();
|
||||
}
|
||||
|
||||
_cache[item.coord] = CachedTile(image, item.zoomBucket);
|
||||
_approxBytes += _estimateBytes(image);
|
||||
_trim();
|
||||
|
||||
item.onReady();
|
||||
} catch (_) {
|
||||
_inFlight.remove(item.coord);
|
||||
} finally {
|
||||
_active--;
|
||||
_drain();
|
||||
}
|
||||
}
|
||||
|
||||
static int _estimateBytes(ui.Image img) => img.width * img.height * 4;
|
||||
|
||||
static void _trim() {
|
||||
while (_cache.length > _maxEntries || _approxBytes > _maxApproxBytes) {
|
||||
final key = _cache.keys.first;
|
||||
final tile = _cache.remove(key);
|
||||
if (tile == null) continue;
|
||||
_approxBytes -= _estimateBytes(tile.image);
|
||||
tile.image.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
static void _pruneQueue() {
|
||||
final filter = _queueFilter;
|
||||
if (filter == null || _queue.isEmpty) return;
|
||||
|
||||
final kept = Queue<_Queued>();
|
||||
while (_queue.isNotEmpty) {
|
||||
final item = _queue.removeFirst();
|
||||
if (filter(item.coord.z, item.coord.x, item.coord.y)) {
|
||||
kept.add(item);
|
||||
} else {
|
||||
_inFlight.remove(item.coord);
|
||||
}
|
||||
}
|
||||
|
||||
_queue.addAll(kept);
|
||||
}
|
||||
|
||||
static void clear() {
|
||||
for (final tile in _cache.values) {
|
||||
tile.image.dispose();
|
||||
}
|
||||
_cache.clear();
|
||||
_inFlight.clear();
|
||||
_queue.clear();
|
||||
_active = 0;
|
||||
_approxBytes = 0;
|
||||
_queueFilter = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _Queued {
|
||||
const _Queued({
|
||||
required this.coord,
|
||||
required this.zoomBucket,
|
||||
required this.render,
|
||||
required this.onReady,
|
||||
});
|
||||
|
||||
final _TileCoord coord;
|
||||
final int zoomBucket;
|
||||
final Future<ui.Image?> Function() render;
|
||||
final void Function() onReady;
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user