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(); });