Files
livewire-material/resources/js/progress.js
T
Andreas Reinhold / reiniandClaude Opus 5 247c596c3a Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed
batches. Behaviour stays the same except where UPGRADE.md says otherwise.

PHP: the showcase and error-page stylesheets are prebuilt into
resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import
(first occurrence kept, the order an application's build gives), instead
of Stylesheets::bundle() inlining imports on every request; only the
import walk DesignGuard needs stays. SchemeStylesheet::withProfiles()
replaces three copies of the scheme-plus-profiles loop, material:scheme
leaves spec and contrast checks to the node script that already made
them, and the error page's scheme cache, the hashed view namespace, the
translations path with no lang/ folder and DesignGuard's 1.x-name hints
are gone.

JS: the androidx shape port progress.js and both bin scripts each carried
lives once in resources/js/shapes.js (the generated SVGs are unchanged);
util.js holds ringIndex(), ms(), reopenGuard() and remember(), which
were written out several times; listeners are released through
AbortController; tooltip.js's hoverPopover() serves the rich tooltip too.

CSS: every rule for an element inside the navigation rail queries
`--md-navigation-rail-value` instead of repeating the seven collapsed
conditions under five media branches; badge, alert, progress, slider and
button read one non-inheriting colour-role table (components/color.css);
the dialog chrome, the submenu's popover chrome, the chip's state layer
and touch target, and the visually-hidden inputs use the shared rules
they copied; foundation/tokens.css is folded into foundation.css.

Views: Support\Field and Support\Link replace the error-key, bound-value
and link-attribute blocks copied into the fields and link components;
the timepicker period group, the menu filter and the showcase head are
partials; the datepicker's steppers and entry fields are loops; component
docblocks no longer restate SKILL.md.

Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces
four per-group files, DesignGuardTest and the layout-component tests use
datasets, browser tests share one ready() helper, CSS parsing lives in
ComponentStylesheet alone. docs/audits and the finding IDs citing it are
removed, as are pestphp/pest-plugin-laravel, the unused composer scripts
and check:font; the lint job runs in the feature job, which now installs
node packages so the prebuilt-stylesheet staleness test runs in CI.

Feature suite 1177 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:29:21 +02:00

896 lines
34 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 shapes.js:
* 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.
* ---------------------------------------------------------------------------------------
*/
import { asCubics, circlePolygon, match, point, rounding, star as starPolygon } from './shapes.js'
import { ms } from './util.js'
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
}
// 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, { innerRadius: 0.75, cornerRounding: rounding(0.35, 0.4), innerCornerRounding: 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],
]
// Pure arithmetic rather than getTotalLength(): WebKit lays the whole page out on that call, even
// for a detached path, and this runs every frame while the amplitude animates.
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 -------------------------------------------------------------------------
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 = ms(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 : (ms(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
},
}
})
})