maplibre-gl-raster 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -190,9 +190,10 @@ Per-layer visualization state, editable via the panel or `setRasterState`:
190
190
 
191
191
  ```typescript
192
192
  interface RasterLayerState {
193
- mode: "rgb" | "single"; // RGB composite or single band + colormap
194
- bands: number[]; // 1-indexed band selection
195
- rescale: [number, number][] | null; // per-channel min/max; null = auto (2-98%)
193
+ mode: "rgb" | "single" | "index"; // RGB composite, single band + colormap, or normalized-difference index
194
+ bands: number[]; // 1-indexed band selection ([A, B] in index mode)
195
+ index?: string; // normalized-difference preset id (index mode), e.g. "ndvi"
196
+ rescale: [number, number][] | null; // per-channel min/max; null = auto (2-98%, or [-1, 1] for index)
196
197
  colormap: string; // colormap name; "palette" = embedded color table
197
198
  reversed: boolean; // sample the named colormap back-to-front
198
199
  nodata: number | "off" | "auto"; // nodata handling
@@ -213,6 +214,8 @@ interface RasterLayerState {
213
214
 
214
215
  When a raster loads, the mode and bands are picked automatically (3+ bands → RGB `[1, 2, 3]`; otherwise single-band), and the rescale range defaults to the 2-98% percentile of sampled statistics. Single-band rasters use the image's embedded color table when it carries one (`colormap: "palette"`) and grayscale otherwise. The first four bands are fetched as GPU textures, so band combinations among them re-render instantly without re-downloading tiles.
215
216
 
217
+ **Index mode** computes a normalized-difference index `(A - B) / (A + B)` of two bands entirely on the GPU (no server or download), then colors the `[-1, 1]` result with a colormap. Presets (`NORMALIZED_DIFFERENCE_INDICES`) pre-fill the band roles and a default ramp for NDVI, NDWI, NDMI, NBR, NDBI, and NDSI, and a "Custom" option lets you pick any two bands; the settings panel seeds the operand bands from the file's band names when present. Index mode requires the default `maplibre-gl-raster` engine (the `cog-tiler-wasm` engine has no band-math endpoint and falls back to a colormapped view of the first operand).
218
+
216
219
  ### RasterControlReact
217
220
 
218
221
  React wrapper component for `RasterControl`.
@@ -1979,6 +1979,26 @@ var FilterNaN = {
1979
1979
  }
1980
1980
  ` }
1981
1981
  };
1982
+ /** Normalized-difference index: replaces the color with `(A - B) / (A + B)`
1983
+ * broadcast across RGB, where `A` is the red channel and `B` the green channel
1984
+ * populated by a preceding `CompositeBands` (r → band A, g → band B). The
1985
+ * result lies in [-1, 1] and is scale-invariant, so it can run on the raw
1986
+ * sampled band values regardless of the texture's sample scale — a downstream
1987
+ * `PerBandLinearRescale` maps the chosen window (default [-1, 1]) into [0, 1]
1988
+ * for the colormap. A zero denominator yields 0 rather than a NaN/Inf. Runs
1989
+ * before rescale / gamma / colormap. */
1990
+ var NormalizedDifference = {
1991
+ name: "normalizedDifference",
1992
+ inject: { "fs:DECKGL_FILTER_COLOR": `
1993
+ {
1994
+ float ndA = color.r;
1995
+ float ndB = color.g;
1996
+ float ndDenom = ndA + ndB;
1997
+ float nd = abs(ndDenom) < 1e-12 ? 0.0 : (ndA - ndB) / ndDenom;
1998
+ color.rgb = vec3(nd);
1999
+ }
2000
+ ` }
2001
+ };
1982
2002
  /** Per-channel `LinearRescale`. Same idea as the shipped `LinearRescale`,
1983
2003
  * but min/max are vec3 so each band gets its own range. */
