mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-08 21:20:45 +08:00
938 lines
28 KiB
TypeScript
938 lines
28 KiB
TypeScript
/* eslint-disable @typescript-eslint/naming-convention */
|
||
import { cached } from "@app/lib/cached";
|
||
|
||
type AtomType = "A" | "B" | "C" | "D";
|
||
type ShapeModel =
|
||
| "rose"
|
||
| "starburst"
|
||
| "ringed"
|
||
| "tip"
|
||
| "notched"
|
||
| "hollow";
|
||
type AtomMode = "bands-ABC" | "angle-stripes" | "parity-ACB" | "balanced-rand";
|
||
|
||
const PALETTE = [
|
||
"#00D4DA", // teal
|
||
"#886BF2", // purple
|
||
"#FFA5FF", // pink
|
||
"#009F67", // green
|
||
"#CCFF38", // lime
|
||
"#585600", // olive
|
||
];
|
||
|
||
const REPO_CONFIG = {
|
||
GRID_SIZE: 16,
|
||
CELL_SIZE: 32,
|
||
PIXEL_DENSITY: 2,
|
||
GLYPH: {
|
||
WIDTH: 5,
|
||
HEIGHT: 7,
|
||
SPACING: 2,
|
||
SCALE_2X_WIDTH: 10,
|
||
SCALE_2X_HEIGHT: 14,
|
||
},
|
||
ATOMS: {
|
||
CIRCLE_B_RATIO: 0.55,
|
||
CIRCLE_C_RATIO: 0.67,
|
||
},
|
||
} as const;
|
||
|
||
const USER_CONFIG = {
|
||
TILE_SIZE: 32,
|
||
DEFAULT_GRID: 10,
|
||
PIXEL_DENSITY: 2,
|
||
ATOMS: {
|
||
ELLIPSE_B_SIZE: 17,
|
||
ELLIPSE_C_SIZE: 21,
|
||
},
|
||
TOLERANCE: {
|
||
ANGLE_NEAR_BASE: Math.PI / 28,
|
||
ANGLE_FAR_BASE: Math.PI / 7,
|
||
NEAR_RANGE: { MIN: 0.7, MAX: 1.2 },
|
||
FAR_RANGE: { MIN: 0.7, MAX: 1.2 },
|
||
},
|
||
SOFTNESS: {
|
||
MIN: 1.2,
|
||
MAX: 4.2,
|
||
},
|
||
RING_PHASE: {
|
||
MIN: 0.2,
|
||
MAX: 0.8,
|
||
},
|
||
} as const;
|
||
|
||
function hash32(str: string): number {
|
||
let h = 2166136261 >>> 0;
|
||
for (let i = 0; i < str.length; i++) {
|
||
h ^= str.charCodeAt(i);
|
||
h = Math.imul(h, 16777619);
|
||
}
|
||
return h >>> 0;
|
||
}
|
||
|
||
function xmur3(str: string): () => number {
|
||
let h = 1779033703 ^ str.length;
|
||
for (let i = 0; i < str.length; i++) {
|
||
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
|
||
h = (h << 13) | (h >>> 19);
|
||
}
|
||
return function () {
|
||
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
||
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
||
return (h ^= h >>> 16) >>> 0;
|
||
};
|
||
}
|
||
|
||
function mulberry32(a: number): () => number {
|
||
return function () {
|
||
let t = (a += 0x6d2b79f5);
|
||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
function makeRNG(key: string): () => number {
|
||
return mulberry32(xmur3(key)());
|
||
}
|
||
|
||
function chooseK<T>(arr: T[], k: number, rnd: () => number): T[] {
|
||
const pool = arr.slice();
|
||
const out = [];
|
||
for (let i = 0; i < k; i++) {
|
||
const idx = Math.floor(rnd() * pool.length);
|
||
out.push(pool.splice(idx, 1)[0]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function pick<T>(rng: () => number, arr: T[]): T {
|
||
return arr[Math.floor(rng() * arr.length)];
|
||
}
|
||
|
||
function createOffscreenCanvas(
|
||
w: number,
|
||
h: number,
|
||
density: number = 2,
|
||
): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = w * density;
|
||
canvas.height = h * density;
|
||
const ctx = canvas.getContext("2d")!;
|
||
ctx.scale(density, density);
|
||
ctx.imageSmoothingEnabled = false;
|
||
return { canvas, ctx };
|
||
}
|
||
|
||
function fillCircle(
|
||
ctx: CanvasRenderingContext2D,
|
||
x: number,
|
||
y: number,
|
||
diameter: number,
|
||
color: string,
|
||
): void {
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, diameter / 2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
|
||
function fillEllipse(
|
||
ctx: CanvasRenderingContext2D,
|
||
x: number,
|
||
y: number,
|
||
w: number,
|
||
h: number,
|
||
color: string,
|
||
): void {
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.ellipse(x, y, w / 2, h / 2, 0, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
|
||
interface AtomDrawConfig {
|
||
cellSize: number;
|
||
circleB: number;
|
||
circleC: number;
|
||
useEllipse?: boolean;
|
||
}
|
||
|
||
function createAtomRenderer(config: AtomDrawConfig) {
|
||
const drawCircleOrEllipse = (
|
||
ctx: CanvasRenderingContext2D,
|
||
x: number,
|
||
y: number,
|
||
w: number,
|
||
h: number,
|
||
color: string,
|
||
) => {
|
||
if (config.useEllipse) {
|
||
fillEllipse(ctx, x, y, w, h, color);
|
||
} else {
|
||
fillCircle(ctx, x, y, w, color); // w is diameter for circles
|
||
}
|
||
};
|
||
|
||
return {
|
||
drawAtomA(
|
||
ctx: CanvasRenderingContext2D,
|
||
gx: number,
|
||
gy: number,
|
||
c1: string,
|
||
): void {
|
||
const x = gx * config.cellSize;
|
||
const y = gy * config.cellSize;
|
||
ctx.fillStyle = c1;
|
||
ctx.fillRect(x, y, config.cellSize, config.cellSize);
|
||
},
|
||
|
||
drawAtomB(
|
||
ctx: CanvasRenderingContext2D,
|
||
gx: number,
|
||
gy: number,
|
||
c1: string,
|
||
c2: string,
|
||
): void {
|
||
const x = gx * config.cellSize;
|
||
const y = gy * config.cellSize;
|
||
ctx.fillStyle = c1;
|
||
ctx.fillRect(x, y, config.cellSize, config.cellSize);
|
||
drawCircleOrEllipse(
|
||
ctx,
|
||
x + config.cellSize / 2,
|
||
y + config.cellSize / 2,
|
||
config.circleB,
|
||
config.circleB,
|
||
c2,
|
||
);
|
||
},
|
||
|
||
drawAtomC(
|
||
ctx: CanvasRenderingContext2D,
|
||
gx: number,
|
||
gy: number,
|
||
c2: string,
|
||
c3: string,
|
||
): void {
|
||
const x = gx * config.cellSize;
|
||
const y = gy * config.cellSize;
|
||
ctx.fillStyle = c2;
|
||
ctx.fillRect(x, y, config.cellSize, config.cellSize);
|
||
drawCircleOrEllipse(
|
||
ctx,
|
||
x + config.cellSize / 2,
|
||
y + config.cellSize / 2,
|
||
config.circleC,
|
||
config.circleC,
|
||
c3,
|
||
);
|
||
},
|
||
|
||
drawAtomD(
|
||
ctx: CanvasRenderingContext2D,
|
||
gx: number,
|
||
gy: number,
|
||
c3: string,
|
||
): void {
|
||
const x = gx * config.cellSize;
|
||
const y = gy * config.cellSize;
|
||
ctx.fillStyle = c3;
|
||
ctx.fillRect(x, y, config.cellSize, config.cellSize);
|
||
},
|
||
|
||
drawAtom(
|
||
ctx: CanvasRenderingContext2D,
|
||
atom: AtomType,
|
||
gx: number,
|
||
gy: number,
|
||
c1: string,
|
||
c2: string,
|
||
c3: string,
|
||
): void {
|
||
switch (atom) {
|
||
case "A":
|
||
this.drawAtomA(ctx, gx, gy, c1);
|
||
break;
|
||
case "B":
|
||
this.drawAtomB(ctx, gx, gy, c1, c2);
|
||
break;
|
||
case "C":
|
||
this.drawAtomC(ctx, gx, gy, c2, c3);
|
||
break;
|
||
case "D":
|
||
this.drawAtomD(ctx, gx, gy, c3);
|
||
break;
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
// 5x7 pixel font glyphs (cached at module level for performance)
|
||
function createGlyphs5x7(): Record<string, number[][]> {
|
||
const L: Record<string, number[][]> = {};
|
||
const r = (s: string[]) =>
|
||
s.map(row => row.split("").map(ch => (ch === "1" ? 1 : 0)));
|
||
L["A"] = r(["01110", "10001", "10001", "11111", "10001", "10001", "10001"]);
|
||
L["B"] = r(["11110", "10001", "10001", "11110", "10001", "10001", "11110"]);
|
||
L["C"] = r(["01111", "10000", "10000", "10000", "10000", "10000", "01111"]);
|
||
L["D"] = r(["11110", "10001", "10001", "10001", "10001", "10001", "11110"]);
|
||
L["E"] = r(["11111", "10000", "10000", "11110", "10000", "10000", "11111"]);
|
||
L["F"] = r(["11111", "10000", "10000", "11110", "10000", "10000", "10000"]);
|
||
L["G"] = r(["01111", "10000", "10000", "10111", "10001", "10001", "01111"]);
|
||
L["H"] = r(["10001", "10001", "10001", "11111", "10001", "10001", "10001"]);
|
||
L["I"] = r(["11111", "00100", "00100", "00100", "00100", "00100", "11111"]);
|
||
L["J"] = r(["11111", "00001", "00001", "00001", "10001", "10001", "01110"]);
|
||
L["K"] = r(["10001", "10010", "10100", "11000", "10100", "10010", "10001"]);
|
||
L["L"] = r(["10000", "10000", "10000", "10000", "10000", "10000", "11111"]);
|
||
L["M"] = r(["10001", "11011", "10101", "10001", "10001", "10001", "10001"]);
|
||
L["N"] = r(["10001", "11001", "10101", "10011", "10001", "10001", "10001"]);
|
||
L["O"] = r(["01110", "10001", "10001", "10001", "10001", "10001", "01110"]);
|
||
L["P"] = r(["11110", "10001", "10001", "11110", "10000", "10000", "10000"]);
|
||
L["Q"] = r(["01110", "10001", "10001", "10001", "10101", "10010", "01101"]);
|
||
L["R"] = r(["11110", "10001", "10001", "11110", "10100", "10010", "10001"]);
|
||
L["S"] = r(["01111", "10000", "11110", "00001", "00001", "10001", "11110"]);
|
||
L["T"] = r(["11111", "00100", "00100", "00100", "00100", "00100", "00100"]);
|
||
L["U"] = r(["10001", "10001", "10001", "10001", "10001", "10001", "01110"]);
|
||
L["V"] = r(["10001", "10001", "10001", "01010", "01010", "00100", "00100"]);
|
||
L["W"] = r(["10001", "10001", "10001", "10101", "10101", "11011", "10001"]);
|
||
L["X"] = r(["10001", "01010", "00100", "00100", "00100", "01010", "10001"]);
|
||
L["Y"] = r(["10001", "01010", "00100", "00100", "00100", "00100", "00100"]);
|
||
L["Z"] = r(["11111", "00001", "00010", "00100", "01000", "10000", "11111"]);
|
||
L["0"] = r(["01110", "10001", "10011", "10101", "11001", "10001", "01110"]);
|
||
L["1"] = r(["00100", "01100", "00100", "00100", "00100", "00100", "01110"]);
|
||
L["2"] = r(["01110", "10001", "00001", "00110", "01000", "10000", "11111"]);
|
||
L["3"] = r(["11110", "00001", "01110", "00001", "00001", "00001", "11110"]);
|
||
L["4"] = r(["10010", "10010", "10010", "11111", "00010", "00010", "00010"]);
|
||
L["5"] = r(["11111", "10000", "11110", "00001", "00001", "00001", "11110"]);
|
||
L["6"] = r(["01110", "10000", "11110", "10001", "10001", "10001", "01110"]);
|
||
L["7"] = r(["11111", "00001", "00010", "00100", "01000", "01000", "01000"]);
|
||
L["8"] = r(["01110", "10001", "01110", "10001", "10001", "10001", "01110"]);
|
||
L["9"] = r(["01110", "10001", "10001", "01111", "00001", "00001", "11110"]);
|
||
L["?"] = r(["11111", "00001", "01110", "00000", "00100", "00000", "00100"]);
|
||
return L;
|
||
}
|
||
|
||
const LETTER_5X7 = createGlyphs5x7();
|
||
|
||
function getInitials(name: string): string[] {
|
||
if (!name || typeof name !== "string") return ["?"];
|
||
const cleaned = name.trim().replace(/\s+/g, " ");
|
||
const parts = cleaned.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
||
const first = parts[0] ? parts[0][0].toUpperCase() : "?";
|
||
const second = parts[1] ? parts[1][0].toUpperCase() : null;
|
||
return second ? [first, second] : [first];
|
||
}
|
||
|
||
function polarFromCell(
|
||
gx: number,
|
||
gy: number,
|
||
cx: number,
|
||
cy: number,
|
||
): { r: number; a: number } {
|
||
const x = gx - cx + 0.5;
|
||
const y = gy - cy + 0.5;
|
||
const r = Math.hypot(x, y);
|
||
let a = Math.atan2(y, x);
|
||
if (a < 0) a += 2 * Math.PI;
|
||
return { r, a };
|
||
}
|
||
|
||
function shapeRose(theta: number, petals: number, tol: number): boolean {
|
||
const sector = Math.PI / petals;
|
||
const nearest = Math.round(theta / sector) * sector;
|
||
let diff = Math.abs(theta - nearest);
|
||
diff = Math.min(diff, 2 * Math.PI - diff);
|
||
return diff <= tol;
|
||
}
|
||
|
||
function shapeStarburst(
|
||
theta: number,
|
||
petals: number,
|
||
softness: number,
|
||
): boolean {
|
||
const period = (2 * Math.PI) / petals;
|
||
const local = theta % period;
|
||
const d = Math.min(local, period - local) / (period / 2);
|
||
const response = Math.pow(Math.cos((d * Math.PI) / 2), softness);
|
||
return response > 0.5;
|
||
}
|
||
|
||
function shapeRinged(
|
||
theta: number,
|
||
petals: number,
|
||
ringPhase: number,
|
||
tol: number,
|
||
): boolean {
|
||
const sector = (2 * Math.PI) / petals;
|
||
const k = Math.floor(theta / sector);
|
||
const center = k * sector + sector * ringPhase;
|
||
let diff = Math.abs(theta - center);
|
||
diff = Math.min(diff, 2 * Math.PI - diff);
|
||
return diff <= tol;
|
||
}
|
||
|
||
function shapeTip(
|
||
theta: number,
|
||
petals: number,
|
||
tol: number,
|
||
t: number,
|
||
): boolean {
|
||
return shapeRose(theta, petals, tol * (0.5 + 1.0 * t)) && t > 0.45;
|
||
}
|
||
|
||
function shapeNotched(
|
||
theta: number,
|
||
petals: number,
|
||
tol: number,
|
||
notchDepth: number = 0.25,
|
||
): boolean {
|
||
const sector = (2 * Math.PI) / petals;
|
||
const local = (theta % sector) / sector;
|
||
const notch = Math.abs(local - 0.5);
|
||
return shapeRose(theta, petals, tol) && notch > notchDepth;
|
||
}
|
||
|
||
function shapeHollow(
|
||
theta: number,
|
||
petals: number,
|
||
tol: number,
|
||
t: number,
|
||
inner: number = 0.28,
|
||
outer: number = 0.9,
|
||
): boolean {
|
||
return shapeRose(theta, petals, tol) && t > inner && t < outer;
|
||
}
|
||
|
||
function sectorGate(theta: number, petals: number, mask: boolean[]): boolean {
|
||
const sector = (2 * Math.PI) / petals;
|
||
const k = Math.floor(theta / sector);
|
||
return mask[k % mask.length];
|
||
}
|
||
|
||
export const cachedRepoAvatar = cached(
|
||
async (key: string) => renderRepoAvatar(key),
|
||
(key: string) => key,
|
||
{ max: 2000 },
|
||
);
|
||
|
||
export const cachedUserAvatar = cached(
|
||
async (key: string) => renderUserAvatar(key),
|
||
(key: string) => key,
|
||
{ max: 2000 },
|
||
);
|
||
|
||
function renderRepoAvatar(key: string): string {
|
||
if (typeof window === "undefined") {
|
||
return "";
|
||
}
|
||
|
||
{
|
||
// Color logic:
|
||
// A: square = Color1
|
||
// B: square = Color1, circle = Color2
|
||
// C: square = Color2, circle = Color3
|
||
// D: square = Color3
|
||
// Letters: solid-only (A or D). Background: other three atoms.
|
||
// Single initial: 2x2 expansion (10x14). Two initials: 5x7 each.
|
||
|
||
const GRID = REPO_CONFIG.GRID_SIZE;
|
||
const CELL = REPO_CONFIG.CELL_SIZE;
|
||
const W = GRID * CELL;
|
||
const H = GRID * CELL;
|
||
|
||
const { canvas, ctx } = createOffscreenCanvas(
|
||
W,
|
||
H,
|
||
REPO_CONFIG.PIXEL_DENSITY,
|
||
);
|
||
|
||
const atoms = createAtomRenderer({
|
||
cellSize: CELL,
|
||
circleB: CELL * REPO_CONFIG.ATOMS.CIRCLE_B_RATIO,
|
||
circleC: CELL * REPO_CONFIG.ATOMS.CIRCLE_C_RATIO,
|
||
useEllipse: false,
|
||
});
|
||
|
||
function renderInitialsAvatar(nameKey: string = "color bright") {
|
||
const initials = getInitials(nameKey);
|
||
const seed = hash32(nameKey.toLowerCase());
|
||
const rnd = mulberry32(seed);
|
||
|
||
// Select three distinct colors deterministically (Color1, Color2, Color3)
|
||
const [c1, c2, c3] = chooseK(PALETTE, 3, rnd);
|
||
|
||
// Choose letter solid atom: 'A' or 'D' (deterministic)
|
||
const letterSolidAtom: AtomType = ((seed >>> 7) & 1) === 0 ? "A" : "D";
|
||
|
||
const bgAtoms: AtomType[] = (["A", "B", "C", "D"] as AtomType[]).filter(
|
||
a => a !== letterSolidAtom,
|
||
);
|
||
|
||
function pickBgAtom(gx: number, gy: number): AtomType {
|
||
const k = (gy * 131 + gx * 197 + seed) >>> 0;
|
||
return bgAtoms[k % bgAtoms.length];
|
||
}
|
||
|
||
// 1) Background: fill with bgAtoms using strict color mapping
|
||
for (let gy = 0; gy < GRID; gy++) {
|
||
for (let gx = 0; gx < GRID; gx++) {
|
||
const atom = pickBgAtom(gx, gy);
|
||
atoms.drawAtom(ctx, atom, gx, gy, c1, c2, c3);
|
||
}
|
||
}
|
||
|
||
// 2) Letters: solid-only atom across glyph pixels, strict mapping (A uses c1, D uses c3)
|
||
const glyphW = REPO_CONFIG.GLYPH.WIDTH;
|
||
const glyphH = REPO_CONFIG.GLYPH.HEIGHT;
|
||
|
||
function placeSolidLetter(
|
||
glyph: number[][],
|
||
startX: number,
|
||
startY: number,
|
||
scale2x: boolean,
|
||
) {
|
||
if (scale2x) {
|
||
// 2x2 expansion → 10x14
|
||
for (let r = 0; r < glyphH; r++) {
|
||
for (let c = 0; c < glyphW; c++) {
|
||
if (!glyph[r][c]) continue;
|
||
const gx = startX + c * 2;
|
||
const gy = startY + r * 2;
|
||
if (letterSolidAtom === "A") {
|
||
atoms.drawAtomA(ctx, gx, gy, c1);
|
||
atoms.drawAtomA(ctx, gx + 1, gy, c1);
|
||
atoms.drawAtomA(ctx, gx, gy + 1, c1);
|
||
atoms.drawAtomA(ctx, gx + 1, gy + 1, c1);
|
||
} else {
|
||
atoms.drawAtomD(ctx, gx, gy, c3);
|
||
atoms.drawAtomD(ctx, gx + 1, gy, c3);
|
||
atoms.drawAtomD(ctx, gx, gy + 1, c3);
|
||
atoms.drawAtomD(ctx, gx + 1, gy + 1, c3);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// 1x scale
|
||
for (let r = 0; r < glyphH; r++) {
|
||
for (let c = 0; c < glyphW; c++) {
|
||
if (!glyph[r][c]) continue;
|
||
const gx = startX + c;
|
||
const gy = startY + r;
|
||
if (letterSolidAtom === "A") {
|
||
atoms.drawAtomA(ctx, gx, gy, c1);
|
||
} else {
|
||
atoms.drawAtomD(ctx, gx, gy, c3);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (initials.length === 1) {
|
||
// Single initial: 2x2 expansion (10x14), centered
|
||
const glyph = LETTER_5X7[initials[0]] || LETTER_5X7["?"];
|
||
const startX = Math.floor(
|
||
(GRID - REPO_CONFIG.GLYPH.SCALE_2X_WIDTH) / 2,
|
||
);
|
||
const startY = Math.floor(
|
||
(GRID - REPO_CONFIG.GLYPH.SCALE_2X_HEIGHT) / 2,
|
||
);
|
||
placeSolidLetter(glyph, startX, startY, true);
|
||
} else {
|
||
// Two initials: 5x7 each, side-by-side
|
||
const leftGlyph = LETTER_5X7[initials[0]] || LETTER_5X7["?"];
|
||
const rightGlyph = LETTER_5X7[initials[1]] || LETTER_5X7["?"];
|
||
const spacing = REPO_CONFIG.GLYPH.SPACING;
|
||
const totalW = REPO_CONFIG.GLYPH.WIDTH * 2 + spacing;
|
||
const totalH = REPO_CONFIG.GLYPH.HEIGHT;
|
||
const startX = Math.floor((GRID - totalW) / 2);
|
||
const startY = Math.floor((GRID - totalH) / 2);
|
||
placeSolidLetter(leftGlyph, startX, startY, false);
|
||
placeSolidLetter(
|
||
rightGlyph,
|
||
startX + REPO_CONFIG.GLYPH.WIDTH + spacing,
|
||
startY,
|
||
false,
|
||
);
|
||
}
|
||
|
||
return canvas.toDataURL();
|
||
}
|
||
|
||
return renderInitialsAvatar(key);
|
||
}
|
||
}
|
||
|
||
function renderUserAvatar(key: string): string {
|
||
if (typeof window === "undefined") {
|
||
return "";
|
||
}
|
||
|
||
{
|
||
const TILE = USER_CONFIG.TILE_SIZE;
|
||
const DEFAULT_GRID = USER_CONFIG.DEFAULT_GRID;
|
||
|
||
const atoms = createAtomRenderer({
|
||
cellSize: TILE,
|
||
circleB: USER_CONFIG.ATOMS.ELLIPSE_B_SIZE,
|
||
circleC: USER_CONFIG.ATOMS.ELLIPSE_C_SIZE,
|
||
useEllipse: true,
|
||
});
|
||
|
||
// Edge-to-edge integer placement
|
||
function drawAt(
|
||
fn: (x: number, y: number) => void,
|
||
gx: number,
|
||
gy: number,
|
||
): void {
|
||
fn(gx * TILE, gy * TILE);
|
||
}
|
||
|
||
// Strict 4-way symmetry (quadrant mirroring)
|
||
function drawQuad(
|
||
fn: (x: number, y: number) => void,
|
||
gx: number,
|
||
gy: number,
|
||
grid: number,
|
||
): void {
|
||
const N = grid - 1;
|
||
drawAt(fn, gx, gy);
|
||
drawAt(fn, N - gx, gy);
|
||
drawAt(fn, gx, N - gy);
|
||
drawAt(fn, N - gx, N - gy);
|
||
}
|
||
|
||
// Draw by atom type (converts pixel coordinates to grid coordinates)
|
||
function drawAtomByType(
|
||
ctx: CanvasRenderingContext2D,
|
||
type: AtomType,
|
||
x: number,
|
||
y: number,
|
||
c1: string,
|
||
c2: string,
|
||
c3: string,
|
||
): void {
|
||
const gx = x / TILE;
|
||
const gy = y / TILE;
|
||
atoms.drawAtom(ctx, type, gx, gy, c1, c2, c3);
|
||
}
|
||
|
||
// Make assigner among active petal atoms (exclude background atom)
|
||
function makeAssigner(
|
||
mode: AtomMode,
|
||
activeAtoms: AtomType[],
|
||
): (rCell: number, theta?: number, sectorIdx?: number) => AtomType {
|
||
if (mode === "bands-ABC")
|
||
return (rCell: number) => activeAtoms[rCell % 3];
|
||
if (mode === "angle-stripes")
|
||
return (_rCell: number, _theta?: number, sectorIdx?: number) =>
|
||
activeAtoms[(sectorIdx || 0) % activeAtoms.length];
|
||
if (mode === "parity-ACB")
|
||
return (rCell: number) => activeAtoms[rCell % 2 ? 1 : 0];
|
||
if (mode === "balanced-rand")
|
||
return (rCell: number, theta?: number, sectorIdx?: number) => {
|
||
const v =
|
||
(Math.sin(
|
||
(theta || 0) * 13.37 + rCell * 2.17 + (sectorIdx || 0) * 0.73,
|
||
) +
|
||
1) /
|
||
2;
|
||
if (v < 0.33) return activeAtoms[0];
|
||
if (v < 0.66) return activeAtoms[1];
|
||
return activeAtoms[2];
|
||
};
|
||
return (rCell: number) => activeAtoms[rCell % 3];
|
||
}
|
||
|
||
function generateFlower(
|
||
ctx: CanvasRenderingContext2D,
|
||
canvas: HTMLCanvasElement,
|
||
key: string,
|
||
grid: number = DEFAULT_GRID,
|
||
): void {
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
const rng = makeRNG(key);
|
||
|
||
const picked = PALETTE.slice().sort(() => rng() - 0.5);
|
||
const [c1, c2, c3] = picked.slice(0, 3);
|
||
|
||
const allAtoms: AtomType[] = ["A", "B", "C", "D"];
|
||
const bgAtom: AtomType = pick(rng, allAtoms);
|
||
const petalAtoms: AtomType[] = allAtoms
|
||
.filter(a => a !== bgAtom)
|
||
.sort(() => rng() - 0.5);
|
||
|
||
const cx = Math.floor(grid / 2),
|
||
cy = Math.floor(grid / 2);
|
||
const maxR = Math.min(cx, cy);
|
||
const petals = pick(rng, [5, 6, 7, 8, 9, 10]);
|
||
const petalDepth = Math.max(5, Math.floor(maxR * (0.6 + 0.35 * rng())));
|
||
const radialThickness = pick(rng, [1, 2, 2, 3, 3]);
|
||
const shapeModel: ShapeModel = pick(rng, [
|
||
"rose",
|
||
"starburst",
|
||
"ringed",
|
||
"tip",
|
||
"notched",
|
||
"hollow",
|
||
]);
|
||
const atomMode: AtomMode = pick(rng, [
|
||
"bands-ABC",
|
||
"angle-stripes",
|
||
"parity-ACB",
|
||
"balanced-rand",
|
||
]);
|
||
const assignAtom = makeAssigner(atomMode, petalAtoms);
|
||
|
||
// Angle tolerances
|
||
const angleTolNear =
|
||
USER_CONFIG.TOLERANCE.ANGLE_NEAR_BASE *
|
||
(USER_CONFIG.TOLERANCE.NEAR_RANGE.MIN +
|
||
rng() *
|
||
(USER_CONFIG.TOLERANCE.NEAR_RANGE.MAX -
|
||
USER_CONFIG.TOLERANCE.NEAR_RANGE.MIN));
|
||
const angleTolFar =
|
||
USER_CONFIG.TOLERANCE.ANGLE_FAR_BASE *
|
||
(USER_CONFIG.TOLERANCE.FAR_RANGE.MIN +
|
||
rng() *
|
||
(USER_CONFIG.TOLERANCE.FAR_RANGE.MAX -
|
||
USER_CONFIG.TOLERANCE.FAR_RANGE.MIN));
|
||
const softness =
|
||
USER_CONFIG.SOFTNESS.MIN +
|
||
rng() * (USER_CONFIG.SOFTNESS.MAX - USER_CONFIG.SOFTNESS.MIN);
|
||
const ringPhase =
|
||
USER_CONFIG.RING_PHASE.MIN +
|
||
rng() * (USER_CONFIG.RING_PHASE.MAX - USER_CONFIG.RING_PHASE.MIN);
|
||
|
||
// Sector gating mask (~70% sectors on), ensures bold shapes
|
||
const sectorMask = Array.from({ length: petals }, () => rng() > 0.3);
|
||
if (sectorMask.every(v => !v)) sectorMask[Math.floor(petals / 2)] = true;
|
||
|
||
const drawBgAtom = (gx: number, gy: number) => {
|
||
drawAt(
|
||
(x: number, y: number) =>
|
||
drawAtomByType(ctx, bgAtom, x, y, c1, c2, c3),
|
||
gx,
|
||
gy,
|
||
);
|
||
};
|
||
|
||
// 0) Base pass: paint every tile once with background atom to avoid gaps
|
||
function renderBasePass() {
|
||
for (let gy = 0; gy < grid; gy++) {
|
||
for (let gx = 0; gx < grid; gx++) {
|
||
drawBgAtom(gx, gy);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 1) Outer edge circumference (1–2 tiles thick) reinforced in background atom
|
||
function renderEdgeReinforcement() {
|
||
const edgeThickness = pick(rng, [1, 1, 2]);
|
||
for (let t = 0; t < edgeThickness; t++) {
|
||
for (let i = 0; i < grid; i++) {
|
||
drawBgAtom(i, t);
|
||
drawBgAtom(i, grid - 1 - t);
|
||
drawBgAtom(t, i);
|
||
drawBgAtom(grid - 1 - t, i);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2) Background atom structural accents inside (deterministic)
|
||
// - mid ring (optional) and a few gated spokes to help define silhouette
|
||
function renderStructuralAccents() {
|
||
// Mid ring
|
||
if (rng() < 0.7) {
|
||
const midR = Math.floor(petalDepth * (0.5 + 0.2 * rng()));
|
||
for (let i = 0; i < grid; i++) {
|
||
const coords: [number, number][] = [
|
||
[cx - midR, i],
|
||
[cx + midR, i],
|
||
[i, cy - midR],
|
||
[i, cy + midR],
|
||
];
|
||
coords.forEach(([gx, gy]) => {
|
||
if (gx >= 0 && gy >= 0 && gx < grid && gy < grid) {
|
||
drawQuad(
|
||
(x: number, y: number) =>
|
||
drawAtomByType(ctx, bgAtom, x, y, c1, c2, c3),
|
||
gx,
|
||
gy,
|
||
grid,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Gated spokes
|
||
if (rng() < 0.6) {
|
||
const gateEvery = pick(rng, [2, 3, 4]);
|
||
for (let s = 0; s < petals; s++) {
|
||
if (s % gateEvery !== 0) continue;
|
||
const theta = s * ((2 * Math.PI) / petals);
|
||
for (let r = 1; r <= petalDepth; r++) {
|
||
const gx = Math.round(cx + r * Math.cos(theta));
|
||
const gy = Math.round(cy + r * Math.sin(theta));
|
||
if (gx >= 0 && gy >= 0 && gx < grid && gy < grid) {
|
||
drawQuad(
|
||
(x: number, y: number) =>
|
||
drawAtomByType(ctx, bgAtom, x, y, c1, c2, c3),
|
||
gx,
|
||
gy,
|
||
grid,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3) Strong center cluster (guarantee coverage; seed form)
|
||
function renderCenterCluster() {
|
||
const centerCluster: [number, number, AtomType][] = [
|
||
[0, 0, "D"],
|
||
[0, -1, petalAtoms[0]],
|
||
[1, 0, petalAtoms[1]],
|
||
[0, 1, petalAtoms[2]],
|
||
[-1, 0, petalAtoms[0]],
|
||
];
|
||
centerCluster.forEach(([dx, dy, t]: [number, number, AtomType]) => {
|
||
const gx = cx + dx,
|
||
gy = cy + dy;
|
||
if (gx < 0 || gy < 0 || gx >= grid || gy >= grid) return;
|
||
drawQuad(
|
||
(x: number, y: number) => drawAtomByType(ctx, t, x, y, c1, c2, c3),
|
||
gx,
|
||
gy,
|
||
grid,
|
||
);
|
||
});
|
||
}
|
||
|
||
// 4) Petals (TL quadrant → mirror)
|
||
function renderPetals() {
|
||
const half = Math.ceil(grid / 2);
|
||
for (let gy = 0; gy < half; gy++) {
|
||
for (let gx = 0; gx < half; gx++) {
|
||
const { r, a } = polarFromCell(gx, gy, cx, cy);
|
||
const rCell = Math.floor(r);
|
||
if (rCell === 0 || rCell > petalDepth) continue;
|
||
|
||
const t = rCell / petalDepth;
|
||
const tol = angleTolNear * (1 - t) + angleTolFar * t;
|
||
|
||
const sectorIdx = Math.floor(a / ((2 * Math.PI) / petals));
|
||
if (!sectorGate(a, petals, sectorMask)) continue;
|
||
|
||
let inside: boolean;
|
||
if (shapeModel === "rose") inside = shapeRose(a, petals, tol);
|
||
else if (shapeModel === "starburst")
|
||
inside = shapeStarburst(a, petals, softness);
|
||
else if (shapeModel === "ringed")
|
||
inside = shapeRinged(a, petals, ringPhase, tol * 0.7);
|
||
else if (shapeModel === "tip") inside = shapeTip(a, petals, tol, t);
|
||
else if (shapeModel === "notched")
|
||
inside = shapeNotched(a, petals, tol, 0.24);
|
||
else inside = shapeHollow(a, petals, tol, t, 0.28, 0.92);
|
||
|
||
if (!inside) continue;
|
||
|
||
const type = assignAtom(rCell, a, sectorIdx);
|
||
|
||
// Draw with radial thickness and 4-way mirroring
|
||
for (let dr = 0; dr < radialThickness; dr++) {
|
||
const x1 = gx + dr,
|
||
y1 = gy + dr;
|
||
const coords: [number, number][] = [
|
||
[x1, y1],
|
||
[grid - 1 - x1, y1],
|
||
[x1, grid - 1 - y1],
|
||
[grid - 1 - x1, grid - 1 - y1],
|
||
];
|
||
coords.forEach(([ix, iy]: [number, number]) => {
|
||
if (ix < 0 || iy < 0 || ix >= grid || iy >= grid) return;
|
||
drawAt(
|
||
(x: number, y: number) =>
|
||
drawAtomByType(ctx, type, x, y, c1, c2, c3),
|
||
ix,
|
||
iy,
|
||
);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5) Ensure all three petal atoms appear at least once
|
||
function renderAccents() {
|
||
const accents: [number, number, AtomType][] = [
|
||
[cx, cy - 2, petalAtoms[0]],
|
||
[cx + 1, cy, petalAtoms[1]],
|
||
[cx, cy + 2, petalAtoms[2]],
|
||
];
|
||
accents.forEach(([gx, gy, t]: [number, number, AtomType]) => {
|
||
if (gx < 0 || gy < 0 || gx >= grid || gy >= grid) return;
|
||
drawQuad(
|
||
(x: number, y: number) => drawAtomByType(ctx, t, x, y, c1, c2, c3),
|
||
gx,
|
||
gy,
|
||
grid,
|
||
);
|
||
});
|
||
}
|
||
|
||
// 6) Final safety: ensure center 6×6 px is covered via 2×2 cluster
|
||
function renderFinalCluster() {
|
||
const cluster: [number, number, AtomType][] = [
|
||
[0, 0, "D"],
|
||
[1, 0, petalAtoms[1]],
|
||
[0, 1, petalAtoms[2]],
|
||
[1, 1, petalAtoms[0]],
|
||
];
|
||
cluster.forEach(([dx, dy, t]: [number, number, AtomType]) => {
|
||
const gx = cx + dx,
|
||
gy = cy + dy;
|
||
if (gx < 0 || gy < 0 || gx >= grid || gy >= grid) return;
|
||
drawQuad(
|
||
(x: number, y: number) => drawAtomByType(ctx, t, x, y, c1, c2, c3),
|
||
gx,
|
||
gy,
|
||
grid,
|
||
);
|
||
});
|
||
}
|
||
|
||
renderBasePass();
|
||
renderEdgeReinforcement();
|
||
renderStructuralAccents();
|
||
renderCenterCluster();
|
||
renderPetals();
|
||
renderAccents();
|
||
renderFinalCluster();
|
||
}
|
||
|
||
function drawFlowerForKey(
|
||
key: string,
|
||
grid: number = DEFAULT_GRID,
|
||
): string {
|
||
const canvasPx = grid * TILE;
|
||
|
||
const { canvas, ctx } = createOffscreenCanvas(
|
||
canvasPx,
|
||
canvasPx,
|
||
USER_CONFIG.PIXEL_DENSITY,
|
||
);
|
||
|
||
generateFlower(ctx, canvas, key, grid);
|
||
|
||
return canvas.toDataURL();
|
||
}
|
||
|
||
return drawFlowerForKey(key, DEFAULT_GRID);
|
||
}
|
||
}
|