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
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { calculatePath } from './text-scribbled.business';
import { AVG_CHAR_WIDTH } from './text-scribbled.const';

describe('calculatePath', () => {
it('should return a non-empty path starting with M', () => {
const path = calculatePath(200, 50, 'test-id');
expect(path).toBeTypeOf('string');
expect(path.startsWith('M')).toBe(true);
expect(path.length).toBeGreaterThan(0);
});

it('should include at least one "C" or second "M" if width allows it', () => {
const path = calculatePath(300, 50, 'example');
expect(/(C| M )/.test(path)).toBe(true);
});

it('should not generate path segments beyond the given width', () => {
const width = 100;
const path = calculatePath(width, 50, 'another-id');

const commands = path.split(' ');
const coords = commands
.filter(c => c.includes(','))
.map(coord => {
const [x] = coord.split(',').map(Number);
return x;
});

coords.forEach(x => {
expect(x).toBeLessThanOrEqual(width);
});
});

it('should eventually stop if the available width is too small', () => {
const width = AVG_CHAR_WIDTH * 2; // not enough for more than 1 char
const path = calculatePath(width, 50, 'tiny');
const count = (path.match(/C/g) || []).length;
expect(count).toBeLessThanOrEqual(1);
});

it('should return empty or minimal path if SEED_PHRASE offset exceeds its length', () => {
const id = 'zzzzzzzzzzzzzzzzzzzzzzzzzzzz'; // large sum of char codes
const path = calculatePath(200, 50, id);
expect(path.startsWith('M')).toBe(true);
// It might not render any curves, just initial M
const segments = path.split(' ');
const hasCurves = segments.some(s => s === 'C');
// It can be empty if SEED_PHRASE was too short after slicing
expect(typeof hasCurves).toBe('boolean');
});

describe('calculatePath respects width and height boundaries', () => {
const testCases = [
{ width: 2000, height: 50, id: 'big-space' },
{ width: 10, height: 50, id: 'tiny-space' },
{ width: 100, height: 50, id: 'medium-space' },
];

testCases.forEach(({ width, height, id }) => {
it(`should keep all coordinates within bounds (width=${width}, height=${height})`, () => {
const path = calculatePath(width, height, id);
const commands = path.split(' ');

const coordinates = commands
.filter(c => c.includes(','))
.map(pair => {
const [xStr, yStr] = pair.split(',');
return {
x: parseFloat(xStr),
y: parseFloat(yStr),
};
});

coordinates.forEach(({ x, y }) => {
expect(x).toBeGreaterThanOrEqual(0);
expect(x).toBeLessThanOrEqual(width);
expect(y).toBeGreaterThanOrEqual(0);
expect(y).toBeLessThanOrEqual(height);
});
});
});
});
});
Original file line number Diff line number Diff line change
@@ -1,94 +1,13 @@
import {
AVG_CHAR_WIDTH,
MAX_START_OFFSET,
SEED_PHRASE,
SPACE_WIDTH,
} from './text-scribbled.const';

export const seededRandom = (seed: number) => {
// Let's get a random value in between -1 and 1
// And let's multiply it by 10000 to get a bigger number (more precision)
const x = Math.sin(seed) * 10000;

// Le's extract the decimal part of the number
// a number in between 0 and 1
return x - Math.floor(x);
};

// 30 characters is enough to get a good random offset phrase[X]
// in the past it was phrase.length, but that can lead to issues
// if the offset start at the end of the phrase then we can get a frozen text when we make it bigger.
const MAX_START_OFFSET = 30;

// We need to add some random offset to start the text at a different position
// BUT we cannot use here just a random number because it will change every time
// the component is re-rendered, so we need to use a deterministic way to get the offset
// based on the Id of the shape
// 👇 Based on the Id deterministic offset
// a bit weird, maybe just a random useEffect []
export const getOffsetFromId = (id: string, max: number) => {
let sum = 0;
for (let i = 0; i < id.length; i++) {
sum += id.charCodeAt(i);
}
return sum % max;
};

export const rounded = (value: number) => Math.round(value * 2) / 2;

