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
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:
co-authored by
Claude Opus 5
parent
b48e879254
commit
cd64f4f371
@@ -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 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}`,
|
||||
)
|
||||
+19
-13
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user