Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/odd-clocks-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"orbweaver-react": patch
"orbweaver-core": patch
"web": patch
---

Add CanvasGradientRenderer and update Canvas component to support gradient rendering
23 changes: 17 additions & 6 deletions packages/orbweaver-react/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
type Renderer,
CanvasAsciiRenderer,
type CanvasAsciiRendererOptions,
CanvasGradientRenderer,
type CanvasGradientRendererOptions,
type RotateParams,
RotateBehavior as RotateBehaviorCore,
type BobParams,
Expand Down Expand Up @@ -141,15 +143,24 @@ export function Orbweaver({ children, ...props }: OrbweaverProps) {
}

export function Canvas({ renderer, rendererOptions, ...props }: Omit<React.CanvasHTMLAttributes<HTMLCanvasElement>, "onMouseMove"> & {
renderer?: CanvasAsciiRenderer;
rendererOptions?: CanvasAsciiRendererOptions;
renderer?: Renderer;
rendererOptions?: CanvasAsciiRendererOptions | CanvasGradientRendererOptions;
onMouseMove?: (event: React.MouseEvent<HTMLCanvasElement>, orbweaver: OrbweaverCore) => void;
}) {
const { canvasRef, rendererRef, orbweaver, setInitialized, logger } = useOrbweaver();
useEffect(() => {
if (canvasRef.current) {
const oldRenderer = rendererRef.current;
const newRenderer = oldRenderer == null ? new CanvasAsciiRenderer(canvasRef.current, rendererOptions) : oldRenderer;
let newRenderer: Renderer | null = oldRenderer ?? null;
if (!newRenderer) {
if (renderer) {
newRenderer = renderer;
} else if (rendererOptions && typeof (rendererOptions as any).colors !== "undefined") {
newRenderer = new CanvasGradientRenderer(canvasRef.current, rendererOptions as CanvasGradientRendererOptions);
} else {
newRenderer = new CanvasAsciiRenderer(canvasRef.current, rendererOptions as CanvasAsciiRendererOptions);
}
}
rendererRef.current = newRenderer;
if (orbweaver && newRenderer) {
logger.log("setting renderer");
Expand All @@ -167,10 +178,10 @@ export function Canvas({ renderer, rendererOptions, ...props }: Omit<React.Canva
}, [renderer, rendererOptions, orbweaver, setInitialized, logger])
useEffect(() => {
if (canvasRef.current) {
const renderer = rendererRef.current;
if (renderer instanceof CanvasAsciiRenderer && renderer.getCanvas() !== canvasRef.current) {
const r = rendererRef.current as any;
if (r && typeof r.getCanvas === "function" && r.getCanvas() !== canvasRef.current && typeof r.setCanvas === "function") {
logger.log("setting canvas");
renderer.setCanvas(canvasRef.current);
r.setCanvas(canvasRef.current);
}
}
})
Expand Down
4 changes: 4 additions & 0 deletions packages/orbweaver/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
CanvasAsciiRenderer,
type CanvasAsciiRendererOptions,
type RendererOptions,
CanvasGradientRenderer,
type CanvasGradientRendererOptions,
} from "./renderer.js";
import {
Behavior,
Expand Down Expand Up @@ -540,6 +542,7 @@ export type {
Renderer,
RendererOptions,
CanvasAsciiRendererOptions,
CanvasGradientRendererOptions,
RotateParams,
BobParams,
OrbitParams,
Expand All @@ -554,6 +557,7 @@ export {
OrbitBehavior,
Channels,
CanvasAsciiRenderer,
CanvasGradientRenderer,
CrosshairBehavior,
};

Expand Down
294 changes: 294 additions & 0 deletions packages/orbweaver/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,297 @@ export class CanvasAsciiRenderer implements Renderer {
this.resizeListeners = [];
}
}

export type CanvasGradientRendererOptions = RendererOptions & {
/**
* Ordered list of CSS colors (hex, rgb(a), hsl(a)).
* Intensity 0 maps to the first color, 1 maps to the last.
*/
colors?: string[];
};

type RGBA = { r: number; g: number; b: number; a: number };

function clamp01(v: number): number {
return Math.max(0, Math.min(1, v));
}

function parseHexColor(input: string): RGBA | null {
const s = input.trim().toLowerCase();
if (!s.startsWith("#")) return null;
let hex = s.slice(1);
if (hex.length === 3) {
const r = parseInt(hex[0]! + hex[0]!, 16);
const g = parseInt(hex[1]! + hex[1]!, 16);
const b = parseInt(hex[2]! + hex[2]!, 16);
return { r, g, b, a: 255 };
}
if (hex.length === 4) {
const r = parseInt(hex[0]! + hex[0]!, 16);
const g = parseInt(hex[1]! + hex[1]!, 16);
const b = parseInt(hex[2]! + hex[2]!, 16);
const a = parseInt(hex[3]! + hex[3]!, 16);
return { r, g, b, a };
}
if (hex.length === 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return { r, g, b, a: 255 };
}
if (hex.length === 8) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = parseInt(hex.slice(6, 8), 16);
return { r, g, b, a };
}
return null;
}

function parseRgbColor(input: string): RGBA | null {
const s = input.trim();
// rgb(255,0,0) or rgba(255,0,0,0.5)
const rgb = /^rgba?\(([^)]+)\)$/i.exec(s);
if (!rgb) return null;
const parts = rgb[1]!.split(',').map((p) => p.trim());
if (parts.length < 3) return null;
const to255 = (v: string): number => {
if (v.endsWith('%')) {
const n = parseFloat(v.slice(0, -1));
return Math.round(clamp01(n / 100) * 255);
}
return Math.max(0, Math.min(255, Math.round(parseFloat(v))));
};
const r = to255(parts[0]!);
const g = to255(parts[1]!);
const b = to255(parts[2]!);
let a = 255;
if (parts.length >= 4) {
const av = parts[3]!;
const alpha = av.endsWith('%')
? clamp01(parseFloat(av) / 100)
: clamp01(parseFloat(av));
a = Math.round(alpha * 255);
}
return { r, g, b, a };
}

function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
// h [0,360), s,l [0,1]
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r1 = 0, g1 = 0, b1 = 0;
if (h < 60) { r1 = c; g1 = x; b1 = 0; }
else if (h < 120) { r1 = x; g1 = c; b1 = 0; }
else if (h < 180) { r1 = 0; g1 = c; b1 = x; }
else if (h < 240) { r1 = 0; g1 = x; b1 = c; }
else if (h < 300) { r1 = x; g1 = 0; b1 = c; }
else { r1 = c; g1 = 0; b1 = x; }
return {
r: Math.round((r1 + m) * 255),
g: Math.round((g1 + m) * 255),
b: Math.round((b1 + m) * 255),
};
}

function parseHslColor(input: string): RGBA | null {
const s = input.trim();
// hsl(120, 100%, 50%) or hsla(120, 100%, 50%, 0.5)
const hsl = /^hsla?\(([^)]+)\)$/i.exec(s);
if (!hsl) return null;
const parts = hsl[1]!.split(',').map((p) => p.trim());
if (parts.length < 3) return null;
const hRaw = parts[0]!;
const sRaw = parts[1]!;
const lRaw = parts[2]!;
const h = ((parseFloat(hRaw) % 360) + 360) % 360;
const sVal = clamp01(parseFloat(sRaw) / (sRaw.endsWith('%') ? 100 : 1));
const lVal = clamp01(parseFloat(lRaw) / (lRaw.endsWith('%') ? 100 : 1));
const { r, g, b } = hslToRgb(h, sVal, lVal);
let a = 255;
if (parts.length >= 4) {
const av = parts[3]!;
const alpha = av.endsWith('%')
? clamp01(parseFloat(av) / 100)
: clamp01(parseFloat(av));
a = Math.round(alpha * 255);
}
return { r, g, b, a };
}

function parseColor(input: string): RGBA | null {
return parseHexColor(input) || parseRgbColor(input) || parseHslColor(input);
}

function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}

function rgbaToCss({ r, g, b, a }: RGBA): string {
const alpha = a / 255;
return `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`;
}

export class CanvasGradientRenderer implements Renderer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private dpr: number;
private cols: number;
private rows: number;
private background: string;
private colorStops: RGBA[];
private resizeListeners: Array<() => void> = [];
private offscreen: HTMLCanvasElement;
private offCtx: CanvasRenderingContext2D;

constructor(canvas: HTMLCanvasElement, options?: CanvasGradientRendererOptions) {
this.canvas = canvas;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D canvas context not available");
this.ctx = ctx;
this.dpr = window.devicePixelRatio || 1;
this.cols = options?.cols ?? 80;
this.rows = options?.rows ?? 30;
this.background = options?.background ?? "#000000";
const defaults = ["#000000", "#ffffff"]; // fallback 2-stop gradient
const inputs = options?.colors && options.colors.length >= 2 ? options.colors : defaults;
this.colorStops = inputs.map((c) => parseColor(c) ?? { r: 0, g: 0, b: 0, a: 255 });

// offscreen buffer used for smooth upscaling
this.offscreen = document.createElement("canvas");
this.offscreen.width = this.cols;
this.offscreen.height = this.rows;
const off = this.offscreen.getContext("2d");
if (!off) throw new Error("2D canvas context not available (offscreen)");
this.offCtx = off;

this.configureCanvas();
}

