fix: prevent RangeError in Proj4Crs.scale/zoom for out-of-range values#2209
fix: prevent RangeError in Proj4Crs.scale/zoom for out-of-range values#2209AlexLaroche wants to merge 1 commit into
Proj4Crs.scale/zoom for out-of-range values#2209Conversation
|
@JaffaKetchup: Could you take a look when you have time? Please. |
|
I'll be back next week, so hopefully in the next few weeks I'll be back on top of things. Apologies it's taken so long! |
|
Hey, I think this looks good, huge thanks :) Just a question, how (if at all) does this relate to #1358? |
Unsure that the PR has actually resolved the issue
JaffaKetchup
left a comment
There was a problem hiding this comment.
Just taking another check over this. Could you please post the code you are using to reproduce this issue?
I'm using the MRE found in #1223, migrated to the latest version, except with a different tile layer since it's not important to the reproduction anyway.
Code sample
final _epsg2039 = Proj4Crs.fromFactory(
code: 'EPSG:2039',
proj4Projection: proj4.Projection.add('EPSG:2039',
'+proj=tmerc +lat_0=31.73439361111111 +lon_0=35.20451694444445 +k=1.0000067 +x_0=219529.584 +y_0=626907.39 +ellps=GRS80 +towgs84=-48,55,52,0,0,0,0 +units=m +no_defs'),
resolutions: <double>[
793.751587503175,
264.583862501058,
132.291931250529,
66.1459656252646,
26.4583862501058,
13.2291931250529,
6.61459656252646,
2.64583862501058,
1.32291931250529,
0.661459656252646,
0.330729828126323
],
origins: [Point(-5403700, 7116700)],
);
@override
Widget build(BuildContext context) {
return Scaffold(
drawer: const MenuDrawer(HomePage.route),
body: Stack(
children: [
FlutterMap(
options: MapOptions(
crs: _epsg2039,
initialCameraFit: CameraFit.bounds(
bounds: LatLngBounds(
LatLng(31.699685, 34.936118),
LatLng(31.697378, 34.932516),
),
maxZoom: 10),
minZoom: 0,
maxZoom: 10,
),
children: [openStreetMapTileLayer], // or any tile layer
),
],
),
);
}That code still doesn't run, and it's for the same reason discussed in that issue. This PR has changed the zoom method, but the scale method still throws. Can you confirm you can reproduce this? If so, I would not consider the issue fixed.
|
@JaffaKetchup Confirmed — I can reproduce it, and Minimal runnable reproduction below. It's a plain app:
dependencies:
flutter:
sdk: flutter
flutter_map: ^8.3.1
proj4dart: ^3.0.0
latlong2: ^0.10.1
import 'dart:math' show Point;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:proj4dart/proj4dart.dart' as proj4;
// A Proj4Crs built from a *finite* resolutions list (NRCan CBMT, EPSG:3978,
// 20 levels). Fitting bounds smaller than the deepest resolution can frame
// asks the CRS for a zoom past the end of the list -> RangeError, thrown
// inside the CRS before maxZoom clamping can apply.
const _resolutions = <double>[
38364.6600626535, 22489.62831259, 13229.1931250529, 7937.5158750318,
4630.2175937685, 2645.8386250106, 1587.5031750064, 926.0435187537,
529.1677250021, 317.5006350013, 185.2087037507, 111.1252222504,
66.1459656253, 38.3646600627, 22.4896283126, 13.2291931251,
7.937515875, 4.6302175938, 2.6458386250, 1.5875031750,
];
final _crs = Proj4Crs.fromFactory(
code: 'EPSG:3978',
proj4Projection: proj4.Projection.add(
'EPSG:3978',
'+proj=lcc +lat_0=49 +lon_0=-95 +lat_1=49 +lat_2=77 +x_0=0 +y_0=0 '
'+ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs',
),
origins: const [Point<double>(-34655800, 39310000)],
resolutions: _resolutions,
bounds: const Rect.fromLTRB(-4926564.35, -5396818.74, 5347435.65, 4623181.26),
);
// Two points ~30 m apart — below what the deepest resolution can frame.
// Widen them to > ~750 m apart and the crash disappears.
const _pointA = LatLng(46.8139, -71.2080);
const _pointB = LatLng(46.8141, -71.2079);
void main() => runApp(const ReproApp());
class ReproApp extends StatelessWidget {
const ReproApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
home: Scaffold(
body: FlutterMap(
options: MapOptions(
crs: _crs,
maxZoom: 17, // does not prevent the crash
initialCameraFit: CameraFit.bounds(
bounds: LatLngBounds.fromPoints(const [_pointA, _pointB]),
padding: const EdgeInsets.all(50),
maxZoom: 17,
),
),
children: const [],
),
),
);
}Result — the fit computation throws before the first frame renders: Widening |
|
Thanks for confirming! Is this something you're looking into fixing? |
…alues `Proj4Crs.scale(zoom)` and `Proj4Crs.zoom(scale)` both indexed `_scales` without bounds-checking, throwing a `RangeError` whenever the requested level fell outside the defined `resolutions`/`scales` list: - `scale(zoom)` indexed `_scales[iZoom]`/`_scales[iZoom + 1]`, throwing on a zoom below the coarsest level (`_scales[-1]`, fleaflet#1358) or above the finest (`_scales[length]`, fleaflet#1223). This is the crash that actually fires first on the fleaflet#1223 reproduction: `CameraFit._getBoundsZoom` calls `crs.scale(initialZoom)` (default 13.0) before any `maxZoom` clamp. - `zoom(scale)` returned `double.negativeInfinity`/threw for out-of-range scales; a non-finite result then threw again on the `.round()`/`.floor()` performed by `_getTransformationByZoom`, `scale`, and `forceIntegerZoomLevel`. Both functions now linearly interpolate within the defined range and extrapolate off the nearest segment beyond it, returning finite values at both ends. `_getTransformationByZoom` clamps its index so a negative or out-of-range zoom can no longer index the transformations list out of bounds. Callers already `.clamp(minZoom, maxZoom)` the result, so the camera settles correctly instead of crashing. Closes fleaflet#1223, closes fleaflet#1358.
c76ed61 to
68af3cc
Compare
|
Yes — I've just pushed a fix and confirmed it against your exact MRE. You were right that the previous revision didn't fully fix it. The reason your reproduction still crashed is that the throw happens in To answer your earlier question about #1358: it's the same defect at the opposite end — This revision makes
Callers already clamp the result to One behavior change worth flagging: I added an integration test using your MRE's CRS + bounds fitted via |
Proj4Crs.zoom for out-of-range scalesProj4Crs.scale/zoom for out-of-range values
Summary
Makes
Proj4Crstotal over out-of-range zoom/scale values, fixing aRangeErrorthat crashes the map whenever the requested level falls outside the CRS's definedresolutions/scaleslist.Closes #1223, closes #1358.
Root cause
Both
Proj4Crs.scale(zoom)andProj4Crs.zoom(scale)inlib/src/geo/crs.dartindexed_scaleswithout bounds-checking:scale(zoom)indexed_scales[iZoom]/_scales[iZoom + 1], throwing on a zoom below the coarsest level (_scales[-1], [BUG] Certain CRSs mistreatminZoom#1358) or above the finest (_scales[length],CameraFit.boundssurpassesmaxZoomparam when using custom CRS #1223).zoom(scale)returneddouble.negativeInfinityfor scales below the coarsest level and threw for scales above the finest. A non-finite result then threw again on the.round()/.floor()performed by_getTransformationByZoom,scale, andforceIntegerZoomLevel.The path that fires first on the #1223 reproduction is actually
scale, notzoom:CameraFit._getBoundsZoomcallscamera.getScaleZoom(scale)→crs.zoom(scale * crs.scale(zoom))with the camera's current zoom (defaultMapOptions.initialZoom= 13.0). For a CRS with fewer than 14 levels,crs.scale(13.0)throws before themaxZoomclamp can ever run — which is why settingmaxZoomdoesn't protect against the crash. (The earlier revision of this PR only patchedzoom, so #1223's own MRE still crashed; see discussion below.)#1358is the same defect at the opposite end —scale()underflowing to_scales[-1]when zooming out past level 0.Fix
scale(zoom)andzoom(scale)now linearly interpolate within the defined range and extrapolate off the nearest segment beyond it, returning finite values at both ends. In-range behavior is unchanged (exact-integer levels still short-circuit to the stored value; the interpolation formula is identical)._getTransformationByZoomclamps its index into range so a negative or out-of-range zoom can no longer index the transformations list out of bounds at either end (the old code only guarded the upper end).Callers already
.clamp(minZoom, maxZoom)the returned zoom, so the camera settles correctly instead of crashing.Behavior change
Proj4Crs.zoomno longer returnsdouble.negativeInfinityfor scales coarser than the deepest level — it returns a finite (negative, extrapolated) value. This is observable only to code that read that sentinel directly; through the map it makes no difference, because the sole consumer (getScaleZoom) clamps the result, andMapCameraactually assertszoom.isFinite. In-range values are bit-for-bit identical to before.Test plan
New tests in
test/geo/crs_test.dartcover:scale/zoomexact-match and in-range interpolation (unchanged behavior).scaleno longer throws below the coarsest ([BUG] Certain CRSs mistreatminZoom#1358) or above the finest (CameraFit.boundssurpassesmaxZoomparam when using custom CRS #1223) level.zoomextrapolates to finite values at both ends instead of-∞/ a crash.scale/zoomround-trip within and beyond the defined range.latLngToXYdoes not throw for an out-of-range zoom.CameraFit.boundssurpassesmaxZoomparam when using custom CRS #1223's MRE fitted viaCameraFit.boundsno longer throws and respectsmaxZoom. Verified this reproduces the originalRangeError(thrown insidescale, index0..10: 13) when the fix is reverted.flutter test— all pass.flutter analyzeanddart formatclean on changed files.