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
698 lines
26 KiB
JavaScript
698 lines
26 KiB
JavaScript
/**
|
||
* 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 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 (
|
||
`<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}`,
|
||
)
|