getCanvas() {
return this.canvas;
}

setCanvas(canvas: HTMLCanvasElement) {
this.canvas = canvas;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D canvas context not available");
this.ctx = ctx;
this.configureCanvas();
}

setBackground(background: string) {
this.background = background;
}

setColors(colors: string[]) {
if (!colors || colors.length < 2) return;
this.colorStops = colors.map((c) => parseColor(c) ?? { r: 0, g: 0, b: 0, a: 255 });
}

private configureCanvas() {
const { canvas } = this;
const rect = canvas.getBoundingClientRect();
const cssWidth = rect.width || 800;
const cssHeight = rect.height || 450;

canvas.width = Math.max(1, Math.floor(cssWidth * this.dpr));
canvas.height = Math.max(1, Math.floor(cssHeight * this.dpr));

this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.scale(this.dpr, this.dpr);
}

private resize() {
this.configureCanvas();
for (const listener of this.resizeListeners) listener();
}

clear() {
const rect = this.canvas.getBoundingClientRect();
this.ctx.fillStyle = this.background;
this.ctx.fillRect(0, 0, rect.width, rect.height);
}

private sampleColor(t: number): RGBA {
const n = this.colorStops.length;
if (n === 0) return { r: 0, g: 0, b: 0, a: 255 };
if (n === 1) return this.colorStops[0]!;
const clamped = clamp01(t);
const scaled = clamped * (n - 1);
const i0 = Math.floor(scaled);
const i1 = Math.min(n - 1, i0 + 1);
const localT = scaled - i0;
const c0 = this.colorStops[i0]!;
const c1 = this.colorStops[i1]!;
return {
r: Math.round(lerp(c0.r, c1.r, localT)),
g: Math.round(lerp(c0.g, c1.g, localT)),
b: Math.round(lerp(c0.b, c1.b, localT)),
a: Math.round(lerp(c0.a, c1.a, localT)),
};
}

private drawGradientGrid(intensityAt: (col: number, row: number) => number) {
const rect = this.canvas.getBoundingClientRect();

// 1) Shade offscreen grid (cols x rows)
const imageData = this.offCtx.createImageData(this.cols, this.rows);
const data = imageData.data;
let p = 0;
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
const intensity = Math.min(1, Math.max(0, intensityAt(c, r)));
const color = this.sampleColor(intensity);
data[p++] = color.r;
data[p++] = color.g;
data[p++] = color.b;
data[p++] = color.a; // already in 0..255
}
}
this.offCtx.putImageData(imageData, 0, 0);

// 2) Clear and upscale with smoothing
this.clear();
this.ctx.imageSmoothingEnabled = true;
(this.ctx as any).imageSmoothingQuality = "high";
this.ctx.drawImage(this.offscreen, 0, 0, rect.width, rect.height);
}

// Renderer interface
getPixelSize(): { width: number; height: number } {
const rect = this.canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
console.warn("Canvas is not visible:", rect);
}
return { width: rect.width, height: rect.height };
}

getGridSize(): { cols: number; rows: number } {
return { cols: this.cols, rows: this.rows };
}

render(intensityAt: (col: number, row: number) => number): void {
this.drawGradientGrid(intensityAt);
}

onResize(callback: () => void): () => void {
if (this.resizeListeners.length === 0) {
// Lazily attach a single window listener
window.addEventListener("resize", () => this.resize());
}
this.resizeListeners.push(callback);
return () => {
this.resizeListeners = this.resizeListeners.filter((cb) => cb !== callback);
};
}

destroy() {
this.resizeListeners.forEach((listener) => {
window.removeEventListener("resize", listener);
});
this.resizeListeners = [];
}
}
Loading