~koehr/donatello

c40320f1aee126af393f64c84c80fc112fad57c8 — Norman K√∂hring 1 year, 3 months ago b45bf62 main
allow color transition
4 files changed, 117 insertions(+), 9 deletions(-)

M package.json
M src/demo.ts
M src/util.ts
M yarn.lock
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"