diff --git a/frontend/code/components/switcherBar.ts b/frontend/code/components/switcherBar.ts index 63366010..534b97d8 100644 --- a/frontend/code/components/switcherBar.ts +++ b/frontend/code/components/switcherBar.ts @@ -1,7 +1,34 @@ import { ComponentBase, ComponentState } from './componentBase'; -import { ColorSet } from '../dataModels'; +import { ColorSet, TextStyle } from '../dataModels'; import { applySwitcheroo } from '../designApplication'; -import { markEventAsHandled } from '../eventHandling'; +import { textStyleToCss } from '../cssUtils'; +import { easeInOut } from '../easeFunctions'; +import { firstDefined } from '../utils'; + +const ACCELERATION: number = 350; // rem/s^2 + +const MARKER_FADE_DURATION: number = 0.18; // s + +// Whitespace around each option +const OPTION_MARGIN: number = 0.5; + +// Width & height of the SVG in each option +const ICON_HEIGHT: number = 1.8; + +// Whitespace between the icon and the text, if both are present +const ICON_MARGIN: number = 0.5; + +const TEXT_STYLE: TextStyle = { + fontName: 'Roboto', + fill: [0, 0, 0, 1], + fontSize: 1, + italic: false, + fontWeight: 'bold', + underlined: false, + allCaps: false, +}; + +const TEXT_STYLE_CSS_OPTIONS: object = textStyleToCss(TEXT_STYLE); export type SwitcherBarState = ComponentState & { _type_: 'SwitcherBar-builtin'; @@ -17,50 +44,366 @@ export type SwitcherBarState = ComponentState & { export class SwitcherBarComponent extends ComponentBase { state: Required; - private optionsContainer: HTMLElement; + private innerElement: HTMLElement; private markerElement: HTMLElement; - private selectedOptionElement: HTMLElement | null = null; + private backgroundOptionsElement: HTMLElement; + private markerOptionsElement: HTMLElement; + + // Marker animation state + private markerCurFade: number; + + private markerCurLeft: number = 0; + private markerCurTop: number = 0; + private markerCurWidth: number = 0; + private markerCurHeight: number = 0; + + private markerCurVelocity: number = 0; + + // -1 if no animation is running + private lastAnimationTickAt: number = -1; + + // Allows to determine whether this is the first time the element is being + // updated. + private isInitialized: boolean = false; createElement(): HTMLElement { // Create the elements - let element = document.createElement('div'); - element.classList.add('rio-switcher-bar'); + let elementOuter = document.createElement('div'); + elementOuter.classList.add('rio-switcher-bar'); + + // Centers the bar + this.innerElement = document.createElement('div'); + elementOuter.appendChild(this.innerElement); // Highlights the selected item this.markerElement = document.createElement('div'); this.markerElement.classList.add('rio-switcher-bar-marker'); - element.appendChild(this.markerElement); - this.optionsContainer = document.createElement('div'); - this.optionsContainer.classList.add( - 'rio-switcher-bar-options-container' + return elementOuter; + } + + /// Instantly move the marker to the position stored in the instance + placeMarkerToState(): void { + // Account for the marker's resize animation + let easedFade = easeInOut(this.markerCurFade); + let scaledWidth = this.markerCurWidth * easedFade; + let scaledHeight = this.markerCurHeight * easedFade; + + let left = this.markerCurLeft + (this.markerCurWidth - scaledWidth) / 2; + let top = this.markerCurTop + (this.markerCurHeight - scaledHeight) / 2; + + // Move the marker + this.markerElement.style.left = `${left}px`; + this.markerElement.style.top = `${top}px`; + this.markerElement.style.width = `${scaledWidth}px`; + this.markerElement.style.height = `${scaledHeight}px`; + + // The inner options are positioned relative to the marker. Move them in + // the opposite direction so they stay put. + this.markerOptionsElement.style.left = `-${left}px`; + this.markerOptionsElement.style.top = `-${top}px`; + } + + /// If an item is selected, returns the position and size the marker should + /// be in order to highlight the selected item. Returns `null` if no item is + /// currently selected. + getMarkerTarget(): [number, number, number, number] | null { + // Nothing selected + if (this.state.selectedName === null) { + return null; + } + + // Find the selected item + let selectedIndex = this.state.names.indexOf(this.state.selectedName!); + console.assert(selectedIndex !== -1); + + // Find the location of the selected item. + + // FIXME: For some reason this seems to yield wrong results + // occasionally. + let optionElement = + this.backgroundOptionsElement.children[selectedIndex]; + let optionRect = optionElement.getBoundingClientRect(); + let parentRect = this.innerElement.getBoundingClientRect(); + + return [ + optionRect.left - parentRect.left, + optionRect.top - parentRect.top, + optionRect.width, + optionRect.height, + ]; + } + + /// Instantly move the marker to the currently selected item + moveMarkerInstantlyIfAnimationIsntRunning(): void { + // Already transitioning? + if (this.lastAnimationTickAt !== -1) { + return; + } + + // Where to move to? + let target = this.getMarkerTarget(); + + // No target + if (target === null) { + target = [0, 0, 0, 0]; + } + + // Teleport + this.markerCurLeft = target[0]; + this.markerCurTop = target[1]; + this.markerCurWidth = target[2]; + this.markerCurHeight = target[3]; + this.placeMarkerToState(); + } + + moveAnimationWorker(deltaTime: number): boolean { + // Where to move to? + let target = this.getMarkerTarget(); + + // The target may disappear while the animation is running. Handle that + // gracefully. + if (target === null) { + return false; + } + + // Calculate the distance to the target + let curPos: number, targetPos: number; + if (this.state.orientation == 'horizontal') { + curPos = this.markerCurLeft; + targetPos = target[0]; + } else { + curPos = this.markerCurTop; + targetPos = target[1]; + } + + let signedRemainingDistance = targetPos - curPos; + + // Which direction to accelerate towards? + let accelerationFactor; // + means towards the target + let brakingDistance = + Math.pow(this.markerCurVelocity, 2) / (2 * ACCELERATION); + + // Case: Moving away from the target + if ( + Math.sign(signedRemainingDistance) != + Math.sign(this.markerCurVelocity) + ) { + accelerationFactor = 3; + } + // Case: Don't run over the target quite so hard + else if (Math.abs(signedRemainingDistance) < brakingDistance) { + accelerationFactor = -1; + } + // Case: Accelerate towards the target + else { + accelerationFactor = 1; + } + + let currentAcceleration = + ACCELERATION * + accelerationFactor * + Math.sign(signedRemainingDistance); + + // Update the velocity + this.markerCurVelocity += currentAcceleration * deltaTime; + let deltaDistance = this.markerCurVelocity * deltaTime; + + // Arrived? + let t; + if (Math.abs(deltaDistance) >= Math.abs(signedRemainingDistance)) { + t = 1; + } else { + t = deltaDistance / signedRemainingDistance; + } + + // Update the marker + this.markerCurLeft += t * (target[0] - this.markerCurLeft); + this.markerCurTop += t * (target[1] - this.markerCurTop); + this.markerCurWidth += t * (target[2] - this.markerCurWidth); + this.markerCurHeight += t * (target[3] - this.markerCurHeight); + + // Done? + return t !== 1; + } + + fadeAnimationWorker(deltaTime: number): boolean { + // Fade in or out? + let target = this.state.selectedName === null ? 0 : 1; + + // Update state + let amount = + (Math.sign(target - this.markerCurFade) * deltaTime) / + MARKER_FADE_DURATION; + this.markerCurFade += amount; + this.markerCurFade = Math.min(Math.max(this.markerCurFade, 0), 1); + + // Keep going? + return this.markerCurFade !== target; + } + + animationWorker() { + // How much time has passed? + let now = Date.now(); + let deltaTime = (now - this.lastAnimationTickAt) / 1000; + this.lastAnimationTickAt = now; + + // Run the animations + let moveKeepGoing = this.moveAnimationWorker(deltaTime); + let fadeKeepGoing = this.fadeAnimationWorker(deltaTime); + let keepGoing = moveKeepGoing || fadeKeepGoing; + + // Update the marker to match the current state + this.placeMarkerToState(); + + // Keep going? + if (keepGoing) { + requestAnimationFrame(this.animationWorker.bind(this)); + } else { + this.lastAnimationTickAt = -1; + } + } + + startAnimationIfNotRunning(): void { + // Already running? + if (this.lastAnimationTickAt !== -1) { + return; + } + + // Nope, get going + this.lastAnimationTickAt = Date.now(); + this.markerCurVelocity = 0; + this.animationWorker(); + } + + /// High level function to update the marker. It will animate the marker as + /// appropriate. + switchMarkerToSelectedName(): void { + // No value selected? Fade out + let target = this.getMarkerTarget(); + if (target === null) { + this.startAnimationIfNotRunning(); + return; + } + + // If the marker is currently invisible, teleport it + if (this.markerCurFade === 0) { + this.markerCurLeft = target[0]; + this.markerCurTop = target[1]; + this.markerCurWidth = target[2]; + this.markerCurHeight = target[3]; + this.placeMarkerToState(); + } + + // Start the animation(s) + this.startAnimationIfNotRunning(); + } + + buildContent(deltaState: SwitcherBarState): HTMLElement { + let result = document.createElement('div'); + result.classList.add('rio-switcher-bar-options'); + Object.assign(result.style, TEXT_STYLE_CSS_OPTIONS); + result.style.removeProperty('color'); + + let names = firstDefined(deltaState.names, this.state.names); + let iconSvgSources = firstDefined( + deltaState.icon_svg_sources, + this.state.icon_svg_sources ); - element.appendChild(this.optionsContainer); - return element; + // Iterate over both + for (let i = 0; i < names.length; i++) { + let name = names[i]; + let iconSvg = iconSvgSources[i]; + + let optionElement = document.createElement('div'); + optionElement.classList.add('rio-switcher-bar-option'); + optionElement.style.padding = `${OPTION_MARGIN}rem`; + result.appendChild(optionElement); + + // Icon + let iconElement; + if (iconSvg !== null) { + optionElement.innerHTML = iconSvg; + iconElement = optionElement.children[0] as HTMLElement; + iconElement.style.width = `${ICON_HEIGHT}rem`; + iconElement.style.height = `${ICON_HEIGHT}rem`; + iconElement.style.marginBottom = `${ICON_MARGIN}rem`; + iconElement.style.fill = 'currentColor'; + } + + // Text + let textElement = document.createElement('div'); + optionElement.appendChild(textElement); + textElement.textContent = name; + + // Detect clicks + optionElement.addEventListener('click', (event) => { + // If this item was already selected, the new value may be `None` + if (this.state.selectedName === name) { + if (this.state.allow_none) { + this.state.selectedName = null; + } else { + return; + } + } else { + this.state.selectedName = name; + } + + // Update the marker + this.switchMarkerToSelectedName(); + + // Notify the backend + this.sendMessageToBackend({ + name: this.state.selectedName, + }); + + // Eat the event + event.stopPropagation(); + }); + } + + // DELETEME + // + // Pass the allocated size on to the content. This can't rely on CSS, + // because the inner content is necessarily located inside of the + // marker, which in turn is smaller than the full space. + if (this.state.orientation == 'horizontal') { + // result.style.width = `${this.allocatedWidth}rem`; + } else { + // result.style.height = `${this.allocatedHeight}rem`; + } + + return result; } updateElement( deltaState: SwitcherBarState, latentComponents: Set ): void { - super.updateElement(deltaState, latentComponents); + let markerPositionNeedsUpdate = false; // Have the options changed? if ( deltaState.names !== undefined || deltaState.icon_svg_sources !== undefined ) { - this.rebuildOptions( - deltaState.names ?? this.state.names, - deltaState.icon_svg_sources ?? this.state.icon_svg_sources - ); + this.markerElement.innerHTML = ''; + this.innerElement.innerHTML = ''; - // Make sure the newly created option element is properly selected - this.selectedOptionElement = null; - if (deltaState.selectedName === undefined) { - deltaState.selectedName = this.state.selectedName; - } + // Background options + this.backgroundOptionsElement = this.buildContent(deltaState); + this.innerElement.appendChild(this.backgroundOptionsElement); + + // Marker + this.innerElement.appendChild(this.markerElement); + + // Marker options + this.markerOptionsElement = this.buildContent(deltaState); + this.markerElement.appendChild(this.markerOptionsElement); + + // Request updates + markerPositionNeedsUpdate = true; } // Color @@ -73,122 +416,41 @@ export class SwitcherBarComponent extends ComponentBase { // Orientation if (deltaState.orientation !== undefined) { - this.optionsContainer.style.flexDirection = + let flexDirection = deltaState.orientation == 'vertical' ? 'column' : 'row'; + + this.element.style.flexDirection = flexDirection; + this.backgroundOptionsElement.style.flexDirection = flexDirection; + this.markerOptionsElement.style.flexDirection = flexDirection; + + // Request updates + markerPositionNeedsUpdate = true; } // Spacing if (deltaState.spacing !== undefined) { - this.optionsContainer.style.gap = `${deltaState.spacing}rem`; + markerPositionNeedsUpdate = true; } // If the selection has changed make sure to move the marker if (deltaState.selectedName !== undefined) { - if (deltaState.selectedName === null) { - this.select(null); + if (this.isInitialized) { + this.state.selectedName = deltaState.selectedName; + this.switchMarkerToSelectedName(); } else { - let i = (deltaState.names ?? this.state.names).indexOf( - deltaState.selectedName - ); - this.select(i); + markerPositionNeedsUpdate = true; + this.markerCurFade = deltaState.selectedName === null ? 0 : 1; } } - } - private rebuildOptions( - names: string[], - iconSvgSources: (string | null)[] - ): void { - this.optionsContainer.innerHTML = ''; + // Any future updates are not the first + this.isInitialized = true; - for (let [index, name] of names.entries()) { - let iconSvg = iconSvgSources[index]; + // Perform any requested updates + Object.assign(this.state, deltaState); - let optionElement = this.buildOptionElement(index, name, iconSvg); - this.optionsContainer.appendChild(optionElement); + if (markerPositionNeedsUpdate) { + this.moveMarkerInstantlyIfAnimationIsntRunning(); } } - - private buildOptionElement( - index: number, - name: string, - iconSvg: string | null - ): HTMLElement { - let optionElement = document.createElement('div'); - optionElement.classList.add('rio-switcher-bar-option'); - optionElement.style.justifyContent = 'center'; - - // Icon - if (iconSvg !== null) { - optionElement.innerHTML = iconSvg; - - // `space-between` looks ugly if there's only a single child (the - // child is at the top instead of centered), so only use that if we - // have an icon *and* text - optionElement.style.justifyContent = 'space-between'; - } - - // Text - let textElement = document.createElement('div'); - optionElement.appendChild(textElement); - textElement.textContent = name; - - // Detect clicks - optionElement.addEventListener('click', (event) => { - // If this item was already selected, the new value may be `None` - if (this.state.selectedName === name) { - if (this.state.allow_none) { - this.state.selectedName = null; - this.select(null); - } else { - return; - } - } else { - this.state.selectedName = name; - this.select(index); - } - - // Notify the backend - this.sendMessageToBackend({ - name: this.state.selectedName, - }); - - // Eat the event - markEventAsHandled(event); - }); - - return optionElement; - } - - private select(index: number | null): void { - if (this.selectedOptionElement !== null) { - this.selectedOptionElement.classList.remove('selected'); - } - - if (index === null) { - this.selectedOptionElement = null; - this.markerElement.style.width = '0'; - this.markerElement.style.height = '0'; - return; - } - - let optionElement = this.optionsContainer.children[ - index - ] as HTMLElement; - - optionElement.classList.add('selected'); - this.selectedOptionElement = optionElement; - - let optionRect = optionElement.getBoundingClientRect(); - let containerRect = this.optionsContainer.getBoundingClientRect(); - - this.markerElement.style.left = `${ - optionRect.left - containerRect.left - }px`; - this.markerElement.style.top = `${ - optionRect.top - containerRect.top - }px`; - this.markerElement.style.width = `${optionRect.width}px`; - this.markerElement.style.height = `${optionRect.height}px`; - } } diff --git a/frontend/css/style.scss b/frontend/css/style.scss index 92ad61ba..a6ca8929 100644 --- a/frontend/css/style.scss +++ b/frontend/css/style.scss @@ -2269,10 +2269,20 @@ $rio-input-box-text-distance-from-bottom: 0.4rem; // To be aligned with the div { + @include single-container(); } .rio-switcher-bar-options-container { + flex-grow: 1; + display: flex; align-items: stretch; justify-content: space-between; @@ -2328,6 +2338,7 @@ $rio-input-box-text-distance-from-bottom: 0.4rem; // To be aligned with the