1984
2004
  var PerBandLinearRescale = {
@@ -2176,6 +2196,29 @@ function pickMapping(data, requested, mode) {
2176
2196
  if (b) mapping.b = b;
2177
2197
  return mapping;
2178
2198
  }
2199
+ /** Push the nodata-discard modules for a tile, in the correct order. Filters
2200
+ * nodata BEFORE any rescale / gamma / colormap so the comparison happens
2201
+ * against the texture's native sample value. NaN nodata uses the custom
2202
+ * isnan() shader; everything else uses FilterNoDataVal with the value
2203
+ * normalized into the GPU's sample space (uint8 255 → 1.0 for r8unorm). Also
2204
+ * applies implicit NaN-as-nodata for float COGs — IEEE-754 NaN is invalid by
2205
+ * definition, and GDAL/QGIS treat it as transparent even when the file
2206
+ * declares no GDAL_NODATA tag (e.g. Sentinel-2 derivatives that mask
2207
+ * outside-swath pixels with NaN). Gated on float textures via sampleScale
2208
+ * (r8unorm uint8 textures can't carry NaN), and skipped when the user
2209
+ * explicitly set nodata to "off" or when FilterNaN is already in the pipeline
2210
+ * (state.nodata was numerically NaN). */
2211
+ function pushNodataFilters(state, data, pipeline) {
2212
+ const nodata = effectiveNodata(state, data.nodata);
2213
+ let explicitNodataModule = null;
2214
+ if (nodata !== null) {
2215
+ explicitNodataModule = nodataModule(nodata, data.sampleScale);
2216
+ if (explicitNodataModule) pipeline.push(explicitNodataModule);
2217
+ }
2218
+ const isFloatTexture = data.sampleScale === 1;
2219
+ const filterNaNAlreadyPushed = explicitNodataModule?.module === FilterNaN;
2220
+ if (isFloatTexture && state.nodata !== "off" && !filterNaNAlreadyPushed) pipeline.push({ module: FilterNaN });
2221
+ }
2179
2222
  /** Shared renderTile builder. Handles both RGB and single-band paths
2180
2223
  * via a discriminated `mode`. The two paths differ only in: (a) which
2181
2224
  * bands feed CompositeBands, (b) whether a Colormap module is appended
@@ -2195,15 +2238,7 @@ function buildRenderTile(state, autoStats, mode) {
2195
2238
  module: CompositeBands,
2196
2239
  props: buildCompositeBandsProps(mapping, data.bands)
2197
2240
  }];
2198
- const nodata = effectiveNodata(state, data.nodata);
2199
- let explicitNodataModule = null;
2200
- if (nodata !== null) {
2201
- explicitNodataModule = nodataModule(nodata, data.sampleScale);
2202
- if (explicitNodataModule) pipeline.push(explicitNodataModule);
2203
- }
2204
- const isFloatTexture = data.sampleScale === 1;
2205
- const filterNaNAlreadyPushed = explicitNodataModule?.module === FilterNaN;
2206
- if (isFloatTexture && state.nodata !== "off" && !filterNaNAlreadyPushed) pipeline.push({ module: FilterNaN });
2241
+ pushNodataFilters(state, data, pipeline);
2207
2242
  if (mode.kind === "palette") {
2208
2243
  const max = 255 / data.sampleScale;
2209
2244
  pipeline.push({
@@ -2284,6 +2319,61 @@ function buildPaletteCompositeRenderTile(state, paletteTexture) {
2284
2319
  colormapTexture: paletteTexture
2285
2320
  });
2286
2321
  }
2322
+ /** Default rescale window for a normalized-difference index: the full [-1, 1]
2323
+ * range the formula can produce. Used when `state.rescale` is unset. */
2324
+ var DEFAULT_INDEX_RANGE = [-1, 1];
2325
+ /** Index renderTile: computes a normalized difference `(A - B) / (A + B)` of
2326
+ * two bands on the GPU, then colormaps the [-1, 1] result. `CompositeBands`
2327
+ * loads band A into the red channel and band B into green; the
2328
+ * `NormalizedDifference` module collapses them to a single value broadcast
2329
+ * across RGB; `PerBandLinearRescale` maps the chosen window (default
2330
+ * {@link DEFAULT_INDEX_RANGE}) into [0, 1] for the colormap. The index is
2331
+ * scale-invariant, so — unlike the single/RGB paths — the rescale window is
2332
+ * NOT divided by `data.sampleScale`. Re-renders without a re-fetch when the
2333
+ * colormap / rescale change (within the cached band set). */
2334
+ function buildIndexCompositeRenderTile(state, colormapTexture) {
2335
+ const colormapIndex = COLORMAP_INDEX[(state.colormap ?? "rdylgn").toLowerCase()] ?? COLORMAP_INDEX.rdylgn;
2336
+ return function renderTile(data) {
2337
+ if (data.bands.size === 0) return { renderPipeline: [] };
2338
+ const bandA = pickBand(data, state.bands?.[0] ?? 1);
2339
+ if (!bandA) return { renderPipeline: [] };
2340
+ const pipeline = [{
2341
+ module: CompositeBands,
2342
+ props: buildCompositeBandsProps({
2343
+ r: bandA,
2344
+ g: pickBand(data, state.bands?.[1] ?? 2) ?? bandA
2345
+ }, data.bands)
2346
+ }];
2347
+ pushNodataFilters(state, data, pipeline);
2348
+ pipeline.push({ module: NormalizedDifference });
2349
+ const [lo, hi] = safeRange(state.rescale?.[0] ?? DEFAULT_INDEX_RANGE);
2350
+ pipeline.push({
2351
+ module: PerBandLinearRescale,
2352
+ props: {
2353
+ rescaleMin: [
2354
+ lo,
2355
+ lo,
2356
+ lo
2357
+ ],
2358
+ rescaleMax: [
2359
+ hi,
2360
+ hi,
2361
+ hi
2362
+ ]
2363
+ }
2364
+ });
2365
+ pushAdjustments(state, pipeline);
2366
+ pipeline.push({
2367
+ module: Colormap,
2368
+ props: {
2369
+ colormapTexture,
2370
+ colormapIndex,
2371
+ reversed: state.reversed ?? false
2372
+ }
2373
+ });
2374
+ return { renderPipeline: pipeline };
2375
+ };
2376
+ }
2287
2377
  //#endregion
2288
2378
  //#region node_modules/@developmentseed/geotiff/dist/colormap.js
2289
2379
  /**
@@ -18010,13 +18100,14 @@ var CogTilerEngine = class {
18010
18100
  * is intentionally excluded (applied as a paint property instead). */
18011
18101
  _renderOptionsFor(layer) {
18012
18102
  const s = layer.state;
18013
- const bidx = s.mode === "single" ? [s.bands[0] ?? 1] : s.bands.slice(0, 3).map((b) => b || 1);
18103
+ const colormapped = s.mode === "single" || s.mode === "index";
18104
+ const bidx = colormapped ? [s.bands[0] ?? 1] : s.bands.slice(0, 3).map((b) => b || 1);
18014
18105
  const opts = {
18015
18106
  bidx,
18016
18107
  stretch: s.stretch,
18017
18108
  gamma: s.gamma
18018
18109
  };
18019
- if (s.mode === "single") {
18110
+ if (colormapped) {
18020
18111
  opts.reversed = s.reversed;
18021
18112
  if (s.colormap && s.colormap !== "palette") opts.colormap = s.colormap;
18022
18113
  }
@@ -18162,7 +18253,7 @@ function fetchBandsFor(layer) {
18162
18253
  1,
18163
18254
  2,
18164
18255
  3
18165
- ]).slice(0, 3) : [bands?.[0] ?? 1];
18256
+ ]).slice(0, 3) : layer.state.mode === "index" ? (bands && bands.length > 0 ? bands : [1, 2]).slice(0, 2) : [bands?.[0] ?? 1];
18166
18257
  const unique = [...new Set(sampled.filter((b) => Number.isInteger(b) && b >= 1))].sort((a, b) => a - b);
