/** * Regenerates resources/svg/loading-indicator: the Material 3 Expressive loading indicator. * * Run from the repository root with `npm run build:loading` (or `node bin/loading-indicator.mjs`). * Maintenance only: plain Node 22+, no dependencies, and the output is deterministic, so running * it twice changes nothing. The shape geometry comes from bin/shapes.mjs. * * indeterminate.svg the animated indicator: pure SVG + SMIL, no script, no ids. It is safe to * inline any number of times on one page; there is no placeholder to replace. * static.svg the first frame of that animation, for prefers-reduced-motion. * * What androidx's indeterminate LoadingIndicator draws, and how it is reproduced here: * * - Seven MaterialShapes morph into one another in a loop (SoftBurst, Cookie9Sided, Pentagon, Pill, * Sunny, Cookie4Sided, Oval, back to SoftBurst). Every 650 ms a morph starts; its progress follows * a spring (damping ratio 0.6, stiffness 200) that overshoots to ~1.095 at ~278 ms and settles. * Each morph is androidx's `Morph` (feature-matched cubics, ported below), and since a morph is a * plain lerp of matched control points, SMIL can interpolate it: every morph gets its own * whose `d` runs start → overshoot → end. Compose ends the spring at its 0.1 visibility threshold * (~297 ms, still ~9% past the target) and snaps; here the same spring is followed to rest over * the 650 ms instead, which removes that one-frame snap. The spring is fitted with two keySplines. * - Each morph path repeats on a 4550 ms cycle that begins at its slot (650 ms × index). Until its * slot it has no `d` (first cycle) or a point (later cycles), so it draws nothing. When its morph * ends, the next path starts from the very same shape, and the finished path shrinks towards the * centre — within one frame to about half its size, then on to a point. Every shape here is * star-shaped about its centre, so a shrunken copy lies inside the shape the next morph starts * from, and (checked while writing this) stays at least 0.18 units inside every later frame of * it: the copy is always covered and never seen. No visibility switching, no ids, no seam. * - The shape turns +90° per morph on the same spring (`morphRotationTargetAngle`, starting at 90°), * a 2600 ms cycle of four morphs that ends where it began (450° = 90°), and the whole indicator * turns 360° every 4666 ms, linearly. Nested rotations about the centre add up, as in Compose. * - The shapes are sized as `calculateScaleFactor` does (so they look alike while rotating) and * scaled by ActiveIndicatorScale (38 / 48) into the 48 × 48 container; like `processPath`, every * drawn path is re-centred on its bounds. Compose takes the control-point bounds, which differ * between two morphs' subdivisions of the same shape and nudge it by up to 0.18 units when one * morph hands over to the next; the exact bounds used here are the same for both, so it holds still. * * --------------------------------------------------------------------------------------- * Ported from androidx (https://github.com/androidx/androidx), commit * 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: * * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/LoadingIndicator.kt * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/LoadingIndicatorTokens.kt * compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/SpringSimulation.kt * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/FeatureMapping.kt * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/FloatMapping.kt * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Morph.kt * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/PolygonMeasure.kt * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/RoundedPolygon.kt (calculateMaxBounds) * * Copyright 2022-2024 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 { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { cubicBounds, point, pointOnCurve, split, SHAPES } from './shapes.mjs' const OUTPUT = 'resources/svg/loading-indicator' const DISTANCE_EPSILON = 1e-4 const ANGLE_EPSILON = 1e-6 // LoadingIndicator.kt / LoadingIndicatorTokens.kt -------------------------------------- const SEQUENCE = ['soft-burst', 'cookie-9', 'pentagon', 'pill', 'sunny', 'cookie-4', 'oval'] const CONTAINER = 48 const ACTIVE_SIZE = 38 const MORPH_INTERVAL = 650 const GLOBAL_ROTATION_DURATION = 4666 const QUARTER_ROTATION = 90 const SPRING = { dampingRatio: 0.6, stiffness: 200 } /** * How long (ms) before the next morph starts the finished path begins to shrink, so it is already * inside the incoming shape and the two outlines do not coincide (which would draw anti-aliased * edges twice). Chromium and WebKit solve keySplines only to about 1/(200 × dur), so there the * shrink shows ~0.4 ms late; a frame in that window merely has slightly bolder edges. A longer lead * would instead let the shape visibly dip before the next path takes over. */ const HANDOVER_LEAD = 0.001 /** The fastest collapse a keySpline can give: half the size within a frame, then a long tail. */ const COLLAPSE = [0, 1, 0, 1] // Utils.kt / FloatMapping.kt ------------------------------------------------------------ const positiveModulo = (num, mod) => ((num % mod) + mod) % mod const progressInRange = (progress, from, to) => to >= from ? progress >= from && progress <= to : progress >= from || progress <= to function progressDistance(a, b) { const d = Math.abs(a - b) return Math.min(d, 1 - d) } 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) } /** DoubleMapper: maps outline progress on one shape to the other and back, from [source, target] pairs. */ function doubleMapper(mappings) { const sources = mappings.map((m) => m[0]) const targets = mappings.map((m) => m[1]) return { map: (x) => linearMap(sources, targets, x), mapBack: (x) => linearMap(targets, sources, x) } } // PolygonMeasure.kt --------------------------------------------------------------------- const MEASURE_SEGMENTS = 3 /** LengthMeasurer.closestProgressTo: [the parameter at which `threshold` length is reached, the length]. */ 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] } const measureCubic = (c) => closestProgressTo(c, Infinity)[1] const findCubicCutPoint = (c, measure) => closestProgressTo(c, measure)[0] class MeasuredCubic { constructor(cubic, startOutlineProgress, endOutlineProgress) { if (endOutlineProgress < startOutlineProgress) { throw new Error('endOutlineProgress is expected to be equal or greater than startOutlineProgress') } this.cubic = cubic this.startOutlineProgress = startOutlineProgress this.endOutlineProgress = endOutlineProgress this.measuredSize = measureCubic(cubic) } cutAtProgress(cutOutlineProgress) { const bounded = Math.min(Math.max(cutOutlineProgress, this.startOutlineProgress), this.endOutlineProgress) const relativeProgress = (bounded - this.startOutlineProgress) / (this.endOutlineProgress - this.startOutlineProgress) const t = findCubicCutPoint(this.cubic, relativeProgress * this.measuredSize) 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((cubic, i) => { if (feature.type === 'corner' && i === Math.floor(feature.cubics.length / 2)) { featureToCubic.push([feature, cubics.length]) } cubics.push(cubic) }) } const measures = [0] for (const cubic of cubics) { measures.push(measures.at(-1) + measureCubic(cubic)) } 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) } } // FeatureMapping.kt --------------------------------------------------------------------- function featureRepresentativePoint(feature) { const first = feature.cubics[0] const last = feature.cubics.at(-1) return point((first[0] + last[6]) / 2, (first[1] + last[7]) / 2) } function featureDistSquared(f1, f2) { if (f1.type === 'corner' && f2.type === 'corner' && f1.convex !== f2.convex) { return Infinity } const p1 = featureRepresentativePoint(f1) const p2 = featureRepresentativePoint(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 }) } } } // Array.prototype.sort is stable, like Kotlin's sortedBy. 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 if (index < mapping.length && mapping[index][0] === f1.progress) { throw new Error("There can't be two features with the same progress") } 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 } function featureMapper(features1, features2) { const corners = (features) => features.filter(({ feature }) => feature.type === 'corner') return doubleMapper(doMapping(corners(features1), corners(features2))) } // Morph.kt ------------------------------------------------------------------------------ /** Morph.match: the start and end shapes cut into pairs of matching cubics. */ function match(p1, p2) { const measuredPolygon1 = MeasuredPolygon.measure(p1) const measuredPolygon2 = MeasuredPolygon.measure(p2) const mapper = featureMapper(measuredPolygon1.features, measuredPolygon2.features) const polygon2CutPoint = mapper.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 : mapper.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(mapper.map(minb) - polygon2CutPoint, 1)) } else { seg2 = b2 b2 = bs2[i2++] } pairs.push([seg1.cubic, seg2.cubic]) } if (b1 !== undefined || b2 !== undefined) { throw new Error("Expected both Polygon's Cubic to be fully matched") } return pairs } /** Morph.asCubics: every matched pair interpolated at `progress`, closed exactly 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 } // LoadingIndicator.kt: calculateScaleFactor, processPath -------------------------------- /** RoundedPolygon.calculateMaxBounds: a square holding the shape in any rotation. */ function maxBounds(polygon) { const { x, y } = polygon.center let maxDistSquared = 0 for (const c of polygon.cubics) { const middle = pointOnCurve(c, 0.5) const anchorDistance = (c[0] - x) ** 2 + (c[1] - y) ** 2 const middleDistance = (middle.x - x) ** 2 + (middle.y - y) ** 2 maxDistSquared = Math.max(maxDistSquared, anchorDistance, middleDistance) } const distance = Math.sqrt(maxDistSquared) return [x - distance, y - distance, x + distance, y + distance] } function calculateScaleFactor(polygons) { let scaleFactor = 1 for (const polygon of polygons) { const [left, top, right, bottom] = polygon.bounds(true) const [maxLeft, maxTop, maxRight, maxBottom] = maxBounds(polygon) const scaleX = (right - left) / (maxRight - maxLeft) const scaleY = (bottom - top) / (maxBottom - maxTop) scaleFactor = Math.min(scaleFactor, Math.max(scaleX, scaleY)) } return scaleFactor } /** processPath: scales the normalised cubics into the container and centres their exact bounds. */ function processPath(cubics, scale) { const scaled = cubics.map((c) => c.map((value) => value * scale)) const bounds = scaled.map((c) => cubicBounds(c, false)) const dx = CONTAINER / 2 - (Math.min(...bounds.map((b) => b[0])) + Math.max(...bounds.map((b) => b[2]))) / 2 const dy = CONTAINER / 2 - (Math.min(...bounds.map((b) => b[1])) + Math.max(...bounds.map((b) => b[3]))) / 2 return scaled.map((c) => c.map((value, i) => value + (i % 2 === 0 ? dx : dy))) } /** DrawScope.rotate about the container centre, clockwise on screen. */ function rotated(cubics, degrees) { const r = degrees * (Math.PI / 180) const [s, c, o] = [Math.sin(r), Math.cos(r), CONTAINER / 2] return cubics.map((cubic) => cubic.map((value, i) => { const [x, y] = i % 2 === 0 ? [value - o, cubic[i + 1] - o] : [cubic[i - 1] - o, value - o] return i % 2 === 0 ? o + c * x - s * y : o + s * x + c * y }), ) } // SpringSimulation.kt and the keySplines that stand in for it --------------------------- /** The spring's value `ms` after it starts from 0 at rest towards 1 (the underdamped branch of updateValues). */ function spring(ms) { const naturalFreq = Math.sqrt(SPRING.stiffness) const r = -SPRING.dampingRatio * naturalFreq const dampedFreq = naturalFreq * Math.sqrt(1 - SPRING.dampingRatio ** 2) const t = ms / 1000 return 1 + Math.exp(r * t) * (-Math.cos(dampedFreq * t) + ((-r * -1) / dampedFreq) * Math.sin(dampedFreq * t)) } /** When the spring peaks: half a period of its damped oscillation. */ const PEAK_TIME = (Math.PI / (Math.sqrt(SPRING.stiffness) * Math.sqrt(1 - SPRING.dampingRatio ** 2))) * 1000 const PEAK = spring(PEAK_TIME) function bezier(p1, p2, s) { const u = 1 - s return 3 * u * u * s * p1 + 3 * u * s * s * p2 + s * s * s } /** A keySpline's output at input `x`, finding the curve parameter by bisection (x is monotonic in it). */ function keySpline([x1, y1, x2, y2], x) { let lo = 0 let hi = 1 for (let i = 0; i < 50; i++) { const mid = (lo + hi) / 2 if (bezier(x1, x2, mid) < x) { lo = mid } else { hi = mid } } return bezier(y1, y2, (lo + hi) / 2) } /** * Least-squares keySpline for one stretch of the spring, mapped onto 0–1 in time and value, by a * deterministic pattern search over the four control values (all kept in 0–1, as SMIL requires). */ function fitKeySpline(fromMs, toMs, fromValue, toValue) { const samples = Array.from({ length: 101 }, (_, i) => { const x = i / 100 return [x, (spring(fromMs + x * (toMs - fromMs)) - fromValue) / (toValue - fromValue)] }) const error = (spline) => samples.reduce((sum, [x, y]) => sum + (keySpline(spline, x) - y) ** 2, 0) let spline = [1 / 3, 1 / 3, 2 / 3, 2 / 3] let best = error(spline) for (let step = 0.25; step > 1e-5; step /= 2) { let improved = true while (improved) { improved = false for (let i = 0; i < 4; i++) { for (const delta of [step, -step]) { const candidate = spline.with(i, Math.min(1, Math.max(0, spline[i] + delta))) const candidateError = error(candidate) if (candidateError < best - 1e-12) { spline = candidate best = candidateError improved = true } } } } } return spline } // SVG ----------------------------------------------------------------------------------- /** Precision of every coordinate: hundredths of a container unit. */ const PRECISION = 100 function decimal(value, places) { return String(Number(value.toFixed(places))).replace(/^(-?)0\./, '$1.') } /** Joins numbers, leaving out the separator where a sign or a second decimal point already splits them. */ function joinNumbers(numbers) { let out = '' for (const text of numbers) { const previous = out.slice(out.lastIndexOf(' ') + 1) if (out === '' || text.startsWith('-') || (text.startsWith('.') && previous.includes('.'))) { out += text } else { out += ` ${text}` } } return out } /** * Path data in hundredths relative to the current point, rounded as absolute positions first so the * rounding never drifts along the outline. `format` writes one count of hundredths. */ function pathData(cubics, format) { const points = cubics.map((c) => c.map((value) => Math.round(value * PRECISION))) const numbers = [] let [x, y] = [points[0][0], points[0][1]] for (const c of points) { numbers.push(c[2] - x, c[3] - y, c[4] - x, c[5] - y, c[6] - x, c[7] - y) ;[x, y] = [c[6], c[7]] } return `M${joinNumbers([points[0][0], points[0][1]].map(format))}c${joinNumbers(numbers.map(format))}Z` } /** Hundredths as decimals, for a path drawn in container units. */ const decimalHundredths = (n) => decimal(n / PRECISION, 2) /** * Hundredths as integers, for a path drawn inside `scale(.01)`: the same precision as two decimals, * and about a sixth shorter. */ const integerHundredths = (n) => String(n) /** The same command structure as a path of `count` cubics, collapsed onto the container centre. */ function collapsedPathData(count) { const centre = (CONTAINER / 2) * PRECISION return `M${centre} ${centre}c${Array(count * 6).fill(0).join(' ')}Z` } const time = (value) => decimal(value, 6) const splineText = (spline) => spline.map((value) => decimal(value, 3)).join(' ') const polygons = SEQUENCE.map((name) => SHAPES[name]().normalized()) const scale = CONTAINER * calculateScaleFactor(polygons) * (ACTIVE_SIZE / CONTAINER) const rise = fitKeySpline(0, PEAK_TIME, 0, PEAK) const settle = fitKeySpline(PEAK_TIME, MORPH_INTERVAL, PEAK, 1) const cycle = MORPH_INTERVAL * SEQUENCE.length const riseTime = time(PEAK_TIME / cycle) const collapseTime = decimal(Math.floor(((MORPH_INTERVAL - HANDOVER_LEAD) / cycle) * 1e7) / 1e7, 7) const morphPaths = SEQUENCE.map((_, index) => { const pairs = match(polygons[index], polygons[(index + 1) % SEQUENCE.length]) const values = [0, PEAK, 1].map((progress) => pathData(processPath(asCubics(pairs, progress), scale), integerHundredths), ) return ( `` ) }) /** Four morphs of +90° bring the shape back to its starting angle. */ const rotationMorphs = 4 const rotationKeyTimes = [] const rotationValues = [] for (let i = 0; i < rotationMorphs; i++) { const angle = QUARTER_ROTATION * (i + 1) rotationKeyTimes.push(i / rotationMorphs, (i + PEAK_TIME / MORPH_INTERVAL) / rotationMorphs) rotationValues.push(angle, angle + QUARTER_ROTATION * PEAK) } rotationKeyTimes.push(1) rotationValues.push(QUARTER_ROTATION * (rotationMorphs + 1)) const centre = `${CONTAINER / 2} ${CONTAINER / 2}` const morphRotation = `` const globalRotation = `` const firstFrame = rotated(processPath(polygons[0].cubics, scale), QUARTER_ROTATION) const open = `` const indicator = `${morphPaths.join('')}` const files = { 'indeterminate.svg': `${open}${globalRotation}${morphRotation}${indicator}\n`, 'static.svg': `${open}\n`, } mkdirSync(OUTPUT, { recursive: true }) for (const file of readdirSync(OUTPUT)) { if (file.endsWith('.svg')) { rmSync(join(OUTPUT, file)) } } for (const [name, svg] of Object.entries(files)) { writeFileSync(join(OUTPUT, name), svg) } console.log( `Wrote ${Object.entries(files) .map(([name, svg]) => `${name} (${Buffer.byteLength(svg)} bytes)`) .join(', ')} to ${OUTPUT}`, )