export const addBlankSpaceToPath = (
currentX: number,
maxWidth: number,
height: number
) => {
currentX += SPACE_WIDTH;

// We don't want to go out of the area, if not transformer won't work well
const adjustedEndX = Math.min(currentX, maxWidth - 1);

return {
pathSlice: `M ${adjustedEndX},${Math.trunc(height / 2)}`,
newCurrentX: currentX,
};
};

const drawCharScribble = (
char: string,
i: number,
currentX: number,
maxWidth: number,
height: number
) => {
// Max Y variation on the scribble
const amplitude = height / 3;
const charWidth = AVG_CHAR_WIDTH;
// Let's generate a psuedo-random number based on the char and the index
const seed = char.charCodeAt(0) + i * 31;

const controlX1 = currentX + charWidth / 2;
const controlY1 = Math.trunc(
rounded(
// Generate a pseudo random number between -amplitude and amplitude
height / 2 + (seededRandom(seed) * amplitude - amplitude / 2)
)
);

const controlX2 = currentX + charWidth;
const controlY2 = Math.trunc(
rounded(height / 2 + (seededRandom(seed + 1) * amplitude - amplitude / 2))
);

// Let's truc it to avoid edge cases with the max
const endX = Math.trunc(currentX + charWidth);
const endY = Math.trunc(height / 2);

// We don't want to go out of the area, if not transformer won't work well
const adjustedEndX = Math.min(endX, maxWidth - 1);

return {
pathSegment: `C ${controlX1},${controlY1} ${controlX2},${controlY2} ${adjustedEndX},${endY}`,
endX,
};
};
import {
addBlankSpaceToPath,
drawCharScribble,
getOffsetFromId,
} from './text-scribbled.utils';

export const calculatePath = (width: number, height: number, id: string) => {
//console.log('** calculatePath', width, height, id);
Expand Down Expand Up @@ -137,8 +56,5 @@ export const calculatePath = (width: number, height: number, id: string) => {
if (currentX + AVG_CHAR_WIDTH >= width) break;
}

const result = path.join(' ');
console.log('** calculatePath result', result);

return path.join(' ');
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ export const AVG_CHAR_WIDTH = 10;
// Blank space width is 1.5 times the average character width
export const SPACE_WIDTH = AVG_CHAR_WIDTH * 1.5;

// 30 characters is enough to get a good random offset phrase[X]
// in the past it was phrase.length, but that can lead to issues
// if the offset start at the end of the phrase then we can get a frozen text when we make it bigger.
export const MAX_START_OFFSET = 30;

// We use this as a seed to generate the random values for the path
// We use this as a seed to generate the random values for the path
export const SEED_PHRASE =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
seededRandom,
getOffsetFromId,
rounded,
addBlankSpaceToPath,
drawCharScribble,
} from './text-scribbled.utils';

describe('seededRandom', () => {
it('should return a number between 0 and 1', () => {
const result = seededRandom(42);
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThan(1);
});

it('should return the same result for the same seed', () => {
const a = seededRandom(123);
const b = seededRandom(123);
expect(a).toBe(b);
});
});

describe('getOffsetFromId', () => {
it('should return a number less than max', () => {
const result = getOffsetFromId('test', 10);
expect(result).toBeLessThan(10);
});

it('should be deterministic', () => {
const a = getOffsetFromId('hello', 50);
const b = getOffsetFromId('hello', 50);
expect(a).toBe(b);
});
});

describe('rounded', () => {
it('should round to nearest 0.5', () => {
expect(rounded(1.2)).toBe(1);
expect(rounded(1.3)).toBe(1.5);
expect(rounded(1.75)).toBe(2);
});
});

describe('addBlankSpaceToPath', () => {
it('should return a pathSlice and newCurrentX', () => {
const result = addBlankSpaceToPath(10, 100, 50);
expect(result).toHaveProperty('pathSlice');
expect(result).toHaveProperty('newCurrentX');
});

it('should not exceed maxWidth - 1', () => {
const result = addBlankSpaceToPath(200, 210, 50);
const x = parseFloat(result.pathSlice.split(' ')[1]);
expect(x).toBeLessThanOrEqual(209);
});
});

