tests / browser (chrome, chromium) (push) Successful in 2m8s
tests / browser (firefox, firefox) (push) Failing after 1m56s
tests / lint (push) Successful in 57s
tests / feature (8.4) (push) Successful in 1m5s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 2m52s
<x-progress>: linear or circular, flat or wavy, determinate or indeterminate, 4px or thick, in any colour role, following a server value through Livewire morphs or an Alpine expression in the browser. The first frame is server-rendered SVG; Compose Material 3's drawing and keyframes are ported to an Alpine component that animates only while something moves and the indicator is on screen. The previous commit already imported progress.js and included its showcase section without the files; this adds them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
1522 lines
55 KiB
JavaScript
1522 lines
55 KiB
JavaScript
/**
|
|
* `materialProgress`: draws `<x-progress>`, M3 Expressive's progress indicator — linear or
|
|
* circular, flat or wavy, determinate or indeterminate — the way androidx Compose Material 3
|
|
* draws it, frame by frame, into the component's SVG.
|
|
*
|
|
* The server renders a first frame (the value, or a still of the indeterminate animation), so
|
|
* the indicator is right before this runs. This then owns the SVG, which is `wire:ignore`: a
|
|
* Livewire morph only changes the root's `data-value` (so does `x-bind`, for `bind`), and a
|
|
* MutationObserver turns that into motion.
|
|
*
|
|
* - A new value moves on M3 Expressive's effects-slow spring (damping ratio 1, stiffness 800,
|
|
* motion.css). Compose recommends a spring that does not bounce either
|
|
* (ProgressIndicatorDefaults.ProgressAnimationSpec): a progress that overshoots is briefly a lie.
|
|
* The duration token scales it, so reduced motion makes it instant.
|
|
* - Wavy: full amplitude between 10% and 95%, flat outside (indicatorAmplitude), reached in
|
|
* 500 ms (standard easing growing, emphasized-accelerate flattening); the wave travels a
|
|
* wavelength a second. The circular wave is a RoundedPolygon star morphing from a circle.
|
|
* - Indeterminate: Compose's keyframes — two lines chasing over 1750 ms; an arc that grows and
|
|
* shrinks over 6 s while it turns 1080° plus a quarter turn every 1.5 s.
|
|
* - Reduced motion: values jump, the wave stands still, and an indeterminate indicator holds
|
|
* one frame of its animation.
|
|
*
|
|
* Frames are drawn only while something moves and the indicator is on screen.
|
|
*
|
|
* ---------------------------------------------------------------------------------------
|
|
* Ported from androidx (https://github.com/androidx/androidx), commit
|
|
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326:
|
|
*
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ProgressIndicator.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/WavyProgressIndicator.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/LinearWavyProgressModifiers.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/CircularWavyProgressModifiers.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/ShapeUtil.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/*ProgressIndicatorTokens.kt
|
|
* compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/VectorizedAnimationSpec.kt (keyframes)
|
|
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/*.kt (via bin/shapes.mjs
|
|
* and bin/loading-indicator.mjs: RoundedPolygon, CornerRounding, Cubic, Morph, FeatureMapping,
|
|
* PolygonMeasure)
|
|
*
|
|
* Copyright 2022-2025 The Android Open Source Project
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
* ---------------------------------------------------------------------------------------
|
|
*/
|
|
|
|
const SVG = 'http://www.w3.org/2000/svg'
|
|
const WATCHED = ['data-value', 'data-max', 'data-circular', 'data-wavy', 'data-thick']
|
|
|
|
// Tokens: *ProgressIndicatorTokens, WavyProgressIndicatorDefaults, ProgressIndicator.kt ----
|
|
|
|
const GAP = 4
|
|
const STOP_SIZE = 4
|
|
const STOP_TRAILING_SPACE = 6
|
|
const LINEAR_WAVELENGTH = 40
|
|
const LINEAR_INDETERMINATE_WAVELENGTH = 20
|
|
const CIRCULAR_WAVELENGTH = 15
|
|
const MIN_CIRCULAR_VERTICES = 5
|
|
const WAVE_PERIOD = 1000
|
|
const AMPLITUDE_DURATION = 500
|
|
const LINEAR_CYCLE = 1750
|
|
const CIRCULAR_CYCLE = 6000
|
|
|
|
// effects-slow (motion.css): the spring a new value moves on, and the time it settles in.
|
|
const VALUE_STIFFNESS = 800
|
|
const VALUE_SETTLE = 330
|
|
|
|
/** The frames an indeterminate indicator holds under reduced motion; the server draws the same. */
|
|
const LINEAR_STILL = 875
|
|
const CIRCULAR_STILL = 2000
|
|
|
|
// Easing and keyframes ------------------------------------------------------------------
|
|
|
|
const linear = (fraction) => fraction
|
|
|
|
/** CubicBezierEasing: the curve's y where its x is `fraction`, x found by bisection. */
|
|
function cubicBezier(x1, y1, x2, y2) {
|
|
const axis = (t, p1, p2) => 3 * (1 - t) * (1 - t) * t * p1 + 3 * (1 - t) * t * t * p2 + t * t * t
|
|
|
|
return (fraction) => {
|
|
if (fraction <= 0 || fraction >= 1) {
|
|
return Math.min(Math.max(fraction, 0), 1)
|
|
}
|
|
|
|
let low = 0
|
|
let high = 1
|
|
|
|
for (let i = 0; i < 24; i++) {
|
|
const middle = (low + high) / 2
|
|
|
|
if (axis(middle, x1, x2) < fraction) {
|
|
low = middle
|
|
} else {
|
|
high = middle
|
|
}
|
|
}
|
|
|
|
return axis((low + high) / 2, y1, y2)
|
|
}
|
|
}
|
|
|
|
const STANDARD = cubicBezier(0.2, 0, 0, 1)
|
|
const EMPHASIZED_ACCELERATE = cubicBezier(0.3, 0, 0.8, 0.15)
|
|
const EMPHASIZED_DECELERATE = cubicBezier(0.05, 0.7, 0.1, 1)
|
|
|
|
/**
|
|
* An infinitely repeating Compose `keyframes` spec: `[time, value, easing]` entries, where an
|
|
* entry's easing shapes the interval that starts at it. As in VectorizedKeyframesSpec, the
|
|
* implicit start (at 0 ms, from `initial`) and any entry without an easing are linear, and the
|
|
* spec ends on `target` when no entry sits at its end.
|
|
*/
|
|
function keyframes(duration, initial, target, entries) {
|
|
const frames = [...entries]
|
|
|
|
if (frames[0][0] !== 0) {
|
|
frames.unshift([0, initial])
|
|
}
|
|
|
|
if (frames.at(-1)[0] !== duration) {
|
|
frames.push([duration, target])
|
|
}
|
|
|
|
return (ms) => {
|
|
const time = ms % duration
|
|
let i = 0
|
|
|
|
while (i < frames.length - 2 && time >= frames[i + 1][0]) {
|
|
i++
|
|
}
|
|
|
|
const [start, from, easing = linear] = frames[i]
|
|
const [end, to] = frames[i + 1]
|
|
|
|
return from + (to - from) * easing((time - start) / (end - start))
|
|
}
|
|
}
|
|
|
|
const line = (delay, duration) => keyframes(LINEAR_CYCLE, 0, 1, [[delay, 0, EMPHASIZED_ACCELERATE], [delay + duration, 1]])
|
|
|
|
/** linearIndeterminate*AnimationSpec, in the order the drawing takes them: [tail, head, tail, head]. */
|
|
const LINEAR_LINES = [line(250, 1000), line(0, 1000), line(900, 850), line(650, 850)]
|
|
|
|
const CIRCULAR_TURN = keyframes(CIRCULAR_CYCLE, 0, 360, [
|
|
[300, 90, EMPHASIZED_DECELERATE],
|
|
[1500, 90],
|
|
[1800, 180],
|
|
[3000, 180],
|
|
[3300, 270],
|
|
[4500, 270],
|
|
[4800, 360],
|
|
])
|
|
|
|
const CIRCULAR_SWEEP = keyframes(CIRCULAR_CYCLE, 0.1, 0.87, [
|
|
[3000, 0.87, STANDARD],
|
|
[6000, 0.1],
|
|
])
|
|
|
|
/** The global rotation (1080° in 6 s, linear) plus the additional quarter turns. */
|
|
const circularRotation = (ms) => ((ms % CIRCULAR_CYCLE) / CIRCULAR_CYCLE) * 1080 + CIRCULAR_TURN(ms)
|
|
|
|
/** WavyProgressIndicatorDefaults.indicatorAmplitude. */
|
|
const amplitudeFor = (progress) => (progress <= 0.1 || progress >= 0.95 ? 0 : 1)
|
|
|
|
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
|
const round = (value) => Math.round(value * 1000) / 1000
|
|
|
|
/** A straight stroke; a zero-length one is nudged so every engine still draws its round caps. */
|
|
function straight(x0, y, x1) {
|
|
return `M${round(x0)} ${round(y)}L${round(Math.abs(x1 - x0) < 0.001 ? x0 + 0.001 : x1)} ${round(y)}`
|
|
}
|
|
|
|
/** DrawScope.drawArc: `sweep` degrees clockwise from `start` (0° is 3 o'clock); none for 0. */
|
|
function arc(cx, cy, radius, start, sweep) {
|
|
if (sweep === 0) {
|
|
return ''
|
|
}
|
|
|
|
if (Math.abs(sweep) >= 360) {
|
|
const [r, left, right] = [round(radius), round(cx - radius), round(cx + radius)]
|
|
|
|
return `M${right} ${round(cy)}A${r} ${r} 0 1 1 ${left} ${round(cy)}A${r} ${r} 0 1 1 ${right} ${round(cy)}Z`
|
|
}
|
|
|
|
const from = (start * Math.PI) / 180
|
|
const to = ((start + sweep) * Math.PI) / 180
|
|
const [x0, y0] = [cx + radius * Math.cos(from), cy + radius * Math.sin(from)]
|
|
const [x1, y1] = [cx + radius * Math.cos(to), cy + radius * Math.sin(to)]
|
|
|
|
if (Math.hypot(x1 - x0, y1 - y0) < 0.01) {
|
|
return `M${round(x0)} ${round(y0)}L${round(x0 + 0.001)} ${round(y0)}`
|
|
}
|
|
|
|
return `M${round(x0)} ${round(y0)}A${round(radius)} ${round(radius)} 0 ${Math.abs(sweep) > 180 ? 1 : 0} ${sweep > 0 ? 1 : 0} ${round(x1)} ${round(y1)}`
|
|
}
|
|
|
|
// The linear wave -----------------------------------------------------------------------
|
|
|
|
const waves = new Map()
|
|
|
|
/**
|
|
* One half-wavelength of LinearProgressDrawingCache's full path: a quadratic from (0, 0) through
|
|
* the control point (h / 2, ±height) to (h, 0). Every half-wave has the same length, so a table
|
|
* of arc length against the curve parameter locates any distance along the whole path.
|
|
*/
|
|
function halfWave(halfWavelength, height) {
|
|
const key = `${halfWavelength}:${height}`
|
|
|
|
if (!waves.has(key)) {
|
|
const steps = 64
|
|
const lengths = new Float64Array(steps + 1)
|
|
const speed = (t) => Math.hypot(halfWavelength, 2 * height * (1 - 2 * t))
|
|
|
|
for (let i = 1; i <= steps; i++) {
|
|
const [a, b] = [(i - 1) / steps, i / steps]
|
|
lengths[i] = lengths[i - 1] + ((b - a) / 6) * (speed(a) + 4 * speed((a + b) / 2) + speed(b))
|
|
}
|
|
|
|
const length = lengths[steps]
|
|
|
|
const parameterAt = (distance) => {
|
|
const d = clamp(distance, 0, length)
|
|
let low = 0
|
|
let high = steps
|
|
|
|
while (high - low > 1) {
|
|
const middle = (low + high) >> 1
|
|
|
|
if (lengths[middle] <= d) {
|
|
low = middle
|
|
} else {
|
|
high = middle
|
|
}
|
|
}
|
|
|
|
const span = lengths[high] - lengths[low]
|
|
|
|
return (low + (span > 0 ? (d - lengths[low]) / span : 0)) / steps
|
|
}
|
|
|
|
waves.set(key, { length, parameterAt })
|
|
}
|
|
|
|
return waves.get(key)
|
|
}
|
|
|
|
/**
|
|
* PathMeasure.getSegment on the full wave from `from` to `to` (distances), then Compose's
|
|
* transform: shifted back by the wave's phase and flattened towards the centre line by
|
|
* `amplitude` — as exact quadratic pieces.
|
|
*/
|
|
function waveSegment(from, to, halfWavelength, height, shift, middle, amplitude) {
|
|
const wave = halfWave(halfWavelength, height)
|
|
|
|
const locate = (distance) => {
|
|
const index = Math.floor(distance / wave.length)
|
|
|
|
return [index, wave.parameterAt(distance - index * wave.length)]
|
|
}
|
|
|
|
const sign = (index) => (index % 2 === 0 ? 1 : -1)
|
|
const x = (index, t) => round((index + t) * halfWavelength - shift)
|
|
const y = (index, bump) => round(middle + amplitude * sign(index) * height * bump)
|
|
|
|
const [first, t0] = locate(from)
|
|
const [last, t1] = locate(to)
|
|
let d = `M${x(first, t0)} ${y(first, 2 * t0 * (1 - t0))}`
|
|
|
|
for (let index = first; index <= last; index++) {
|
|
const a = index === first ? t0 : 0
|
|
const b = index === last ? t1 : 1
|
|
|
|
if (b <= a) {
|
|
continue
|
|
}
|
|
|
|
// The quadratic's blossom at (a, b) is the control point of its piece from a to b.
|
|
d += `Q${x(index, (a + b) / 2)} ${y(index, a + b - 2 * a * b)} ${x(index, b)} ${y(index, 2 * b * (1 - b))}`
|
|
}
|
|
|
|
return d
|
|
}
|
|
|
|
// Shapes: bin/shapes.mjs (RoundedPolygon) --------------------------------------------------
|
|
|
|
const DISTANCE_EPSILON = 1e-4
|
|
const ANGLE_EPSILON = 1e-6
|
|
|
|
const point = (x, y) => ({ x, y })
|
|
const plus = (a, b) => point(a.x + b.x, a.y + b.y)
|
|
const minus = (a, b) => point(a.x - b.x, a.y - b.y)
|
|
const times = (a, k) => point(a.x * k, a.y * k)
|
|
const div = (a, k) => point(a.x / k, a.y / k)
|
|
const dot = (a, b) => a.x * b.x + a.y * b.y
|
|
const length = (a) => Math.sqrt(a.x * a.x + a.y * a.y)
|
|
const rotate90 = (a) => point(-a.y, a.x)
|
|
const lerp = (a, b, f) => (1 - f) * a + f * b
|
|
const lerpPoint = (a, b, f) => point(lerp(a.x, b.x, f), lerp(a.y, b.y, f))
|
|
const direction = (a) => div(a, length(a))
|
|
const radialToCartesian = (radius, angle) => point(Math.cos(angle) * radius, Math.sin(angle) * radius)
|
|
const convex = (previous, current, next) => {
|
|
const [a, b] = [minus(current, previous), minus(next, current)]
|
|
|
|
return a.x * b.y - a.y * b.x > 0
|
|
}
|
|
|
|
/** A cubic is [anchor0X, anchor0Y, control0X, control0Y, control1X, control1Y, anchor1X, anchor1Y]. */
|
|
const cubic = (a0, c0, c1, a1) => [a0.x, a0.y, c0.x, c0.y, c1.x, c1.y, a1.x, a1.y]
|
|
|
|
const straightLine = (x0, y0, x1, y1) => [x0, y0, lerp(x0, x1, 1 / 3), lerp(y0, y1, 1 / 3), lerp(x0, x1, 2 / 3), lerp(y0, y1, 2 / 3), x1, y1]
|
|
|
|
function circularArc(centerX, centerY, x0, y0, x1, y1) {
|
|
const p0d = direction(point(x0 - centerX, y0 - centerY))
|
|
const p1d = direction(point(x1 - centerX, y1 - centerY))
|
|
const rotatedP0 = rotate90(p0d)
|
|
const rotatedP1 = rotate90(p1d)
|
|
const clockwise = dot(rotatedP0, point(x1 - centerX, y1 - centerY)) >= 0
|
|
const cosa = dot(p0d, p1d)
|
|
|
|
if (cosa > 0.999) {
|
|
return straightLine(x0, y0, x1, y1)
|
|
}
|
|
|
|
const k =
|
|
(((length(point(x0 - centerX, y0 - centerY)) * 4) / 3) * (Math.sqrt(2 * (1 - cosa)) - Math.sqrt(1 - cosa * cosa))) /
|
|
(1 - cosa) *
|
|
(clockwise ? 1 : -1)
|
|
|
|
return [x0, y0, x0 + rotatedP0.x * k, y0 + rotatedP0.y * k, x1 - rotatedP1.x * k, y1 - rotatedP1.y * k, x1, y1]
|
|
}
|
|
|
|
function pointOnCurve(c, t) {
|
|
const u = 1 - t
|
|
|
|
return point(
|
|
c[0] * (u * u * u) + c[2] * (3 * t * u * u) + c[4] * (3 * t * t * u) + c[6] * (t * t * t),
|
|
c[1] * (u * u * u) + c[3] * (3 * t * u * u) + c[5] * (3 * t * t * u) + c[7] * (t * t * t),
|
|
)
|
|
}
|
|
|
|
function split(c, t) {
|
|
const u = 1 - t
|
|
const p = pointOnCurve(c, t)
|
|
|
|
return [
|
|
[c[0], c[1], c[0] * u + c[2] * t, c[1] * u + c[3] * t, c[0] * (u * u) + c[2] * (2 * u * t) + c[4] * (t * t), c[1] * (u * u) + c[3] * (2 * u * t) + c[5] * (t * t), p.x, p.y],
|
|
[p.x, p.y, c[2] * (u * u) + c[4] * (2 * u * t) + c[6] * (t * t), c[3] * (u * u) + c[5] * (2 * u * t) + c[7] * (t * t), c[4] * u + c[6] * t, c[5] * u + c[7] * t, c[6], c[7]],
|
|
]
|
|
}
|
|
|
|
const reverse = (c) => [c[6], c[7], c[4], c[5], c[2], c[3], c[0], c[1]]
|
|
const zeroLength = (c) => Math.abs(c[0] - c[6]) < DISTANCE_EPSILON && Math.abs(c[1] - c[7]) < DISTANCE_EPSILON
|
|
|
|
const rounding = (radius = 0, smoothing = 0) => ({ radius, smoothing })
|
|
|
|
class RoundedCorner {
|
|
constructor(p0, p1, p2, cornerRounding) {
|
|
this.p0 = p0
|
|
this.p1 = p1
|
|
this.p2 = p2
|
|
|
|
const v01 = minus(p0, p1)
|
|
const v21 = minus(p2, p1)
|
|
const d01 = length(v01)
|
|
const d21 = length(v21)
|
|
|
|
if (d01 > 0 && d21 > 0) {
|
|
this.d1 = div(v01, d01)
|
|
this.d2 = div(v21, d21)
|
|
this.cornerRadius = cornerRounding?.radius ?? 0
|
|
this.smoothing = cornerRounding?.smoothing ?? 0
|
|
this.cosAngle = dot(this.d1, this.d2)
|
|
this.sinAngle = Math.sqrt(1 - this.cosAngle * this.cosAngle)
|
|
this.expectedRoundCut = this.sinAngle > 1e-3 ? (this.cornerRadius * (this.cosAngle + 1)) / this.sinAngle : 0
|
|
} else {
|
|
this.d1 = point(0, 0)
|
|
this.d2 = point(0, 0)
|
|
this.cornerRadius = 0
|
|
this.smoothing = 0
|
|
this.cosAngle = 0
|
|
this.sinAngle = 0
|
|
this.expectedRoundCut = 0
|
|
}
|
|
}
|
|
|
|
get expectedCut() {
|
|
return (1 + this.smoothing) * this.expectedRoundCut
|
|
}
|
|
|
|
getCubics(allowedCut0, allowedCut1 = allowedCut0) {
|
|
const allowedCut = Math.min(allowedCut0, allowedCut1)
|
|
|
|
if (this.expectedRoundCut < DISTANCE_EPSILON || allowedCut < DISTANCE_EPSILON || this.cornerRadius < DISTANCE_EPSILON) {
|
|
return [straightLine(this.p1.x, this.p1.y, this.p1.x, this.p1.y)]
|
|
}
|
|
|
|
const actualRoundCut = Math.min(allowedCut, this.expectedRoundCut)
|
|
const actualSmoothing0 = this.actualSmoothing(allowedCut0)
|
|
const actualSmoothing1 = this.actualSmoothing(allowedCut1)
|
|
const actualR = (this.cornerRadius * actualRoundCut) / this.expectedRoundCut
|
|
const centerDistance = Math.sqrt(actualR * actualR + actualRoundCut * actualRoundCut)
|
|
const center = plus(this.p1, times(direction(div(plus(this.d1, this.d2), 2)), centerDistance))
|
|
const circleIntersection0 = plus(this.p1, times(this.d1, actualRoundCut))
|
|
const circleIntersection2 = plus(this.p1, times(this.d2, actualRoundCut))
|
|
const flanking0 = this.flankingCurve(actualRoundCut, actualSmoothing0, this.p1, this.p0, circleIntersection0, circleIntersection2, center, actualR)
|
|
const flanking2 = reverse(this.flankingCurve(actualRoundCut, actualSmoothing1, this.p1, this.p2, circleIntersection2, circleIntersection0, center, actualR))
|
|
|
|
return [flanking0, circularArc(center.x, center.y, flanking0[6], flanking0[7], flanking2[0], flanking2[1]), flanking2]
|
|
}
|
|
|
|
actualSmoothing(allowedCut) {
|
|
if (allowedCut > this.expectedCut) {
|
|
return this.smoothing
|
|
}
|
|
|
|
if (allowedCut > this.expectedRoundCut) {
|
|
return (this.smoothing * (allowedCut - this.expectedRoundCut)) / (this.expectedCut - this.expectedRoundCut)
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
flankingCurve(actualRoundCut, smoothing, corner, sideStart, intersection, otherIntersection, circleCenter, actualR) {
|
|
const sideDirection = direction(minus(sideStart, corner))
|
|
const curveStart = plus(corner, times(sideDirection, actualRoundCut * (1 + smoothing)))
|
|
const p = lerpPoint(intersection, div(plus(intersection, otherIntersection), 2), smoothing)
|
|
const curveEnd = plus(circleCenter, times(direction(minus(p, circleCenter)), actualR))
|
|
const circleTangent = rotate90(minus(curveEnd, circleCenter))
|
|
const anchorEnd = lineIntersection(sideStart, sideDirection, curveEnd, circleTangent) ?? intersection
|
|
const anchorStart = div(plus(curveStart, times(anchorEnd, 2)), 3)
|
|
|
|
return cubic(curveStart, anchorStart, anchorEnd, curveEnd)
|
|
}
|
|
}
|
|
|
|
function lineIntersection(p0, d0, p1, d1) {
|
|
const rotatedD1 = rotate90(d1)
|
|
const den = dot(d0, rotatedD1)
|
|
|
|
if (Math.abs(den) < DISTANCE_EPSILON) {
|
|
return null
|
|
}
|
|
|
|
const num = dot(minus(p1, p0), rotatedD1)
|
|
|
|
if (Math.abs(den) < DISTANCE_EPSILON * Math.abs(num)) {
|
|
return null
|
|
}
|
|
|
|
return plus(p0, times(d0, num / den))
|
|
}
|
|
|
|
class RoundedPolygon {
|
|
constructor(features, center) {
|
|
this.features = features
|
|
this.center = center
|
|
this.cubics = flatten(features, center)
|
|
}
|
|
|
|
transformed(f) {
|
|
const move = (c) => {
|
|
const out = []
|
|
|
|
for (let i = 0; i < 8; i += 2) {
|
|
const p = f(c[i], c[i + 1])
|
|
out.push(p.x, p.y)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
return new RoundedPolygon(
|
|
this.features.map((feature) => ({ ...feature, cubics: feature.cubics.map(move) })),
|
|
f(this.center.x, this.center.y),
|
|
)
|
|
}
|
|
|
|
/** RoundedPolygon.normalized: into the unit square, by the approximate (control point) bounds. */
|
|
normalized() {
|
|
let [left, top, right, bottom] = [Infinity, Infinity, -Infinity, -Infinity]
|
|
|
|
for (const c of this.cubics) {
|
|
const count = zeroLength(c) ? 2 : 8
|
|
|
|
for (let i = 0; i < count; i += 2) {
|
|
left = Math.min(left, c[i])
|
|
right = Math.max(right, c[i])
|
|
top = Math.min(top, c[i + 1])
|
|
bottom = Math.max(bottom, c[i + 1])
|
|
}
|
|
}
|
|
|
|
const [width, height] = [right - left, bottom - top]
|
|
const side = Math.max(width, height)
|
|
const offsetX = (side - width) / 2 - left
|
|
const offsetY = (side - height) / 2 - top
|
|
|
|
return this.transformed((x, y) => point((x + offsetX) / side, (y + offsetY) / side))
|
|
}
|
|
}
|
|
|
|
function flatten(features, center) {
|
|
const out = []
|
|
let firstCubic = null
|
|
let lastCubic = null
|
|
let firstFeatureSplitStart = null
|
|
let firstFeatureSplitEnd = null
|
|
|
|
if (features.length > 0 && features[0].cubics.length === 3) {
|
|
const [start, end] = split(features[0].cubics[1], 0.5)
|
|
firstFeatureSplitStart = [features[0].cubics[0], start]
|
|
firstFeatureSplitEnd = [end, features[0].cubics[2]]
|
|
}
|
|
|
|
for (let i = 0; i <= features.length; i++) {
|
|
let featureCubics
|
|
|
|
if (i === 0 && firstFeatureSplitEnd !== null) {
|
|
featureCubics = firstFeatureSplitEnd
|
|
} else if (i === features.length) {
|
|
if (firstFeatureSplitStart === null) {
|
|
break
|
|
}
|
|
|
|
featureCubics = firstFeatureSplitStart
|
|
} else {
|
|
featureCubics = features[i].cubics
|
|
}
|
|
|
|
for (const c of featureCubics) {
|
|
if (!zeroLength(c)) {
|
|
if (lastCubic !== null) {
|
|
out.push(lastCubic)
|
|
}
|
|
|
|
lastCubic = c
|
|
firstCubic ??= c
|
|
} else if (lastCubic !== null) {
|
|
lastCubic = [...lastCubic]
|
|
lastCubic[6] = c[0]
|
|
lastCubic[7] = c[1]
|
|
}
|
|
}
|
|
}
|
|
|
|
if (lastCubic !== null && firstCubic !== null) {
|
|
out.push([...lastCubic.slice(0, 6), firstCubic[0], firstCubic[1]])
|
|
} else {
|
|
out.push([center.x, center.y, center.x, center.y, center.x, center.y, center.x, center.y])
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
/** RoundedPolygon(vertices, rounding, perVertexRounding, centerX, centerY). */
|
|
function polygonFromVertices(vertices, perVertexRounding, center) {
|
|
const n = vertices.length
|
|
const roundedCorners = vertices.map((vertex, i) => new RoundedCorner(vertices[(i + n - 1) % n], vertex, vertices[(i + 1) % n], perVertexRounding[i]))
|
|
|
|
const cutAdjusts = vertices.map((vertex, i) => {
|
|
const next = (i + 1) % n
|
|
const expectedRoundCut = roundedCorners[i].expectedRoundCut + roundedCorners[next].expectedRoundCut
|
|
const expectedCut = roundedCorners[i].expectedCut + roundedCorners[next].expectedCut
|
|
const sideSize = length(minus(vertex, vertices[next]))
|
|
|
|
if (expectedRoundCut > sideSize) {
|
|
return [sideSize / expectedRoundCut, 0]
|
|
}
|
|
|
|
if (expectedCut > sideSize) {
|
|
return [1, (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut)]
|
|
}
|
|
|
|
return [1, 1]
|
|
})
|
|
|
|
const corners = roundedCorners.map((corner, i) => {
|
|
const allowedCuts = [0, 1].map((delta) => {
|
|
const [roundCutRatio, cutRatio] = cutAdjusts[(i + n - 1 + delta) % n]
|
|
|
|
return corner.expectedRoundCut * roundCutRatio + (corner.expectedCut - corner.expectedRoundCut) * cutRatio
|
|
})
|
|
|
|
return corner.getCubics(allowedCuts[0], allowedCuts[1])
|
|
})
|
|
|
|
const features = []
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const end = corners[i].at(-1)
|
|
const start = corners[(i + 1) % n][0]
|
|
|
|
features.push({ type: 'corner', convex: convex(vertices[(i + n - 1) % n], vertices[i], vertices[(i + 1) % n]), cubics: corners[i] })
|
|
features.push({ type: 'edge', cubics: [straightLine(end[6], end[7], start[0], start[1])] })
|
|
}
|
|
|
|
return new RoundedPolygon(features, center)
|
|
}
|
|
|
|
/** RoundedPolygon.circle(numVertices): a regular polygon rounded all the way round. */
|
|
function circlePolygon(vertexCount) {
|
|
const radius = 1 / Math.cos(Math.PI / vertexCount)
|
|
const vertices = Array.from({ length: vertexCount }, (_, i) => radialToCartesian(radius, ((Math.PI / vertexCount) * 2 * i)))
|
|
|
|
return polygonFromVertices(vertices, vertices.map(() => rounding(1)), point(0, 0))
|
|
}
|
|
|
|
/** RoundedPolygon.star(numVerticesPerRadius, innerRadius, rounding, innerRounding). */
|
|
function starPolygon(vertexCount, innerRadius, outerRounding, innerRounding) {
|
|
const vertices = []
|
|
const roundings = []
|
|
|
|
for (let i = 0; i < vertexCount; i++) {
|
|
vertices.push(radialToCartesian(1, (Math.PI / vertexCount) * 2 * i), radialToCartesian(innerRadius, (Math.PI / vertexCount) * (2 * i + 1)))
|
|
roundings.push(outerRounding, innerRounding)
|
|
}
|
|
|
|
return polygonFromVertices(vertices, roundings, point(0, 0))
|
|
}
|
|
|
|
// Morph: bin/loading-indicator.mjs (FloatMapping, PolygonMeasure, FeatureMapping, Morph) ---
|
|
|
|
const positiveModulo = (num, mod) => ((num % mod) + mod) % mod
|
|
const progressInRange = (progress, from, to) => (to >= from ? progress >= from && progress <= to : progress >= from || progress <= to)
|
|
const progressDistance = (a, b) => Math.min(Math.abs(a - b), 1 - Math.abs(a - b))
|
|
|
|
function linearMap(xValues, yValues, x) {
|
|
const n = xValues.length
|
|
const start = xValues.findIndex((_, i) => progressInRange(x, xValues[i], xValues[(i + 1) % n]))
|
|
const end = (start + 1) % n
|
|
const sizeX = positiveModulo(xValues[end] - xValues[start], 1)
|
|
const sizeY = positiveModulo(yValues[end] - yValues[start], 1)
|
|
const position = sizeX < 0.001 ? 0.5 : positiveModulo(x - xValues[start], 1) / sizeX
|
|
|
|
return positiveModulo(yValues[start] + sizeY * position, 1)
|
|
}
|
|
|
|
const MEASURE_SEGMENTS = 3
|
|
|
|
function closestProgressTo(c, threshold) {
|
|
let total = 0
|
|
let remainder = threshold
|
|
let previous = point(c[0], c[1])
|
|
|
|
for (let i = 1; i <= MEASURE_SEGMENTS; i++) {
|
|
const progress = i / MEASURE_SEGMENTS
|
|
const p = pointOnCurve(c, progress)
|
|
const segment = Math.hypot(p.x - previous.x, p.y - previous.y)
|
|
|
|
if (segment >= remainder) {
|
|
return [progress - (1 - remainder / segment) / MEASURE_SEGMENTS, threshold]
|
|
}
|
|
|
|
remainder -= segment
|
|
total += segment
|
|
previous = p
|
|
}
|
|
|
|
return [1, total]
|
|
}
|
|
|
|
class MeasuredCubic {
|
|
constructor(c, startOutlineProgress, endOutlineProgress) {
|
|
this.cubic = c
|
|
this.startOutlineProgress = startOutlineProgress
|
|
this.endOutlineProgress = endOutlineProgress
|
|
this.measuredSize = closestProgressTo(c, Infinity)[1]
|
|
}
|
|
|
|
cutAtProgress(cutOutlineProgress) {
|
|
const bounded = clamp(cutOutlineProgress, this.startOutlineProgress, this.endOutlineProgress)
|
|
const relativeProgress = (bounded - this.startOutlineProgress) / (this.endOutlineProgress - this.startOutlineProgress)
|
|
const t = closestProgressTo(this.cubic, relativeProgress * this.measuredSize)[0]
|
|
const [c1, c2] = split(this.cubic, t)
|
|
|
|
return [new MeasuredCubic(c1, this.startOutlineProgress, bounded), new MeasuredCubic(c2, bounded, this.endOutlineProgress)]
|
|
}
|
|
}
|
|
|
|
class MeasuredPolygon {
|
|
constructor(features, cubics, outlineProgress) {
|
|
this.features = features
|
|
this.cubics = []
|
|
|
|
let startOutlineProgress = 0
|
|
|
|
for (let i = 0; i < cubics.length; i++) {
|
|
if (outlineProgress[i + 1] - outlineProgress[i] > DISTANCE_EPSILON) {
|
|
this.cubics.push(new MeasuredCubic(cubics[i], startOutlineProgress, outlineProgress[i + 1]))
|
|
startOutlineProgress = outlineProgress[i + 1]
|
|
}
|
|
}
|
|
|
|
this.cubics.at(-1).endOutlineProgress = 1
|
|
}
|
|
|
|
static measure(polygon) {
|
|
const cubics = []
|
|
const featureToCubic = []
|
|
|
|
for (const feature of polygon.features) {
|
|
feature.cubics.forEach((c, i) => {
|
|
if (feature.type === 'corner' && i === Math.floor(feature.cubics.length / 2)) {
|
|
featureToCubic.push([feature, cubics.length])
|
|
}
|
|
|
|
cubics.push(c)
|
|
})
|
|
}
|
|
|
|
const measures = [0]
|
|
|
|
for (const c of cubics) {
|
|
measures.push(measures.at(-1) + closestProgressTo(c, Infinity)[1])
|
|
}
|
|
|
|
const outlineProgress = measures.map((measure) => measure / measures.at(-1))
|
|
const features = featureToCubic.map(([feature, ix]) => ({
|
|
progress: positiveModulo((outlineProgress[ix] + outlineProgress[ix + 1]) / 2, 1),
|
|
feature,
|
|
}))
|
|
|
|
return new MeasuredPolygon(features, cubics, outlineProgress)
|
|
}
|
|
|
|
cutAndShift(cuttingPoint) {
|
|
if (cuttingPoint < DISTANCE_EPSILON) {
|
|
return this
|
|
}
|
|
|
|
const n = this.cubics.length
|
|
const targetIndex = this.cubics.findIndex((c) => cuttingPoint >= c.startOutlineProgress && cuttingPoint <= c.endOutlineProgress)
|
|
const [b1, b2] = this.cubics[targetIndex].cutAtProgress(cuttingPoint)
|
|
const cubics = [b2.cubic]
|
|
|
|
for (let i = 1; i < n; i++) {
|
|
cubics.push(this.cubics[(i + targetIndex) % n].cubic)
|
|
}
|
|
|
|
cubics.push(b1.cubic)
|
|
|
|
const outlineProgress = Array.from({ length: n + 2 }, (_, index) => {
|
|
if (index === 0) {
|
|
return 0
|
|
}
|
|
|
|
if (index === n + 1) {
|
|
return 1
|
|
}
|
|
|
|
return positiveModulo(this.cubics[(targetIndex + index - 1) % n].endOutlineProgress - cuttingPoint, 1)
|
|
})
|
|
|
|
const features = this.features.map(({ progress, feature }) => ({ progress: positiveModulo(progress - cuttingPoint, 1), feature }))
|
|
|
|
return new MeasuredPolygon(features, cubics, outlineProgress)
|
|
}
|
|
}
|
|
|
|
function featureDistSquared(f1, f2) {
|
|
if (f1.type === 'corner' && f2.type === 'corner' && f1.convex !== f2.convex) {
|
|
return Infinity
|
|
}
|
|
|
|
const representative = (feature) => {
|
|
const [first, last] = [feature.cubics[0], feature.cubics.at(-1)]
|
|
|
|
return point((first[0] + last[6]) / 2, (first[1] + last[7]) / 2)
|
|
}
|
|
|
|
const [p1, p2] = [representative(f1), representative(f2)]
|
|
|
|
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2
|
|
}
|
|
|
|
function doMapping(features1, features2) {
|
|
const distanceVertexList = []
|
|
|
|
for (const f1 of features1) {
|
|
for (const f2 of features2) {
|
|
const distance = featureDistSquared(f1.feature, f2.feature)
|
|
|
|
if (distance !== Infinity) {
|
|
distanceVertexList.push({ distance, f1, f2 })
|
|
}
|
|
}
|
|
}
|
|
|
|
distanceVertexList.sort((a, b) => a.distance - b.distance)
|
|
|
|
if (distanceVertexList.length === 0) {
|
|
return [
|
|
[0, 0],
|
|
[0.5, 0.5],
|
|
]
|
|
}
|
|
|
|
if (distanceVertexList.length === 1) {
|
|
const { f1, f2 } = distanceVertexList[0]
|
|
|
|
return [
|
|
[f1.progress, f2.progress],
|
|
[(f1.progress + 0.5) % 1, (f2.progress + 0.5) % 1],
|
|
]
|
|
}
|
|
|
|
const mapping = []
|
|
const usedF1 = new Set()
|
|
const usedF2 = new Set()
|
|
|
|
for (const { f1, f2 } of distanceVertexList) {
|
|
if (usedF1.has(f1) || usedF2.has(f2)) {
|
|
continue
|
|
}
|
|
|
|
const insertionIndex = mapping.findIndex((m) => m[0] >= f1.progress)
|
|
const index = insertionIndex === -1 ? mapping.length : insertionIndex
|
|
const n = mapping.length
|
|
|
|
if (n >= 1) {
|
|
const [before1, before2] = mapping[(index + n - 1) % n]
|
|
const [after1, after2] = mapping[index % n]
|
|
|
|
if (
|
|
progressDistance(f1.progress, before1) < DISTANCE_EPSILON ||
|
|
progressDistance(f1.progress, after1) < DISTANCE_EPSILON ||
|
|
progressDistance(f2.progress, before2) < DISTANCE_EPSILON ||
|
|
progressDistance(f2.progress, after2) < DISTANCE_EPSILON
|
|
) {
|
|
continue
|
|
}
|
|
|
|
if (n > 1 && !progressInRange(f2.progress, before2, after2)) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
mapping.splice(index, 0, [f1.progress, f2.progress])
|
|
usedF1.add(f1)
|
|
usedF2.add(f2)
|
|
}
|
|
|
|
return mapping
|
|
}
|
|
|
|
/** Morph.match: both shapes cut into pairs of matching cubics. */
|
|
function match(p1, p2) {
|
|
const measuredPolygon1 = MeasuredPolygon.measure(p1)
|
|
const measuredPolygon2 = MeasuredPolygon.measure(p2)
|
|
const corners = (features) => features.filter(({ feature }) => feature.type === 'corner')
|
|
const mappings = doMapping(corners(measuredPolygon1.features), corners(measuredPolygon2.features))
|
|
const [sources, targets] = [mappings.map((m) => m[0]), mappings.map((m) => m[1])]
|
|
const map = (x) => linearMap(sources, targets, x)
|
|
const mapBack = (x) => linearMap(targets, sources, x)
|
|
const polygon2CutPoint = map(0)
|
|
const bs1 = measuredPolygon1.cubics
|
|
const bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint).cubics
|
|
const pairs = []
|
|
|
|
let i1 = 0
|
|
let i2 = 0
|
|
let b1 = bs1[i1++]
|
|
let b2 = bs2[i2++]
|
|
|
|
while (b1 !== undefined && b2 !== undefined) {
|
|
const b1a = i1 === bs1.length ? 1 : b1.endOutlineProgress
|
|
const b2a = i2 === bs2.length ? 1 : mapBack(positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1))
|
|
const minb = Math.min(b1a, b2a)
|
|
let seg1
|
|
let seg2
|
|
|
|
if (b1a > minb + ANGLE_EPSILON) {
|
|
;[seg1, b1] = b1.cutAtProgress(minb)
|
|
} else {
|
|
seg1 = b1
|
|
b1 = bs1[i1++]
|
|
}
|
|
|
|
if (b2a > minb + ANGLE_EPSILON) {
|
|
;[seg2, b2] = b2.cutAtProgress(positiveModulo(map(minb) - polygon2CutPoint, 1))
|
|
} else {
|
|
seg2 = b2
|
|
b2 = bs2[i2++]
|
|
}
|
|
|
|
pairs.push([seg1.cubic, seg2.cubic])
|
|
}
|
|
|
|
return pairs
|
|
}
|
|
|
|
/** Morph.asCubics: every matched pair interpolated at `progress`, closed on its first anchor. */
|
|
function asCubics(pairs, progress) {
|
|
const cubics = pairs.map(([start, end]) => start.map((value, i) => value + (end[i] - value) * progress))
|
|
|
|
cubics.at(-1)[6] = cubics[0][0]
|
|
cubics.at(-1)[7] = cubics[0][1]
|
|
|
|
return cubics
|
|
}
|
|
|
|
// The circular wave ---------------------------------------------------------------------
|
|
|
|
const circularShapes = new Map()
|
|
|
|
/** CircularShapes: the track's circle and the active indicator's star, matched in vertex count. */
|
|
function shapesFor(vertexCount) {
|
|
if (!circularShapes.has(vertexCount)) {
|
|
const circle = circlePolygon(vertexCount).normalized()
|
|
const star = starPolygon(vertexCount, 0.75, rounding(0.35, 0.4), rounding(0.5)).normalized()
|
|
let pairs = null
|
|
|
|
circularShapes.set(vertexCount, {
|
|
circle,
|
|
star,
|
|
morph: () => (pairs ??= match(circle, star)),
|
|
})
|
|
}
|
|
|
|
return circularShapes.get(vertexCount)
|
|
}
|
|
|
|
const GAUSS_LEGENDRE = [
|
|
[0.1834346424956498, 0.362683783378362],
|
|
[0.525532409916329, 0.3137066458778873],
|
|
[0.7966664774136267, 0.2223810344533745],
|
|
[0.9602898564975363, 0.1012285362903763],
|
|
]
|
|
|
|
function cubicLength(c) {
|
|
let sum = 0
|
|
|
|
for (const [node, weight] of GAUSS_LEGENDRE) {
|
|
for (const t of [(1 - node) / 2, (1 + node) / 2]) {
|
|
const u = 1 - t
|
|
const dx = 3 * (u * u * (c[2] - c[0]) + 2 * u * t * (c[4] - c[2]) + t * t * (c[6] - c[4]))
|
|
const dy = 3 * (u * u * (c[3] - c[1]) + 2 * u * t * (c[5] - c[3]) + t * t * (c[7] - c[5]))
|
|
sum += weight * Math.hypot(dx, dy)
|
|
}
|
|
}
|
|
|
|
return sum / 2
|
|
}
|
|
|
|
/**
|
|
* pathFromCubics (turned so it starts at 12 o'clock, repeated once when the wave travels) and
|
|
* processPath (scaled to the container less the stroke, its control-point bounds centred).
|
|
*/
|
|
function circularPath(cubics, pivot, repeat, scale, cx, cy) {
|
|
const angle = Math.atan2(cubics[0][1] - pivot.y, cubics[0][0] - pivot.x)
|
|
const turn = -angle + (270 * Math.PI) / 180
|
|
const [sin, cos] = [Math.sin(turn), Math.cos(turn)]
|
|
let [left, top, right, bottom] = [Infinity, Infinity, -Infinity, -Infinity]
|
|
|
|
const moved = cubics.map((c) => {
|
|
const out = new Array(8)
|
|
|
|
for (let i = 0; i < 8; i += 2) {
|
|
out[i] = (cos * c[i] - sin * c[i + 1]) * scale
|
|
out[i + 1] = (sin * c[i] + cos * c[i + 1]) * scale
|
|
left = Math.min(left, out[i])
|
|
right = Math.max(right, out[i])
|
|
top = Math.min(top, out[i + 1])
|
|
bottom = Math.max(bottom, out[i + 1])
|
|
}
|
|
|
|
return out
|
|
})
|
|
|
|
const [dx, dy] = [cx - (left + right) / 2, cy - (top + bottom) / 2]
|
|
const loop = moved.map((c) => `C${round(c[2] + dx)} ${round(c[3] + dy)} ${round(c[4] + dx)} ${round(c[5] + dy)} ${round(c[6] + dx)} ${round(c[7] + dy)}`).join('')
|
|
const start = `${round(moved[0][0] + dx)} ${round(moved[0][1] + dy)}`
|
|
const loopLength = moved.reduce((sum, c) => sum + cubicLength(c), 0)
|
|
|
|
return {
|
|
d: repeat ? `M${start}${loop}L${start}${loop}Z` : `M${start}${loop}Z`,
|
|
length: repeat ? loopLength * 2 : loopLength,
|
|
}
|
|
}
|
|
|
|
// The component -------------------------------------------------------------------------
|
|
|
|
/** A duration token in milliseconds, whichever unit a minifier left it in; null when unset. */
|
|
function tokenDuration(element, token) {
|
|
const value = getComputedStyle(element).getPropertyValue(token).trim()
|
|
const number = parseFloat(value)
|
|
|
|
if (Number.isNaN(number)) {
|
|
return null
|
|
}
|
|
|
|
return value.endsWith('ms') ? number : number * 1000
|
|
}
|
|
|
|
function svgElement(name, attributes) {
|
|
const element = document.createElementNS(SVG, name)
|
|
|
|
for (const [key, value] of Object.entries(attributes)) {
|
|
element.setAttribute(key, value)
|
|
}
|
|
|
|
return element
|
|
}
|
|
|
|
class Indicator {
|
|
constructor(element) {
|
|
this.element = element
|
|
this.svg = element.querySelector(':scope > svg')
|
|
this.frame = null
|
|
this.last = null
|
|
this.visible = true
|
|
this.width = 0
|
|
this.height = 0
|
|
this.written = new Map()
|
|
this.motion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
this.onMotionChange = () => this.schedule()
|
|
this.motion.addEventListener('change', this.onMotionChange)
|
|
|
|
this.read(true)
|
|
this.measure()
|
|
|
|
this.resizes = new ResizeObserver(() => this.measure() && this.schedule())
|
|
this.resizes.observe(element)
|
|
|
|
this.intersections = new IntersectionObserver((entries) => {
|
|
this.visible = entries.at(-1).isIntersecting
|
|
|
|
if (this.visible) {
|
|
this.schedule()
|
|
}
|
|
})
|
|
this.intersections.observe(element)
|
|
|
|
this.mutations = new MutationObserver(() => {
|
|
this.read(false)
|
|
this.schedule()
|
|
})
|
|
this.mutations.observe(element, { attributes: true, attributeFilter: WATCHED })
|
|
|
|
this.draw(this.motion.matches)
|
|
this.schedule()
|
|
}
|
|
|
|
destroy() {
|
|
cancelAnimationFrame(this.frame)
|
|
this.motion.removeEventListener('change', this.onMotionChange)
|
|
this.resizes.disconnect()
|
|
this.intersections.disconnect()
|
|
this.mutations.disconnect()
|
|
}
|
|
|
|
/** Takes the root's attributes: the shape, and the value to show (null: indeterminate). */
|
|
read(initial) {
|
|
const element = this.element
|
|
const shape = WATCHED.slice(2).map((name) => element.hasAttribute(name)).join()
|
|
const maximum = parseFloat(element.getAttribute('data-max')) > 0 ? parseFloat(element.getAttribute('data-max')) : 100
|
|
const raw = element.getAttribute('data-value')
|
|
const number = raw === null || raw.trim() === '' ? NaN : Number(raw)
|
|
const target = Number.isFinite(number) ? clamp(number / maximum, 0, 1) : null
|
|
|
|
if (initial || shape !== this.shape) {
|
|
this.shape = shape
|
|
this.circular = element.hasAttribute('data-circular')
|
|
this.wavy = element.hasAttribute('data-wavy')
|
|
this.stroke = element.hasAttribute('data-thick') ? 8 : 4
|
|
this.build()
|
|
this.reset(target)
|
|
} else if ((target === null) !== (this.target === null)) {
|
|
// Compose draws determinate and indeterminate indicators as two different composables.
|
|
this.reset(target)
|
|
} else if (target !== null && target !== this.target) {
|
|
const duration = tokenDuration(element, '--md-sys-motion-effects-slow-duration') ?? VALUE_SETTLE
|
|
this.timeScale = duration > 0 && !this.motion.matches ? VALUE_SETTLE / duration : 0
|
|
}
|
|
|
|
this.target = target
|
|
}
|
|
|
|
reset(target) {
|
|
this.target = target
|
|
this.progress = target
|
|
this.velocity = 0
|
|
this.timeScale = 0
|
|
this.clock = 0
|
|
this.offset = 0
|
|
this.amplitude = target === null ? 1 : amplitudeFor(target)
|
|
this.amplitudeGoal = this.amplitude
|
|
this.amplitudeAnimation = null
|
|
this.morphed = false
|
|
this.vertexCount = MIN_CIRCULAR_VERTICES
|
|
this.paths = {}
|
|
}
|
|
|
|
build() {
|
|
const stroke = { fill: 'none', 'stroke-width': this.stroke, 'stroke-linecap': 'round' }
|
|
|
|
this.group = svgElement('g', {})
|
|
this.track = svgElement('path', stroke)
|
|
this.active = svgElement('path', { ...stroke, stroke: 'currentColor' })
|
|
this.stop = svgElement('circle', { fill: 'currentColor', stroke: 'none', r: 0 })
|
|
this.written.clear()
|
|
|
|
this.group.append(this.track, this.active, this.stop)
|
|
this.svg.removeAttribute('viewBox')
|
|
this.svg.replaceChildren(this.group)
|
|
}
|
|
|
|
measure() {
|
|
const [width, height] = [this.element.offsetWidth, this.element.offsetHeight]
|
|
const changed = width !== this.width || height !== this.height
|
|
|
|
this.width = width
|
|
this.height = height
|
|
|
|
return changed
|
|
}
|
|
|
|
schedule() {
|
|
if (this.frame === null) {
|
|
this.frame = requestAnimationFrame((now) => this.tick(now))
|
|
}
|
|
}
|
|
|
|
tick(now) {
|
|
this.frame = null
|
|
|
|
const reduced = this.motion.matches
|
|
const elapsed = this.last === null ? 0 : Math.min(now - this.last, 64)
|
|
this.last = now
|
|
|
|
if (!reduced) {
|
|
this.clock += elapsed
|
|
}
|
|
|
|
const moving = this.advance(elapsed, reduced)
|
|
this.draw(reduced)
|
|
|
|
if (moving && this.visible && this.element.isConnected) {
|
|
this.schedule()
|
|
} else {
|
|
this.last = null
|
|
}
|
|
}
|
|
|
|
/** Steps every animation by `elapsed` ms; true while any of them still moves. */
|
|
advance(elapsed, reduced) {
|
|
let moving = false
|
|
|
|
if (this.target === null) {
|
|
moving = !reduced
|
|
} else if (this.progress !== this.target || this.velocity !== 0) {
|
|
if (this.timeScale === 0 || reduced) {
|
|
this.progress = this.target
|
|
this.velocity = 0
|
|
} else {
|
|
// A critically damped spring, solved exactly, so a new target keeps the velocity.
|
|
const omega = Math.sqrt(VALUE_STIFFNESS)
|
|
const t = (elapsed / 1000) * this.timeScale
|
|
const d0 = this.progress - this.target
|
|
const c = this.velocity + omega * d0
|
|
const decay = Math.exp(-omega * t)
|
|
|
|
this.progress = this.target + (d0 + c * t) * decay
|
|
this.velocity = (c - omega * (d0 + c * t)) * decay
|
|
|
|
if (Math.abs(this.progress - this.target) < 1e-4 && Math.abs(this.velocity) < 1e-3) {
|
|
this.progress = this.target
|
|
this.velocity = 0
|
|
} else {
|
|
moving = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!this.wavy) {
|
|
return moving
|
|
}
|
|
|
|
const animation = this.amplitudeAnimation
|
|
|
|
if (animation) {
|
|
animation.elapsed += elapsed
|
|
const fraction = animation.duration > 0 ? Math.min(animation.elapsed / animation.duration, 1) : 1
|
|
this.amplitude = animation.from + (animation.to - animation.from) * animation.easing(fraction)
|
|
|
|
if (fraction >= 1) {
|
|
this.amplitudeAnimation = null
|
|
} else {
|
|
moving = true
|
|
}
|
|
}
|
|
|
|
// As in Compose, a new amplitude animation starts only once the running one has ended.
|
|
const goal = this.target === null ? 1 : amplitudeFor(clamp(this.progress, 0, 1))
|
|
|
|
if (!this.amplitudeAnimation && goal !== this.amplitudeGoal) {
|
|
const duration = reduced ? 0 : (tokenDuration(this.element, '--md-sys-motion-duration-long') ?? AMPLITUDE_DURATION)
|
|
|
|
this.amplitudeGoal = goal
|
|
this.morphed = true
|
|
|
|
if (duration > 0) {
|
|
this.amplitudeAnimation = {
|
|
from: this.amplitude,
|
|
to: goal,
|
|
easing: this.amplitude < goal ? STANDARD : EMPHASIZED_ACCELERATE,
|
|
duration,
|
|
elapsed: 0,
|
|
}
|
|
moving = true
|
|
} else {
|
|
this.amplitude = goal
|
|
}
|
|
}
|
|
|
|
if (this.amplitude > 0 && !reduced) {
|
|
const period = this.circular ? WAVE_PERIOD * this.vertexCount : WAVE_PERIOD
|
|
this.offset = (this.offset + elapsed / period) % 1
|
|
moving = true
|
|
}
|
|
|
|
return moving
|
|
}
|
|
|
|
draw(reduced) {
|
|
if (this.width <= 0 || this.height <= 0) {
|
|
return
|
|
}
|
|
|
|
const still = this.circular ? CIRCULAR_STILL : LINEAR_STILL
|
|
const time = reduced ? still : this.clock
|
|
const offset = reduced ? 0 : this.offset
|
|
|
|
if (this.circular) {
|
|
this.wavy ? this.drawCircularWavy(time, offset) : this.drawCircular(time)
|
|
} else {
|
|
this.wavy ? this.drawLinearWavy(time, offset) : this.drawLinear(time)
|
|
}
|
|
}
|
|
|
|
write(element, name, value) {
|
|
const key = element === this.track ? `t${name}` : element === this.active ? `a${name}` : element === this.stop ? `s${name}` : `g${name}`
|
|
|
|
if (this.written.get(key) === value) {
|
|
return
|
|
}
|
|
|
|
this.written.set(key, value)
|
|
|
|
if (value === null) {
|
|
element.removeAttribute(name)
|
|
} else {
|
|
element.setAttribute(name, value)
|
|
}
|
|
}
|
|
|
|
stopAt(cx, cy, radius) {
|
|
this.write(this.stop, 'cx', round(cx))
|
|
this.write(this.stop, 'cy', round(cy))
|
|
this.write(this.stop, 'r', round(Math.max(radius, 0)))
|
|
}
|
|
|
|
/** LinearProgressIndicator. */
|
|
drawLinear(time) {
|
|
const [width, height, stroke] = [this.width, this.height, this.stroke]
|
|
const cap = height > width ? 0 : stroke / 2
|
|
const gap = (GAP + (height > width ? 0 : stroke)) / width
|
|
const middle = height / 2
|
|
let track = ''
|
|
let active = ''
|
|
|
|
// drawLinearIndicator: a line between two fractions, kept inside for its round caps.
|
|
const bar = (start, end) => (Math.abs(end - start) > 0 ? straight(clamp(start * width, cap, width - cap), middle, clamp(end * width, cap, width - cap)) : '')
|
|
|
|
if (this.target !== null) {
|
|
const progress = clamp(this.progress, 0, 1)
|
|
const trackStart = progress + Math.min(progress, gap)
|
|
|
|
if (trackStart <= 1) {
|
|
track += bar(trackStart, 1)
|
|
}
|
|
|
|
active += bar(0, progress)
|
|
|
|
const size = Math.min(STOP_SIZE, height)
|
|
this.stopAt(width - size / 2 - Math.min((height - size) / 2, STOP_TRAILING_SPACE), middle, size / 2)
|
|
} else {
|
|
const [firstTail, firstHead, secondTail, secondHead] = LINEAR_LINES.map((spec) => spec(time))
|
|
|
|
if (firstHead < 1 - gap) {
|
|
track += bar(firstHead > 0 ? firstHead + gap : 0, 1)
|
|
}
|
|
|
|
if (firstHead - firstTail > 0) {
|
|
active += bar(firstHead, firstTail)
|
|
}
|
|
|
|
if (firstTail > gap) {
|
|
track += bar(secondHead > 0 ? secondHead + gap : 0, firstTail < 1 ? firstTail - gap : 1)
|
|
}
|
|
|
|
if (secondHead - secondTail > 0) {
|
|
active += bar(secondHead, secondTail)
|
|
}
|
|
|
|
if (secondTail > gap) {
|
|
track += bar(0, secondTail < 1 ? secondTail - gap : 1)
|
|
}
|
|
|
|
this.stopAt(0, 0, 0)
|
|
}
|
|
|
|
this.write(this.track, 'd', track)
|
|
this.write(this.active, 'd', active)
|
|
}
|
|
|
|
/** LinearWavyProgressIndicator: LinearProgressDrawingCache.updateDrawPaths and drawStopIndicator. */
|
|
drawLinearWavy(time, offset) {
|
|
const [width, height, stroke] = [this.width, this.height, this.stroke]
|
|
const determinate = this.target !== null
|
|
const cap = height > width ? 0 : stroke / 2
|
|
const middle = height / 2
|
|
const wavelength = determinate ? LINEAR_WAVELENGTH : LINEAR_INDETERMINATE_WAVELENGTH
|
|
const halfWavelength = wavelength / 2
|
|
const waveHeight = height - stroke
|
|
const amplitude = this.amplitude
|
|
const fractions = determinate ? [0, clamp(this.progress, 0, 1)] : LINEAR_LINES.map((spec) => spec(time))
|
|
|
|
// The full path: a line, or enough half-waves to cover the width plus two wavelengths.
|
|
const halfWaves = Math.floor((width + wavelength * 2) / halfWavelength)
|
|
const fullLength = amplitude !== 0 ? halfWaves * halfWave(halfWavelength, waveHeight).length : width
|
|
const scale = fullLength / ((amplitude !== 0 ? halfWaves * halfWavelength : width) + 0.00000001)
|
|
|
|
let trackGap = GAP
|
|
let activeVisible = false
|
|
let nextTrackEnd = width - cap
|
|
let track = `M${round(nextTrackEnd)} ${round(middle)}`
|
|
let active = ''
|
|
|
|
for (let i = 0; i < fractions.length / 2; i++) {
|
|
const [start, end] = [fractions[i * 2], fractions[i * 2 + 1]]
|
|
const [tail, head] = [start * width, end * width]
|
|
|
|
if (i === 0) {
|
|
trackGap = head < cap ? 0 : Math.min(head - cap, GAP)
|
|
activeVisible = head >= cap
|
|
}
|
|
|
|
const adjustedHead = clamp(head, cap, width - cap)
|
|
const adjustedTail = clamp(tail, cap, width - cap)
|
|
|
|
if (Math.abs(end - start) > 0) {
|
|
const shift = amplitude !== 0 ? offset * wavelength : 0
|
|
const from = Math.max((adjustedTail + shift) * scale, 0)
|
|
const to = Math.min((adjustedHead + shift) * scale, fullLength)
|
|
|
|
// android.graphics.PathMeasure.getSegment draws nothing for an empty segment.
|
|
if (from < to) {
|
|
active += amplitude !== 0 ? waveSegment(from, to, halfWavelength, waveHeight, shift, middle, amplitude) : straight(from, middle, to)
|
|
}
|
|
}
|
|
|
|
const spacing = activeVisible ? trackGap + cap * 2 : trackGap
|
|
|
|
if (nextTrackEnd > adjustedHead + spacing) {
|
|
track += `L${round(Math.max(cap, adjustedHead + spacing))} ${round(middle)}`
|
|
}
|
|
|
|
if (head > tail) {
|
|
nextTrackEnd = Math.max(cap, adjustedTail - spacing)
|
|
track += `M${round(nextTrackEnd)} ${round(middle)}`
|
|
}
|
|
}
|
|
|
|
if (nextTrackEnd > cap) {
|
|
track += `L${round(cap)} ${round(middle)}`
|
|
}
|
|
|
|
this.write(this.track, 'd', track)
|
|
this.write(this.active, 'd', active)
|
|
|
|
if (!determinate) {
|
|
this.stopAt(0, 0, 0)
|
|
|
|
return
|
|
}
|
|
|
|
let size = Math.min(stroke, STOP_SIZE)
|
|
let x = width - size - (size === stroke ? 0 : stroke / 4)
|
|
const progressX = width * fractions[1] + cap
|
|
|
|
if (x <= progressX) {
|
|
size = Math.max(0, size - (progressX - x))
|
|
x = progressX
|
|
}
|
|
|
|
this.stopAt(x + size / 2, middle, size / 2)
|
|
}
|
|
|
|
/** CircularProgressIndicator. */
|
|
drawCircular(time) {
|
|
const size = Math.min(this.width, this.height)
|
|
const [cx, cy, stroke] = [this.width / 2, this.height / 2, this.stroke]
|
|
const radius = (size - stroke) / 2
|
|
const gapSweep = ((GAP + stroke) / (Math.PI * size)) * 360
|
|
|
|
if (this.target !== null) {
|
|
const sweep = clamp(this.progress, 0, 1) * 360
|
|
const trackGap = Math.min(sweep, gapSweep)
|
|
|
|
this.write(this.group, 'transform', null)
|
|
this.write(this.track, 'd', arc(cx, cy, radius, 270 + sweep + trackGap, 360 - sweep - trackGap * 2))
|
|
this.write(this.active, 'd', arc(cx, cy, radius, 270, sweep))
|
|
} else {
|
|
// circularIndeterminateTrackColor is transparent: no track while it spins.
|
|
this.write(this.group, 'transform', `rotate(${round(circularRotation(time))} ${round(cx)} ${round(cy)})`)
|
|
this.write(this.track, 'd', '')
|
|
this.write(this.active, 'd', arc(cx, cy, radius, 0, CIRCULAR_SWEEP(time) * 360))
|
|
}
|
|
}
|
|
|
|
/** CircularWavyProgressIndicator: CircularProgressDrawingCache.updateDrawPaths, as dashes. */
|
|
drawCircularWavy(time, offset) {
|
|
const size = Math.min(this.width, this.height)
|
|
const [cx, cy, stroke] = [this.width / 2, this.height / 2, this.stroke]
|
|
const cap = stroke / 2
|
|
const determinate = this.target !== null
|
|
const vertexCount = Math.max(MIN_CIRCULAR_VERTICES, Math.round((2 * Math.PI * (size / 2 - stroke / 2)) / CIRCULAR_WAVELENGTH))
|
|
const amplitude = this.amplitude
|
|
const motion = determinate || amplitude > 0
|
|
const end = determinate ? clamp(this.progress, 0, 1) : CIRCULAR_SWEEP(time)
|
|
const shapes = shapesFor(vertexCount)
|
|
|
|
this.vertexCount = vertexCount
|
|
|
|
const trackKey = `${vertexCount}|${size}|${stroke}|${cx}|${cy}`
|
|
|
|
if (this.paths.trackKey !== trackKey) {
|
|
this.paths.trackKey = trackKey
|
|
this.paths.track = circularPath(shapes.circle.cubics, shapes.circle.center, false, size - stroke, cx, cy)
|
|
}
|
|
|
|
// CircularShapes.getProgressPath: the Morph once an amplitude animation has needed one.
|
|
const progressKey = `${trackKey}|${amplitude}|${motion}|${this.morphed}`
|
|
|
|
if (this.paths.progressKey !== progressKey) {
|
|
let [cubics, pivot] = [shapes.circle.cubics, shapes.circle.center]
|
|
|
|
if (this.morphed) {
|
|
;[cubics, pivot] = [asCubics(shapes.morph(), amplitude), point(0.5, 0.5)]
|
|
} else if (amplitude === 1) {
|
|
;[cubics, pivot] = [shapes.star.cubics, shapes.star.center]
|
|
}
|
|
|
|
this.paths.progressKey = progressKey
|
|
this.paths.progress = circularPath(cubics, pivot, motion, size - stroke, cx, cy)
|
|
}
|
|
|
|
const { track, progress } = this.paths
|
|
const progressLength = motion ? progress.length / 2 : progress.length
|
|
const stop = end * progressLength
|
|
const spacing = Math.min(stop, cap) * 2 + Math.min(stop, GAP)
|
|
const phase = motion ? clamp(offset, 0, 1) : 0
|
|
const shift = phase * progressLength
|
|
|
|
this.write(this.group, 'transform', determinate ? null : `rotate(${round(circularRotation(time) + 90)} ${round(cx)} ${round(cy)})`)
|
|
this.dash(this.active, progress, shift, stop + shift)
|
|
this.write(this.active, 'transform', phase * 360 % 360 !== 0 ? `rotate(${round(-(phase * 360) % 360)} ${round(cx)} ${round(cy)})` : null)
|
|
this.dash(this.track, track, end * track.length + spacing, track.length - spacing)
|
|
}
|
|
|
|
/** PathMeasure.getSegment as a single dash: nothing when the segment is empty. */
|
|
dash(element, path, from, to) {
|
|
const [start, stop] = [Math.max(from, 0), Math.min(to, path.length)]
|
|
|
|
this.write(element, 'd', path.d)
|
|
this.write(element, 'pathLength', round(path.length))
|
|
|
|
if (!(start < stop)) {
|
|
this.write(element, 'visibility', 'hidden')
|
|
|
|
return
|
|
}
|
|
|
|
this.write(element, 'visibility', null)
|
|
this.write(element, 'stroke-dasharray', `${round(stop - start)} ${round(path.length * 2)}`)
|
|
this.write(element, 'stroke-dashoffset', round(-start))
|
|
}
|
|
}
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
window.Alpine.data('materialProgress', () => {
|
|
// Kept out of the returned object: Alpine makes that reactive, and a proxy on every
|
|
// per-frame read would cost more than the drawing.
|
|
let indicator = null
|
|
|
|
return {
|
|
init() {
|
|
// After this element's own x-bind has written a bound value.
|
|
this.$nextTick(() => {
|
|
if (this.$el.isConnected && indicator === null) {
|
|
indicator = new Indicator(this.$el)
|
|
}
|
|
})
|
|
},
|
|
|
|
destroy() {
|
|
indicator?.destroy()
|
|
indicator = null
|
|
},
|
|
}
|
|
})
|
|
})
|