Files
livewire-material/resources/js/progress.js
T
Andreas Reinhold / reiniandClaude Opus 5 e57063b0f4
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
Add M3 Expressive progress indicators
<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
2026-09-13 07:05:52 +02:00

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
},
}
})
})