• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

keplergl / kepler.gl / 24678467843

20 Apr 2026 04:38PM UTC coverage: 59.525% (-0.4%) from 59.936%
24678467843

push

github

web-flow
fix(effects): fixes for effects regressions (#3376)

* fix: fixes for effects regressions

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>

* possible fixes

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>
Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix: update allow hover tooltip (#3377)

* update allow hover tooltip

Signed-off-by: bdjulbic <bdjulbic@foursquare.com>

* Update src/constants/src/layers.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: bdjulbic <bdjulbic@foursquare.com>

* update allow hover tooltip

Signed-off-by: bdjulbic <bdjulbic@foursquare.com>

---------

Signed-off-by: bdjulbic <bdjulbic@foursquare.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix: video export fixes (#3378)

* fix: video export fixes

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>

* cleanup; patch on mount

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>

* tests

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>

---------

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>
Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* revert extra

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>
Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* color fixes

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix video export and tiled layers

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix light intensity

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* extend far frustum - prevent cutting off far geometries

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* animate fog/sea level

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* get 'water leve' animation

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* lint

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBo... (continued)

6795 of 13681 branches covered (49.67%)

Branch coverage included in aggregate %.

148 of 331 new or added lines in 15 files covered. (44.71%)

143 existing lines in 7 files now uncovered.

14002 of 21257 relevant lines covered (65.87%)

76.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

57.63
/src/utils/src/effect-utils.ts
1
// SPDX-License-Identifier: MIT
2
// Copyright contributors to the kepler.gl project
3

4
import SunCalc from 'suncalc';
5
import cloneDeep from 'lodash/cloneDeep';
6

7
import type {Effect as DeckEffect} from '@deck.gl/core';
8

9
import {
10
  LIGHT_AND_SHADOW_EFFECT,
11
  LIGHT_AND_SHADOW_EFFECT_TIME_MODES,
12
  FILTER_TYPES,
13
  FILTER_VIEW_TYPES,
14
  DISTANCE_FOG_TYPE,
15
  SURFACE_FOG_TYPE
16
} from '@kepler.gl/constants';
17
import {arrayMove} from '@kepler.gl/common-utils';
18
import {MapState, Effect, EffectProps, EffectDescription} from '@kepler.gl/types';
19
import {findById} from './utils';
20
import {clamp} from './data-utils';
21
import {normalizeColor} from './color-utils';
22

23
// TODO isolate types - depends on @kepler.gl/schemas
24
type VisState = any;
25

26
// Retains the last LightingEffect deckEffect so we can keep it in the
27
// effects array (with shadows disabled) after the user removes the
28
// Light & Shadow effect from the UI. Without this, deck.gl calls
29
// cleanup() which removes the shadow shader module, but existing layer
30
// models still have shadow_uShadowMap bindings → texture errors.
31
let _lastLightingDeckEffect: any = null;
17✔
32

33
export function computeDeckEffects({
34
  visState,
35
  mapState,
36
  isExport
37
}: {
38
  visState: VisState;
39
  mapState: MapState;
40
  isExport?: boolean;
41
}): DeckEffect[] {
42
  // TODO: 1) deck effects per deck context 2) preserved between draws
43
  let hasLightingShadow = false;
2✔
44

45
  const deckEffects = visState.effectOrder
2✔
46
    .map(effectId => {
47
      const effect = findById(effectId)(visState.effects) as Effect | undefined;
4✔
48
      if (effect?.deckEffect) {
4!
49
        // Always reset isExportMode so the flag doesn't persist from a
50
        // previous export session on reused effect instances.
51
        if (
4✔
52
          effect.type === LIGHT_AND_SHADOW_EFFECT.type ||
8✔
53
          effect.type === SURFACE_FOG_TYPE ||
54
          effect.type === DISTANCE_FOG_TYPE
55
        ) {
56
          effect.deckEffect.isExportMode = Boolean(isExport);
2✔
57
        }
58

59
        if (effect.isEnabled) {
4!
60
          // deck.gl's EffectManager matches effects by id and reuses old
61
          // instances (calling oldEffect.setProps(newEffect.props)) instead
62
          // of replacing them. When a lighting effect is removed and
63
          // re-added, the cached _lastLightingDeckEffect is the instance
64
          // that deck.gl will actually render with. Adopt it so that
65
          // parameter syncing in updateEffect targets the right object.
66
          if (
4!
67
            !isExport &&
11✔
68
            effect.type === LIGHT_AND_SHADOW_EFFECT.type &&
69
            _lastLightingDeckEffect &&
70
            _lastLightingDeckEffect !== effect.deckEffect
71
          ) {
NEW
72
            const orphaned = effect.deckEffect;
×
NEW
73
            effect.deckEffect = _lastLightingDeckEffect;
×
NEW
74
            if (orphaned && typeof orphaned.cleanup === 'function') {
×
NEW
75
              orphaned.cleanup();
×
76
            }
77
          }
78
          updateEffect({visState, mapState, effect});
4✔
79
        } else if (effect.type === LIGHT_AND_SHADOW_EFFECT.type) {
×
80
          // Keep lighting effects in the array even when disabled to avoid
81
          // removing the shadow shader module. Composite layer sublayers
82
          // don't regenerate models when default shader modules change,
83
          // leaving stale pipelines with shadow_uShadowMap bindings.
84
          // Disabling shadow on the lights avoids visual effects.
85
          disableLightingEffect(effect);
×
86
        }
87
        if (effect.isEnabled || effect.type === LIGHT_AND_SHADOW_EFFECT.type) {
4!
88
          if (effect.type === LIGHT_AND_SHADOW_EFFECT.type) {
4✔
89
            hasLightingShadow = true;
2✔
90
            if (!isExport) {
2!
91
              _lastLightingDeckEffect = effect.deckEffect;
2✔
92
            }
93
          }
94
          return effect.deckEffect;
4✔
95
        }
96
      }
97
      return null;
×
98
    })
99
    .filter(effect => effect);
4✔
100

101
  if (!hasLightingShadow && _lastLightingDeckEffect) {
2!
102
    disableDeckLightingEffect(_lastLightingDeckEffect);
×
103
    deckEffects.unshift(_lastLightingDeckEffect);
×
104
  }
105

106
  return deckEffects;
2✔
107
}
108

109
/**
110
 * Always keep light & shadow effect at the top, then distance fog and
111
 * surface fog right after it (before other post-processing effects).
112
 * Both fog effects read the depth buffer from renderBuffers[0];
113
 * subsequent effects clear depth during their render passes, so fog
114
 * must run before that happens.
115
 */
116
export const fixEffectOrder = (effects: Effect[], effectOrder: string[]): string[] => {
17✔
117
  const lightShadowEffect = effects.find(effect => effect.type === LIGHT_AND_SHADOW_EFFECT.type);
52✔
118
  if (lightShadowEffect) {
26✔
119
    const ind = effectOrder.indexOf(lightShadowEffect.id);
7✔
120
    if (ind > 0) {
7✔
121
      effectOrder.splice(ind, 1);
1✔
122
      effectOrder.unshift(lightShadowEffect.id);
1✔
123
    }
124
  }
125

126
  const distanceFogEffect = effects.find(effect => effect.type === DISTANCE_FOG_TYPE);
53✔
127
  if (distanceFogEffect) {
26!
128
    const ind = effectOrder.indexOf(distanceFogEffect.id);
×
129
    const targetPos = lightShadowEffect ? 1 : 0;
×
130
    if (ind > targetPos) {
×
131
      effectOrder.splice(ind, 1);
×
132
      effectOrder.splice(targetPos, 0, distanceFogEffect.id);
×
133
    }
134
  }
135

136
  const surfaceFogEffect = effects.find(effect => effect.type === SURFACE_FOG_TYPE);
53✔
137
  if (surfaceFogEffect) {
26!
138
    const ind = effectOrder.indexOf(surfaceFogEffect.id);
×
139
    let targetPos = 0;
×
140
    if (lightShadowEffect) targetPos++;
×
141
    if (distanceFogEffect) targetPos++;
×
142
    if (ind > targetPos) {
×
143
      effectOrder.splice(ind, 1);
×
144
      effectOrder.splice(targetPos, 0, surfaceFogEffect.id);
×
145
    }
146
  }
147

148
  return effectOrder;
26✔
149
};
150

151
export function reorderEffectOrder(
152
  effectOrder: string[],
153
  originEffectId: string,
154
  destinationEffectId: string
155
): string[] {
156
  const activeIndex = effectOrder.indexOf(originEffectId);
×
157
  const overIndex = effectOrder.indexOf(destinationEffectId);
×
158
  return arrayMove(effectOrder, activeIndex, overIndex);
×
159
}
160

161
/**
162
 * Check if the current time is daytime at the given location
163
 * @param {number} lat Latitude
164
 * @param {number} lon Longitude
165
 * @param {number} timestamp Milliseconds since the Unix Epoch
166
 * @returns boolean
167
 */
168
function isDaytime(lat, lon, timestamp) {
169
  const date = new Date(timestamp);
2✔
170
  const {sunrise, sunset} = SunCalc.getTimes(date, lat, lon);
2✔
171
  return date >= sunrise && date <= sunset;
2✔
172
}
173

174
/**
175
 * Disable shadow rendering on a lighting effect without removing it.
176
 * This keeps the shadow shader module registered and prevents stale
177
 * texture binding errors in composite layer sublayers.
178
 */
179
function disableLightingEffect(effect: Effect) {
180
  const deckEffect = effect.deckEffect;
×
181
  if (!deckEffect) return;
×
182
  disableDeckLightingEffect(deckEffect);
×
183
}
184

185
/**
186
 * Disable shadow rendering directly on a deck.gl LightingEffect instance.
187
 */
188
function disableDeckLightingEffect(deckEffect: any) {
189
  deckEffect.shadow = false;
×
190
  deckEffect.outputUniformShadow = false;
×
191
  for (const light of deckEffect.directionalLights || []) {
×
192
    light.shadow = false;
×
193
  }
194
}
195

196
/**
197
 * Update effect to match latest vis and map states.
198
 *
199
 * deck.gl's EffectManager compares effects by `id` and reuses the
200
 * existing (old) instance when a new one with the same id is supplied
201
 * (calling `oldEffect.setProps(newEffect.props)` instead of replacing).
202
 * LightingEffect.setProps only updates light sources, not shadowColor.
203
 * So we must sync ALL parameters here every frame to ensure the deck
204
 * effect always reflects the kepler-side state — even if deck.gl
205
 * silently swapped the instance under us.
206
 */
207
function updateEffect({visState, mapState, effect}: {visState: any; mapState: any; effect: any}) {
208
  if (effect.type === LIGHT_AND_SHADOW_EFFECT.type) {
4✔
209
    const deckEffect = effect.deckEffect;
2✔
210
    const {parameters} = effect;
2✔
211

212
    // Re-enable shadow rendering in case it was previously disabled
213
    for (const light of deckEffect.directionalLights || []) {
2!
214
      light.shadow = true;
2✔
215
    }
216
    deckEffect.shadow = deckEffect.directionalLights?.some(l => l.shadow) ?? false;
2!
217

218
    // Sync shadow color & intensity (not handled by deck.gl setProps)
219
    deckEffect.shadowColor = [
2✔
220
      ...normalizeColor(parameters.shadowColor),
221
      parameters.shadowIntensity
222
    ];
223

224
    // Sync ambient light
225
    if (deckEffect.ambientLight) {
2!
226
      deckEffect.ambientLight.intensity = parameters.ambientLightIntensity;
2✔
227
      deckEffect.ambientLight.color = parameters.ambientLightColor.slice();
2✔
228
    }
229

230
    let {timestamp} = parameters;
2✔
231
    const {timeMode} = parameters;
2✔
232
    const sunLight = deckEffect.directionalLights?.[0];
2✔
233
    if (sunLight) {
2!
234
      sunLight.color = parameters.sunLightColor.slice();
2✔
235
    }
236

237
    // set timestamp for shadow
238
    if (timeMode === LIGHT_AND_SHADOW_EFFECT_TIME_MODES.current) {
2!
239
      timestamp = Date.now();
×
NEW
240
      if (sunLight) sunLight.timestamp = timestamp;
×
241
    } else if (timeMode === LIGHT_AND_SHADOW_EFFECT_TIME_MODES.animation) {
2!
242
      timestamp = visState.animationConfig.currentTime ?? 0;
×
243
      if (!timestamp) {
×
244
        const filter = visState.filters.find(
×
245
          filter =>
246
            filter.type === FILTER_TYPES.timeRange &&
×
247
            (filter.view === FILTER_VIEW_TYPES.enlarged || filter.syncedWithLayerTimeline)
248
        );
249
        if (filter) {
×
250
          timestamp = filter.value?.[0] ?? 0;
×
251
        }
252
      }
NEW
253
      if (sunLight) sunLight.timestamp = timestamp;
×
254
    }
255

256
    // output uniform shadow during nighttime
257
    if (isDaytime(mapState.latitude, mapState.longitude, timestamp)) {
2✔
258
      deckEffect.outputUniformShadow = false;
1✔
259
      if (sunLight) sunLight.intensity = parameters.sunLightIntensity;
1!
260
    } else {
261
      deckEffect.outputUniformShadow = true;
1✔
262
      if (sunLight) sunLight.intensity = 0;
1!
263
    }
264
  }
265
}
266

267
/**
268
 * Validates parameters for an effect, clamps numbers to allowed ranges
269
 * or applies default values in case of wrong non-numeric values.
270
 * All unknown properties aren't modified.
271
 * @param parameters Parameters candidate for an effect.
272
 * @param effectDescription Description of an effect.
273
 * @returns
274
 */
275
export function validateEffectParameters(
276
  parameters: EffectProps['parameters'] = {},
26✔
277
  effectDescription: EffectDescription['parameters']
278
): EffectProps['parameters'] {
279
  const result = cloneDeep(parameters);
59✔
280
  effectDescription.forEach(description => {
59✔
281
    const {defaultValue, name, type, min, max} = description;
135✔
282

283
    if (!Object.prototype.hasOwnProperty.call(result, name)) return;
135✔
284
    const property = result[name];
20✔
285

286
    if (type === 'color' || type === 'array') {
20✔
287
      if (!Array.isArray(defaultValue)) return;
6!
288
      if (property.length !== defaultValue?.length) {
6✔
289
        result[name] = defaultValue;
4✔
290
        return;
4✔
291
      }
292
      defaultValue.forEach((v, i) => {
2✔
293
        let value = property[i];
5✔
294
        value = Number.isFinite(value) ? clamp([min, max], value) : defaultValue[i] ?? min;
5!
295
        if (value !== undefined) {
5!
296
          property[i] = value;
5✔
297
        }
298
      });
299
      return;
2✔
300
    }
301

302
    if (type === 'checkbox') {
14!
303
      result[name] = Boolean(property);
×
304
      return;
×
305
    }
306

307
    const value = Number.isFinite(property) ? clamp([min, max], property) : defaultValue ?? min;
14✔
308

309
    if (value !== undefined) {
14!
310
      result[name] = value;
14✔
311
    }
312
  });
313
  return result;
59✔
314
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc