feat: add size variation control
Browse files- index.html +4 -0
- src/layout.test.ts +17 -0
- src/layout.ts +10 -3
- src/main.ts +6 -2
index.html
CHANGED
|
@@ -57,6 +57,10 @@
|
|
| 57 |
<span>Logo size <output id="size-value">1.00×</output></span>
|
| 58 |
<input id="logo-size" type="range" min="0.5" max="1.6" step="0.01" value="1" />
|
| 59 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
<label class="range-label">
|
| 61 |
<span>Spacing <output id="spacing-value">1.00×</output></span>
|
| 62 |
<input id="spacing" type="range" min="0.25" max="2" step="0.01" value="1" />
|
|
|
|
| 57 |
<span>Logo size <output id="size-value">1.00×</output></span>
|
| 58 |
<input id="logo-size" type="range" min="0.5" max="1.6" step="0.01" value="1" />
|
| 59 |
</label>
|
| 60 |
+
<label class="range-label">
|
| 61 |
+
<span>Size variation <output id="size-variation-value">100%</output></span>
|
| 62 |
+
<input id="size-variation" type="range" min="0" max="1.5" step="0.01" value="1" />
|
| 63 |
+
</label>
|
| 64 |
<label class="range-label">
|
| 65 |
<span>Spacing <output id="spacing-value">1.00×</output></span>
|
| 66 |
<input id="spacing" type="range" min="0.25" max="2" step="0.01" value="1" />
|
src/layout.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ const options = {
|
|
| 5 |
aspect: 16 / 9,
|
| 6 |
count: 88,
|
| 7 |
logoScale: 1,
|
|
|
|
| 8 |
spacing: 1,
|
| 9 |
rotation: 52,
|
| 10 |
seed: 84237,
|
|
@@ -43,6 +44,22 @@ describe("generateLayout", () => {
|
|
| 43 |
expect([...layout].every(Number.isFinite)).toBe(true);
|
| 44 |
});
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
it("changes spacing continuously and visibly", () => {
|
| 47 |
const compact = generateLayout({ ...options, spacing: 0.5 });
|
| 48 |
const neutral = generateLayout({ ...options, spacing: 1 });
|
|
|
|
| 5 |
aspect: 16 / 9,
|
| 6 |
count: 88,
|
| 7 |
logoScale: 1,
|
| 8 |
+
sizeVariation: 1,
|
| 9 |
spacing: 1,
|
| 10 |
rotation: 52,
|
| 11 |
seed: 84237,
|
|
|
|
| 44 |
expect([...layout].every(Number.isFinite)).toBe(true);
|
| 45 |
});
|
| 46 |
|
| 47 |
+
it("controls size variation continuously", () => {
|
| 48 |
+
const uniform = generateLayout({ ...options, sizeVariation: 0 });
|
| 49 |
+
const varied = generateLayout({ ...options, sizeVariation: 1 });
|
| 50 |
+
const exaggerated = generateLayout({ ...options, sizeVariation: 1.5 });
|
| 51 |
+
const widths = (layout: Float32Array): number[] => {
|
| 52 |
+
const values: number[] = [];
|
| 53 |
+
for (let offset = 2; offset < layout.length; offset += PLACEMENT_STRIDE) values.push(layout[offset] ?? 0);
|
| 54 |
+
return values;
|
| 55 |
+
};
|
| 56 |
+
const range = (values: number[]): number => Math.max(...values) - Math.min(...values);
|
| 57 |
+
|
| 58 |
+
expect(range(widths(uniform))).toBeLessThan(0.000001);
|
| 59 |
+
expect(range(widths(varied))).toBeGreaterThan(0.15);
|
| 60 |
+
expect(range(widths(exaggerated))).toBeGreaterThan(range(widths(varied)));
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
it("changes spacing continuously and visibly", () => {
|
| 64 |
const compact = generateLayout({ ...options, spacing: 0.5 });
|
| 65 |
const neutral = generateLayout({ ...options, spacing: 1 });
|
src/layout.ts
CHANGED
|
@@ -2,6 +2,7 @@ export interface LayoutOptions {
|
|
| 2 |
aspect: number;
|
| 3 |
count: number;
|
| 4 |
logoScale: number;
|
|
|
|
| 5 |
spacing: number;
|
| 6 |
rotation: number;
|
| 7 |
seed: number;
|
|
@@ -31,10 +32,14 @@ class Random {
|
|
| 31 |
}
|
| 32 |
}
|
| 33 |
|
| 34 |
-
function pickWidth(random: Random, scale: number): number {
|
| 35 |
const roll = random.next();
|
| 36 |
for (const [threshold, low, high] of SIZE_BANDS) {
|
| 37 |
-
if (roll <= threshold)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
return 0.07 * scale;
|
| 40 |
}
|
|
@@ -49,7 +54,9 @@ export function generateLayout(options: LayoutOptions): Float32Array {
|
|
| 49 |
const width = Math.max(0.5, options.aspect);
|
| 50 |
const height = 1;
|
| 51 |
const widths = new Float32Array(count);
|
| 52 |
-
for (let index = 0; index < count; index += 1)
|
|
|
|
|
|
|
| 53 |
widths.sort();
|
| 54 |
widths.reverse();
|
| 55 |
|
|
|
|
| 2 |
aspect: number;
|
| 3 |
count: number;
|
| 4 |
logoScale: number;
|
| 5 |
+
sizeVariation: number;
|
| 6 |
spacing: number;
|
| 7 |
rotation: number;
|
| 8 |
seed: number;
|
|
|
|
| 32 |
}
|
| 33 |
}
|
| 34 |
|
| 35 |
+
function pickWidth(random: Random, scale: number, variation: number): number {
|
| 36 |
const roll = random.next();
|
| 37 |
for (const [threshold, low, high] of SIZE_BANDS) {
|
| 38 |
+
if (roll <= threshold) {
|
| 39 |
+
const sampledWidth = low + random.next() * (high - low);
|
| 40 |
+
const uniformWidth = 0.115;
|
| 41 |
+
return Math.max(0.025, uniformWidth + (sampledWidth - uniformWidth) * variation) * scale;
|
| 42 |
+
}
|
| 43 |
}
|
| 44 |
return 0.07 * scale;
|
| 45 |
}
|
|
|
|
| 54 |
const width = Math.max(0.5, options.aspect);
|
| 55 |
const height = 1;
|
| 56 |
const widths = new Float32Array(count);
|
| 57 |
+
for (let index = 0; index < count; index += 1) {
|
| 58 |
+
widths[index] = pickWidth(random, options.logoScale, Math.max(0, options.sizeVariation));
|
| 59 |
+
}
|
| 60 |
widths.sort();
|
| 61 |
widths.reverse();
|
| 62 |
|
src/main.ts
CHANGED
|
@@ -21,6 +21,7 @@ const widthInput = element<HTMLInputElement>("width");
|
|
| 21 |
const heightInput = element<HTMLInputElement>("height");
|
| 22 |
const densityInput = element<HTMLInputElement>("density");
|
| 23 |
const sizeInput = element<HTMLInputElement>("logo-size");
|
|
|
|
| 24 |
const spacingInput = element<HTMLInputElement>("spacing");
|
| 25 |
const rotationInput = element<HTMLInputElement>("rotation");
|
| 26 |
const seedInput = element<HTMLInputElement>("seed");
|
|
@@ -46,6 +47,7 @@ function readConfig(): Config {
|
|
| 46 |
aspect: width / height,
|
| 47 |
count: Math.round(numberValue(densityInput, 88)),
|
| 48 |
logoScale: numberValue(sizeInput, 1),
|
|
|
|
| 49 |
spacing: numberValue(spacingInput, 1),
|
| 50 |
rotation: numberValue(rotationInput, 52),
|
| 51 |
seed: Math.round(numberValue(seedInput, 84237)) >>> 0,
|
|
@@ -53,12 +55,13 @@ function readConfig(): Config {
|
|
| 53 |
}
|
| 54 |
|
| 55 |
function configKey(config: Config): string {
|
| 56 |
-
return [config.aspect.toFixed(6), config.count, config.logoScale, config.spacing, config.rotation, config.seed].join(":");
|
| 57 |
}
|
| 58 |
|
| 59 |
function updateLabels(config: Config): void {
|
| 60 |
element<HTMLOutputElement>("density-value").value = String(config.count);
|
| 61 |
element<HTMLOutputElement>("size-value").value = `${config.logoScale.toFixed(2)}×`;
|
|
|
|
| 62 |
element<HTMLOutputElement>("spacing-value").value = `${config.spacing.toFixed(2)}×`;
|
| 63 |
element<HTMLOutputElement>("rotation-value").value = `${Math.round(config.rotation)}°`;
|
| 64 |
element<HTMLSpanElement>("pixel-count").textContent = `${((config.width * config.height) / 1_000_000).toFixed(1)} MP`;
|
|
@@ -151,6 +154,7 @@ function setDefaults(): void {
|
|
| 151 |
heightInput.value = "2160";
|
| 152 |
densityInput.value = "88";
|
| 153 |
sizeInput.value = "1";
|
|
|
|
| 154 |
spacingInput.value = "1";
|
| 155 |
rotationInput.value = "52";
|
| 156 |
seedInput.value = "84237";
|
|
@@ -168,7 +172,7 @@ resolution.addEventListener("change", () => {
|
|
| 168 |
schedulePreview();
|
| 169 |
});
|
| 170 |
|
| 171 |
-
for (const input of [widthInput, heightInput, densityInput, sizeInput, spacingInput, rotationInput, seedInput]) {
|
| 172 |
input.addEventListener("input", () => {
|
| 173 |
if (input === widthInput || input === heightInput) resolution.value = "custom";
|
| 174 |
schedulePreview();
|
|
|
|
| 21 |
const heightInput = element<HTMLInputElement>("height");
|
| 22 |
const densityInput = element<HTMLInputElement>("density");
|
| 23 |
const sizeInput = element<HTMLInputElement>("logo-size");
|
| 24 |
+
const sizeVariationInput = element<HTMLInputElement>("size-variation");
|
| 25 |
const spacingInput = element<HTMLInputElement>("spacing");
|
| 26 |
const rotationInput = element<HTMLInputElement>("rotation");
|
| 27 |
const seedInput = element<HTMLInputElement>("seed");
|
|
|
|
| 47 |
aspect: width / height,
|
| 48 |
count: Math.round(numberValue(densityInput, 88)),
|
| 49 |
logoScale: numberValue(sizeInput, 1),
|
| 50 |
+
sizeVariation: numberValue(sizeVariationInput, 1),
|
| 51 |
spacing: numberValue(spacingInput, 1),
|
| 52 |
rotation: numberValue(rotationInput, 52),
|
| 53 |
seed: Math.round(numberValue(seedInput, 84237)) >>> 0,
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
function configKey(config: Config): string {
|
| 58 |
+
return [config.aspect.toFixed(6), config.count, config.logoScale, config.sizeVariation, config.spacing, config.rotation, config.seed].join(":");
|
| 59 |
}
|
| 60 |
|
| 61 |
function updateLabels(config: Config): void {
|
| 62 |
element<HTMLOutputElement>("density-value").value = String(config.count);
|
| 63 |
element<HTMLOutputElement>("size-value").value = `${config.logoScale.toFixed(2)}×`;
|
| 64 |
+
element<HTMLOutputElement>("size-variation-value").value = `${Math.round(config.sizeVariation * 100)}%`;
|
| 65 |
element<HTMLOutputElement>("spacing-value").value = `${config.spacing.toFixed(2)}×`;
|
| 66 |
element<HTMLOutputElement>("rotation-value").value = `${Math.round(config.rotation)}°`;
|
| 67 |
element<HTMLSpanElement>("pixel-count").textContent = `${((config.width * config.height) / 1_000_000).toFixed(1)} MP`;
|
|
|
|
| 154 |
heightInput.value = "2160";
|
| 155 |
densityInput.value = "88";
|
| 156 |
sizeInput.value = "1";
|
| 157 |
+
sizeVariationInput.value = "1";
|
| 158 |
spacingInput.value = "1";
|
| 159 |
rotationInput.value = "52";
|
| 160 |
seedInput.value = "84237";
|
|
|
|
| 172 |
schedulePreview();
|
| 173 |
});
|
| 174 |
|
| 175 |
+
for (const input of [widthInput, heightInput, densityInput, sizeInput, sizeVariationInput, spacingInput, rotationInput, seedInput]) {
|
| 176 |
input.addEventListener("input", () => {
|
| 177 |
if (input === widthInput || input === heightInput) resolution.value = "custom";
|
| 178 |
schedulePreview();
|