gitbreathing

gitlab

gitBreathing: A deterministic procedural contribution-art generator that transforms GitHub and GitLab activity into animated Demon Slayer-inspired breathing effects, with customizable SVG and GIF output.

0 stars0 forksupdated 2d agodefault branch: main
clonehttps://gitlab.com/bitzzdev/gitbreathing.git
@ main
import { STYLES, type StyleId } from './styles';
export type Background = 'transparent' | 'dark';
export type RenderOptions = {
size: number;
styles: StyleId[];
power: number;
phase?: number;
background?: Background;
seed?: string;
styleWeights?: Partial<Record<StyleId, number>>;
styleColors?: Partial<Record<StyleId, string[]>>;
effects?: { glow?: boolean; particles?: boolean; secondaryCuts?: boolean; motion?: boolean; particleDensity?: number; strokeScale?: number };
};
type RGB = [number, number, number];
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const rgb = (hex: string): RGB => {
const value = hex.replace('#', '');
const full = value.length === 3 ? value.split('').map((x) => x + x).join('') : value;
const n = Number.parseInt(full, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const hash = (text: string) => {
let h = 2166136261;
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
return h >>> 0;
};
const randomAt = (seed: number, index: number) => {
let x = (seed + Math.imul(index + 1, 0x9e3779b1)) >>> 0;
x ^= x >>> 16; x = Math.imul(x, 0x7feb352d); x ^= x >>> 15; x = Math.imul(x, 0x846ca68b); x ^= x >>> 16;
return (x >>> 0) / 4294967296;
};
export function renderSlash(options: RenderOptions): Uint8ClampedArray {
const size = clamp(Math.round(options.size), 8, 128);
const power = clamp(options.power, 0, 1);
const phase = options.phase ?? 0;
const effects = options.effects ?? {};
const particleDensity = clamp(effects.particleDensity ?? 1, 0, 3);
const strokeScale = clamp(effects.strokeScale ?? 1, 0.5, 3);
const background = options.background ?? 'transparent';
const styles: StyleId[] = options.styles.length ? options.styles : ['sun'];
const seed = hash(options.seed ?? 'gitbreathing');
const out = new Uint8ClampedArray(size * size * 4);
if (background === 'dark') {
for (let i = 0; i < size * size; i++) {
out[i * 4] = 9; out[i * 4 + 1] = 11; out[i * 4 + 2] = 18; out[i * 4 + 3] = 255;
}
}
const put = (x: number, y: number, color: RGB, alpha = 255) => {
x = Math.round(x); y = Math.round(y);
if (x < 0 || y < 0 || x >= size || y >= size || alpha <= 0) return;
const i = (y * size + x) * 4;
const oldA = out[i + 3] / 255;
const a = clamp(alpha / 255, 0, 1);
const nextA = a + oldA * (1 - a);
if (nextA <= 0) return;
out[i] = Math.round((color[0] * a + out[i] * oldA * (1 - a)) / nextA);
out[i + 1] = Math.round((color[1] * a + out[i + 1] * oldA * (1 - a)) / nextA);
out[i + 2] = Math.round((color[2] * a + out[i + 2] * oldA * (1 - a)) / nextA);
out[i + 3] = Math.round(nextA * 255);
};
const line = (x0: number, y0: number, x1: number, y1: number, radius: number, color: RGB, alpha = 255) => {
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)) * 1.5));
for (let i = 0; i <= steps; i++) {
const t = i / steps; const x = x0 + (x1 - x0) * t; const y = y0 + (y1 - y0) * t;
for (let yy = -radius; yy <= radius; yy++) for (let xx = -radius; xx <= radius; xx++) {
if (xx * xx + yy * yy <= radius * radius + 0.8) put(x + xx, y + yy, color, alpha);
}
}
};
// The activity curve controls both the slash body and the amount of surrounding detail.
const bodyRadius = Math.max(1, Math.round((0.7 + power * size * 0.025) * strokeScale));
styles.forEach((id, layer) => {
const definition = STYLES[id];
if (!definition) return;
const palette = (options.styleColors?.[id]?.length ? options.styleColors[id]! : [...definition.colors]).map(rgb);
const weight = clamp(options.styleWeights?.[id] ?? 1, 0, 3);
if (weight === 0) return;
const layerScale = 1 / (1 + layer * 0.22);
const startX = size * (0.12 - layer * 0.012);
const endX = size * (0.88 + layer * 0.012);
const baseY = size * (0.78 - layer * 0.025);
const amplitude = size * (0.12 + 0.15 * power) * layerScale * weight;
const thickness = Math.max(1, Math.round(bodyRadius * layerScale * Math.min(1.35, weight)));
const point = (t: number): [number, number] => {
const diagonal = -t * size * 0.52;
const wave = Math.sin(t * Math.PI * (definition.shape === 'wave' ? 2.2 : 1.15) + layer * 0.7) * amplitude * 0.23;
const bend = Math.sin(t * Math.PI) * amplitude;
const motion = effects.motion === false ? 0 : Math.sin(t * 10 + phase * Math.PI * 2 + layer) * size * 0.012 * power;
return [startX + (endX - startX) * t, baseY + diagonal - bend + wave + motion];
};
const samples = Math.max(12, Math.round(size * (0.18 + power * 0.82)));
for (let i = 0; i < samples; i++) {
const a = point(i / samples); const b = point((i + 1) / samples);
if (effects.glow !== false) line(a[0], a[1], b[0], b[1], thickness + 1, palette[2] ?? palette[0], 65);
line(a[0], a[1], b[0], b[1], thickness, palette[0]);
if (power > 0.12) line(a[0], a[1] - 1, b[0], b[1] - 1, Math.max(1, thickness - 1), palette[1] ?? palette[0], 220);
}
const particleCount = effects.particles === false ? 0 : Math.floor((2 + power * 46 * layerScale * weight) * particleDensity);
for (let i = 0; i < particleCount; i++) {
const r1 = randomAt(seed ^ hash(id), i + layer * 97);
const r2 = randomAt(seed ^ hash(id), i + 991 + layer * 97);
const t = 0.04 + r1 * 0.92;
const [x, y] = point(t);
const spread = size * (0.012 + power * 0.12);
const px = x + (r2 - 0.5) * spread * 1.8;
const py = y + (randomAt(seed, i + 1777 + layer * 31) - 0.5) * spread * 1.5;
const length = Math.max(1, size * (0.008 + power * (0.018 + r1 * 0.065)));
const radius = power > 0.72 && r2 > 0.72 ? 2 : 1;
if (definition.shape === 'crescent') line(px, py, px + length * 0.42, py - length, radius, palette[i % palette.length]);
else if (definition.shape === 'wave') line(px - length * 0.5, py + length * 0.15, px + length * 0.6, py - length * 0.2, radius, palette[i % palette.length]);
else if (definition.shape === 'mist') line(px - length * 0.35, py, px + length * 0.5, py - length * 0.12, radius, palette[i % palette.length], 180);
else line(px, py, px + length * 0.22, py - length, radius, palette[i % palette.length]);
}
// Higher activity reveals secondary parallel cuts and detached sparks.
if (effects.secondaryCuts !== false && power > 0.35) {
const cuts = Math.floor(1 + power * 5 * weight);
for (let i = 0; i < cuts; i++) {
const t = 0.12 + randomAt(seed, i + layer * 211 + 4000) * 0.76;
const [x, y] = point(t);
const len = size * (0.025 + power * 0.055);
line(x - len * 0.35, y + len * 0.18, x + len * 0.45, y - len * 0.65, 1, palette[(i + 1) % palette.length], 210);
}
}
});
return out;
}
export function pixelsToSvg(pixels: Uint8ClampedArray, size: number, title = 'GitBreathing generated slash'): string {
const rects: string[] = [];
for (let y = 0; y < size; y++) {
let x = 0;
while (x < size) {
const i = (y * size + x) * 4; const alpha = pixels[i + 3];
if (!alpha) { x++; continue; }
const r = pixels[i], g = pixels[i + 1], b = pixels[i + 2];
let end = x + 1;
while (end < size) {
const j = (y * size + end) * 4;
if (pixels[j + 3] !== alpha || pixels[j] !== r || pixels[j + 1] !== g || pixels[j + 2] !== b) break;
end++;
}
rects.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="rgb(${r},${g},${b})"${alpha === 255 ? '' : ` opacity="${(alpha / 255).toFixed(3)}"`}/>`);
x = end;
}
}
return `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" shape-rendering="crispEdges" role="img" aria-label="${title.replace(/[&<>\"]/g, '')}">\n<title>${title.replace(/[&<>]/g, '')}</title>\n${rects.join('\n')}\n</svg>\n`;
}