Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/common/installShaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ export async function installShaders(stage: Stage, renderMode: string) {
stage.shManager.registerShaderType('HolePunch', shaders.HolePunch);
stage.shManager.registerShaderType('RadialGradient', shaders.RadialGradient);
stage.shManager.registerShaderType('LinearGradient', shaders.LinearGradient);
stage.shManager.registerShaderType('RadialProgress', shaders.RadialProgress);
}
121 changes: 121 additions & 0 deletions examples/tests/shader-radial-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { ExampleSettings } from '../common/ExampleSettings.js';

export async function automation(settings: ExampleSettings) {
await test(settings);
await settings.snapshot();
}

export default async function test({ renderer, testRoot }: ExampleSettings) {
// 1. Fill-up animation: progress 0 → 1, looping. Cyan ring on a dim track.
const fillRing = renderer.createNode({
x: 40,
y: 40,
w: 300,
h: 300,
color: 0x00000000,
shader: renderer.createShader('RadialProgress', {
width: 16,
progress: 0,
colors: [0x4aff80ff],
trackColor: 0x1c3a2aff,
}),
parent: testRoot,
});

// fillRing
// .animate(
// { shaderProps: { progress: 1 } },
// { duration: 2000, loop: true, easing: 'linear' },
// )
// .start();

// 2. Countdown animation: progress 1 → 0, looping. Matches the reference
// screenshot recipe (blue arc, dim blue track).
const countdownRing = renderer.createNode({
x: 380,
y: 40,
w: 300,
h: 300,
color: 0x00000000,
shader: renderer.createShader('RadialProgress', {
width: 14,
progress: 1,
colors: [0x4aa3ffff],
trackColor: 0x1f3a5cff,
}),
parent: testRoot,
});

// countdownRing
// .animate(
// { shaderProps: { progress: 0 } },
// { duration: 2000, loop: true, easing: 'linear' },
// )
// .start();

// 3. Multi-stop gradient swept along the arc, 50% progress
renderer.createNode({
x: 720,
y: 40,
w: 300,
h: 300,
color: 0x00000000,
shader: renderer.createShader('RadialProgress', {
width: 24,
progress: 0.5,
colors: [0x4aa3ffff, 0x9ad6ffff, 0xffffffff],
trackColor: 0x1f3a5c66,
}),
parent: testRoot,
});

// 4. Counter-clockwise, butt caps, partial sweep
renderer.createNode({
x: 40,
y: 380,
w: 300,
h: 300,
color: 0x00000000,
shader: renderer.createShader('RadialProgress', {
width: 20,
progress: 0.35,
direction: -1,
cap: 0,
colors: [0xff66aaff, 0xffaa66ff],
trackColor: 0x33333366,
}),
parent: testRoot,
});

// 5. Full ring with multi-stop sweep (no track)
renderer.createNode({
x: 380,
y: 380,
w: 300,
h: 300,
color: 0x00000000,
shader: renderer.createShader('RadialProgress', {
width: 18,
progress: 1,
colors: [0xff0080ff, 0xffaa00ff, 0x00ffaaff, 0xff0080ff],
}),
parent: testRoot,
});

// 6. Custom startAngle (9 o'clock), 80% progress
renderer.createNode({
x: 720,
y: 380,
w: 300,
h: 300,
color: 0x00000000,
shader: renderer.createShader('RadialProgress', {
width: 12,
progress: 0.8,
startAngle: Math.PI,
colors: [0xffffffff],
trackColor: 0x33333366,
}),
parent: testRoot,
});
}
1 change: 1 addition & 0 deletions exports/canvas-shaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export { Shadow } from '../src/core/shaders/canvas/Shadow.js';
export { HolePunch } from '../src/core/shaders/canvas/HolePunch.js';
export { LinearGradient } from '../src/core/shaders/canvas/LinearGradient.js';
export { RadialGradient } from '../src/core/shaders/canvas/RadialGradient.js';
export { RadialProgress } from '../src/core/shaders/canvas/RadialProgress.js';
1 change: 1 addition & 0 deletions exports/webgl-shaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export { Shadow } from '../src/core/shaders/webgl/Shadow.js';
export { HolePunch } from '../src/core/shaders/webgl/HolePunch.js';
export { LinearGradient } from '../src/core/shaders/webgl/LinearGradient.js';
export { RadialGradient } from '../src/core/shaders/webgl/RadialGradient.js';
export { RadialProgress } from '../src/core/shaders/webgl/RadialProgress.js';
export { Default } from '../src/core/shaders/webgl/Default.js';
113 changes: 113 additions & 0 deletions src/core/shaders/canvas/RadialProgress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { CanvasShaderType } from '../../renderers/canvas/CanvasShaderNode.js';
import {
RadialProgressTemplate,
type RadialProgressProps,
} from '../templates/RadialProgressTemplate.js';

