diff --git a/src/components/HexColorPicker.tsx b/src/components/HexColorPicker.tsx new file mode 100644 index 00000000..b9f44287 --- /dev/null +++ b/src/components/HexColorPicker.tsx @@ -0,0 +1,23 @@ +import { styled } from '@mui/material/styles'; +import { + HexColorInput as _HexColorInput, + HexColorPicker as _HexColorPicker, +} from 'react-colorful'; + +export const HexColorInput = styled(_HexColorInput)(({ theme }) => ({ + background: theme.palette.action.hover, + border: 'none', + borderRadius: theme.shape.borderRadius, + color: theme.palette.text.primary, + padding: theme.spacing(1), + textAlign: 'center', + textTransform: 'uppercase', + + '&:focus': { + outline: 'none', + }, +})); + +export const HexColorPicker = styled(_HexColorPicker)(({ theme }) => ({ + padding: theme.spacing(1, 0), +})); diff --git a/src/features/show-configurator/AdaptParametersForm.tsx b/src/features/show-configurator/AdaptParametersForm.tsx index 4f59d9a8..d27758ef 100644 --- a/src/features/show-configurator/AdaptParametersForm.tsx +++ b/src/features/show-configurator/AdaptParametersForm.tsx @@ -17,6 +17,11 @@ import type { OptionalShowAdaptParameters, ShowAdaptParameters, } from './actions'; +import LightConfigurationForm, { + type LightConfigurationProps, +} from './LightConfigurationForm'; +import { Accordion, AccordionDetails, AccordionSummary } from '@mui/material'; +import { ExpandMore } from '@mui/icons-material'; const defaultAdaptParameters: ShowAdaptParameters = { minDistance: 2, @@ -26,10 +31,15 @@ const defaultAdaptParameters: ShowAdaptParameters = { takeoffDuration: 0, }; -const useStyles = makeStyles((theme: Theme) => ({ - formGroup: { +const useStyles = makeStyles((theme) => ({ + accordionDetails: { + display: 'flex', + flexDirection: 'column', gap: theme.spacing(2), - marginTop: theme.spacing(-2), + '& .react-colorful': { + height: '120px', + width: '100%', + }, }, })); @@ -169,12 +179,13 @@ export function useAdaptParametersFormState( type Props = Readonly< ReturnType & { disabled: boolean; - } + } & { lights: LightConfigurationProps } >; const AdaptParametersForm = (props: Props): React.JSX.Element => { const { disabled, + lights, parameters, onMinDistanceChanged, onAltitudeChanged, @@ -186,55 +197,86 @@ const AdaptParametersForm = (props: Props): React.JSX.Element => { keyPrefix: 'showConfiguratorDialog.adaptParameters', }); const styles = useStyles(); + const [shownSection, setShownSection] = useState< + 'none' | 'parameters' | 'lights' + >('parameters'); return ( - - {t('section.parameters')} - - - - - + + { + setShownSection( + shownSection === 'parameters' ? 'none' : 'parameters' + ); + }} + > + }> + {t('panel.trajectories')} + + + + + + + + + + { + setShownSection(shownSection === 'lights' ? 'none' : 'lights'); + }} + > + }> + {t('panel.lights')} + + + + + ); diff --git a/src/features/show-configurator/LightConfigurationForm.tsx b/src/features/show-configurator/LightConfigurationForm.tsx new file mode 100644 index 00000000..51ee199b --- /dev/null +++ b/src/features/show-configurator/LightConfigurationForm.tsx @@ -0,0 +1,235 @@ +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import Slider from '@mui/material/Slider'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { SimpleDurationField } from '~/components/forms'; +import { HexColorInput, HexColorPicker } from '~/components/HexColorPicker'; + +import type { LightEffectConfiguration, LightEffectType } from './actions'; + +const lightEffectTypes: LightEffectType[] = [ + 'off', + 'default', + 'solid', + 'sparks', +]; + +type DefaultConfigurationFormProps = { + brightness: number; + disabled?: boolean; + onChange: (event: Event, value: number) => void; +}; + +const DefaultConfigurationForm = ({ + brightness, + disabled, + onChange, +}: DefaultConfigurationFormProps) => { + const { t } = useTranslation(undefined, { + keyPrefix: 'showConfiguratorDialog.lights', + }); + return ( + <> + {t('default.brightness')} + + + ); +}; + +type SolidConfigurationFormProps = { + color: string; + disabled?: boolean; + onChange: (color: string) => void; +}; + +const SolidConfigurationForm = ({ + color, + disabled, + onChange, +}: SolidConfigurationFormProps) => { + const { t } = useTranslation(undefined, { + keyPrefix: 'showConfiguratorDialog.lights', + }); + // TODO: update when upgrading to React 19 which recognizes the + // inert as a boolean attribute. Hopefully TS also won't complain + // if we set the attribute directly on the picker... + const extraProps = disabled ? { inert: '' } : {}; + return ( + <> + {t('solid.color')} + + + + ); +}; + +type SparksConfigurationFormProps = { + disabled?: boolean; + offDuration: number; + onChange: (event: React.ChangeEvent) => void; +}; + +const SparksConfigurationForm = ({ + disabled, + offDuration, + onChange, +}: SparksConfigurationFormProps) => { + const { t } = useTranslation(undefined, { + keyPrefix: 'showConfiguratorDialog.lights', + }); + return ( + + ); +}; + +export const useLightConfigurationFormState = (onChange?: () => void) => { + const [lightEffectType, setLightEffectType] = + useState('default'); + const [defaultConfigBrightness, setDefaultConfigBrightness] = useState(0.05); + const [solidConfigColor, setSolidConfigColor] = useState('FFF'); + const [sparksConfigOffDuration, setSparksConfigOffDuration] = useState(3); + + const onLightEffectTypeChanged = useCallback( + (evt: { target: { value: LightEffectType } }) => { + setLightEffectType(evt.target.value); + onChange?.(); + }, + [onChange] + ); + const onDefaultConfigBrightnessChanged = useCallback( + (_evt: Event, brightness: number) => { + setDefaultConfigBrightness( + Number.isFinite(brightness) ? brightness : 0.05 + ); + onChange?.(); + }, + [onChange] + ); + const onSolidConfigColorChanged = useCallback( + (color: string) => { + setSolidConfigColor(color); + onChange?.(); + }, + [onChange] + ); + const onSparksConfigOffDurationChanged = useCallback( + (evt: React.ChangeEvent) => { + const offDuration = + Math.round(Number.parseFloat(evt.target.value) * 100) * 0.01; + setSparksConfigOffDuration( + Number.isFinite(offDuration) ? offDuration : 3 + ); + onChange?.(); + }, + [onChange] + ); + + const configuration: LightEffectConfiguration = useMemo(() => { + if (lightEffectType === 'default') { + return { type: 'default', brightness: defaultConfigBrightness }; + } else if (lightEffectType === 'solid') { + return { type: 'solid', color: solidConfigColor }; + } else if (lightEffectType === 'sparks') { + return { type: 'sparks', off_duration: sparksConfigOffDuration }; + } else { + return { type: 'off' }; + } + }, [ + lightEffectType, + defaultConfigBrightness, + solidConfigColor, + sparksConfigOffDuration, + ]); + + return { + lightEffectType, + configuration, + onLightEffectTypeChanged, + defaultConfigBrightness, + onDefaultConfigBrightnessChanged, + solidConfigColor, + onSolidConfigColorChanged, + sparksConfigOffDuration, + onSparksConfigOffDurationChanged, + }; +}; + +export type LightConfigurationProps = Omit< + ReturnType, + 'configuration' +> & { disabled?: boolean }; + +const LightConfigurationForm = (props: LightConfigurationProps) => { + const { disabled, lightEffectType, onLightEffectTypeChanged } = props; + const { t } = useTranslation(undefined, { + keyPrefix: 'showConfiguratorDialog.lights', + }); + const labelId = 'light-effect-type-label'; + return ( + <> + + {t('selectLabel')} + + + {lightEffectType === 'default' && ( + + )} + {lightEffectType === 'solid' && ( + + )} + {lightEffectType === 'sparks' && ( + + )} + + ); +}; + +export default LightConfigurationForm; diff --git a/src/features/show-configurator/ShowConfiguratorDialog.tsx b/src/features/show-configurator/ShowConfiguratorDialog.tsx index b84a8cbd..0e10d00a 100644 --- a/src/features/show-configurator/ShowConfiguratorDialog.tsx +++ b/src/features/show-configurator/ShowConfiguratorDialog.tsx @@ -31,10 +31,12 @@ import AdaptParametersForm, { } from './AdaptParametersForm'; import AdaptReviewForm from './AdaptReviewForm'; import InteractionHints from './InteractionHints'; +import { useLightConfigurationFormState } from './LightConfigurationForm'; import Map from './ShowConfiguratorMap'; import { adaptShow, adjustHomePositionsToDronePositions, + type LightEffectConfiguration, saveAdaptedShow, type ShowAdaptParameters, } from './actions'; @@ -99,7 +101,10 @@ const useStyles = makeStyles((theme: Theme) => ({ })); type DispatchProps = Readonly<{ - adaptShow: (parameters: ShowAdaptParameters) => void; + adaptShow: ( + parameters: ShowAdaptParameters, + lights: LightEffectConfiguration + ) => void; adjustHomePositionsToDronePositions: () => void; approveAdaptedShow: ( base64Blob: string, @@ -155,6 +160,8 @@ function useOwnState(props: Props) { }, resetAdaptResult ); + const { configuration: lightConfig, ...lights } = + useLightConfigurationFormState(resetAdaptResult); const back = useCallback(() => { if (backDisabled) { @@ -189,7 +196,7 @@ function useOwnState(props: Props) { setStage('review'); if (adaptedBase64Show === undefined) { - adaptShow(adaptParameters.parameters); + adaptShow(adaptParameters.parameters, lightConfig); } } else if (stage === 'review') { if (adaptedBase64Show === undefined) { @@ -213,6 +220,7 @@ function useOwnState(props: Props) { approveAdaptedShow, closeDialog, coordinateSystem, + lightConfig, stage, submitDisabled, ]); @@ -224,6 +232,7 @@ function useOwnState(props: Props) { approveAdaptedShow, back, coordinateSystem, + lights, stage, submit, submitDisabled, @@ -242,7 +251,7 @@ const ShowConfiguratorDialog = (props: Props): React.JSX.Element => { t, } = props; const styles = useStyles(); - const { adaptParameters, back, stage, submit, submitDisabled } = + const { adaptParameters, back, lights, stage, submit, submitDisabled } = useOwnState(props); return ( @@ -257,6 +266,7 @@ const ShowConfiguratorDialog = (props: Props): React.JSX.Element => { ({ - adaptShow: (params: ShowAdaptParameters): void => { - dispatch(adaptShow(params)); + adaptShow: ( + params: ShowAdaptParameters, + lights: LightEffectConfiguration + ): void => { + dispatch(adaptShow(params, lights)); }, adjustHomePositionsToDronePositions: (): void => { dispatch(adjustHomePositionsToDronePositions()); diff --git a/src/features/show-configurator/actions.ts b/src/features/show-configurator/actions.ts index 98d21dc0..437d7f04 100644 --- a/src/features/show-configurator/actions.ts +++ b/src/features/show-configurator/actions.ts @@ -4,10 +4,7 @@ import { ModifyEvent } from 'ol/interaction/Modify'; import { getDistance as haversineDistance } from 'ol/sphere'; import { batch } from 'react-redux'; -import { - findAssignmentBetweenPoints, - findAssignmentInDistanceMatrix, -} from '~/algorithms/matching'; +import { findAssignmentBetweenPoints } from '~/algorithms/matching'; import type { TransformFeaturesInteractionEvent } from '~/components/map/interactions/TransformFeatures'; import { errorToString } from '~/error-handling'; import { getBase64ShowBlob } from '~/features/show/selectors'; @@ -28,7 +25,7 @@ import type { AppThunk } from '~/store/reducers'; import type { Identifier } from '~/utils/collections'; import { writeBlobToFile } from '~/utils/filesystem'; import type { EasNor, Easting, LonLat, Northing } from '~/utils/geography'; -import { calculateDistanceMatrix, toDegrees } from '~/utils/math'; +import { toDegrees } from '~/utils/math'; import { getHomePositions, @@ -315,8 +312,65 @@ export type OptionalShowAdaptParameters = { export type ShowAdaptParameters = Required; +/** + * "Off" configuration, no lights. + */ +type OffConfiguration = { + type: 'off'; +}; + +/** + * Default light configuration. + */ +type DefaultConfiguration = { + type: 'default'; + + /** + * Brightness in the [0,1] interval. + */ + brightness: number; +}; + +/** + * Solid colored lights configuration. + */ +type SolidConfiguration = { + type: 'solid'; + + /** + * RGB color code. + */ + color: string; +}; + +/** + * Sparks with an off duration between them. + */ +type SparksConfiguration = { + type: 'sparks'; + off_duration: number; +}; + +/** + * Light effect types. + */ +export type LightEffectType = + | OffConfiguration['type'] + | DefaultConfiguration['type'] + | SolidConfiguration['type'] + | SparksConfiguration['type']; + +/** + * Light effect configurations. + */ +export type LightEffectConfiguration = + | OffConfiguration + | DefaultConfiguration + | SolidConfiguration + | SparksConfiguration; + export const adaptShow = - (params: ShowAdaptParameters): AppThunk => + (params: ShowAdaptParameters, lights: LightEffectConfiguration): AppThunk => async (dispatch, getState) => { const state = getState(); @@ -343,12 +397,14 @@ export const adaptShow = parameters: { positions, duration: params.takeoffDuration || undefined, + lights, ...common, }, }, { type: 'rth', parameters: { + lights, ...common, }, }, diff --git a/src/i18n/en.json b/src/i18n/en.json index 58e29d67..6542645c 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -639,8 +639,9 @@ "label": "Vertical velocity" } }, - "section": { - "parameters": "Parameters" + "panel": { + "lights": "Lights", + "trajectories": "Trajectories" } }, "adaptReview": { @@ -666,6 +667,24 @@ "config": "This tool lets you adjust takeoff positions and net show layout independently.\nSelect takeoff positions (yellow triangles), the net show (purple convex hull) or both (yellow convex hull) and move or rotate them as you need.\nWhen done, setup transformation parameters on the right side and press ADAPT to recalculate trajectories, keeping the original net show content, yet aligned to your new arrangement.\nThe new show layout should be validated thoroughly and saved to a production ready .skyc file on exit.", "review": "Review the changes before applying them to the show." }, + "lights": { + "default": { + "brightness": "Brightness" + }, + "solid": { + "color": "Color" + }, + "selectLabel": "Light effect", + "sparks": { + "offDuration": "Off duration" + }, + "type": { + "off": "Off", + "default": "Default", + "solid": "Solid", + "sparks": "Sparks" + } + }, "settings": { "dronesVisible": "Show drones" },