diff --git a/src/components/src/effects/effect-time-configurator.tsx b/src/components/src/effects/effect-time-configurator.tsx
index 1d0cd378ea..ad19d2e3e6 100644
--- a/src/components/src/effects/effect-time-configurator.tsx
+++ b/src/components/src/effects/effect-time-configurator.tsx
@@ -92,20 +92,21 @@ export default function EffectTimeConfiguratorFactory(
onTimeModeChange,
intl
}: EffectTimeConfiguratorProps) => {
- const [selectedDate, selectedTimeString] = useMemo(() => {
+ const [dateOnly, selectedTimeString] = useMemo(() => {
const date = new Date(timestamp);
- const h = date.getHours();
- const m = date.getMinutes();
+ const h = date.getUTCHours();
+ const m = date.getUTCMinutes();
const timeString = `${h < 10 ? `0${h}` : `${h}`}:${m < 10 ? `0${m}` : `${m}`}`;
+ date.setUTCHours(0, 0, 0);
return [date, timeString];
}, [timestamp]);
const timeSliderValue = useMemo(() => {
- const base = new Date(timestamp).setHours(0, 0, 0).valueOf();
+ const base = dateOnly.valueOf();
return clamp([0, 1], (parseInt(timestamp) - base) / DAY_SLIDER_RANGE);
- }, [timestamp, selectedDate]);
+ }, [timestamp, dateOnly]);
const timeSliderProps = useMemo(() => {
return {
@@ -115,7 +116,7 @@ export default function EffectTimeConfiguratorFactory(
range: [0, 1],
value0: 0,
onChange: value => {
- const start = new Date(timestamp).setHours(0, 0, 0).valueOf();
+ const start = new Date(timestamp).setUTCHours(0, 0, 0).valueOf();
onDateTimeChange(Math.floor(start + DAY_SLIDER_RANGE * clamp([0, 0.9999], value[1])));
},
showInput: false,
@@ -125,7 +126,9 @@ export default function EffectTimeConfiguratorFactory(
const setDate = useCallback(
newDate => {
- onDateTimeChange(Math.floor(newDate.valueOf() + DAY_SLIDER_RANGE * timeSliderValue));
+ const adjustedTime = newDate.valueOf() - newDate.getTimezoneOffset() * 1000 * 60;
+ const newTimestamp = Math.floor(adjustedTime + DAY_SLIDER_RANGE * timeSliderValue);
+ onDateTimeChange(newTimestamp);
},
[timeSliderValue, onDateTimeChange]
);
@@ -133,7 +136,7 @@ export default function EffectTimeConfiguratorFactory(
const setTime = useCallback(
time => {
const conf = time.split(':');
- const start = new Date(timestamp).setHours(conf[0], conf[1]).valueOf();
+ const start = new Date(timestamp).setUTCHours(conf[0], conf[1]).valueOf();
onDateTimeChange(start);
},
[timestamp, onDateTimeChange]
@@ -167,7 +170,7 @@ export default function EffectTimeConfiguratorFactory(
-
+
{
@@ -791,7 +796,9 @@ export default function MapContainerFactory(
};
}
- const effects = this._isOKToRenderEffects() ? computeDeckEffects({visState, mapState}) : [];
+ const effects = this._isOKToRenderEffects(index)
+ ? computeDeckEffects({visState, mapState})
+ : [];
const views = deckGlProps?.views
? deckGlProps?.views()
diff --git a/src/effects/package.json b/src/effects/package.json
index 24d0474067..be9d5b7581 100644
--- a/src/effects/package.json
+++ b/src/effects/package.json
@@ -32,6 +32,7 @@
"dependencies": {
"suncalc": "^1.9.0",
"@deck.gl/core": "^8.9.12",
+ "@luma.gl/core": "^8.5.19",
"@luma.gl/shadertools": "^8.5.19",
"@kepler.gl/utils": "3.0.0-alpha.1",
"@kepler.gl/constants": "3.0.0-alpha.1",
diff --git a/src/effects/src/custom-deck-lighting-effect.ts b/src/effects/src/custom-deck-lighting-effect.ts
new file mode 100644
index 0000000000..000aa91f58
--- /dev/null
+++ b/src/effects/src/custom-deck-lighting-effect.ts
@@ -0,0 +1,132 @@
+// @ts-nocheck This is a hack, don't check types
+
+import {console as Console} from 'global/window';
+import {LightingEffect, shadow} from '@deck.gl/core';
+import {Texture2D, ProgramManager} from '@luma.gl/core';
+
+/**
+ * Inserts shader code before detected part.
+ * @param {string} vs Original shader code.
+ * @param {string} type Debug string.
+ * @param {string} insertBeforeText Text chunk to insert before.
+ * @param {string} textToInsert Text to insert.
+ * @returns Modified shader code.
+ */
+export function insertBefore(vs, type, insertBeforeText, textToInsert) {
+ const at = vs.indexOf(insertBeforeText);
+ if (at < 0) {
+ Console.error(`Cannot edit ${type} layer shader`);
+ return vs;
+ }
+
+ return vs.slice(0, at) + textToInsert + vs.slice(at);
+}
+
+const CustomShadowModule = shadow ? {...shadow} : undefined;
+
+/**
+ * Custom shadow module
+ * 1) Add u_outputUniformShadow uniform
+ * 2) always produce full shadow when the uniform is set to true.
+ */
+CustomShadowModule.fs = insertBefore(
+ CustomShadowModule.fs,
+ 'custom shadow #1',
+ 'uniform vec4 shadow_uColor;',
+ 'uniform bool u_outputUniformShadow;'
+);
+
+CustomShadowModule.fs = insertBefore(
+ CustomShadowModule.fs,
+ 'custom shadow #1',
+ 'vec4 rgbaDepth = texture2D(shadowMap, position.xy);',
+ 'if(u_outputUniformShadow) return 1.0;'
+);
+
+CustomShadowModule.getUniforms = (opts = {}, context = {}) => {
+ const u = shadow.getUniforms(opts, context);
+ if (opts.outputUniformShadow !== undefined) {
+ u['u_outputUniformShadow'] = opts.outputUniformShadow;
+ }
+ return u;
+};
+
+/**
+ * Custom LightingEffect
+ * 1) adds CustomShadowModule
+ * 2) pass outputUniformShadow as module parameters
+ * 3) properly removes CustomShadowModule
+ */
+class CustomDeckLightingEffect extends LightingEffect {
+ constructor(props) {
+ super(props);
+ this.useOutputUniformShadow = false;
+ }
+
+ preRender(gl, {layers, layerFilter, viewports, onViewportActive, views}) {
+ if (!this.shadow) return;
+
+ // create light matrix every frame to make sure always updated from light source
+ this.shadowMatrices = this._calculateMatrices();
+
+ if (this.shadowPasses.length === 0) {
+ this._createShadowPasses(gl);
+ }
+ if (!this.programManager) {
+ this.programManager = ProgramManager.getDefaultProgramManager(gl);
+ if (CustomShadowModule) {
+ this.programManager.addDefaultModule(CustomShadowModule);
+ }
+ }
+
+ if (!this.dummyShadowMap) {
+ this.dummyShadowMap = new Texture2D(gl, {
+ width: 1,
+ height: 1
+ });
+ }
+
+ for (let i = 0; i < this.shadowPasses.length; i++) {
+ const shadowPass = this.shadowPasses[i];
+ shadowPass.render({
+ layers,
+ layerFilter,
+ viewports,
+ onViewportActive,
+ views,
+ moduleParameters: {
+ shadowLightId: i,
+ dummyShadowMap: this.dummyShadowMap,
+ shadowMatrices: this.shadowMatrices,
+ useOutputUniformShadow: false
+ }
+ });
+ }
+ }
+
+ getModuleParameters(layer) {
+ const parameters = super.getModuleParameters(layer);
+ parameters.outputUniformShadow = this.outputUniformShadow;
+ return parameters;
+ }
+
+ cleanup() {
+ for (const shadowPass of this.shadowPasses) {
+ shadowPass.delete();
+ }
+ this.shadowPasses.length = 0;
+ this.shadowMaps.length = 0;
+
+ if (this.dummyShadowMap) {
+ this.dummyShadowMap.delete();
+ this.dummyShadowMap = null;
+ }
+
+ if (this.shadow && this.programManager) {
+ this.programManager.removeDefaultModule(CustomShadowModule);
+ this.programManager = null;
+ }
+ }
+}
+
+export default CustomDeckLightingEffect;
diff --git a/src/effects/src/lighting-effect.ts b/src/effects/src/lighting-effect.ts
index 9376f93dad..78dea35496 100644
--- a/src/effects/src/lighting-effect.ts
+++ b/src/effects/src/lighting-effect.ts
@@ -1,14 +1,11 @@
-import {
- LightingEffect as DeckLightingEffect,
- AmbientLight,
- _SunLight as SunLight
-} from '@deck.gl/core';
+import {AmbientLight, _SunLight as SunLight} from '@deck.gl/core';
import {LIGHT_AND_SHADOW_EFFECT, DEFAULT_LIGHT_AND_SHADOW_PROPS} from '@kepler.gl/constants';
import {normalizeColor} from '@kepler.gl/utils';
import {EffectConfig, EffectParamsPartial} from '@kepler.gl/types';
import Effect from './effect';
+import CustomDeckLightingEffect from './custom-deck-lighting-effect';
const LIGHT_AND_SHADOW_EFFECT_DESC = {
...LIGHT_AND_SHADOW_EFFECT,
@@ -38,7 +35,7 @@ class LightingEffect extends Effect {
_shadow: true
});
- this.deckEffect = new DeckLightingEffect({
+ this.deckEffect = new CustomDeckLightingEffect({
ambientLight,
sunLight
});
diff --git a/src/utils/src/effect-utils.ts b/src/utils/src/effect-utils.ts
index 861379e152..89fb0ffccc 100644
--- a/src/utils/src/effect-utils.ts
+++ b/src/utils/src/effect-utils.ts
@@ -3,7 +3,12 @@ import SunCalc from 'suncalc';
import {PostProcessEffect} from '@deck.gl/core/typed';
-import {LIGHT_AND_SHADOW_EFFECT, LIGHT_AND_SHADOW_EFFECT_TIME_MODES} from '@kepler.gl/constants';
+import {
+ LIGHT_AND_SHADOW_EFFECT,
+ LIGHT_AND_SHADOW_EFFECT_TIME_MODES,
+ FILTER_TYPES,
+ FILTER_VIEW_TYPES
+} from '@kepler.gl/constants';
import {findById} from './utils';
import {VisState} from '@kepler.gl/schemas';
import {MapState, Effect} from '@kepler.gl/types';
@@ -22,27 +27,10 @@ export function computeDeckEffects({
})
.filter(effect => Boolean(effect && effect.config.isEnabled && effect.deckEffect)) as Effect[];
- const lightShadowEffect = effects.find(effect => effect.type === LIGHT_AND_SHADOW_EFFECT.type);
- if (lightShadowEffect) {
- const {timestamp, timeMode} = lightShadowEffect.config.params;
-
- if (timeMode === LIGHT_AND_SHADOW_EFFECT_TIME_MODES.current) {
- lightShadowEffect.deckEffect.directionalLights[0].timestamp = Date.now();
- } else if (timeMode === LIGHT_AND_SHADOW_EFFECT_TIME_MODES.animation) {
- // TODO: find an easy way to get current animation time
- const filter = visState.filters.find(filter => filter.fieldType === 'timestamp');
- if (filter) {
- lightShadowEffect.deckEffect.directionalLights[0].timestamp = filter.value?.[0] ?? 0;
- }
- }
-
- if (!isDaytime(mapState.latitude, mapState.longitude, timestamp)) {
- // TODO: interpolate for dusk/dawn
- // TODO: Should we avoid mutating the effect? (didn't work when tried defensive copying)
- lightShadowEffect.deckEffect.shadowColor[3] = 0;
- }
- }
- return effects.map(effect => effect.deckEffect);
+ return effects.map(effect => {
+ updateEffect({visState, mapState, effect});
+ return effect.deckEffect;
+ });
}
/**
@@ -82,3 +70,41 @@ function isDaytime(lat, lon, timestamp) {
const {sunrise, sunset} = SunCalc.getTimes(date, lat, lon);
return date >= sunrise && date <= sunset;
}
+
+/**
+ * Update effect to match latest vis and map states
+ */
+function updateEffect({visState, mapState, effect}) {
+ if (effect.type === LIGHT_AND_SHADOW_EFFECT.type) {
+ let {timestamp, timeMode} = effect.config.params;
+ const sunLight = effect.deckEffect.directionalLights[0];
+
+ // set timestamp for shadow
+ if (timeMode === LIGHT_AND_SHADOW_EFFECT_TIME_MODES.current) {
+ timestamp = Date.now();
+ sunLight.timestamp = timestamp;
+ } else if (timeMode === LIGHT_AND_SHADOW_EFFECT_TIME_MODES.animation) {
+ timestamp = visState.animationConfig.currentTime ?? 0;
+ if (!timestamp) {
+ const filter = visState.filters.find(
+ filter =>
+ filter.type === FILTER_TYPES.timeRange &&
+ (filter.view === FILTER_VIEW_TYPES.enlarged || filter.syncedWithLayerTimeline)
+ );
+ if (filter) {
+ timestamp = filter.value?.[0] ?? 0;
+ }
+ }
+ sunLight.timestamp = timestamp;
+ }
+
+ // output uniform shadow during nighttime
+ if (isDaytime(mapState.latitude, mapState.longitude, timestamp)) {
+ effect.deckEffect.outputUniformShadow = false;
+ sunLight.intensity = effect.config.params.sunLightIntensity;
+ } else {
+ effect.deckEffect.outputUniformShadow = true;
+ sunLight.intensity = 0;
+ }
+ }
+}
diff --git a/test/browser/components/effects/effect-time-configurator-test.js b/test/browser/components/effects/effect-time-configurator-test.js
new file mode 100644
index 0000000000..d341914a65
--- /dev/null
+++ b/test/browser/components/effects/effect-time-configurator-test.js
@@ -0,0 +1,150 @@
+import React from 'react';
+import test from 'tape';
+import sinon from 'sinon';
+
+import {LIGHT_AND_SHADOW_EFFECT_TIME_MODES} from '@kepler.gl/constants';
+import {appInjector, EffectTimeConfiguratorFactory} from '@kepler.gl/components';
+
+import {mountWithTheme, IntlWrapper} from 'test/helpers/component-utils';
+
+const EffectTimeConfigurator = appInjector.get(EffectTimeConfiguratorFactory);
+
+const TEST_TIMESTAMP = 1690303570534;
+
+test('Components -> EffectTimeConfigurator -> render', t => {
+ const props = {
+ timestamp: TEST_TIMESTAMP,
+ timeMode: LIGHT_AND_SHADOW_EFFECT_TIME_MODES.pick
+ };
+
+ let wrapper;
+ t.doesNotThrow(() => {
+ wrapper = mountWithTheme(
+
+
+
+ );
+ }, `EffectTimeConfigurator should not fail`);
+
+ t.equal(wrapper.find('Checkbox').length, 3, `should render 3 Checkboxes`);
+ t.equal(wrapper.find('Button').length, 1, `should render 1 Button`);
+ t.equal(wrapper.find('DatePicker').length, 1, `should render 1 DatePicker`);
+ t.equal(wrapper.find('TimePicker').length, 1, `should render 1 TimePicker`);
+
+ t.end();
+});
+
+test('Components -> EffectTimeConfigurator -> time type', t => {
+ const onTimeModeChange = sinon.spy();
+
+ const props = {
+ timestamp: TEST_TIMESTAMP,
+ timeMode: LIGHT_AND_SHADOW_EFFECT_TIME_MODES.pick,
+ onTimeModeChange
+ };
+
+ let wrapper;
+ t.doesNotThrow(() => {
+ wrapper = mountWithTheme(
+
+
+
+ );
+ }, `EffectTimeConfigurator should not fail`);
+
+ wrapper
+ .find('Checkbox')
+ .at(0)
+ .invoke('onChange')();
+ t.ok(
+ onTimeModeChange.calledWith(LIGHT_AND_SHADOW_EFFECT_TIME_MODES.pick),
+ `Should set ${LIGHT_AND_SHADOW_EFFECT_TIME_MODES.pick} mode`
+ );
+
+ wrapper
+ .find('Checkbox')
+ .at(1)
+ .invoke('onChange')();
+ t.ok(
+ onTimeModeChange.calledWith(LIGHT_AND_SHADOW_EFFECT_TIME_MODES.current),
+ `Should set ${LIGHT_AND_SHADOW_EFFECT_TIME_MODES.current} mode`
+ );
+
+ wrapper
+ .find('Checkbox')
+ .at(2)
+ .invoke('onChange')();
+ t.ok(
+ onTimeModeChange.calledWith(LIGHT_AND_SHADOW_EFFECT_TIME_MODES.animation),
+ `Should set ${LIGHT_AND_SHADOW_EFFECT_TIME_MODES.animation} mode`
+ );
+
+ t.ok(onTimeModeChange.calledThrice, 'should call onTimeModeChange thrice');
+
+ t.end();
+});
+
+test('Components -> EffectTimeConfigurator -> pick time', t => {
+ const onDateTimeChange = sinon.spy();
+
+ const props = {
+ timestamp: TEST_TIMESTAMP,
+ onDateTimeChange,
+ timeMode: LIGHT_AND_SHADOW_EFFECT_TIME_MODES.pick,
+ onTimeModeChange: () => {}
+ };
+
+ let wrapper;
+ t.doesNotThrow(() => {
+ wrapper = mountWithTheme(
+
+
+
+ );
+ }, `EffectTimeConfigurator should not fail`);
+
+ // date
+ const inputs = wrapper.find('input');
+ inputs
+ .find('.react-date-picker__inputGroup__month')
+ .at(0)
+ .simulate('change', {target: {value: '2', name: 'month'}});
+ inputs
+ .find('.react-date-picker__inputGroup__day')
+ .at(0)
+ .simulate('change', {target: {value: '2', name: 'day'}});
+ inputs
+ .find('.react-date-picker__inputGroup__year')
+ .at(0)
+ .simulate('change', {target: {value: '2022', name: 'year'}})
+ // properly propagate in tests
+ .simulate('change', {target: {value: '2022', name: 'year'}});
+
+ t.equal(onDateTimeChange.getCall(3).args[0], 1643820370000, `Date should be updated`);
+
+ // time
+ wrapper
+ .find('.react-time-picker__inputGroup__hour')
+ .at(0)
+ .simulate('change', {target: {value: '20', name: 'hour24'}});
+ wrapper
+ .find('.react-time-picker__inputGroup__minute')
+ .at(0)
+ .simulate('change', {target: {value: '30', name: 'minute'}})
+ // properly propagate in tests
+ .simulate('change', {target: {value: '30', name: 'minute'}});
+
+ t.equal(onDateTimeChange.getCall(6).args[0], 1690317010534, `Time should be updated`);
+
+ // pick current time button
+ wrapper
+ .find('Button')
+ .at(0)
+ .simulate('click');
+ t.ok(
+ Math.abs(onDateTimeChange.getCall(7).args[0] - Date.now()) < 1000,
+ `Should pick current date & time`
+ );
+
+ t.end();
+});
diff --git a/test/browser/components/effects/index.js b/test/browser/components/effects/index.js
index 3d1a5e1e2e..b1adfb1573 100644
--- a/test/browser/components/effects/index.js
+++ b/test/browser/components/effects/index.js
@@ -20,3 +20,4 @@
import './effect-manager-test';
import './effect-configurator-test';
+import './effect-time-configurator-test';
diff --git a/test/node/utils/effect-utils-test.js b/test/node/utils/effect-utils-test.js
index 5c987eafe7..b7a014cc14 100644
--- a/test/node/utils/effect-utils-test.js
+++ b/test/node/utils/effect-utils-test.js
@@ -55,14 +55,18 @@ test('effectUtils -> computeDeckEffects', t => {
t.ok(deckEffects[0] instanceof LightingEffect, 'lighting effect should be generated');
t.ok(deckEffects[1] instanceof PostProcessEffect, 'post-processing effect should be generated');
- t.equal(deckEffects[0].shadowColor[3], 0, 'shadows should be disabled');
+ // nighttime
+ t.equal(deckEffects[0].outputUniformShadow, true, 'shadows should be applied uniformly');
+ t.equal(deckEffects[0].directionalLights[0].intensity, 0, 'directional light should be disabled');
+ // daytime
nextState.effects[1].updateConfig({params: {timestamp: 1689415852635}});
deckEffects = computeDeckEffects({
visState: nextState,
mapState: {latitude: 51.033105, longitude: 0.348512}
});
t.equal(deckEffects[0].shadowColor[3], 0.5, 'shadows should be enabled');
+ t.equal(deckEffects[0].directionalLights[0].intensity, 1, 'directional light should be enabled');
t.end();
});