export interface ComputedRadialProgressValues {
cx: number;
cy: number;
radius: number;
colorChannels: number[][]; // [r,g,b,a] per stop color, 0..255 (a in 0..1)
trackColor: string | null;
}

const SEGMENTS = 64;

function lerpColor(a: number[], b: number[], t: number): string {
const r = Math.round(a[0]! + (b[0]! - a[0]!) * t);
const g = Math.round(a[1]! + (b[1]! - a[1]!) * t);
const bl = Math.round(a[2]! + (b[2]! - a[2]!) * t);
const al = a[3]! + (b[3]! - a[3]!) * t;
return `rgba(${r},${g},${bl},${al})`;
}

function colorAt(channels: number[][], stops: number[], t: number): string {
const last = channels.length - 1;
if (t <= stops[0]!) return lerpColor(channels[0]!, channels[0]!, 0);
if (t >= stops[last]!) return lerpColor(channels[last]!, channels[last]!, 0);
for (let i = 0; i < last; i++) {
const left = stops[i]!;
const right = stops[i + 1]!;
if (t >= left && t <= right) {
const lt = (t - left) / (right - left);
return lerpColor(channels[i]!, channels[i + 1]!, lt);
}
}
return lerpColor(channels[last]!, channels[last]!, 0);
}

function toChannels(rgba: number): number[] {
return [
(rgba >>> 24) & 0xff,
(rgba >>> 16) & 0xff,
(rgba >>> 8) & 0xff,
(rgba & 0xff) / 255,
];
}

export const RadialProgress: CanvasShaderType<
RadialProgressProps,
ComputedRadialProgressValues
> = {
props: RadialProgressTemplate.props,
update(node) {
const props = this.props!;
const autoRadius = Math.min(node.w, node.h) * 0.5 - props.width * 0.5;
const radius = props.radius > 0 ? props.radius : autoRadius;

const colorChannels: number[][] = [];
for (let i = 0; i < props.colors.length; i++) {
colorChannels.push(toChannels(props.colors[i]!));
}

this.computed = {
cx: node.w * 0.5,
cy: node.h * 0.5,
radius,
colorChannels,
trackColor:
props.trackColor !== 0 ? this.toColorString(props.trackColor) : null,
};
},
render(ctx, node, renderContext) {
renderContext();
const { cx, cy, radius, colorChannels, trackColor } = this
.computed as ComputedRadialProgressValues;
const { tx, ty } = node.globalTransform!;
const props = this.props!;
const { width, progress, startAngle, direction, cap } = props;
const stops = props.stops;

const ax = tx + cx;
const ay = ty + cy;

ctx.lineWidth = width;
ctx.lineCap = cap === 1 ? 'round' : 'butt';

if (trackColor !== null) {
ctx.strokeStyle = trackColor;
ctx.beginPath();
ctx.arc(ax, ay, radius, 0, Math.PI * 2);
ctx.stroke();
}

if (progress <= 0) return;

const sweep = Math.PI * 2 * progress * direction;
const step = sweep / SEGMENTS;
// Overlap segments by a tiny amount so the seams don't show on canvas AA
const overlap = Math.abs(step) * 0.02;

for (let i = 0; i < SEGMENTS; i++) {
const t = i / (SEGMENTS - 1);
ctx.strokeStyle = colorAt(colorChannels, stops, t);
ctx.beginPath();
const a0 = startAngle + step * i;
const a1 =
startAngle + step * (i + 1) + (direction === 1 ? overlap : -overlap);
ctx.arc(ax, ay, radius, a0, a1, direction === -1);
ctx.stroke();
}
},
};
125 changes: 125 additions & 0 deletions src/core/shaders/templates/RadialProgressTemplate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest';
import {
RadialProgressTemplate,
type RadialProgressProps,
} from './RadialProgressTemplate.js';
import {
isAdvancedShaderProp,
resolveShaderProps,
} from '../../renderers/CoreShaderNode.js';

