Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions v3/src/components/axis/models/base-numeric-axis-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export const BaseNumericAxisModel = AxisModel
},
setAllowRangeToShrink(allowRangeToShrink: boolean) {
self.allowRangeToShrink = allowRangeToShrink
},
clearDynamicDomain() {
self.dynamicMin = undefined
self.dynamicMax = undefined
}
}))
export interface IBaseNumericAxisModel extends Instance<typeof BaseNumericAxisModel> {}
Expand Down
24 changes: 10 additions & 14 deletions v3/src/components/slider/editable-slider-value.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { observer } from "mobx-react-lite"
import { isAlive } from "mobx-state-tree"
import React, { useState, useEffect } from "react"
import { convertToDate } from "../../utilities/date-utils"
import { logMessageWithReplacement } from "../../lib/log-message"
import { MultiScale } from "../axis/models/multi-scale"
import { ISliderModel } from "./slider-model"
import { valueChangeNotification } from "./slider-utils"
import { logMessageWithReplacement } from "../../lib/log-message"
import { useSliderAxisAnimation } from "./use-slider-axis-animation"

import './slider.scss'

Expand All @@ -18,6 +19,7 @@ interface IProps {

export const EditableSliderValue = observer(function EditableSliderValue({sliderModel, multiScale}: IProps) {
const [candidate, setCandidate] = useState("")
const { animateAxisToEncompass } = useSliderAxisAnimation(sliderModel)

useEffect(() => {
return autorun(() => {
Expand Down Expand Up @@ -52,19 +54,13 @@ export const EditableSliderValue = observer(function EditableSliderValue({slider
const handleSubmitValue = (e: React.FocusEvent<HTMLInputElement>) => {
const inputValue = parseValue(e.target.value)
if (isFinite(inputValue)) {
sliderModel.applyModelChange(
() => {
sliderModel.encompassValue(inputValue)
sliderModel.setValue(inputValue)
},
{
notify: () => valueChangeNotification(sliderModel.value, sliderModel.name),
undoStringKey: "DG.Undo.slider.change",
redoStringKey: "DG.Redo.slider.change",
log: logMessageWithReplacement("sliderEdit: { expression: %@ = %@ }",
{name: sliderModel.name, value: inputValue})
}
)
animateAxisToEncompass(inputValue, {
notify: () => valueChangeNotification(sliderModel.value, sliderModel.name),
undoStringKey: "DG.Undo.slider.change",
redoStringKey: "DG.Redo.slider.change",
log: logMessageWithReplacement("sliderEdit: { expression: %@ = %@ }",
{name: sliderModel.name, value: inputValue})
})
}
}

Expand Down
8 changes: 7 additions & 1 deletion v3/src/components/slider/slider-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export const SliderModel = TileContentModel
() => NumericAxisModel.create({ place: 'bottom', min: kDefaultSliderAxisMin, max: kDefaultSliderAxisMax }))
})
.volatile(() => ({
axisHelper: undefined as Maybe<AxisHelper>
axisHelper: undefined as Maybe<AxisHelper>,
_isAxisAnimating: false
}))
.views(self => ({
get name() {
Expand Down Expand Up @@ -141,13 +142,18 @@ export const SliderModel = TileContentModel
},
setAxisHelper(place: AxisPlace, subAxisIndex: number, helper: AxisHelper) {
self.axisHelper = helper
},
setIsAxisAnimating(animating: boolean) {
self._isAxisAnimating = animating
}
}))
.actions(self => ({
afterCreate() {
addDisposer(self, reaction(
() => self.axis.domain,
([axisMin, axisMax]) => {
// skip constraining value during axis animation (value is intentionally outside bounds)
if (self._isAxisAnimating) return
// keep the thumbnail within axis bounds when axis bounds are changed
if (self.value < axisMin) self.setDynamicValueIfDynamic(axisMin)
if (self.value > axisMax) self.setDynamicValueIfDynamic(axisMax)
Expand Down
104 changes: 104 additions & 0 deletions v3/src/components/slider/use-slider-axis-animation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef } from "react"
import { isAlive } from "mobx-state-tree"
import { IApplyModelChangeOptions } from "../../models/history/apply-model-change"
import { ISliderModel } from "./slider-model"

const kAnimationDuration = 500 // ms

// Ease-out cubic: fast start, slow finish
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3)
}

/**
* Hook that provides animated axis rescaling for the slider.
* When the user edits the slider value to a position outside the current axis bounds,
* this animates the axis from the old bounds to the new bounds using requestAnimationFrame
* and the axis model's volatile setDynamicDomain, so the thumb slides into view.
*/
export function useSliderAxisAnimation(_sliderModel: ISliderModel | undefined) {
const rafRef = useRef<number>(0)

const cancelAnimation = useCallback(() => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = 0
}
if (_sliderModel && isAlive(_sliderModel) && _sliderModel._isAxisAnimating) {
_sliderModel.setIsAxisAnimating(false)
_sliderModel.axis.clearDynamicDomain()
}
}, [_sliderModel])

// Clean up on unmount
useEffect(() => cancelAnimation, [cancelAnimation])

const animateAxisToEncompass = useCallback((inputValue: number, options: IApplyModelChangeOptions) => {
if (!_sliderModel) return

// const assignment doesn't require `!` below
const sliderModel = _sliderModel

// Cancel any in-progress animation
cancelAnimation()

// Save old domain
const oldMin = sliderModel.axis.domain[0]
const oldMax = sliderModel.axis.domain[1]

// Apply the model change atomically (for undo/redo)
sliderModel.applyModelChange(
() => {
sliderModel.encompassValue(inputValue)
sliderModel.setValue(inputValue)
},
options
)

// Read new persisted bounds after encompassValue
const newMin = sliderModel.axis.min
const newMax = sliderModel.axis.max

// If bounds didn't change, no animation needed
if (newMin === oldMin && newMax === oldMax) return

// Start animation: override domain back to old bounds, then animate to new
sliderModel.setIsAxisAnimating(true)
sliderModel.axis.setDynamicDomain(oldMin, oldMax)

const startTime = performance.now()

function animate(currentTime: number) {
// Stop if model was destroyed (e.g. tile closed) or persisted bounds changed (e.g. undo)
if (!isAlive(sliderModel) ||
sliderModel.axis.min !== newMin || sliderModel.axis.max !== newMax) {
rafRef.current = 0
if (isAlive(sliderModel)) {
sliderModel.setIsAxisAnimating(false)
sliderModel.axis.clearDynamicDomain()
}
return
}

const elapsed = currentTime - startTime
const t = Math.min(elapsed / kAnimationDuration, 1)
const eased = easeOutCubic(t)

const min = oldMin + (newMin - oldMin) * eased
const max = oldMax + (newMax - oldMax) * eased
sliderModel.axis.setDynamicDomain(min, max)

if (t < 1) {
rafRef.current = requestAnimationFrame(animate)
} else {
rafRef.current = 0
sliderModel.setIsAxisAnimating(false)
sliderModel.axis.clearDynamicDomain()
}
}

rafRef.current = requestAnimationFrame(animate)
}, [_sliderModel, cancelAnimation])

return { animateAxisToEncompass, cancelAnimation }
}
Loading