Plan step 36 (actions). <x-progress> renders data-md-progress with data-md-color and data-md-circular/-wavy/-thick, and data-md-value/-max in place of data-value/data-max; no class list. progress.css draws LinearProgressIndicatorTokens and CircularProgressIndicatorTokens' stroke, sizes and colours, and the linear indicator's unconditional right-to-left mirror. Its sizing is a default only, same as loading.css: a caller's own class, unlayered or in Tailwind's utilities layer, still outranks it, so the width/ size detection regex the view carried is gone with it. resources/js/progress.js's WATCHED array and its reads follow the renamed hooks. ProgressTest (Feature and Browser) is rewritten on the hooks and ComponentStylesheet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
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-md-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-md-value', 'data-md-max', 'data-md-circular', 'data-md-wavy', 'data-md-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-md-max')) > 0 ? parseFloat(element.getAttribute('data-md-max')) : 100
|
|
const raw = element.getAttribute('data-md-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-md-circular')
|
|
this.wavy = element.hasAttribute('data-md-wavy')
|
|
this.stroke = element.hasAttribute('data-md-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
|
|
},
|
|
}
|
|
})
|
|
})
|