describe('drawCharScribble', () => {
it('should return a valid path segment', () => {
const result = drawCharScribble('A', 0, 0, 100, 50);
expect(result.pathSegment).toMatch(/^C \d+,\d+ \d+,\d+ \d+,\d+$/);
expect(result).toHaveProperty('endX');
});

it('should respect maxWidth constraint', () => {
const result = drawCharScribble('Z', 3, 95, 100, 50);
const parts = result.pathSegment.split(' ');
const endX = parseInt(parts[3].split(',')[0], 10);
expect(endX).toBeLessThanOrEqual(99);
});

it('should respect maxWidth constraint for multiple random chars and dimensions', () => {
const randomChar = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
return chars[Math.floor(Math.random() * chars.length)];
};

const testCases = Array.from({ length: 10 }, () => ({
char: randomChar(),
index: Math.floor(Math.random() * 10),
currentX: Math.floor(Math.random() * 50),
maxWidth: 80 + Math.floor(Math.random() * 50), // values between 80 and 129
height: 30 + Math.floor(Math.random() * 30), // values between 30 and 59
}));

testCases.forEach(({ char, index, currentX, maxWidth, height }) => {
const result = drawCharScribble(char, index, currentX, maxWidth, height);
const parts = result.pathSegment.split(' ');
const [, , , end] = parts;
const endX = parseInt(end.split(',')[0], 10);

expect(endX).toBeLessThanOrEqual(maxWidth - 1);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { AVG_CHAR_WIDTH, SPACE_WIDTH } from './text-scribbled.const';

export const seededRandom = (seed: number) => {
// Let's get a random value in between -1 and 1
// And let's multiply it by 10000 to get a bigger number (more precision)
const x = Math.sin(seed) * 10000;

// Le's extract the decimal part of the number
// a number in between 0 and 1
return x - Math.floor(x);
};

// We need to add some random offset to start the text at a different position
// BUT we cannot use here just a random number because it will change every time
// the component is re-rendered, so we need to use a deterministic way to get the offset
// based on the Id of the shape
// 👇 Based on the Id deterministic offset
// a bit weird, maybe just a random useEffect []
export const getOffsetFromId = (id: string, max: number) => {
let sum = 0;
for (let i = 0; i < id.length; i++) {
sum += id.charCodeAt(i);
}
return sum % max;
};

export const rounded = (value: number) => Math.round(value * 2) / 2;

export const addBlankSpaceToPath = (
currentX: number,
maxWidth: number,
height: number
) => {
currentX += SPACE_WIDTH;

// We don't want to go out of the area, if not transformer won't work well
const adjustedEndX = Math.min(currentX, maxWidth - 1);

return {
pathSlice: `M ${adjustedEndX},${Math.trunc(height / 2)}`,
newCurrentX: currentX,
};
};

export const drawCharScribble = (
char: string,
i: number,
currentX: number,
maxWidth: number,
height: number
) => {
// Max Y variation on the scribble
const amplitude = height / 3;
const charWidth = AVG_CHAR_WIDTH;
// Let's generate a psuedo-random number based on the char and the index
const seed = char.charCodeAt(0) + i * 31;

const controlX1 = currentX + charWidth / 2;
const controlY1 = Math.trunc(
rounded(
// Generate a pseudo random number between -amplitude and amplitude
height / 2 + (seededRandom(seed) * amplitude - amplitude / 2)
)
);

const controlX2 = currentX + charWidth;
const controlY2 = Math.trunc(
rounded(height / 2 + (seededRandom(seed + 1) * amplitude - amplitude / 2))
);

// Let's truc it to avoid edge cases with the max
const endX = Math.trunc(currentX + charWidth);
const endY = Math.trunc(height / 2);

// We don't want to go out of the area, if not transformer won't work well
const adjustedEndX = Math.min(endX, maxWidth - 1);

return {
pathSegment: `C ${controlX1},${controlY1} ${controlX2},${controlY2} ${adjustedEndX},${endY}`,
endX,
};
};