Skip to content

fix: prevent RangeError in Proj4Crs.zoom for out-of-range scales#2209

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

fix: prevent RangeError in Proj4Crs.zoom for out-of-range scales#2209
AlexLaroche wants to merge 2 commits into
fleaflet:masterfrom
AlexLaroche:fix/proj4crs-zoom-out-of-range

Conversation

@AlexLaroche

Copy link
Copy Markdown

Summary

Fixes a RangeError thrown by Proj4Crs.zoom when the requested scale is finer than the deepest level defined in the CRS's resolutions list. Closes #1223 (which has documented this exact crash since 2022).

Root cause

In lib/src/geo/crs.dart:

final downScale = _closestElement(_scales, scale);     // returns _scales.last when scale > all entries
final downZoom = _scales.indexOf(downScale);           // = _scales.length - 1
// ...
final nextZoom = downZoom + 1;                         // = _scales.length
final nextScale = _scales[nextZoom];                   // RangeError

When the caller asks for a scale beyond the largest defined level, _closestElement returns _scales.last, so downZoom is the last index. The interpolation block then indexes _scales[downZoom + 1] and throws.

The most common path that hits this is FitBounds._getBoundsZoom, which calls camera.getScaleZoom(scale) before clamping the result to maxZoom (camera_fit.dart:162 vs :176). So setting maxZoom on MapOptions / CameraFit.bounds does not protect against the crash — it crashes inside Proj4Crs.zoom before the clamp can run. This is also why issue #1223's reproduction is fitBounds with a custom CRS.

Fix

When downZoom == _scales.length - 1 and the requested scale is strictly larger than downScale, return double.infinity instead of falling through to the out-of-bounds index access.

This mirrors the existing lower-bound behavior in the same function: when the requested scale is smaller than every entry in _scales, the function already returns double.negativeInfinity. The fix makes the upper bound symmetric.

Callers like FitBounds._getBoundsZoom already .clamp(min, max) the returned zoom, so +∞ becomes maxZoom and the camera settles correctly. The whole frame no longer throws.

Why double.infinity rather than clamping to _scales.length - 1?

  • Symmetric with the existing double.negativeInfinity for the lower bound.
  • Lets the caller decide the clamp policy — FitBounds already has its own maxZoom that may be lower than the deepest CRS level.
  • Unambiguous sentinel: distinguishes "scale exactly at deepest level" from "scale beyond all defined levels".

Happy to switch to a finite clamp if reviewers prefer.

Test plan

  • New regression test in test/geo/crs_test.dart covers exact match, interpolation, lower-bound -∞, and upper-bound +∞.
  • Verified the new +∞ test reproduces the original RangeError at crs.dart:356:30 when the fix is reverted.
  • flutter test test/geo/ test/flutter_map_test.dart — all 20 tests pass.
  • flutter analyze lib/src/geo/crs.dart test/geo/crs_test.dart — no issues.
  • dart format clean on changed files.

When the requested scale is larger than the largest entry in the CRS's
`_scales`, `_closestElement` returns the last element, so `downZoom` is
`_scales.length - 1`. Falling through to the interpolation block then
indexes `_scales[downZoom + 1]` and throws `RangeError`.

Return `double.infinity` in that case, mirroring the
`double.negativeInfinity` already returned when the requested scale is
smaller than every defined level. Callers such as
`FitBounds._getBoundsZoom` then clamp via `.clamp(min, max)` and the
camera ends up at `maxZoom`, instead of the whole frame throwing.

Closes fleaflet#1223.
@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CameraFit.bounds surpasses maxZoom param when using custom CRS

2 participants