Skip to content

fix: prevent RangeError in Proj4Crs.scale/zoom for out-of-range values#2209

Open
AlexLaroche wants to merge 1 commit into
fleaflet:masterfrom
AlexLaroche:fix/proj4crs-zoom-out-of-range
Open

fix: prevent RangeError in Proj4Crs.scale/zoom for out-of-range values#2209
AlexLaroche wants to merge 1 commit into
fleaflet:masterfrom
AlexLaroche:fix/proj4crs-zoom-out-of-range

Conversation

@AlexLaroche

@AlexLaroche AlexLaroche commented Jun 2, 2026

Copy link
Copy Markdown

Summary

Makes Proj4Crs total over out-of-range zoom/scale values, fixing a RangeError that crashes the map whenever the requested level falls outside the CRS's defined resolutions/scales list.

Closes #1223, closes #1358.

Root cause

Both Proj4Crs.scale(zoom) and Proj4Crs.zoom(scale) in lib/src/geo/crs.dart indexed _scales without bounds-checking:

The path that fires first on the #1223 reproduction is actually scale, not zoom: CameraFit._getBoundsZoom calls camera.getScaleZoom(scale)crs.zoom(scale * crs.scale(zoom)) with the camera's current zoom (default MapOptions.initialZoom = 13.0). For a CRS with fewer than 14 levels, crs.scale(13.0) throws before the maxZoom clamp can ever run — which is why setting maxZoom doesn't protect against the crash. (The earlier revision of this PR only patched zoom, so #1223's own MRE still crashed; see discussion below.)

#1358 is the same defect at the opposite end — scale() underflowing to _scales[-1] when zooming out past level 0.

Fix

  • scale(zoom) and zoom(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).
  • _getTransformationByZoom clamps 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.zoom no longer returns double.negativeInfinity for 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, and MapCamera actually asserts zoom.isFinite. In-range values are bit-for-bit identical to before.

Test plan

New tests in test/geo/crs_test.dart cover:

@AlexLaroche

Copy link
Copy Markdown
Author

@JaffaKetchup: Could you take a look when you have time? Please.

@AlexLaroche
AlexLaroche marked this pull request as ready for review June 24, 2026 09:16
@JaffaKetchup

Copy link
Copy Markdown
Member

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!

@JaffaKetchup
JaffaKetchup requested a review from a team June 30, 2026 23:34
@JaffaKetchup

Copy link
Copy Markdown
Member

Hey, I think this looks good, huge thanks :)

Just a question, how (if at all) does this relate to #1358?

JaffaKetchup

This comment was marked as outdated.

@JaffaKetchup
JaffaKetchup dismissed their stale review July 1, 2026 22:20

Unsure that the PR has actually resolved the issue

@JaffaKetchup JaffaKetchup left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@AlexLaroche

Copy link
Copy Markdown
Author

@JaffaKetchup Confirmed — I can reproduce it, and scale still throws on the latest release (flutter_map 8.3.1, proj4dart 3.0.0). Setting maxZoom on both MapOptions and CameraFit.bounds doesn't help, because the RangeError is raised inside the CRS before the fit result is ever clamped.

Minimal runnable reproduction below. It's a plain app: flutter create, drop in these two files, then flutter run -d linux (or -d chrome). It crashes on the first frame while computing the initial camera fit.

pubspec.yaml (dependencies):

dependencies:
  flutter:
    sdk: flutter
  flutter_map: ^8.3.1
  proj4dart: ^3.0.0
  latlong2: ^0.10.1

lib/main.dart:

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:

RangeError (index): Invalid value: Not in inclusive range 0..19: 20

Widening _pointB so the two points span more than ~750 m makes the required fit zoom land within the resolutions list, and the crash goes away — which points at scale/zoom overflowing on the fine-scale end rather than clamping. So I'd agree this isn't fully fixed yet: zoom returning double.infinity handles one entry point, but scale still indexes out of range on the same tiny-bounds fit.

@JaffaKetchup

Copy link
Copy Markdown
Member

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.
@AlexLaroche
AlexLaroche force-pushed the fix/proj4crs-zoom-out-of-range branch from c76ed61 to 68af3cc Compare July 8, 2026 22:36
@AlexLaroche

Copy link
Copy Markdown
Author

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 scale, not zoom: CameraFit._getBoundsZoom calls camera.getScaleZoom(scale)crs.zoom(scale * crs.scale(zoom)) with the camera's current zoom, which is the default initialZoom of 13.0. Your CRS has 11 levels (max index 10), so crs.scale(13.0) indexes _scales[13] and throws before zoom is ever reached and before any maxZoom clamp can run. That's why maxZoom never helped. Reverting the fix, your MRE throws RangeError ... 0..10: 13 from inside scale, exactly as expected.

To answer your earlier question about #1358: it's the same defect at the opposite endscale() underflowing to _scales[-1] when zooming out past level 0. So I've addressed both here.

This revision makes Proj4Crs total over out-of-range values:

  • scale(zoom) and zoom(scale) interpolate within the defined range and extrapolate off the nearest segment beyond it, returning finite values at both ends. In-range behavior is unchanged.
  • _getTransformationByZoom now clamps its index at both ends (previously only the upper end was guarded).

Callers already clamp the result to minZoom/maxZoom, so the camera settles correctly instead of crashing.

One behavior change worth flagging: zoom no longer returns double.negativeInfinity for scales coarser than the deepest level — it returns a finite extrapolated value. Through the map this is invisible (the only consumer clamps it, and MapCamera asserts zoom.isFinite), but I wanted to call it out in case you'd prefer a different policy at the boundary.

I added an integration test using your MRE's CRS + bounds fitted via CameraFit.bounds, plus unit tests for both ends of both functions and round-trips. flutter test/analyze/format all clean. Happy to adjust anything.

@AlexLaroche AlexLaroche changed the title fix: prevent RangeError in Proj4Crs.zoom for out-of-range scales fix: prevent RangeError in Proj4Crs.scale/zoom for out-of-range values Jul 8, 2026
@JaffaKetchup
JaffaKetchup requested review from a team and JaffaKetchup July 9, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Certain CRSs mistreat minZoom CameraFit.bounds surpasses maxZoom param when using custom CRS

2 participants