Add buttons, menus and the rest of M3 Expressive's actions
tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / browser (safari, webkit) (push) Successful in 2m17s
tests / lint (push) Successful in 59s
tests / feature (8.4) (push) Successful in 1m7s
tests / feature (8.5) (push) Successful in 1m0s
tests / browser (chrome, chromium) (push) Successful in 1m49s

<x-button> (label buttons, icon buttons and toggles in five sizes, with
filled, tonal, outlined, elevated and text variants in any colour role),
<x-tooltip>, <x-menu> with items, groups and separators, <x-button-group>,
<x-group> as a connected button group, <x-split-button>, <x-fab>,
<x-fab-menu> and <x-loading>. Sizes, colours and shapes come from
androidx Compose Material 3's tokens; the loading indicator ports its
Morph into SVG + SMIL.

Menus follow WAI-ARIA's menu button pattern on popovers placed by CSS
anchor positioning. Browser tests run in Chromium, Firefox and WebKit.
The showcase fetches the icon names on demand: inlined, they tripped
Pest's test server into HTTP 431s under Firefox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 06:09:28 +02:00
co-authored by Claude Opus 5
parent b48e879254
commit cd64f4f371
46 changed files with 2968 additions and 36 deletions
+697
View File
@@ -0,0 +1,697 @@
/**
* 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 <path>
* 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 01 in time and value, by a
* deterministic pattern search over the four control values (all kept in 01, 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 (
`<path><animate attributeName="d" begin="${MORPH_INTERVAL * index}ms" dur="${cycle}ms" repeatCount="indefinite" ` +
`calcMode="spline" keyTimes="0;${riseTime};${collapseTime};1" ` +
`keySplines="${splineText(rise)};${splineText(settle)};${splineText(COLLAPSE)}" ` +
`values="${[...values, collapsedPathData(pairs.length)].join(';')}"/></path>`
)
})
/** 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 =
`<animateTransform attributeName="transform" type="rotate" dur="${MORPH_INTERVAL * rotationMorphs}ms" repeatCount="indefinite" ` +
`calcMode="spline" keyTimes="${rotationKeyTimes.map(time).join(';')}" ` +
`keySplines="${Array(rotationMorphs).fill(`${splineText(rise)};${splineText(settle)}`).join(';')}" ` +
`values="${rotationValues.map((angle) => `${decimal(angle, 3)} ${centre}`).join(';')}"/>`
const globalRotation =
`<animateTransform attributeName="transform" type="rotate" from="0 ${centre}" to="360 ${centre}" ` +
`dur="${GLOBAL_ROTATION_DURATION}ms" repeatCount="indefinite"/>`
const firstFrame = rotated(processPath(polygons[0].cubics, scale), QUARTER_ROTATION)
const open = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${CONTAINER} ${CONTAINER}" fill="currentColor">`
const indicator = `<g transform="scale(${decimal(1 / PRECISION, 2)})">${morphPaths.join('')}</g>`
const files = {
'indeterminate.svg': `${open}<g>${globalRotation}<g>${morphRotation}${indicator}</g></g></svg>\n`,
'static.svg': `${open}<path d="${pathData(firstFrame, decimalHundredths)}"/></svg>\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}`,
)
+19 -13
View File
@@ -42,8 +42,9 @@
* limitations under the License.
* ---------------------------------------------------------------------------------------
*/
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const OUTPUT = 'resources/svg/shapes'
const VIEWBOX = 100
@@ -864,18 +865,23 @@ function pathData(polygon) {
return `${d}Z`
}
mkdirSync(OUTPUT, { recursive: true })
/** The geometry, for other build scripts (bin/loading-indicator.mjs); importing this module writes nothing. */
export { point, pointOnCurve, split, cubicBounds, RoundedPolygon, SHAPES }
for (const file of readdirSync(OUTPUT)) {
if (file.endsWith('.svg')) {
rmSync(join(OUTPUT, file))
if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
mkdirSync(OUTPUT, { recursive: true })
for (const file of readdirSync(OUTPUT)) {
if (file.endsWith('.svg')) {
rmSync(join(OUTPUT, file))
}
}
for (const [name, build] of Object.entries(SHAPES)) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VIEWBOX} ${VIEWBOX}" fill="currentColor"><path d="${pathData(fitted(build().normalized()))}"/></svg>\n`
writeFileSync(join(OUTPUT, `${name}.svg`), svg)
}
console.log(`Wrote ${Object.keys(SHAPES).length} shapes to ${OUTPUT}`)
}
for (const [name, build] of Object.entries(SHAPES)) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VIEWBOX} ${VIEWBOX}" fill="currentColor"><path d="${pathData(fitted(build().normalized()))}"/></svg>\n`
writeFileSync(join(OUTPUT, `${name}.svg`), svg)
}
console.log(`Wrote ${Object.keys(SHAPES).length} shapes to ${OUTPUT}`)