feat(ant-colony): add ant colony optimization demo for tsp

Ant System on the travelling salesman problem, run on the CPU.
Click the canvas to add cities while the colony runs.
Show the nearest-neighbor tour as a baseline. Tick ROADMAP.
This commit is contained in:
lobo committed 2026-10-23 10:20:06 +02:00
1 parent 26601bef46
commit 4432e38cd2
14 files changed
+995 -2

No files matched your search

+1
View File
@@ -28,3 +28,4 @@ Open ideas for the playground frontend. Tick a box when done.
- [x] A* vs Dijkstra side by side (reuse pathfinding) - [x] A* vs Dijkstra side by side (reuse pathfinding)
- [x] Fourier epicycles - [x] Fourier epicycles
- [x] Reaction-diffusion (Gray-Scott) shader (reuse the fractal shader setup) - [x] Reaction-diffusion (Gray-Scott) shader (reuse the fractal shader setup)
- [x] **Ant colony** (ACO on TSP, CPU + Canvas 2D)
+1
View File
@@ -19,6 +19,7 @@ export const routes: Routes = [
{ path: RouterConstants.BOIDS.PATH, loadComponent: () => import('./pages/algorithms/boids/boids.component').then(m => m.BoidsComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.BOIDS.PATH, loadComponent: () => import('./pages/algorithms/boids/boids.component').then(m => m.BoidsComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.PHYSARUM.PATH, loadComponent: () => import('./pages/algorithms/physarum/physarum.component').then(m => m.PhysarumComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.PHYSARUM.PATH, loadComponent: () => import('./pages/algorithms/physarum/physarum.component').then(m => m.PhysarumComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.GRAY_SCOTT.PATH, loadComponent: () => import('./pages/algorithms/gray-scott/gray-scott.component').then(m => m.GrayScottComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.GRAY_SCOTT.PATH, loadComponent: () => import('./pages/algorithms/gray-scott/gray-scott.component').then(m => m.GrayScottComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.ANT_COLONY.PATH, loadComponent: () => import('./pages/algorithms/ant-colony/ant-colony.component').then(m => m.AntColonyComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } }, { path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } },
{ path: RouterConstants.FOURIER.PATH, loadComponent: () => import('./pages/algorithms/fourier/fourier.component').then(m => m.FourierComponent), data: { seo: 'FOURIER' } }, { path: RouterConstants.FOURIER.PATH, loadComponent: () => import('./pages/algorithms/fourier/fourier.component').then(m => m.FourierComponent), data: { seo: 'FOURIER' } },
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } }, { path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } },
+5
View File
@@ -70,6 +70,11 @@
LINK: '/algorithms/gray-scott', LINK: '/algorithms/gray-scott',
}; };
static readonly ANT_COLONY = {
PATH: 'algorithms/ant-colony',
LINK: '/algorithms/ant-colony',
};
static readonly FOUR_COLOR = { static readonly FOUR_COLOR = {
PATH: 'algorithms/four_color', PATH: 'algorithms/four_color',
LINK: '/algorithms/four_color', LINK: '/algorithms/four_color',
+2
View File
@@ -27,6 +27,8 @@
static readonly PHYSARUM_PAPER = 'https://doi.org/10.1162/artl.2010.16.2.16202' static readonly PHYSARUM_PAPER = 'https://doi.org/10.1162/artl.2010.16.2.16202'
static readonly GRAY_SCOTT_WIKI = 'https://en.wikipedia.org/wiki/Reaction%E2%80%93diffusion_system' static readonly GRAY_SCOTT_WIKI = 'https://en.wikipedia.org/wiki/Reaction%E2%80%93diffusion_system'
static readonly GRAY_SCOTT_SIMS = 'https://www.karlsims.com/rd.html' static readonly GRAY_SCOTT_SIMS = 'https://www.karlsims.com/rd.html'
static readonly ANT_COLONY_WIKI = 'https://de.wikipedia.org/wiki/Ameisenalgorithmus'
static readonly TSP_WIKI = 'https://de.wikipedia.org/wiki/Problem_des_Handlungsreisenden'
static readonly GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units' static readonly GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units'
static readonly DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur' static readonly DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur'
static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz' static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz'
@@ -43,6 +43,13 @@ export class AlgorithmsService {
routerLink: RouterConstants.FRACTAL.LINK, routerLink: RouterConstants.FRACTAL.LINK,
icon: 'blur_on' icon: 'blur_on'
}, },
{
id: 'antColony',
title: 'ALGORITHM.ANT_COLONY.TITLE',
description: 'ALGORITHM.ANT_COLONY.DESCRIPTION',
routerLink: RouterConstants.ANT_COLONY.LINK,
icon: 'pest_control'
},
{ {
id: 'fractal3d', id: 'fractal3d',
title: 'ALGORITHM.FRACTAL3D.TITLE', title: 'ALGORITHM.FRACTAL3D.TITLE',
@@ -0,0 +1,128 @@
import {AntColonySimulation} from './ant-colony-simulation';
import {
City, DEFAULT_SETTINGS, MAX_CITIES, MIN_CITY_DISTANCE, nearestNeighborTour, PHEROMONE_Q, randomCities, TAU_MIN, tourLength
} from './ant-colony.model';
function mulberry(seed: number): () => number {
let a = seed;
return () => {
a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeSim(count = 12, seed = 7, settings = DEFAULT_SETTINGS): AntColonySimulation {
const random = mulberry(seed);
return new AntColonySimulation(randomCities(count, random), settings, random);
}
describe('AntColonySimulation', () => {
it('constructTour visits every city once and starts at start', () => {
const sim = makeSim();
sim['prepareWeights']();
const tour = sim.constructTour(4);
expect(tour[0]).toBe(4);
expect([...tour].sort((a, b) => a - b)).toEqual(sim.cities.map((_, i) => i));
});
it('constructTour follows the nearest city when distance dominates', () => {
const cities: City[] = [{x: 0, y: 0}, {x: 300, y: 0}, {x: 100, y: 0}, {x: 500, y: 0}, {x: 190, y: 0}];
const sim = new AntColonySimulation(cities, {...DEFAULT_SETTINGS, alpha: 0, beta: 50}, () => 0.5);
sim['prepareWeights']();
expect(sim.constructTour(0)).toEqual(nearestNeighborTour(cities, 0));
});
it('evaporate multiplies by (1 - evaporation) and keeps the floor', () => {
const sim = makeSim(5, 1, {...DEFAULT_SETTINGS, evaporation: 0.5});
sim.pheromone[1] = 8;
sim.pheromone[2] = TAU_MIN;
const before = Float32Array.from(sim.pheromone);
sim.evaporate();
expect(sim.pheromone[1]).toBeCloseTo(4);
expect(sim.pheromone[2]).toBeCloseTo(TAU_MIN, 9);
expect(sim.pheromone[3]).toBeCloseTo(Math.max(TAU_MIN, before[3] * 0.5), 9);
});
it('deposit adds Q / length on both directions of tour edges only', () => {
const sim = makeSim(5, 2);
const before = Float32Array.from(sim.pheromone);
sim.deposit([0, 1, 2], 10);
const n = 5;
const edges = new Set(['0,1', '1,0', '1,2', '2,1', '2,0', '0,2']);
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const added = sim.pheromone[i * n + j] - before[i * n + j];
expect(added).toBeCloseTo(edges.has(`${i},${j}`) ? PHEROMONE_Q / 10 : 0, 6);
}
}
});
it('step counts iterations, never worsens the best and tracks the max pheromone', () => {
const sim = makeSim();
let last = sim.bestLength;
for (let i = 0; i < 50; i++) {
sim.step();
expect(sim.bestLength).toBeLessThanOrEqual(last);
last = sim.bestLength;
}
expect(sim.iteration).toBe(50);
expect(sim.maxPheromone).toBe(Math.max(...sim.pheromone));
expect(sim.bestLength).toBeCloseTo(tourLength(sim.bestTour, sim.cities), 3);
});
it('finds a tour within 10% of nearest neighbor on default settings', () => {
const sim = makeSim(20, 11);
for (let i = 0; i < 150; i++) {
sim.step();
}
expect(sim.bestLength).toBeLessThanOrEqual(1.1 * sim.nnLength);
});
it('addCity grows the matrices and inserts the city into the best tour', () => {
const sim = makeSim(10, 3);
for (let i = 0; i < 5; i++) {
sim.step();
}
const old = Float32Array.from(sim.pheromone);
const mean = old.reduce((s, v) => s + v, 0) / old.length;
const nnBefore = sim.nnLength;
expect(sim.addCity({x: 500.5, y: 500.5})).toBe(true);
const n = 11;
expect(sim.cities.length).toBe(n);
expect(sim.pheromone.length).toBe(n * n);
expect(sim.pheromone[10]).toBeCloseTo(mean, 6);
expect(sim.pheromone[n * 10 + 3]).toBeCloseTo(mean, 6);
expect(sim.pheromone[n * 2 + 3]).toBe(old[10 * 2 + 3]);
expect(sim.bestTour.length).toBe(n);
expect(sim.bestTour).toContain(10);
expect(sim.nnLength).not.toBe(nnBefore);
sim.step();
expect(sim.antTours[0].length).toBe(n);
});
it('addCity refuses a point near an existing city', () => {
const sim = makeSim(6, 4);
const near = {x: sim.cities[0].x + MIN_CITY_DISTANCE - 1, y: sim.cities[0].y};
expect(sim.addCity(near)).toBe(false);
expect(sim.cities.length).toBe(6);
expect(sim.pheromone.length).toBe(36);
});
it('addCity refuses at MAX_CITIES', () => {
const cities = Array.from({length: MAX_CITIES}, (_, i) => ({x: (i % 12) * 80 + 20, y: Math.floor(i / 12) * 80 + 20}));
const sim = new AntColonySimulation(cities, DEFAULT_SETTINGS, mulberry(1));
expect(sim.addCity({x: 999, y: 999})).toBe(false);
expect(sim.cities.length).toBe(MAX_CITIES);
});
it('does nothing with fewer than 3 cities', () => {
const sim = new AntColonySimulation([{x: 0, y: 0}, {x: 30, y: 40}], DEFAULT_SETTINGS, mulberry(1));
const tour = [...sim.bestTour];
sim.step();
expect(sim.iteration).toBe(0);
expect(sim.bestTour).toEqual(tour);
expect(sim.bestLength).toBeCloseTo(100);
});
});
@@ -0,0 +1,178 @@
import {
AntColonySettings, City, cheapestInsertion, distance, MAX_CITIES, MIN_CITIES, MIN_CITY_DISTANCE, nearestNeighborTour,
PHEROMONE_Q, TAU_MIN, tourLength
} from './ant-colony.model';
/** Ant System on a symmetric TSP. */
export class AntColonySimulation {
readonly cities: City[];
pheromone = new Float32Array(0);
maxPheromone = 0;
iteration = 0;
bestTour: number[] = [];
bestLength = 0;
nnLength = 0;
antTours: number[][] = [];
private distances = new Float32Array(0);
private weights = new Float64Array(0);
private visited = new Uint8Array(0);
constructor(cities: City[], private settings: AntColonySettings, private readonly random: () => number = Math.random) {
this.cities = [...cities];
const n = this.cities.length;
this.rebuildDistances();
this.updateBaseline();
this.pheromone = new Float32Array(n * n).fill(n >= MIN_CITIES ? 1 / (n * this.nnLength) : 1);
this.maxPheromone = this.matrixMax();
}
setSettings(settings: AntColonySettings): void {
this.settings = settings;
}
step(): void {
const n = this.cities.length;
if (n < MIN_CITIES) {
return;
}
this.prepareWeights();
const ants = this.settings.ants;
this.antTours.length = ants;
const lengths: number[] = [];
for (let k = 0; k < ants; k++) {
const tour = this.constructTour(Math.floor(this.random() * n));
this.antTours[k] = tour;
lengths.push(tourLength(tour, this.cities));
}
this.evaporate();
for (let k = 0; k < ants; k++) {
this.deposit(this.antTours[k], lengths[k]);
if (lengths[k] < this.bestLength) {
this.bestLength = lengths[k];
this.bestTour = this.antTours[k];
}
}
this.maxPheromone = this.matrixMax();
this.iteration++;
}
/** Roulette wheel on the weights of the last prepareWeights() call. */
constructTour(start: number): number[] {
const n = this.cities.length;
this.visited.fill(0);
const tour = [start];
this.visited[start] = 1;
let current = start;
while (tour.length < n) {
let sum = 0;
for (let j = 0; j < n; j++) {
if (!this.visited[j]) {
sum += this.weights[current * n + j];
}
}
let pick = this.random() * sum;
let next = -1;
for (let j = 0; j < n; j++) {
if (!this.visited[j]) {
next = j;
pick -= this.weights[current * n + j];
if (pick <= 0) {
break;
}
}
}
this.visited[next] = 1;
tour.push(next);
current = next;
}
return tour;
}
evaporate(): void {
const keep = 1 - this.settings.evaporation;
for (let i = 0; i < this.pheromone.length; i++) {
this.pheromone[i] = Math.max(TAU_MIN, keep * this.pheromone[i]);
}
}
deposit(tour: readonly number[], length: number): void {
const n = this.cities.length;
const amount = PHEROMONE_Q / length;
for (let i = 0; i < tour.length; i++) {
const a = tour[i];
const b = tour[(i + 1) % tour.length];
this.pheromone[a * n + b] += amount;
this.pheromone[b * n + a] += amount;
}
}
addCity(city: City): boolean {
const n = this.cities.length;
if (n >= MAX_CITIES || this.cities.some(c => distance(c, city) < MIN_CITY_DISTANCE)) {
return false;
}
const old = this.pheromone;
let mean = 0;
for (let i = 0; i < old.length; i++) {
mean += old[i];
}
mean = old.length ? mean / old.length : 1;
const m = n + 1;
const grown = new Float32Array(m * m).fill(mean);
for (let i = 0; i < n; i++) {
grown.set(old.subarray(i * n, (i + 1) * n), i * m);
}
this.cities.push(city);
this.pheromone = grown;
this.rebuildDistances();
this.bestTour = cheapestInsertion(this.bestTour, this.cities, n);
this.bestLength = tourLength(this.bestTour, this.cities);
this.updateBaseline();
this.antTours = [];
this.maxPheromone = this.matrixMax();
return true;
}
private updateBaseline(): void {
const tour = nearestNeighborTour(this.cities);
this.nnLength = tourLength(tour, this.cities);
if (this.bestTour.length === 0) {
this.bestTour = tour;
this.bestLength = this.nnLength;
}
}
private rebuildDistances(): void {
const n = this.cities.length;
this.distances = new Float32Array(n * n);
this.weights = new Float64Array(n * n);
this.visited = new Uint8Array(n);
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
this.distances[i * n + j] = distance(this.cities[i], this.cities[j]);
}
}
}
/** tau^alpha * (1/d)^beta once per step, so every ant only sums. */
private prepareWeights(): void {
const n = this.cities.length;
const {alpha, beta} = this.settings;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const k = i * n + j;
const d = this.distances[k];
this.weights[k] = i === j || d === 0 ? 0 : Math.pow(this.pheromone[k], alpha) * Math.pow(1 / d, beta);
}
}
}
private matrixMax(): number {
let max = 0;
for (let i = 0; i < this.pheromone.length; i++) {
max = Math.max(max, this.pheromone[i]);
}
return max;
}
}
@@ -0,0 +1,48 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'ANT_COLONY.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4">
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button mat-raised-button color="accent" (click)="togglePlay()">
<mat-icon>{{ running() ? 'pause' : 'play_arrow' }}</mat-icon>{{ (running() ? 'ANT_COLONY.PAUSE' : 'ANT_COLONY.PLAY') | translate }}
</button>
<button mat-raised-button (click)="reset()">{{ 'ANT_COLONY.RESET' | translate }}</button>
<button mat-raised-button (click)="reroll()">{{ 'ANT_COLONY.REROLL' | translate }}</button>
</div>
<div class="flex items-center gap-2.5">
<span>{{ 'ANT_COLONY.CITIES' | translate }}: {{ cityCount() }}</span>
<mat-slider [min]="MIN_CITIES" [max]="MAX_CITIES" step="1">
<input matSliderThumb [(ngModel)]="cityCount" (valueChange)="reroll()">
</mat-slider>
</div>
@for (slider of sliders; track slider.key) {
<div class="flex items-center gap-2.5">
<span>{{ slider.label | translate }}: {{ slider.model() }}</span>
<mat-slider [min]="limits[slider.key].min" [max]="limits[slider.key].max" [step]="limits[slider.key].step">
<input matSliderThumb [ngModel]="slider.model()" (ngModelChange)="slider.model.set($event)">
</mat-slider>
</div>
}
<div class="flex items-center gap-2.5">
<span>{{ 'ANT_COLONY.SPEED' | translate }}: {{ speed() }}</span>
<mat-slider [min]="MIN_SPEED" [max]="MAX_SPEED" step="1">
<input matSliderThumb [(ngModel)]="speed">
</mat-slider>
</div>
<p class="text-[0.9em] opacity-80">{{ 'ANT_COLONY.CLICK_HINT' | translate }}</p>
</div>
<app-metrics [metrics]="metrics()"/>
<div class="w-full max-w-[800px] mx-auto">
<canvas #antCanvas
class="touch-none cursor-crosshair max-w-full"
(pointerdown)="onPointerDown($event)"
(pointerup)="onPointerUp($event)"
(pointercancel)="onPointerLeave()"
(pointerleave)="onPointerLeave()"
></canvas>
</div>
</mat-card-content>
</mat-card>
@@ -0,0 +1,299 @@
import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, signal, ViewChild, WritableSignal} from '@angular/core';
import {isPlatformBrowser} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {MatButton} from '@angular/material/button';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {MatIcon} from '@angular/material/icon';
import {MatSliderModule} from '@angular/material/slider';
import {TranslatePipe} from '@ngx-translate/core';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {UrlConstants} from '../../../constants/UrlConstants';
import {ThemeService} from '../../../service/theme.service';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
import {AntColonySimulation} from './ant-colony-simulation';
import {
AntColonyColors, AntColonySettings, CLICK_MOVE_PX, City, clampCityCount, clampSettings, DEFAULT_CITIES, DEFAULT_SETTINGS, DEFAULT_SPEED, MAX_CANVAS_PX,
MAX_CITIES, MAX_SPEED, MIN_CITIES, MIN_DRAW_RATIO, MIN_SPEED, pointOnTour, randomCities, SETTINGS_LIMITS, STEP_BUDGET_MS, THEME_COLORS,
WORLD_SIZE
} from './ant-colony.model';
interface SettingSlider {
key: keyof AntColonySettings;
label: string;
model: WritableSignal<number>;
}
@Component({
selector: 'app-ant-colony',
imports: [
MatCard,
MatCardContent,
MatCardHeader,
MatCardTitle,
TranslatePipe,
Information,
MatButton,
MatIcon,
MatSliderModule,
FormsModule,
MetricsComponent
],
templateUrl: './ant-colony.component.html'
})
export class AntColonyComponent implements AfterViewInit, OnDestroy {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly themeService = inject(ThemeService);
private readonly ngZone = inject(NgZone);
private readonly fpsCounter = new FpsCounter();
@ViewChild('antCanvas', {static: true}) canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private sim: AntColonySimulation | null = null;
private animationFrameId = 0;
private lastFrameTime = 0;
private pending = 0;
private phase = 0;
private cssSize = MAX_CANVAS_PX;
private pressed: {pointerId: number; x: number; y: number} | null = null;
protected readonly MIN_CITIES = MIN_CITIES;
protected readonly MAX_CITIES = MAX_CITIES;
protected readonly MIN_SPEED = MIN_SPEED;
protected readonly MAX_SPEED = MAX_SPEED;
protected readonly limits = SETTINGS_LIMITS;
readonly cityCount = signal(DEFAULT_CITIES);
readonly ants = signal(DEFAULT_SETTINGS.ants);
readonly alpha = signal(DEFAULT_SETTINGS.alpha);
readonly beta = signal(DEFAULT_SETTINGS.beta);
readonly evaporation = signal(DEFAULT_SETTINGS.evaporation);
readonly speed = signal(DEFAULT_SPEED);
readonly running = signal(true);
readonly iteration = signal(0);
readonly bestLength = signal(0);
readonly nnLength = signal(0);
readonly gap = computed(() => this.nnLength() ? (this.bestLength() - this.nnLength()) / this.nnLength() * 100 : 0);
readonly sliders: SettingSlider[] = [
{key: 'ants', label: 'ANT_COLONY.ANTS', model: this.ants},
{key: 'alpha', label: 'ANT_COLONY.ALPHA', model: this.alpha},
{key: 'beta', label: 'ANT_COLONY.BETA', model: this.beta},
{key: 'evaporation', label: 'ANT_COLONY.EVAPORATION', model: this.evaporation}
];
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
{label: 'METRICS.ITERATION', value: this.iteration()},
{label: 'ANT_COLONY.BEST_LENGTH', value: this.bestLength(), fractionDigits: 0},
{label: 'ANT_COLONY.NN_LENGTH', value: this.nnLength(), fractionDigits: 0},
{label: 'ANT_COLONY.GAP', value: this.gap(), unit: '%', fractionDigits: 1}
]);
readonly algoInformation: AlgorithmInformation = {
title: 'ANT_COLONY.EXPLANATION.TITLE',
entries: [
{
name: 'ANT_COLONY.EXPLANATION.ACO_TITLE',
description: 'ANT_COLONY.EXPLANATION.ACO',
link: UrlConstants.ANT_COLONY_WIKI,
translateName: true
},
{
name: 'ANT_COLONY.EXPLANATION.TSP_TITLE',
description: 'ANT_COLONY.EXPLANATION.TSP',
link: UrlConstants.TSP_WIKI,
translateName: true
}
],
disclaimer: 'ANT_COLONY.EXPLANATION.DISCLAIMER',
disclaimerBottom: '',
disclaimerListEntry: ['ANT_COLONY.EXPLANATION.DISCLAIMER_1', 'ANT_COLONY.EXPLANATION.DISCLAIMER_2', 'ANT_COLONY.EXPLANATION.DISCLAIMER_3'],
howItWorks: {
intro: 'ANT_COLONY.HOW_IT_WORKS.INTRO',
items: ['ANT_COLONY.HOW_IT_WORKS.ITEM_1', 'ANT_COLONY.HOW_IT_WORKS.ITEM_2', 'ANT_COLONY.HOW_IT_WORKS.ITEM_3', 'ANT_COLONY.HOW_IT_WORKS.ITEM_4', 'ANT_COLONY.HOW_IT_WORKS.ITEM_5']
},
sources: SOURCE_PATHS.ANT_COLONY
};
ngAfterViewInit(): void {
if (!this.isBrowser) {
return;
}
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
this.resizeCanvas();
this.reroll();
this.ngZone.runOutsideAngular(() => {
this.animationFrameId = requestAnimationFrame(this.frame);
});
}
ngOnDestroy(): void {
if (this.isBrowser) {
cancelAnimationFrame(this.animationFrameId);
}
}
/** Changes only the canvas size. The simulation lives in world units. */
@HostListener('window:resize')
resizeCanvas(): void {
const canvas = this.canvasRef.nativeElement;
const dpr = window.devicePixelRatio || 1;
this.cssSize = Math.min(canvas.parentElement!.clientWidth, MAX_CANVAS_PX);
canvas.width = canvas.height = Math.round(this.cssSize * dpr);
canvas.style.width = canvas.style.height = `${this.cssSize}px`;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
reroll(): void {
this.createSimulation(randomCities(clampCityCount(this.cityCount())));
}
reset(): void {
if (this.sim) {
this.createSimulation(this.sim.cities);
}
}
togglePlay(): void {
this.running.update(v => !v);
if (this.running()) {
this.lastFrameTime = 0;
}
}
onPointerDown(event: PointerEvent): void {
if (event.isPrimary) {
this.pressed = {pointerId: event.pointerId, x: event.clientX, y: event.clientY};
}
}
onPointerUp(event: PointerEvent): void {
const down = this.pressed;
this.pressed = null;
if (!down || down.pointerId !== event.pointerId || Math.hypot(event.clientX - down.x, event.clientY - down.y) >= CLICK_MOVE_PX) {
return;
}
if (this.sim?.addCity(this.toWorld(event))) {
this.cityCount.set(this.sim.cities.length);
this.nnLength.set(this.sim.nnLength);
this.bestLength.set(this.sim.bestLength);
}
}
onPointerLeave(): void {
this.pressed = null;
}
private createSimulation(cities: City[]): void {
this.sim = new AntColonySimulation(cities, this.currentSettings());
this.cityCount.set(cities.length);
this.pending = 0;
this.iteration.set(0);
this.bestLength.set(this.sim.bestLength);
this.nnLength.set(this.sim.nnLength);
}
private currentSettings(): AntColonySettings {
return clampSettings({ants: this.ants(), alpha: this.alpha(), beta: this.beta(), evaporation: this.evaporation()});
}
private toWorld(event: PointerEvent): City {
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
return {
x: (event.clientX - rect.left) / rect.width * WORLD_SIZE,
y: (event.clientY - rect.top) / rect.height * WORLD_SIZE
};
}
private readonly frame = (now: number): void => {
this.fpsCounter.tick(now);
const dt = this.lastFrameTime ? Math.min((now - this.lastFrameTime) / 1000, 0.25) : 0;
this.lastFrameTime = now;
const sim = this.sim;
if (sim) {
if (this.running()) {
this.advance(sim, dt);
}
this.draw(sim);
}
this.animationFrameId = requestAnimationFrame(this.frame);
};
private advance(sim: AntColonySimulation, dt: number): void {
sim.setSettings(this.currentSettings());
const iterations = dt * this.speed();
this.pending += iterations;
this.phase = (this.phase + iterations) % 1;
const start = performance.now();
while (this.pending >= 1 && performance.now() - start < STEP_BUDGET_MS) {
sim.step();
this.pending--;
}
if (this.pending >= 1) {
this.pending = 0;
}
this.iteration.set(sim.iteration);
this.bestLength.set(sim.bestLength);
}
private draw(sim: AntColonySimulation): void {
const colors = THEME_COLORS[this.themeService.theme()];
const scale = this.cssSize / WORLD_SIZE;
this.ctx.clearRect(0, 0, this.cssSize, this.cssSize);
this.drawPheromone(sim, colors, scale);
this.drawBestTour(sim, colors, scale);
this.drawDots(sim.cities, colors.city, 4, scale);
const count = sim.antTours.length;
const ants = sim.antTours.map((tour, k) => pointOnTour(tour, sim.cities, (this.phase + k / count) % 1));
this.drawDots(ants, colors.ant, 3, scale);
}
private drawPheromone(sim: AntColonySimulation, colors: AntColonyColors, scale: number): void {
if (sim.maxPheromone <= 0) {
return;
}
const ctx = this.ctx;
const n = sim.cities.length;
ctx.lineWidth = 1.5;
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const ratio = sim.pheromone[i * n + j] / sim.maxPheromone;
if (ratio > MIN_DRAW_RATIO) {
ctx.strokeStyle = `rgba(${colors.pheromone}, ${ratio})`;
ctx.beginPath();
ctx.moveTo(sim.cities[i].x * scale, sim.cities[i].y * scale);
ctx.lineTo(sim.cities[j].x * scale, sim.cities[j].y * scale);
ctx.stroke();
}
}
}
}
private drawBestTour(sim: AntColonySimulation, colors: AntColonyColors, scale: number): void {
if (sim.bestTour.length < 2) {
return;
}
const ctx = this.ctx;
ctx.strokeStyle = colors.best;
ctx.lineWidth = 2.5;
ctx.beginPath();
for (const index of sim.bestTour) {
ctx.lineTo(sim.cities[index].x * scale, sim.cities[index].y * scale);
}
ctx.closePath();
ctx.stroke();
}
private drawDots(points: readonly City[], color: string, radius: number, scale: number): void {
const ctx = this.ctx;
ctx.fillStyle = color;
for (const p of points) {
ctx.beginPath();
ctx.arc(p.x * scale, p.y * scale, radius, 0, 2 * Math.PI);
ctx.fill();
}
}
}
@@ -0,0 +1,78 @@
import {
CITY_MARGIN, City, cheapestInsertion, clampCityCount, clampSettings, DEFAULT_CITIES, DEFAULT_SETTINGS, DEFAULT_SPEED, MAX_CITIES,
MAX_SPEED, MIN_CITIES, MIN_SPEED, nearestNeighborTour, pointOnTour, randomCities, SETTINGS_LIMITS, tourLength, WORLD_SIZE
} from './ant-colony.model';
const square: City[] = [{x: 0, y: 0}, {x: 100, y: 0}, {x: 100, y: 100}, {x: 0, y: 100}];
function onGrid(value: number, min: number, step: number): boolean {
const steps = (value - min) / step;
return Math.abs(steps - Math.round(steps)) * step < 1e-9;
}
describe('ant colony model', () => {
it('tourLength closes the loop', () => {
expect(tourLength([0, 1, 2, 3], square)).toBeCloseTo(400);
});
it('tourLength is 0 for one city', () => {
expect(tourLength([0], square)).toBe(0);
});
it('nearestNeighborTour picks the closest unvisited city each step', () => {
const cities: City[] = [{x: 0, y: 0}, {x: 90, y: 0}, {x: 10, y: 0}, {x: 50, y: 0}];
expect(nearestNeighborTour(cities, 0)).toEqual([0, 2, 3, 1]);
});
it('nearestNeighborTour visits every city once', () => {
const cities = randomCities(25);
expect([...nearestNeighborTour(cities, 3)].sort((a, b) => a - b)).toEqual(cities.map((_, i) => i));
});
it('cheapestInsertion puts a city on the edge between its neighbors', () => {
const cities = [...square, {x: 50, y: 0}];
expect(cheapestInsertion([0, 1, 2, 3], cities, 4)).toEqual([0, 4, 1, 2, 3]);
});
it('pointOnTour returns city 0 at 0 and the opposite corner at 0.5', () => {
expect(pointOnTour([0, 1, 2, 3], square, 0)).toEqual({x: 0, y: 0});
const half = pointOnTour([0, 1, 2, 3], square, 0.5);
expect(half.x).toBeCloseTo(100);
expect(half.y).toBeCloseTo(100);
});
it('randomCities stays inside the margins', () => {
for (const value of [0, 0.999]) {
for (const c of randomCities(5, () => value)) {
expect(c.x).toBeGreaterThanOrEqual(CITY_MARGIN);
expect(c.x).toBeLessThanOrEqual(WORLD_SIZE - CITY_MARGIN);
expect(c.y).toBeGreaterThanOrEqual(CITY_MARGIN);
expect(c.y).toBeLessThanOrEqual(WORLD_SIZE - CITY_MARGIN);
}
}
});
it('keeps slider defaults and maxima on the step grid', () => {
for (const key of Object.keys(SETTINGS_LIMITS) as (keyof typeof SETTINGS_LIMITS)[]) {
const {min, max, step} = SETTINGS_LIMITS[key];
expect(onGrid(DEFAULT_SETTINGS[key], min, step), `${key} default`).toBe(true);
expect(onGrid(max, min, step), `${key} max`).toBe(true);
}
expect(onGrid(DEFAULT_CITIES, MIN_CITIES, 1)).toBe(true);
expect(onGrid(MAX_CITIES, MIN_CITIES, 1)).toBe(true);
expect(onGrid(DEFAULT_SPEED, MIN_SPEED, 1)).toBe(true);
expect(onGrid(MAX_SPEED, MIN_SPEED, 1)).toBe(true);
});
it('clampSettings limits every value', () => {
expect(clampSettings({ants: 1000, alpha: -1, beta: 99, evaporation: 0})).toEqual({
ants: SETTINGS_LIMITS.ants.max, alpha: SETTINGS_LIMITS.alpha.min, beta: SETTINGS_LIMITS.beta.max, evaporation: SETTINGS_LIMITS.evaporation.min
});
});
it('clampCityCount handles NaN, low and high', () => {
expect(clampCityCount(NaN)).toBe(DEFAULT_CITIES);
expect(clampCityCount(1)).toBe(MIN_CITIES);
expect(clampCityCount(9999)).toBe(MAX_CITIES);
});
});
@@ -0,0 +1,163 @@
export interface City {
x: number;
y: number;
}
export interface AntColonySettings {
ants: number;
/** Weight of the pheromone in the choice of the next city. */
alpha: number;
/** Weight of the closeness in the choice of the next city. */
beta: number;
/** Share of the pheromone that vanishes each iteration. */
evaporation: number;
}
export interface AntColonyColors {
city: string;
/** rgb triple, the alpha is set per edge. */
pheromone: string;
best: string;
ant: string;
}
/** Cities and tour lengths live in these world units, not in pixels. */
export const WORLD_SIZE = 1000;
export const CITY_MARGIN = 40;
export const MIN_CITIES = 3;
export const MAX_CITIES = 120;
export const DEFAULT_CITIES = 30;
/** Duplicate click guard, world units. */
export const MIN_CITY_DISTANCE = 15;
/** Iterations per second. */
export const MIN_SPEED = 1;
export const MAX_SPEED = 30;
export const DEFAULT_SPEED = 5;
export const MAX_CANVAS_PX = 800;
/** Per frame, so max cities and max ants drop iterations instead of freezing the page. */
export const STEP_BUDGET_MS = 8;
/** Floor after evaporation, keeps the probabilities finite. */
export const TAU_MIN = 1e-6;
/** Deposit of an ant is Q / tour length. */
export const PHEROMONE_Q = 1;
/** Edges with a weaker share of the strongest trail are not drawn. */
export const MIN_DRAW_RATIO = 0.02;
export const CLICK_MOVE_PX = 8;
export const SETTINGS_LIMITS: Record<keyof AntColonySettings, {min: number; max: number; step: number}> = {
ants: {min: 1, max: 64, step: 1},
alpha: {min: 0, max: 5, step: 0.1},
beta: {min: 0, max: 10, step: 0.5},
evaporation: {min: 0.01, max: 0.5, step: 0.01}
};
export const DEFAULT_SETTINGS: AntColonySettings = {ants: 20, alpha: 1, beta: 3, evaporation: 0.1};
export const THEME_COLORS: Record<'light' | 'dark', AntColonyColors> = {
light: {city: '#222', pheromone: '15, 79, 138', best: '#d32f2f', ant: '#2e7d32'},
dark: {city: '#eee', pheromone: '109, 188, 255', best: '#ff7961', ant: '#81c784'}
};
export function clampSettings(settings: AntColonySettings): AntColonySettings {
const clamped = {...settings};
for (const key of Object.keys(SETTINGS_LIMITS) as (keyof AntColonySettings)[]) {
const {min, max} = SETTINGS_LIMITS[key];
clamped[key] = Math.min(max, Math.max(min, settings[key]));
}
clamped.ants = Math.round(clamped.ants);
return clamped;
}
export function clampCityCount(count: number): number {
if (!Number.isFinite(count)) {
return DEFAULT_CITIES;
}
return Math.min(MAX_CITIES, Math.max(MIN_CITIES, Math.round(count)));
}
export function randomCities(count: number, random: () => number = Math.random): City[] {
const span = WORLD_SIZE - 2 * CITY_MARGIN;
return Array.from({length: count}, () => ({x: CITY_MARGIN + random() * span, y: CITY_MARGIN + random() * span}));
}
export function distance(a: City, b: City): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
/** Closed loop. */
export function tourLength(tour: readonly number[], cities: readonly City[]): number {
if (tour.length < 2) {
return 0;
}
let length = 0;
for (let i = 0; i < tour.length; i++) {
length += distance(cities[tour[i]], cities[tour[(i + 1) % tour.length]]);
}
return length;
}
export function nearestNeighborTour(cities: readonly City[], start = 0): number[] {
if (cities.length === 0) {
return [];
}
const visited = new Set<number>([start]);
const tour = [start];
while (tour.length < cities.length) {
const last = cities[tour[tour.length - 1]];
let best = -1;
let bestDistance = Infinity;
for (let i = 0; i < cities.length; i++) {
if (!visited.has(i)) {
const d = distance(last, cities[i]);
if (d < bestDistance) {
bestDistance = d;
best = i;
}
}
}
visited.add(best);
tour.push(best);
}
return tour;
}
/** New tour with city `index` at the cheapest position. */
export function cheapestInsertion(tour: readonly number[], cities: readonly City[], index: number): number[] {
if (tour.length < 2) {
return [...tour, index];
}
const city = cities[index];
let bestPosition = 1;
let bestCost = Infinity;
for (let i = 0; i < tour.length; i++) {
const a = cities[tour[i]];
const b = cities[tour[(i + 1) % tour.length]];
const cost = distance(a, city) + distance(city, b) - distance(a, b);
if (cost < bestCost) {
bestCost = cost;
bestPosition = i + 1;
}
}
const result = [...tour];
result.splice(bestPosition, 0, index);
return result;
}
/** Point at `fraction` (0..1) of the closed polyline. */
export function pointOnTour(tour: readonly number[], cities: readonly City[], fraction: number): City {
if (tour.length === 0) {
return {x: 0, y: 0};
}
let remaining = (((fraction % 1) + 1) % 1) * tourLength(tour, cities);
for (let i = 0; i < tour.length; i++) {
const a = cities[tour[i]];
const b = cities[tour[(i + 1) % tour.length]];
const d = distance(a, b);
if (remaining <= d) {
const t = d === 0 ? 0 : remaining / d;
return {x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t};
}
remaining -= d;
}
return cities[tour[0]];
}
@@ -13,4 +13,5 @@ export const SOURCE_PATHS = {
BOIDS: ['src/app/pages/algorithms/boids/boids.shader.ts', 'src/app/pages/algorithms/boids/strategies/boids-gpu.strategy.ts', 'src/app/pages/algorithms/boids/strategies/boids-cpu-physics.ts'], BOIDS: ['src/app/pages/algorithms/boids/boids.shader.ts', 'src/app/pages/algorithms/boids/strategies/boids-gpu.strategy.ts', 'src/app/pages/algorithms/boids/strategies/boids-cpu-physics.ts'],
PHYSARUM: ['src/app/pages/algorithms/physarum/physarum.shader.ts', 'src/app/pages/algorithms/physarum/strategies/physarum-gpu.strategy.ts', 'src/app/pages/algorithms/physarum/strategies/physarum-cpu-physics.ts'], PHYSARUM: ['src/app/pages/algorithms/physarum/physarum.shader.ts', 'src/app/pages/algorithms/physarum/strategies/physarum-gpu.strategy.ts', 'src/app/pages/algorithms/physarum/strategies/physarum-cpu-physics.ts'],
GRAY_SCOTT: ['src/app/pages/algorithms/gray-scott/gray-scott.shader.ts', 'src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts'], GRAY_SCOTT: ['src/app/pages/algorithms/gray-scott/gray-scott.shader.ts', 'src/app/pages/algorithms/gray-scott/gray-scott-simulation.ts'],
ANT_COLONY: ['src/app/pages/algorithms/ant-colony/ant-colony-simulation.ts', 'src/app/pages/algorithms/ant-colony/ant-colony.model.ts'],
} as const satisfies Record<string, readonly string[]>; } as const satisfies Record<string, readonly string[]>;
+42 -1
View File
@@ -884,6 +884,10 @@
"TITLE": "Reaktions-Diffusion (Gray-Scott)", "TITLE": "Reaktions-Diffusion (Gray-Scott)",
"DESCRIPTION": "Punkte, Würmer und Korallen aus zwei reagierenden Chemikalien, berechnet auf der GPU mit Float-Texturen (WebGPU und WebGL)." "DESCRIPTION": "Punkte, Würmer und Korallen aus zwei reagierenden Chemikalien, berechnet auf der GPU mit Float-Texturen (WebGPU und WebGL)."
}, },
"ANT_COLONY": {
"TITLE": "Ameisenkolonie (Handlungsreisender)",
"DESCRIPTION": "Ameisen finden mit Pheromonspuren eine kurze Rundreise durch Städte. Ein Klick auf die Fläche setzt eine neue Stadt."
},
"NOTE": "HINWEIS", "NOTE": "HINWEIS",
"GRID_HEIGHT": "Höhe", "GRID_HEIGHT": "Höhe",
"GRID_WIDTH": "Breite", "GRID_WIDTH": "Breite",
@@ -958,6 +962,42 @@
"ITEM_5": "Weniger Kreise wirken wie ein Tiefpass: nur die größten Radien bleiben, die Form wird weicher." "ITEM_5": "Weniger Kreise wirken wie ein Tiefpass: nur die größten Radien bleiben, die Form wird weicher."
} }
}, },
"ANT_COLONY": {
"TITLE": "Ameisenkolonie (Handlungsreisender)",
"PLAY": "Start",
"PAUSE": "Pause",
"RESET": "Zurücksetzen",
"REROLL": "Neue Städte",
"CITIES": "Städte",
"ANTS": "Ameisen",
"ALPHA": "Alpha (Gewicht der Spur)",
"BETA": "Beta (Gewicht der Entfernung)",
"EVAPORATION": "Verdunstung",
"SPEED": "Iterationen/s",
"CLICK_HINT": "Klicke oder tippe auf die Fläche, um eine Stadt zu setzen. Die Kolonie läuft damit weiter.",
"BEST_LENGTH": "Beste Tour",
"NN_LENGTH": "Tour nächster Nachbar",
"GAP": "Beste Tour gegen nächsten Nachbarn",
"EXPLANATION": {
"TITLE": "Ameisen finden kurze Wege",
"ACO_TITLE": "Ameisenalgorithmus",
"ACO": "Echte Ameisen markieren ihren Weg mit Pheromon. Kurze Wege bekommen mehr Pheromon, weil Ameisen sie öfter laufen. Hier baut jede Ameise eine Rundreise durch alle Städte. In jedem Schritt bevorzugt sie eine nahe Stadt und eine Kante mit starker Spur. Nach jeder Iteration verblassen die Spuren etwas, und jede Ameise legt Pheromon auf ihre eigene Tour. Kurze Touren legen mehr ab.",
"TSP_TITLE": "Problem des Handlungsreisenden",
"TSP": "Gesucht ist die kürzeste Rundreise, die jede Stadt einmal besucht. Das Problem ist schwer: Die Zahl der Touren wächst schneller als jede Potenz der Städtezahl. Ameisenkolonien liefern gute Touren, nicht immer die beste. Die Vergleichsbasis ist die Tour des nächsten Nachbarn: immer zur nächsten unbesuchten Stadt gehen. Die Längen sind in Welteinheiten (die Fläche ist 1000 x 1000), nicht in Pixeln.",
"DISCLAIMER": "Vereinfachungen in dieser Demo:",
"DISCLAIMER_1": "Es ist das einfache Ant System. Es gibt keine Elite-Ameise, keine lokale Suche und keine Spurgrenzen außer einem winzigen Mindestwert.",
"DISCLAIMER_2": "Die sichtbaren Ameisen laufen entlang ihrer letzten Tour. Die Tour selbst entsteht in einem Schritt pro Iteration.",
"DISCLAIMER_3": "Pro Bild gibt es ein Zeitlimit, die Demo lässt dann Iterationen aus. Mit vielen Städten und Ameisen kann die echte Geschwindigkeit unter dem Regler liegen."
},
"HOW_IT_WORKS": {
"INTRO": "In jeder Iteration baut jede Ameise eine Tour mit einem Roulette-Rad. Von Stadt i geht sie zur unbesuchten Stadt j mit einer Wahrscheinlichkeit proportional zu tau(i,j)^alpha * (1/d(i,j))^beta. Danach verdunsten alle Spuren, und jede Ameise legt Pheromon auf ihre Kanten.",
"ITEM_1": "Die Gewichte tau^alpha * (1/d)^beta werden einmal pro Iteration berechnet, jede Ameise addiert dann nur noch Zahlen.",
"ITEM_2": "Die Verdunstung multipliziert jede Spur mit (1 - Rate). Ein winziger Mindestwert hält alle Wahrscheinlichkeiten über null.",
"ITEM_3": "Eine Ameise mit einer Tour der Länge L legt 1/L auf jede ihrer Kanten, in beide Richtungen.",
"ITEM_4": "Eine neue Stadt bekommt die mittlere Spur der alten Matrix. Die beste Tour wird durch billigstes Einfügen ergänzt, die Kolonie fängt also nicht bei null an.",
"ITEM_5": "Gezeichnet wird mit Canvas 2D und requestAnimationFrame außerhalb der Angular-Zone. Die Deckkraft der Linien zeigt die Spurstärke, die rote Linie ist die beste Tour."
}
},
"STOPWATCH": { "STOPWATCH": {
"TITLE": "Stoppuhr", "TITLE": "Stoppuhr",
"START": "Start", "START": "Start",
@@ -1039,6 +1079,7 @@
"PAIR_CHECKS": "Paarprüfungen pro Bild", "PAIR_CHECKS": "Paarprüfungen pro Bild",
"TRAIL_MAP": "Spurkarte", "TRAIL_MAP": "Spurkarte",
"GRID": "Gitter", "GRID": "Gitter",
"STEPS_PER_SECOND": "Schritte/s" "STEPS_PER_SECOND": "Schritte/s",
"ITERATION": "Iteration"
} }
} }
+42 -1
View File
@@ -884,6 +884,10 @@
"TITLE": "Reaction-diffusion (Gray-Scott)", "TITLE": "Reaction-diffusion (Gray-Scott)",
"DESCRIPTION": "Spots, worms and coral from two reacting chemicals, computed on the GPU with float textures (WebGPU and WebGL)." "DESCRIPTION": "Spots, worms and coral from two reacting chemicals, computed on the GPU with float textures (WebGPU and WebGL)."
}, },
"ANT_COLONY": {
"TITLE": "Ant colony (travelling salesman)",
"DESCRIPTION": "Ants find a short round trip through cities with pheromone trails. Click the canvas to add a city."
},
"NOTE": "Note", "NOTE": "Note",
"GRID_HEIGHT": "Height", "GRID_HEIGHT": "Height",
"GRID_WIDTH": "Width", "GRID_WIDTH": "Width",
@@ -958,6 +962,42 @@
"ITEM_5": "Fewer circles act like a low-pass filter: only the largest radii stay, the shape gets smoother." "ITEM_5": "Fewer circles act like a low-pass filter: only the largest radii stay, the shape gets smoother."
} }
}, },
"ANT_COLONY": {
"TITLE": "Ant colony (travelling salesman)",
"PLAY": "Play",
"PAUSE": "Pause",
"RESET": "Reset",
"REROLL": "New cities",
"CITIES": "Cities",
"ANTS": "Ants",
"ALPHA": "Alpha (pheromone weight)",
"BETA": "Beta (distance weight)",
"EVAPORATION": "Evaporation",
"SPEED": "Iterations/s",
"CLICK_HINT": "Click or tap the canvas to add a city. The colony keeps running with it.",
"BEST_LENGTH": "Best tour",
"NN_LENGTH": "Nearest-neighbor tour",
"GAP": "Best vs. nearest neighbor",
"EXPLANATION": {
"TITLE": "Ants find short routes",
"ACO_TITLE": "Ant colony optimization",
"ACO": "Real ants mark their way with pheromone. Short routes get more pheromone because ants walk them more often. Here every ant builds a round trip through all cities. At each step it prefers a near city and an edge with a strong trail. After each iteration the trails fade a little, and every ant adds pheromone to its own tour. Short tours add more.",
"TSP_TITLE": "Travelling salesman problem",
"TSP": "Find the shortest round trip that visits every city once. The problem is hard: the number of tours grows faster than any power of the city count. Ant colonies give good tours, not always the best one. The baseline is the nearest-neighbor tour: always go to the closest unvisited city. Lengths are in world units (the canvas is 1000 x 1000), not pixels.",
"DISCLAIMER": "Simplifications in this demo:",
"DISCLAIMER_1": "This is the plain Ant System. There is no elite ant, no local search and no pheromone limits except a tiny floor.",
"DISCLAIMER_2": "The ants you see move along their last tour. The tour itself is built in one step per iteration.",
"DISCLAIMER_3": "Under a time limit per frame the demo drops iterations, so with many cities and ants the real speed can be lower than the slider."
},
"HOW_IT_WORKS": {
"INTRO": "Each iteration every ant builds a tour with a roulette wheel. From city i it goes to the unvisited city j with a probability proportional to tau(i,j)^alpha * (1/d(i,j))^beta. Then all trails evaporate and every ant deposits pheromone on its edges.",
"ITEM_1": "The weights tau^alpha * (1/d)^beta are computed once per iteration, so each ant only adds numbers.",
"ITEM_2": "Evaporation multiplies every trail by (1 - rate). A tiny floor keeps all probabilities above zero.",
"ITEM_3": "An ant with a tour of length L adds 1/L to each of its edges, in both directions.",
"ITEM_4": "A new city gets the mean trail of the old matrix. The best tour is repaired by cheapest insertion, so the colony does not start from zero.",
"ITEM_5": "Drawing uses Canvas 2D with requestAnimationFrame outside the Angular zone. Line opacity shows the trail strength, the red line is the best tour."
}
},
"STOPWATCH": { "STOPWATCH": {
"TITLE": "Stopwatch", "TITLE": "Stopwatch",
"START": "Start", "START": "Start",
@@ -1039,6 +1079,7 @@
"PAIR_CHECKS": "Pair checks per frame", "PAIR_CHECKS": "Pair checks per frame",
"TRAIL_MAP": "Trail map", "TRAIL_MAP": "Trail map",
"GRID": "Grid", "GRID": "Grid",
"STEPS_PER_SECOND": "Steps/s" "STEPS_PER_SECOND": "Steps/s",
"ITERATION": "Iteration"
} }
} }