18167
18258
  if (unique.length === 0) unique.push(1);
18168
18259
  return unique.slice(0, 4);
@@ -18795,6 +18886,7 @@ var LayerManager = class {
18795
18886
  if (layer.paletteTexture) return buildPaletteCompositeRenderTile(layer.state, layer.paletteTexture);
18796
18887
  } else if (this._colormapTexture) return buildSingleCompositeRenderTile(layer.state, this._colormapTexture, layer.autoStats);
18797
18888
  }
18889
+ if (layer.state.mode === "index" && this._colormapTexture) return buildIndexCompositeRenderTile(layer.state, this._colormapTexture);
18798
18890
  return buildRgbCompositeRenderTile(layer.state, layer.autoStats);
18799
18891
  }
18800
18892
  };
@@ -19044,8 +19136,8 @@ var PixelInspector = class {
19044
19136
  //#region src/lib/ui/AddDataSection.ts
19045
19137
  /**
19046
19138
  * "Add data" section: URL input + Load button, plus a drop zone that accepts
19047
- * `.tif` / `.tiff` files via click-to-browse or drag-and-drop. Ported from
19048
- * cog-viewer's EmptyState.
19139
+ * one or more `.tif` / `.tiff` files via click-to-browse (multi-select) or
19140
+ * drag-and-drop. Ported from cog-viewer's EmptyState.
19049
19141
  */
19050
19142
  var AddDataSection = class {
19051
19143
  /** Root element to insert into the panel. */
@@ -19057,6 +19149,11 @@ var AddDataSection = class {
19057
19149
  */
19058
19150
  constructor(options) {
19059
19151
  const isTiff = (file) => /\.tiff?$/i.test(file.name);
19152
+ const addFiles = (files) => {
19153
+ if (!files) return;
19154
+ const beforeId = currentBeforeId();
19155
+ for (const file of Array.from(files)) if (isTiff(file)) options.onAddFile(file, beforeId);
19156
+ };
19060
19157
  const input = el("input", {
19061
19158
  className: "mlr-input",
19062
19159
  type: "text",
@@ -19158,17 +19255,19 @@ var AddDataSection = class {
19158
19255
  const fileInput = el("input", {
19159
19256
  type: "file",
19160
19257
  ariaLabel: "raster-file",
19161
- attrs: { accept: ".tif,.tiff" }
19258
+ attrs: {
19259
+ accept: ".tif,.tiff",
19260
+ multiple: ""
19261
+ }
19162
19262
  });
19163
19263
  fileInput.style.display = "none";
19164
19264
  fileInput.addEventListener("change", () => {
19165
- const f = fileInput.files?.[0];
19166
- if (f && isTiff(f)) options.onAddFile(f, currentBeforeId());
19265
+ addFiles(fileInput.files);
19167
19266
  fileInput.value = "";
19168
19267
  });
19169
19268
  const dropZone = el("div", {
19170
19269
  className: "mlr-drop-zone",
19171
- text: "Drop a .tif file here, or click to browse",
19270
+ text: "Drop .tif files here, or click to browse",
19172
19271
  attrs: {
19173
19272
  role: "button",
19174
19273
  tabindex: "0"
@@ -19191,8 +19290,7 @@ var AddDataSection = class {
19191
19290
  dropZone.addEventListener("drop", (e) => {
19192
19291
  e.preventDefault();
19193
19292
  dropZone.classList.remove("dragover");
19194
- const f = e.dataTransfer?.files[0];
19195
- if (f && isTiff(f)) options.onAddFile(f, currentBeforeId());
19293
+ addFiles(e.dataTransfer?.files);
19196
19294
  });
19197
19295
  this.el = el("div", { className: "mlr-section mlr-add-data" }, el("div", {
19198
19296
  className: "mlr-section-title",
@@ -19337,6 +19435,135 @@ var LayerList = class {
19337
19435
  }
19338
19436
  };
19339
19437
  //#endregion
19438
+ //#region src/lib/raster/indices.ts
19439
+ /** The `id` used for a free-form normalized difference (generic Band A / Band
19440
+ * B, no role hints). */
19441
+ var CUSTOM_INDEX_ID = "custom";
19442
+ /**
19443
+ * Built-in normalized-difference index presets, in menu order. Each maps two
19444
+ * band roles onto the shared `(A - B) / (A + B)` formula.
19445
+ */
19446
+ var NORMALIZED_DIFFERENCE_INDICES = [
19447
+ {
19448
+ id: "ndvi",
19449
+ label: "NDVI",
19450
+ name: "Normalized Difference Vegetation Index — (NIR - Red) / (NIR + Red)",
19451
+ roleA: "NIR",
19452
+ roleB: "Red",
19453
+ colormap: "rdylgn"
19454
+ },
19455
+ {
19456
+ id: "ndwi",
19457
+ label: "NDWI",
19458
+ name: "Normalized Difference Water Index — (Green - NIR) / (Green + NIR)",
19459
+ roleA: "Green",
19460
+ roleB: "NIR",
19461
+ colormap: "blues"
19462
+ },
19463
+ {
19464
+ id: "ndmi",
19465
+ label: "NDMI",
19466
+ name: "Normalized Difference Moisture Index — (NIR - SWIR1) / (NIR + SWIR1)",
19467
+ roleA: "NIR",
19468
+ roleB: "SWIR1",
19469
+ colormap: "brbg"
19470
+ },
19471
+ {
19472
+ id: "nbr",
19473
+ label: "NBR",
19474
+ name: "Normalized Burn Ratio — (NIR - SWIR2) / (NIR + SWIR2)",
19475
+ roleA: "NIR",
19476
+ roleB: "SWIR2",
19477
+ colormap: "rdylgn"
19478
+ },
19479
+ {
19480
+ id: "ndbi",
19481
+ label: "NDBI",
19482
+ name: "Normalized Difference Built-up Index — (SWIR1 - NIR) / (SWIR1 + NIR)",
19483
+ roleA: "SWIR1",
19484
+ roleB: "NIR",
19485
+ colormap: "inferno"
19486
+ },
19487
+ {
19488
+ id: "ndsi",
19489
+ label: "NDSI",
19490
+ name: "Normalized Difference Snow Index — (Green - SWIR1) / (Green + SWIR1)",
19491
+ roleA: "Green",
19492
+ roleB: "SWIR1",
19493
+ colormap: "blues"
19494
+ }
19495
+ ];
19496
+ /** The generic "custom" preset: two unlabeled bands and a neutral diverging
19497
+ * ramp. Kept separate from the named presets so callers can list it last. */
19498
+ var CUSTOM_NORMALIZED_DIFFERENCE = {
19499
+ id: CUSTOM_INDEX_ID,
19500
+ label: "Custom",
19501
+ name: "Custom normalized difference — (Band A - Band B) / (Band A + Band B)",
19502
+ roleA: "Band A",
19503
+ roleB: "Band B",
19504
+ colormap: "rdylgn"
19505
+ };
19506
+ /** Look up a preset by id (including the custom preset), or null. */
19507
+ function indexById(id) {
19508
+ if (!id) return null;
19509
+ if (id === "custom") return CUSTOM_NORMALIZED_DIFFERENCE;
19510
+ return NORMALIZED_DIFFERENCE_INDICES.find((i) => i.id === id) ?? null;
19511
+ }
19512
+ /**
19513
+ * Guess the 1-based band number that plays a spectral role (e.g. "NIR") for a
19514
+ * raster, matching the role against GDAL band names. Common Sentinel-2 /
19515
+ * Landsat aliases are recognized. Returns null when no band name matches.
19516
+ *
19517
+ * @param role - The role label from a preset (e.g. "Red", "NIR", "SWIR1").
19518
+ * @param bandNames - 1-indexed band-number → name map, when the COG carries one.
19519
+ */
19520
+ function guessBandForRole(role, bandNames) {
19521
+ if (!bandNames || bandNames.size === 0) return null;
19522
+ const aliases = ROLE_ALIASES[role.toLowerCase()] ?? [role.toLowerCase()];
19523
+ for (const [band, rawName] of bandNames) {
19524
+ const name = rawName.toLowerCase();
19525
+ if (aliases.some((alias) => name.includes(alias))) return band;
19526
+ }
19527
+ return null;
19528
+ }
19529
+ /** Substrings that identify a spectral role inside a GDAL band name. */
19530
+ var ROLE_ALIASES = {
19531
+ red: [
19532
+ "red",
19533
+ "b04",
19534
+ "b4"
19535
+ ],
19536
+ green: [
19537
+ "green",
19538
+ "b03",
19539
+ "b3"
19540
+ ],
19541
+ blue: [
19542
+ "blue",
19543
+ "b02",
19544
+ "b2"
19545
+ ],
19546
+ nir: [
19547
+ "nir",
19548
+ "near infrared",
19549
+ "b08",
19550
+ "b8",
19551
+ "b8a"
19552
+ ],
19553
+ swir1: [
19554
+ "swir1",
19555
+ "swir 1",
19556
+ "swir_1",
19557
+ "b11"
19558
+ ],
19559
+ swir2: [
19560
+ "swir2",
19561
+ "swir 2",
19562
+ "swir_2",
19563
+ "b12"
19564
+ ]
19565
+ };
19566
+ //#endregion
19340
19567
  //#region src/lib/ui/BandHistogram.ts
19341
19568
  var HANDLE_SIZE = 12;
19342
19569
  /**
@@ -19558,9 +19785,11 @@ var RGB_CHANNELS = [
19558
19785
  }
19559
19786
  ];
19560
19787
  var HELP = {
19561
- mode: "RGB / composite picks one band per output channel for true- or false-color images. Single band sends one band through a colormap.",
19788
+ mode: "RGB / composite picks one band per output channel for true- or false-color images. Single band sends one band through a colormap. Index computes a normalized-difference index of two bands.",
19562
19789
  bandsRgb: "Pick which band feeds each output channel. Native order is usually 1=red, 2=green, 3=blue; reorder to make false-color composites.",
19563
19790
  bandSingle: "Which band's pixel values feed the colormap.",
19791
+ index: "Compute a normalized-difference index (A - B) / (A + B) of two bands, then color the [-1, 1] result. Pick a preset (e.g. NDVI) or Custom.",
19792
+ indexBands: "Assign each operand of the index to a band. Presets name a role (e.g. NIR, Red); the initial guess uses the file's band names when present.",
19564
19793
  rescale: "Maps a window of source values to the colormap input. Drag the histogram handles, type values, or pick a preset.",
19565
19794
  colormap: "Color lookup applied to the rescaled value (after the curve, before nodata).",
19566
19795
  reversed: "Sample the colormap from end to start, equivalent to a reversed variant of the ramp.",
@@ -19758,13 +19987,14 @@ var SettingsSection = class {
19758
19987
  const bandOptions = Array.from({ length: Math.max(1, layer.bandCount ?? 1) }, (_, i) => i + 1);
19759
19988
  const paletteActive = mode === "single" && state.colormap === "palette" && layer.palette !== null;
19760
19989
  this._body.appendChild(this._buildModeField(state, mode, bandOptions));
19761
- this._body.appendChild(this._buildBandsField(layer, state, mode, bandOptions));
19990
+ if (mode === "index") this._body.appendChild(this._buildIndexField(layer, state, bandOptions));
19991
+ else this._body.appendChild(this._buildBandsField(layer, state, mode, bandOptions));
19762
19992
  if (!paletteActive) this._body.appendChild(this._buildRescaleField(layer, state, mode));
19763
- if (mode === "single") {
19993
+ if (mode === "single" || mode === "index") {
19764
19994
  const picker = new ColormapPicker({
19765
19995
  value: state.colormap,
19766
- palette: layer.palette,
19767
- stats: statsForBand(layer.autoStats, state.bands[0] ?? 1),
19996
+ palette: mode === "single" ? layer.palette : null,
19997
+ stats: mode === "single" ? statsForBand(layer.autoStats, state.bands[0] ?? 1) : null,
19768
19998
  onChange: (name) => {
19769
19999
  this._setState({ colormap: name });
19770
20000
  this.render();
@@ -19791,14 +20021,22 @@ var SettingsSection = class {
19791
20021
  2,
19792
20022
  3
19793
20023
  ].map((b) => Math.min(b, maxBand));
19794
- return field("Mode", select([{
19795
- value: "rgb",
19796
- label: "RGB / composite"
19797
- }, {
19798
- value: "single",
19799
- label: "Single band + colormap"
19800
- }], mode, (next) => {
19801
- this._setState({
20024
+ return field("Mode", select([
20025
+ {
20026
+ value: "rgb",
20027
+ label: "RGB / composite"
20028
+ },
20029
+ {
20030
+ value: "single",
20031
+ label: "Single band + colormap"
20032
+ },
20033
+ {
20034
+ value: "index",
20035
+ label: "Index (normalized difference)"
20036
+ }
20037
+ ], mode, (next) => {
20038
+ if (next === "index") this._setState(this._indexPatchFor(indexById(state.index)));
20039
+ else this._setState({
19802
20040
  mode: next,
19803
20041
  bands: next === "single" ? [state.bands[0] ?? 1] : rgbDefault,
19804
20042
  rescale: null
@@ -19806,6 +20044,26 @@ var SettingsSection = class {
19806
20044
  this.render();
19807
20045
  }, "mode"), HELP.mode);
19808
20046
  }
20047
+ /** Builds the state patch for entering / switching an index preset: assigns
20048
+ * each operand a band (guessed from band names, else 1 and 2), applies the
20049
+ * preset's default colormap, and resets rescale to the [-1, 1] auto range. */
20050
+ _indexPatchFor(preset) {
20051
+ const index = preset ?? NORMALIZED_DIFFERENCE_INDICES[0];
20052
+ const layer = this._getLayer();
20053
+ const bandCount = Math.max(1, layer?.bandCount ?? 1);
20054
+ const names = layer?.bandNames ?? null;
20055
+ const clamp = (b) => Math.min(Math.max(1, b), bandCount);
20056
+ const a = clamp(guessBandForRole(index.roleA, names) ?? 1);
20057
+ const b = clamp(guessBandForRole(index.roleB, names) ?? (a === 2 ? 1 : 2));
20058
+ return {
20059
+ mode: "index",
20060
+ index: index.id,
20061
+ bands: [a, b],
20062
+ colormap: index.colormap,
20063
+ reversed: false,
20064
+ rescale: null
20065
+ };
20066
+ }
19809
20067
  _buildBandsField(layer, state, mode, bandOptions) {
19810
20068
  const options = bandOptions.map((n) => ({
19811
20069
  value: String(n),
@@ -19826,14 +20084,46 @@ var SettingsSection = class {
19826
20084
  });
19827
20085
  return field("Bands (R, G, B)", row, HELP.bandsRgb);
19828
20086
  }
20087
+ /** Index mode UI: a preset selector plus a band picker for each operand of
20088
+ * `(A - B) / (A + B)`, labelled with the preset's roles (e.g. NIR / Red). */
20089
+ _buildIndexField(layer, state, bandOptions) {
20090
+ const preset = indexById(state.index) ?? NORMALIZED_DIFFERENCE_INDICES[0];
20091
+ const wrap = el("div");
20092
+ const presetSelect = select([...NORMALIZED_DIFFERENCE_INDICES.map((i) => ({
20093
+ value: i.id,
20094
+ label: i.label
20095
+ })), {
20096
+ value: CUSTOM_NORMALIZED_DIFFERENCE.id,
20097
+ label: CUSTOM_NORMALIZED_DIFFERENCE.label
20098
+ }], preset.id, (next) => {
20099
+ this._setState(this._indexPatchFor(indexById(next)));
20100
+ this.render();
20101
+ }, "index-preset");
20102
+ wrap.appendChild(field("Index", presetSelect, preset.name));
20103
+ const bandChoices = bandOptions.map((n) => ({
20104
+ value: String(n),
20105
+ label: bandLabel(n, layer.bandNames)
20106
+ }));
20107
+ const operandSelect = (slot, ariaLabel) => select(bandChoices, String(state.bands[slot] ?? (slot === 0 ? 1 : 2)), (next) => {
20108
+ const bands = [state.bands[0] ?? 1, state.bands[1] ?? 2];
20109
+ bands[slot] = Number(next);
20110
+ this._setState({ bands });
20111
+ this.render();
20112
+ }, ariaLabel);
20113
+ const grid = el("div", { className: "mlr-band-grid" }, operandSelect(0, "index-band-a"), operandSelect(1, "index-band-b"));
20114
+ wrap.appendChild(field(`Bands (${preset.roleA}, ${preset.roleB})`, grid, HELP.indexBands));
20115
+ return wrap;
20116
+ }
19829
20117
  _buildRescaleField(layer, state, mode) {
19830
20118
  const autoStats = layer.autoStats;
19831
- const channelCount = mode === "single" ? 1 : 3;
20119
+ const channelCount = mode === "rgb" ? 3 : 1;
19832
20120
  const bands = state.bands;
19833
- const perBandStats = Array.from({ length: channelCount }, (_, i) => statsForBand(autoStats, bands[i] ?? bands[0] ?? 1));
20121
+ const isIndex = mode === "index";
20122
+ const perBandStats = Array.from({ length: channelCount }, (_, i) => isIndex ? null : statsForBand(autoStats, bands[i] ?? bands[0] ?? 1));
19834
20123
  const perBandPercentile = perBandStats.map((s) => s ? autoRangeFor(s) : null);
19835
20124
  const perBandMinMax = perBandStats.map((s) => s ? [s.min, s.max] : null);
19836
- const values = Array.from({ length: channelCount }, (_, i) => state.rescale?.[i] ?? perBandPercentile[i] ?? [0, 1]);
20125
+ const defaultRange = isIndex ? [DEFAULT_INDEX_RANGE[0], DEFAULT_INDEX_RANGE[1]] : [0, 1];
20126
+ const values = Array.from({ length: channelCount }, (_, i) => state.rescale?.[i] ?? perBandPercentile[i] ?? defaultRange);
19837
20127
  const setChannel = (i, next) => {
19838
20128
  const out = values.map((v) => [...v]);
19839
20129
  out[i] = next;
@@ -19845,11 +20135,11 @@ var SettingsSection = class {
19845
20135
  const wrap = el("div", { className: "mlr-rescale" });
19846
20136
  const rows = [];
19847
20137
  for (let i = 0; i < channelCount; i++) {
19848
- const channel = mode === "single" ? null : RGB_CHANNELS[i];
20138
+ const channel = mode === "rgb" ? RGB_CHANNELS[i] : null;
19849
20139
  const row = new RescaleRow({
19850
20140
  color: channel?.color ?? "var(--mlr-histogram-neutral)",
19851
20141
  label: channel?.label,
19852
- ariaPrefix: mode === "single" ? "rescale" : `rescale-${channel.label.toLowerCase()}`,
20142
+ ariaPrefix: channel ? `rescale-${channel.label.toLowerCase()}` : "rescale",
19853
20143
  onChange: (next) => setChannel(i, next),
19854
20144
  onDragStart: () => {
19855
20145
  this._dragCount++;
@@ -20839,7 +21129,7 @@ var RasterControl = class {
20839
21129
  if (!map || !manager) return;
20840
21130
  const seen = /* @__PURE__ */ new Set();
20841
21131
  for (const layer of manager.getLayers()) {
20842
- if (!layer.state.colorbar?.visible || !layer.state.visible || layer.state.mode !== "single" || layer.state.colormap === "palette") continue;
21132
+ if (!layer.state.colorbar?.visible || !layer.state.visible || layer.state.mode !== "single" && layer.state.mode !== "index" || layer.state.mode === "single" && layer.state.colormap === "palette") continue;
20843
21133
  seen.add(layer.id);
20844
21134
  const options = this._colorbarOptionsFor(layer);
20845
21135
  const existing = this._colorbars.get(layer.id);
@@ -20864,7 +21154,8 @@ var RasterControl = class {
20864
21154
  _colorbarOptionsFor(layer) {
20865
21155
  const band = layer.state.bands[0] ?? 1;
20866
21156
  const stats = statsForBand(layer.autoStats, band);
20867
- const range = layer.state.rescale?.[0] ?? (stats ? autoRangeFor(stats) : [0, 1]);
21157
+ const autoRange = layer.state.mode === "index" ? [-1, 1] : stats ? autoRangeFor(stats) : [0, 1];
21158
+ const range = layer.state.rescale?.[0] ?? autoRange;
20868
21159
  const cb = layer.state.colorbar;
20869
21160
  return {
20870
21161
  colormap: layer.state.colormap,
@@ -21137,6 +21428,6 @@ var RasterControl = class {
21137
21428
  }
21138
21429
  };
21139
21430
  //#endregion
21140
- export { autoRangeFor as C, percentileFromHistogram as D, computeAutoStats as E, readBandNames as O, colormaps_default as S, MAX_SAMPLE_TILES as T, COLORMAP_DISPLAY_NAMES as _, LayerManager as a, COLORMAP_ROW_COUNT as b, loadColormapSprite as c, classNames as d, debounce as f, loadGeoTIFF as g, throttle as h, DEFAULT_ENGINE as i, createResilientEpsgResolver as k, sampleColormapStops as l, generateId as m, Colorbar as n, PALETTE_COLORMAP as o, formatNumericValue as p, readPixelValues as r, isKnownColormap as s, RasterControl as t, clamp as u, COLORMAP_NAMES as v, statsForBand as w, colormapDisplayName as x, COLORMAP_OPTIONS as y };
21431
+ export { statsForBand as A, COLORMAP_NAMES as C, colormaps_default as D, colormapDisplayName as E, createResilientEpsgResolver as F, computeAutoStats as M, percentileFromHistogram as N, DEFAULT_INDEX_RANGE as O, readBandNames as P, COLORMAP_DISPLAY_NAMES as S, COLORMAP_ROW_COUNT as T, debounce as _, NORMALIZED_DIFFERENCE_INDICES as a, throttle as b, readPixelValues as c, PALETTE_COLORMAP as d, isKnownColormap as f, classNames as g, clamp as h, CUSTOM_NORMALIZED_DIFFERENCE as i, MAX_SAMPLE_TILES as j, autoRangeFor as k, DEFAULT_ENGINE as l, sampleColormapStops as m, Colorbar as n, guessBandForRole as o, loadColormapSprite as p, CUSTOM_INDEX_ID as r, indexById as s, RasterControl as t, LayerManager as u, formatNumericValue as v, COLORMAP_OPTIONS as w, loadGeoTIFF as x, generateId as y };
21141
21432
 
21142
- //# sourceMappingURL=RasterControl-CNxONQGm.js.map
21433
+ //# sourceMappingURL=RasterControl-BfTDWAnW.js.map