M package.json => package.json +5 -1
@@ 22,7 22,11 @@
"type-check": "tsc"
},
"devDependencies": {
+ "@types/color-string": "^1.5.2",
"typescript": "^5.0.0",
"vite": "^4.2.0"
+ },
+ "dependencies": {
+ "color-string": "^1.9.1"
}
-}>
\ No newline at end of file
+}
M src/demo.ts => src/demo.ts +1 -1
@@ 16,7 16,7 @@ document.getElementById('demo01')!.append(demo01.getNode())
new Circle(
demo01,
{ r: 30, fill: "#FFF", stroke: "#A00", strokeWidth: 5 }
-).animate({ r: 200, cx: 400 }, 2000)
+).animate({ r: 200, cx: 400, fill: '#900' }, 2000)
const demo02 = new Container('test', 800, 600)
new Circle(
M src/util.ts => src/util.ts +81 -7
@@ 1,3 1,5 @@
+import { get as parseColor, to as toColor, type Color } from 'color-string'
+
/** reasonable attribute defaults for many attributes */
export const AttributeDefaults = {
blur: 0,
@@ 40,8 42,9 @@ export const AttributeDefaults = {
y: 0,
}
-export type Attributes = Record<string, number | string>
+export type Attributes = Record<string, number | string | Color>
export type NumberAttributes = Record<string, number>
+export type ColorAttributes = Record<string, Color>
export type AttributeName = keyof typeof AttributeDefaults
export type AttributeType = 'number' | 'color' | 'path' | 'transform'
@@ 82,15 85,26 @@ export function ensureKebabCase (s: string): string {
/** applies attributes, transforming from snakeCase to kebab-case as necessary */
export function applyAttributes(el: Element, attrs: Attributes) {
for (const attr in attrs) {
- const kebabAttr = ensureKebabCase(attr)
- const value = `${attrs[attr]}`
+ const kebabAttr = ensureKebabCase(attr) as AttributeName
+ const origValue = attrs[attr]
+ let value: string
+
+ switch (AnimatedAttrs[kebabAttr]) {
+ case 'color':
+ value = Array.isArray(origValue) ? toColor.hex(origValue) : `${origValue}`
+ break
+ // TODO: support transform and path
+ default:
+ value = `${attrs[attr]}`
+ }
+
el.setAttribute(kebabAttr, value)
}
}
-export function animateNumberAttr(el: Element, toAttrs: NumberAttributes, duration: number) {
- const intermediate = {} as NumberAttributes // start attributes
- const singleStep = {} as NumberAttributes // attributes set each animation step
+function animateNumberAttrs(el: Element, toAttrs: NumberAttributes, duration: number) {
+ const intermediate = {} as NumberAttributes // attributes to apply
+ const singleStep = {} as NumberAttributes // how much to add each step
for (const attr in toAttrs) {
const fromAttr = Number(el.getAttribute(attr)) ?? AttributeDefaults[attr as AttributeName]
@@ 119,13 133,73 @@ export function animateNumberAttr(el: Element, toAttrs: NumberAttributes, durati
}
requestAnimationFrame(step)
}
+
+function animateColorAttrs(el: Element, toAttrs: ColorAttributes, duration: number) {
+ const intermediate = {} as ColorAttributes // attributes to apply
+ const singleStep = {} as ColorAttributes // how much to add each step
+
+ for (const attr in toAttrs) {
+ const value = `${
+ el.getAttribute(attr) ?? AttributeDefaults[attr as AttributeName]
+ }`
+ const fromAttr = parseColor.rgb(value)
+ const toAttr = toAttrs[attr]
+
+ intermediate[attr] = [...fromAttr]
+ singleStep[attr] = [
+ (toAttr[0] - fromAttr[0]) / duration,
+ (toAttr[1] - fromAttr[1]) / duration,
+ (toAttr[2] - fromAttr[2]) / duration,
+ (toAttr[3] - fromAttr[3]) / duration,
+ ]
+ }
+
+ let start: number
+ let lastTimestamp: number
+
+ function step(timestamp: number) {
+ if (start === undefined) start = timestamp
+ if (lastTimestamp === undefined) lastTimestamp = timestamp
+ const elapsed = timestamp - start
+ const tickLength = timestamp - lastTimestamp
+
+ for (const attr in intermediate) {
+ intermediate[attr][0] += singleStep[attr][0] * tickLength
+ intermediate[attr][1] += singleStep[attr][1] * tickLength
+ intermediate[attr][2] += singleStep[attr][2] * tickLength
+ intermediate[attr][3] += singleStep[attr][3] * tickLength
+
+ for (const index in intermediate[attr]) {
+ const value = intermediate[attr][index]
+ const target = toAttrs[attr][index]
+ if (singleStep[attr][index] < 0 && value < target) intermediate[attr][index] = target
+ else if (singleStep[attr][index] > 0 && value > target) intermediate[attr][index] = target
+ }
+ }
+ applyAttributes(el, intermediate)
+
+ if (elapsed > duration) return
+ requestAnimationFrame(step)
+ lastTimestamp = timestamp
+ }
+ requestAnimationFrame(step)
+}
+
export function animateAttributes(el: Element, toAttrs: Attributes, duration: number) {
// TODO: support more attribute types
const numberAttrs = {} as NumberAttributes
+ const colorAttrs = {} as ColorAttributes
+
for (const attr in toAttrs) {
const attrType = AnimatedAttrs[attr as AttributeName]
if (attrType === 'number') numberAttrs[attr] = Number(toAttrs[attr])
+ else if (attrType === 'color') {
+ const attrValue = `${toAttrs[attr]}`
+ const colorAttr = parseColor.rgb(attrValue)
+ if (colorAttr !== null) colorAttrs[attr] = colorAttr
+ }
}
- animateNumberAttr(el, numberAttrs, duration)
+ animateNumberAttrs(el, numberAttrs, duration)
+ animateColorAttrs(el, colorAttrs, duration)
}
M yarn.lock => yarn.lock +30 -0
@@ 112,6 112,24 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.12.tgz#3a11d13e9a5b0c05db88991b234d8baba1f96487"
integrity sha512-JOOxw49BVZx2/5tW3FqkdjSD/5gXYeVGPDcB0lvap0gLQshkh1Nyel1QazC+wNxus3xPlsYAgqU1BUmrmCvWtw==
+"@types/color-string@^1.5.2":
+ version "1.5.2"
+ resolved "https://registry.yarnpkg.com/@types/color-string/-/color-string-1.5.2.tgz#cdb9879b6feca2aa27846adfc3988ef2413ee138"
+ integrity sha512-hAhTmfFYVdzgsKwpC9Flc6h9Do64PhKoNxy3YxE0ze+0LIh3a7TrDQAxiujmANQbDRDgGduEz+9sMS+Zd+J7hA==
+
+color-name@^1.0.0:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+ integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
+color-string@^1.9.1:
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4"
+ integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==
+ dependencies:
+ color-name "^1.0.0"
+ simple-swizzle "^0.2.2"
+
esbuild@^0.17.5:
version "0.17.12"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.12.tgz#2ad7523bf1bc01881e9d904bc04e693bd3bdcf2f"
@@ 157,6 175,11 @@ has@^1.0.3:
dependencies:
function-bind "^1.1.1"
+is-arrayish@^0.3.1:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
+ integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
+
is-core-module@^2.9.0:
version "2.11.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144"
@@ 204,6 227,13 @@ rollup@^3.18.0:
optionalDependencies:
fsevents "~2.3.2"
+simple-swizzle@^0.2.2:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
+ integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==
+ dependencies:
+ is-arrayish "^0.3.1"
+
source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"