function resolve(input: Partial<RadialProgressProps>): RadialProgressProps {
const props = { ...input } as Record<string, unknown>;
resolveShaderProps(props, RadialProgressTemplate.props as never);
return props as unknown as RadialProgressProps;
}

describe('RadialProgressTemplate', () => {
describe('progress', () => {
const cfg = RadialProgressTemplate.props!.progress;
if (!isAdvancedShaderProp(cfg))
throw new Error('progress should be advanced');

it('clamps values below 0 to 0', () => {
expect(cfg.resolve!(-0.5, {} as never)).toBe(0);
});

it('clamps values above 1 to 1', () => {
expect(cfg.resolve!(2, {} as never)).toBe(1);
});

it('passes through in-range values', () => {
expect(cfg.resolve!(0.42, {} as never)).toBe(0.42);
});

it('returns default when undefined', () => {
expect(cfg.resolve!(undefined as never, {} as never)).toBe(1);
});
});

describe('colors', () => {
const cfg = RadialProgressTemplate.props!.colors;
if (!isAdvancedShaderProp(cfg))
throw new Error('colors should be advanced');

it('falls back to default on undefined', () => {
expect(cfg.resolve!(undefined as never, {} as never)).toEqual([
0xffffffff,
]);
});

it('falls back to default on empty array', () => {
expect(cfg.resolve!([] as never, {} as never)).toEqual([0xffffffff]);
});

it('passes through user-provided colors', () => {
const input = [0xff0000ff, 0x00ff00ff];
expect(cfg.resolve!(input, {} as never)).toEqual(input);
});
});

describe('stops', () => {
const cfg = RadialProgressTemplate.props!.stops;
if (!isAdvancedShaderProp(cfg)) throw new Error('stops should be advanced');

it('auto-distributes when omitted (n=3)', () => {
const out = cfg.resolve!(
undefined as never,
{
colors: [1, 2, 3],
} as never,
);
expect(out).toEqual([0, 0.5, 1]);
});

it('auto-distributes when length mismatches', () => {
const out = cfg.resolve!(
[0, 1] as never,
{
colors: [1, 2, 3],
} as never,
);
expect(out).toEqual([0, 0.5, 1]);
});

it('handles single color (n=1) without NaN', () => {
const out = cfg.resolve!(undefined as never, { colors: [1] } as never);
expect(out).toEqual([0]);
});

it('passes through valid stops', () => {
const out = cfg.resolve!(
[0, 0.3, 1] as never,
{
colors: [1, 2, 3],
} as never,
);
expect(out).toEqual([0, 0.3, 1]);
});
});

describe('defaults via resolveShaderProps', () => {
it('applies all defaults when no props given', () => {
const r = resolve({});
expect(r.width).toBe(8);
expect(r.radius).toBe(0);
expect(r.progress).toBe(1);
expect(r.startAngle).toBeCloseTo(-Math.PI / 2);
expect(r.direction).toBe(1);
expect(r.colors).toEqual([0xffffffff]);
expect(r.stops).toEqual([0]);
expect(r.trackColor).toBe(0x00000000);
expect(r.cap).toBe(1);
});

it('clamps progress through full resolution path', () => {
const r = resolve({ progress: 1.5 });
expect(r.progress).toBe(1);
});

it('auto-distributes stops through full resolution path', () => {
const r = resolve({ colors: [0xff0000ff, 0x00ff00ff, 0x0000ffff] });
expect(r.stops).toEqual([0, 0.5, 1]);
});
});
});
Loading
Loading