diff --git a/.github/workflows/perf-comment.yml b/.github/workflows/perf-comment.yml new file mode 100644 index 0000000000..67db766348 --- /dev/null +++ b/.github/workflows/perf-comment.yml @@ -0,0 +1,37 @@ +name: Comment Benchmark Results +on: + workflow_run: + workflows: [Performance Benchmarks] + types: [completed] + +permissions: + actions: read + pull-requests: write + +jobs: + comment: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Download benchmark comment artifact + id: download + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: perf-comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: perf-report + + - name: Read PR number + if: steps.download.outcome == 'success' + id: pr + run: echo "number=$(cat perf-report/pr-number.txt)" >> "$GITHUB_OUTPUT" + + - name: Comment benchmark results on PR + if: steps.download.outcome == 'success' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: benchmark-results + path: perf-report/comment.md + number: ${{ steps.pr.outputs.number }} diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 0000000000..1cd05050b7 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,63 @@ +name: Performance Benchmarks +on: + pull_request: + branches: [master] + + workflow_dispatch: + +permissions: + contents: read + +jobs: + perf: + timeout-minutes: 20 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v5 + with: + node-version: "24" + cache: "npm" + - name: Install dependencies + run: npm ci + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-${{ hashFiles('package-lock.json') }} + - name: Install Playwright Browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + - name: Install Playwright system deps + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + + - name: Run A/B perf benchmarks against master + id: perf + continue-on-error: true + run: npm run perf:ab -- --base origin/master --head HEAD --rounds 3 --threshold 0.25 --markdown-out perf-report/comment.md + + - name: Save PR number + if: always() && github.event_name == 'pull_request' + run: | + mkdir -p perf-report + echo "${{ github.event.pull_request.number }}" > perf-report/pr-number.txt + + - name: Upload benchmark comment artifact + if: always() && github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: perf-comment + path: | + perf-report/comment.md + perf-report/pr-number.txt + if-no-files-found: ignore + + - name: Fail job on performance regression + if: steps.perf.outcome == 'failure' + run: exit 1 diff --git a/.gitignore b/.gitignore index 950d224915..3f83d764c0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /coverage /playwright-report /test-results +/perf-report /_bmad /_bmad-output /memory diff --git a/package.json b/package.json index c61df05866..edc37846a1 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "preview": "vite preview", "test": "vitest", "test:e2e": "playwright test", + "perf:ab": "node tests/perf/ab.mjs", "lint": "biome check --write", "prepare": "simple-git-hooks" }, diff --git a/public/main.js b/public/main.js index 8da264a11b..594fdd4591 100644 --- a/public/main.js +++ b/public/main.js @@ -484,8 +484,9 @@ async function generate(options) { AddedLabels.initiate(); Names.getMapName(); - WARN && console.warn(`TOTAL: ${rn((performance.now() - timeStart) / 1000, 2)}s`); - showStatistics(); + const totalMs = performance.now() - timeStart; + WARN && console.warn(`TOTAL: ${rn(totalMs / 1000, 2)}s`); + showStatistics(totalMs); } catch (error) { ERROR && console.error(error); const parsedError = parseError(error); @@ -532,7 +533,7 @@ function setSeed(precreatedSeed) { } function addLakesInDeepDepressions() { - TIME && console.time("addLakesInDeepDepressions"); + TIME && timeStart("addLakesInDeepDepressions"); const elevationLimit = +ensureEl("lakeElevationLimitOutput").value; if (elevationLimit === 80) return; @@ -588,7 +589,7 @@ function addLakesInDeepDepressions() { features.push({ i: f, land: false, border: false, type: "lake" }); } - TIME && console.timeEnd("addLakesInDeepDepressions"); + TIME && timeEnd("addLakesInDeepDepressions"); } // near sea lakes usually get a lot of water inflow, most of them should break threshold and flow out to sea (see Ancylus Lake) @@ -598,7 +599,7 @@ function openNearSeaLakes() { const cells = grid.cells; const features = grid.features; if (!features.find(f => f.type === "lake")) return; // no lakes - TIME && console.time("openLakes"); + TIME && timeStart("openLakes"); const LIMIT = 22; // max height that can be breached by water for (const i of cells.i) { @@ -631,7 +632,7 @@ function openNearSeaLakes() { features[lakeFeatureId].type = "ocean"; // mark former lake as ocean } - TIME && console.timeEnd("openLakes"); + TIME && timeEnd("openLakes"); } // define map size and position based on template and random factor @@ -712,7 +713,7 @@ function calculateMapCoordinates() { // temperature model, trying to follow real-world data // based on http://www-das.uwyo.edu/~geerts/cwx/notes/chap16/Image64.gif function calculateTemperatures() { - TIME && console.time("calculateTemperatures"); + TIME && timeStart("calculateTemperatures"); const cells = grid.cells; cells.temp = new Int8Array(cells.i.length); // temperature array @@ -756,12 +757,12 @@ function calculateTemperatures() { return rn((height / 1000) * 6.5); } - TIME && console.timeEnd("calculateTemperatures"); + TIME && timeEnd("calculateTemperatures"); } // simplest precipitation model function generatePrecipitation() { - TIME && console.time("generatePrecipitation"); + TIME && timeStart("generatePrecipitation"); d3.select("#prec").selectAll("*").remove(); const { cells, cellsX, cellsY } = grid; cells.prec = new Uint8Array(cells.i.length); // precipitation array @@ -920,12 +921,12 @@ function generatePrecipitation() { .text("\u21C8"); })(); - TIME && console.timeEnd("generatePrecipitation"); + TIME && timeEnd("generatePrecipitation"); } // recalculate Voronoi Graph to pack cells function reGraph() { - TIME && console.time("reGraph"); + TIME && timeStart("reGraph"); const { cells: gridCells, points, features } = grid; const newCells = { p: [], g: [], h: [] }; // store new data const spacing2 = grid.spacing ** 2; @@ -975,7 +976,7 @@ function reGraph() { } ); - TIME && console.timeEnd("reGraph"); + TIME && timeEnd("reGraph"); } function isWetLand(moisture, temperature, height) { @@ -986,7 +987,7 @@ function isWetLand(moisture, temperature, height) { // assess cells suitability to calculate population and rand cells for culture center and burgs placement function rankCells() { - TIME && console.time("rankCells"); + TIME && timeStart("rankCells"); const { cells, features } = pack; cells.s = new Int16Array(cells.i.length); // cell suitability array cells.pop = new Float32Array(cells.i.length); // cell population array @@ -1039,11 +1040,11 @@ function rankCells() { cells.pop[i] = cells.s[i] > 0 ? (cells.s[i] * cells.area[i]) / meanArea : 0; } - TIME && console.timeEnd("rankCells"); + TIME && timeEnd("rankCells"); } // show map stats on generation complete -function showStatistics() { +function showStatistics(totalMs) { const heightmap = ensureEl("templateInput").value; const isTemplate = heightmap in heightmapTemplates; const heightmapType = isTemplate ? "template" : "precreated"; @@ -1069,7 +1070,8 @@ function showStatistics() { INFO && console.info(stats); // Dispatch event for test automation and external integrations - window.dispatchEvent(new CustomEvent("map:generated", { detail: { seed, mapId } })); + const detail = typeof totalMs === "number" ? { seed, mapId, totalMs } : { seed, mapId }; + window.dispatchEvent(new CustomEvent("map:generated", { detail })); } const regenerateMap = debounce(async function (config) { diff --git a/src/controllers/heightmap-editor.ts b/src/controllers/heightmap-editor.ts index 71102e35a1..93cd1c1687 100644 --- a/src/controllers/heightmap-editor.ts +++ b/src/controllers/heightmap-editor.ts @@ -9,7 +9,7 @@ import { heightmapTemplates } from "@/data/heightmap-templates"; import { GraphOverride } from "@/generators/graph-override"; import { removeEmblem } from "@/renderers/draw-emblems"; import { moveCircle, removeCircle } from "@/renderers/overlays/brush-circle"; -import { downloadFile, getFileName, uploadFile } from "@/utils"; +import { downloadFile, getFileName, timeEnd, timeStart, uploadFile } from "@/utils"; import { ensureEl, findEl, @@ -477,7 +477,7 @@ function finalizeHeightmap(): void { function regenerateErasedData(): void { INFO && console.group("Edit Heightmap"); - TIME && console.time("regenerateErasedData"); + TIME && timeStart("regenerateErasedData"); // remove data pack.cultures = []; @@ -541,7 +541,7 @@ function regenerateErasedData(): void { Military.generate(); Markers.generate(); Zones.generate(); - TIME && console.timeEnd("regenerateErasedData"); + TIME && timeEnd("regenerateErasedData"); INFO && console.groupEnd(); } @@ -577,7 +577,7 @@ export const createAvailableLandCellFinder = (cells: { function restoreRiskedData(): void { INFO && console.group("Edit Heightmap"); - TIME && console.time("restoreRiskedData"); + TIME && timeStart("restoreRiskedData"); const erosionAllowed = ensureEl("allowErosion").checked; // assign pack data to grid cells @@ -802,7 +802,7 @@ function restoreRiskedData(): void { Ice.generate(); select("#ice").selectAll("*").remove(); - TIME && console.timeEnd("restoreRiskedData"); + TIME && timeEnd("restoreRiskedData"); INFO && console.groupEnd(); } diff --git a/src/generators/biomes-generator.ts b/src/generators/biomes-generator.ts index 229cb90007..b4d28358a4 100644 --- a/src/generators/biomes-generator.ts +++ b/src/generators/biomes-generator.ts @@ -1,5 +1,5 @@ import { mean } from "d3"; -import { rn } from "../utils"; +import { rn, timeEnd, timeStart } from "../utils"; export interface Biome { i: number; @@ -103,7 +103,7 @@ class BiomesGenerator { } define(): void { - TIME && console.time("defineBiomes"); + TIME && timeStart("defineBiomes"); if (!pack.biomes?.length) pack.biomes = this.getDefault(); const { fl: flux, r: riverIds, h: heights, c: neighbors, g: gridReference } = pack.cells; @@ -128,7 +128,7 @@ class BiomesGenerator { pack.cells.biome[cellId] = this.getId(moisture, temperature, height, Boolean(riverIds[cellId])); } - TIME && console.timeEnd("defineBiomes"); + TIME && timeEnd("defineBiomes"); } getId(moisture: number, temperature: number, height: number, hasRiver: boolean) { diff --git a/src/generators/burgs-generator.ts b/src/generators/burgs-generator.ts index 65b15a9b84..70b8e6d417 100644 --- a/src/generators/burgs-generator.ts +++ b/src/generators/burgs-generator.ts @@ -2,7 +2,7 @@ import { quadtree } from "d3-quadtree"; import { Emblems } from "@/generators/emblems-generator"; import type { BurgGroup } from "@/types/burg-groups"; import type { Emblem } from "@/types/emblems"; -import { each, ensureEl, findClosestCell, gauss, minmax, normalize, P, rn } from "../utils"; +import { each, ensureEl, findClosestCell, gauss, minmax, normalize, P, rn, timeEnd, timeStart } from "../utils"; import { type CultureType, DEFAULT_CULTURE_TYPE } from "./cultures-generator"; import { NON_NAVIGABLE_LAKE_GROUPS } from "./features"; import type { Label } from "./labels-generator"; @@ -52,7 +52,7 @@ type PortCandidate = { class BurgModule { generate() { - TIME && console.time("generateBurgs"); + TIME && timeStart("generateBurgs"); const { cells } = pack; let burgs: Burg[] = [0 as any]; // burgs array @@ -151,7 +151,7 @@ class BurgModule { pack.burgs = burgs; this.assignPorts(); - TIME && console.timeEnd("generateBurgs"); + TIME && timeEnd("generateBurgs"); function getCapitalsNumber() { let number = (ensureEl("statesNumber") as HTMLInputElement).valueAsNumber; @@ -524,7 +524,7 @@ class BurgModule { } specify() { - TIME && console.time("specifyBurgs"); + TIME && timeStart("specifyBurgs"); pack.burgs.forEach(burg => { if (!burg.i || burg.removed || burg.lock) return; @@ -543,7 +543,7 @@ class BurgModule { this.defineGroup(burg, populations); }); - TIME && console.timeEnd("specifyBurgs"); + TIME && timeEnd("specifyBurgs"); } private createWatabouCityLinks(burg: Burg) { diff --git a/src/generators/cultures-generator.ts b/src/generators/cultures-generator.ts index da3bc4994f..c563c5cfd9 100644 --- a/src/generators/cultures-generator.ts +++ b/src/generators/cultures-generator.ts @@ -1,6 +1,19 @@ import { max, quadtree, range } from "d3"; import { Emblems } from "@/generators/emblems-generator"; -import { abbreviate, biased, ensureEl, getColors, getRandomColor, minmax, P, rand, rn, rw } from "../utils"; +import { + abbreviate, + biased, + ensureEl, + getColors, + getRandomColor, + minmax, + P, + rand, + rn, + rw, + timeEnd, + timeStart +} from "../utils"; declare global { var Cultures: CulturesGenerator; @@ -1016,7 +1029,7 @@ class CulturesGenerator { } generate() { - TIME && console.time("generateCultures"); + TIME && timeStart("generateCultures"); this.cells = pack.cells; const cultureIds = new Uint16Array(this.cells.i.length); // cell cultures @@ -1204,7 +1217,7 @@ class CulturesGenerator { c.base = c.base % Names.nameBases.length; }); - TIME && console.timeEnd("generateCultures"); + TIME && timeEnd("generateCultures"); } add(center: number) { @@ -1246,7 +1259,7 @@ class CulturesGenerator { } expand() { - TIME && console.time("expandCultures"); + TIME && timeStart("expandCultures"); const { cells, cultures } = pack; const queue = new FlatQueue(); @@ -1337,7 +1350,7 @@ class CulturesGenerator { }); } - TIME && console.timeEnd("expandCultures"); + TIME && timeEnd("expandCultures"); } regenerate(): void { diff --git a/src/generators/features.ts b/src/generators/features.ts index 0bd3634cb5..5bb1eb7b6d 100644 --- a/src/generators/features.ts +++ b/src/generators/features.ts @@ -8,7 +8,9 @@ import { isLand, isWater, rn, - TYPED_ARRAY_MAX + TYPED_ARRAY_MAX, + timeEnd, + timeStart } from "../utils"; declare global { @@ -97,7 +99,7 @@ class FeatureModule { * mark Grid features (ocean, lakes, islands) and calculate distance field */ markupGrid() { - TIME && console.time("markupGrid"); + TIME && timeStart("markupGrid"); Math.random = Alea(seed); // get the same result on heightmap edit in Erase mode const { h: heights, c: neighbors, b: borderCells, i } = grid.cells; @@ -149,7 +151,7 @@ class FeatureModule { grid.cells.f = featureIds; grid.features = [0, ...features]; - TIME && console.timeEnd("markupGrid"); + TIME && timeEnd("markupGrid"); } /** @@ -248,7 +250,7 @@ class FeatureModule { } as Feature; }; - TIME && console.time("markupPack"); + TIME && timeStart("markupPack"); const { cells, vertices } = pack; const { c: neighbors, b: borderCells, i } = cells; @@ -322,7 +324,7 @@ class FeatureModule { pack.cells.haven = haven; pack.cells.harbor = harbor; pack.features = [0 as unknown as Feature, ...features]; - TIME && console.timeEnd("markupPack"); + TIME && timeEnd("markupPack"); } /** diff --git a/src/generators/goods-generator.ts b/src/generators/goods-generator.ts index 5ff22f6426..d8b81a14c1 100644 --- a/src/generators/goods-generator.ts +++ b/src/generators/goods-generator.ts @@ -1,5 +1,6 @@ import Alea from "alea"; import { color, shuffler } from "d3"; +import { timeEnd, timeStart } from "@/utils"; import type { PackedGraph } from "../types/PackedGraph"; import type { CultureType } from "./cultures-generator"; @@ -964,7 +965,7 @@ export class GoodsModule { // Place a bonus good on every eligible cell based on the current catalogue generate(options: { randomSeed?: number } = {}) { - TIME && console.time("generateGoods"); + TIME && timeStart("generateGoods"); Math.random = Alea(options.randomSeed ?? seed); const shuffle = shuffler(() => Math.random()); @@ -1002,7 +1003,7 @@ export class GoodsModule { } } - TIME && console.timeEnd("generateGoods"); + TIME && timeEnd("generateGoods"); this.sync(); } @@ -1011,7 +1012,7 @@ export class GoodsModule { const good = this.get(goodId); if (!good) return; - TIME && console.time("regenerateGoodPlacement"); + TIME && timeStart("regenerateGoodPlacement"); this.cells = pack.cells; if (!this.cells.good || this.cells.good.length !== this.cells.i.length) { this.cells.good = new Uint16Array(this.cells.i.length); @@ -1022,7 +1023,7 @@ export class GoodsModule { } if (!good.distribution || !good.chance) { - TIME && console.timeEnd("regenerateGoodPlacement"); + TIME && timeEnd("regenerateGoodPlacement"); return; } @@ -1046,7 +1047,7 @@ export class GoodsModule { resources[good.i] = (resources[good.i] || 0) + 1; } - TIME && console.timeEnd("regenerateGoodPlacement"); + TIME && timeEnd("regenerateGoodPlacement"); } restoreDefaults() { diff --git a/src/generators/heightmap-generator.ts b/src/generators/heightmap-generator.ts index 277ff50dd0..db37437321 100644 --- a/src/generators/heightmap-generator.ts +++ b/src/generators/heightmap-generator.ts @@ -1,7 +1,18 @@ import Alea from "alea"; import { range as d3Range, leastIndex, mean } from "d3"; import { heightmapTemplates } from "@/data/heightmap-templates"; -import { createTypedArray, ensureEl, findGridCell, getNumberInRange, lim, minmax, P, rand } from "../utils"; +import { + createTypedArray, + ensureEl, + findGridCell, + getNumberInRange, + lim, + minmax, + P, + rand, + timeEnd, + timeStart +} from "../utils"; declare global { var HeightmapGenerator: HeightmapModule; @@ -547,13 +558,13 @@ class HeightmapModule { } async generate(graph: any): Promise { - TIME && console.time("defineHeightmap"); + TIME && timeStart("defineHeightmap"); const id = (ensureEl("templateInput")! as HTMLInputElement).value; Math.random = Alea(seed); const isTemplate = id in heightmapTemplates; const heights = isTemplate ? this.fromTemplate(graph, id) : await this.fromPrecreated(graph, id); - TIME && console.timeEnd("defineHeightmap"); + TIME && timeEnd("defineHeightmap"); this.clearData(); return heights as Uint8Array; diff --git a/src/generators/markers-generator.ts b/src/generators/markers-generator.ts index 8cc27c2de3..133afd5f64 100644 --- a/src/generators/markers-generator.ts +++ b/src/generators/markers-generator.ts @@ -12,7 +12,9 @@ import { ra, rand, rn, - rw + rw, + timeEnd, + timeStart } from "../utils"; declare global { @@ -499,7 +501,7 @@ class MarkersModule { } private generateTypes() { - TIME && console.time("addMarkers"); + TIME && timeStart("addMarkers"); this.config.forEach(({ type, icon, dx, dy, px, size, pin, fill, stroke, min, each, multiplier, list, add }) => { if (multiplier === 0) return; @@ -519,7 +521,7 @@ class MarkersModule { }); this.occupied = []; - TIME && console.timeEnd("addMarkers"); + TIME && timeEnd("addMarkers"); } private getQuantity(array: any[], min: number, each: number, multiplier: number) { diff --git a/src/generators/markets-generator.ts b/src/generators/markets-generator.ts index aeca923096..1efabb47c4 100644 --- a/src/generators/markets-generator.ts +++ b/src/generators/markets-generator.ts @@ -1,6 +1,6 @@ import Alea from "alea"; import { quadtree } from "d3-quadtree"; -import { rn } from "@/utils"; +import { rn, timeEnd, timeStart } from "@/utils"; import { minmax } from "../utils"; import { getColors, getRandomColor } from "../utils/colorUtils"; import type { Burg } from "./burgs-generator"; @@ -41,7 +41,7 @@ export class MarketsModule { } generate(regenerate: boolean = false): Market[] { - TIME && console.time("generateMarkets"); + TIME && timeStart("generateMarkets"); if (!regenerate) Math.random = Alea(seed); const markets = this.createMarkets(); this.expandMarkets(markets); @@ -49,7 +49,7 @@ export class MarketsModule { pack.markets = markets; pack.deals = []; - TIME && console.timeEnd("generateMarkets"); + TIME && timeEnd("generateMarkets"); return markets; } diff --git a/src/generators/measurers-generator.ts b/src/generators/measurers-generator.ts index 4835bbc766..0987d28cde 100644 --- a/src/generators/measurers-generator.ts +++ b/src/generators/measurers-generator.ts @@ -1,3 +1,4 @@ +import { timeEnd, timeStart } from "@/utils"; import type { Point } from "./voronoi"; export type MeasurerType = "Ruler" | "Opisometer" | "RouteOpisometer" | "Planimeter"; @@ -20,7 +21,7 @@ function remove(measurer: Measurer): void { // default ruler across the largest landmass, created on map generation function createDefaultRuler(): void { - TIME && console.time("createDefaultRuler"); + TIME && timeStart("createDefaultRuler"); const { features, vertices } = pack; const areas = features.map(f => (f.land ? f.area || 0 : -Infinity)); @@ -45,7 +46,7 @@ function createDefaultRuler(): void { pack.measurers = []; create("Ruler", [leftmostVertex, rightmostVertex]); - TIME && console.timeEnd("createDefaultRuler"); + TIME && timeEnd("createDefaultRuler"); } export const Measurers = { create, remove, createDefaultRuler }; diff --git a/src/generators/military-generator.ts b/src/generators/military-generator.ts index 85c2583aae..4dba67c5a0 100644 --- a/src/generators/military-generator.ts +++ b/src/generators/military-generator.ts @@ -1,5 +1,5 @@ import { quadtree, sum } from "d3"; -import { findAllInQuadtree, gauss, minmax, nth, ra, rand, rn, si } from "../utils"; +import { findAllInQuadtree, gauss, minmax, nth, ra, rand, rn, si, timeEnd, timeStart } from "../utils"; import type { State } from "./states-generator"; declare global { @@ -49,7 +49,7 @@ class MilitaryModule { } generate() { - TIME && console.time("generateMilitary"); + TIME && timeStart("generateMilitary"); const { cells, states } = pack; const { p } = cells; const valid = states.filter(s => s.i && !s.removed); // valid states @@ -459,7 +459,7 @@ class MilitaryModule { delete s.temp; // do not store temp data }); - TIME && console.timeEnd("generateMilitary"); + TIME && timeEnd("generateMilitary"); } getDefaultOptions() { diff --git a/src/generators/ocean-generator.ts b/src/generators/ocean-generator.ts index d424c96d91..3de7e7ce56 100644 --- a/src/generators/ocean-generator.ts +++ b/src/generators/ocean-generator.ts @@ -1,4 +1,4 @@ -import { clipPoly } from "@/utils"; +import { clipPoly, timeEnd, timeStart } from "@/utils"; /** * Ocean outlines: closed rings traced around the coast at a given distance from it. `t` is the @@ -19,7 +19,7 @@ class OceanModule { /** trace the ocean rings for the requested distances, clipped to the map */ generate(limits: number[]): OceanOutline[] { - TIME && console.time("generateOcean"); + TIME && timeStart("generateOcean"); const { cells, vertices } = grid; const pointsN = cells.i.length; @@ -50,7 +50,7 @@ class OceanModule { outlines.get(t)!.push(ring); } - TIME && console.timeEnd("generateOcean"); + TIME && timeEnd("generateOcean"); // in limits order, so the renderer stacks the rings from the coast outwards return limits.map(t => ({ t, rings: outlines.get(t)! })); diff --git a/src/generators/production-generator.ts b/src/generators/production-generator.ts index cf2ea71758..d83a50f155 100644 --- a/src/generators/production-generator.ts +++ b/src/generators/production-generator.ts @@ -1,5 +1,5 @@ import { sum } from "d3"; -import { rn } from "@/utils"; +import { rn, timeEnd, timeStart } from "@/utils"; import { minmax } from "../utils"; import type { Burg } from "./burgs-generator"; import { DEFAULT_CULTURE_TYPE } from "./cultures-generator"; @@ -33,7 +33,7 @@ export class ProductionModule { } produce() { - TIME && console.time("generateProduction"); + TIME && timeStart("generateProduction"); this.zoneCellSets = null; // rebuild lookup to reflect any in-place zone edits Markets.collectRuralProduction(); @@ -62,7 +62,7 @@ export class ProductionModule { Markets.runGlobalTrade(); this.fillBurgsDemand(sortedBurgs, index); - TIME && console.timeEnd("generateProduction"); + TIME && timeEnd("generateProduction"); } private fillBurgsDemand(sortedBurgs: Burg[], index: ProductionIndex): void { diff --git a/src/generators/provinces-generator.ts b/src/generators/provinces-generator.ts index 68e8ea49a8..02e6d706c1 100644 --- a/src/generators/provinces-generator.ts +++ b/src/generators/provinces-generator.ts @@ -2,7 +2,18 @@ import Alea from "alea"; import { max } from "d3"; import { Emblems } from "@/generators/emblems-generator"; import type { Emblem } from "@/types/emblems"; -import { ensureEl, gauss, generateSeed, getMixedColor, getPolesOfInaccessibility, P, rand, rw } from "../utils"; +import { + ensureEl, + gauss, + generateSeed, + getMixedColor, + getPolesOfInaccessibility, + P, + rand, + rw, + timeEnd, + timeStart +} from "../utils"; import type { Label } from "./labels-generator"; declare global { @@ -77,7 +88,7 @@ class ProvinceModule { } generate(regenerate = false, regenerateLockedStates = false) { - TIME && console.time("generateProvinces"); + TIME && timeStart("generateProvinces"); const localSeed = regenerate ? generateSeed() : seed; Math.random = Alea(localSeed); @@ -327,7 +338,7 @@ class ProvinceModule { cells.province = provinceIds; pack.provinces = provinces; - TIME && console.timeEnd("generateProvinces"); + TIME && timeEnd("generateProvinces"); } // calculate pole of inaccessibility for each province diff --git a/src/generators/relief-generator.ts b/src/generators/relief-generator.ts index c93bad84d3..dd1e6ab061 100644 --- a/src/generators/relief-generator.ts +++ b/src/generators/relief-generator.ts @@ -1,7 +1,7 @@ import { extent, polygonContains } from "d3"; import { RELIEF_ICONS, RELIEF_SETS } from "@/data/relief-icons"; import type { ReliefSet, ReliefTypeIcons } from "@/types/relief"; -import { getPackPolygon, minmax, poissonDiscSampler, ra, rn } from "@/utils"; +import { getPackPolygon, minmax, poissonDiscSampler, ra, rn, timeEnd, timeStart } from "@/utils"; declare global { var Relief: ReliefModule; @@ -16,7 +16,7 @@ export interface ReliefIcon { class ReliefModule { generate(): ReliefIcon[] { - TIME && console.time("generateRelief"); + TIME && timeStart("generateRelief"); const cells = pack.cells; const { set, size, density } = style.relief; @@ -82,7 +82,7 @@ class ReliefModule { relief.sort((a, b) => a.y + a.s - (b.y + b.s)); pack.relief = relief; - TIME && console.timeEnd("generateRelief"); + TIME && timeEnd("generateRelief"); return relief; } diff --git a/src/generators/religions-generator.ts b/src/generators/religions-generator.ts index eb5fcfdf25..0799c9fb42 100644 --- a/src/generators/religions-generator.ts +++ b/src/generators/religions-generator.ts @@ -11,6 +11,8 @@ import { ra, rand, rw, + timeEnd, + timeStart, trimVowels } from "../utils"; @@ -620,7 +622,7 @@ class ReligionsModule { } generate() { - TIME && console.time("generateReligions"); + TIME && timeStart("generateReligions"); const lockedReligions = pack.religions?.filter(r => r.i && r.lock && !r.removed) || []; const folkReligions = this.generateFolkReligions(); @@ -639,7 +641,7 @@ class ReligionsModule { this.checkCenters(); - TIME && console.timeEnd("generateReligions"); + TIME && timeEnd("generateReligions"); } private generateFolkReligions(): ReligionBase[] { diff --git a/src/generators/river-generator.ts b/src/generators/river-generator.ts index 936a3eee27..5667691886 100644 --- a/src/generators/river-generator.ts +++ b/src/generators/river-generator.ts @@ -1,6 +1,6 @@ import Alea from "alea"; import { curveBasis, curveCatmullRom, line, mean, min, select, sum } from "d3"; -import { each, rn, round, rw } from "../utils"; +import { each, rn, round, rw, timeEnd, timeStart } from "../utils"; import { meander, projectToNearestEdge } from "../utils/pathUtils"; import type { Label } from "./labels-generator"; import type { Point } from "./voronoi"; @@ -165,7 +165,7 @@ class RiverModule { } generate(allowErosion = true) { - TIME && console.time("generateRivers"); + TIME && timeStart("generateRivers"); Math.random = Alea(seed); const { cells, features } = pack; @@ -410,7 +410,7 @@ class RiverModule { downcutRivers(); // downcut river beds } - TIME && console.timeEnd("generateRivers"); + TIME && timeEnd("generateRivers"); } alterHeights(): number[] { diff --git a/src/generators/routes-generator.ts b/src/generators/routes-generator.ts index a96e69429d..c9b2e38362 100644 --- a/src/generators/routes-generator.ts +++ b/src/generators/routes-generator.ts @@ -1,7 +1,19 @@ import Alea from "alea"; import { curveCatmullRom, line, select } from "d3"; import Delaunator from "delaunator"; -import { distanceSquared, findClosestCell, findPath, getAdjective, isLand, ra, rn, round, rw } from "../utils"; +import { + distanceSquared, + findClosestCell, + findPath, + getAdjective, + isLand, + ra, + rn, + round, + rw, + timeEnd, + timeStart +} from "../utils"; import { meander } from "../utils/pathUtils"; import type { Burg } from "./burgs-generator"; import type { Label } from "./labels-generator"; @@ -383,7 +395,7 @@ class RoutesModule { } private generateMainRoads() { - TIME && console.time("generateMainRoads"); + TIME && timeStart("generateMainRoads"); const { capitalsByFeature } = this.sortBurgsByFeature(pack.burgs); const mainRoads: Route[] = []; @@ -402,7 +414,7 @@ class RoutesModule { }); } - TIME && console.timeEnd("generateMainRoads"); + TIME && timeEnd("generateMainRoads"); return mainRoads; } @@ -418,7 +430,7 @@ class RoutesModule { } private generateTrails() { - TIME && console.time("generateTrails"); + TIME && timeStart("generateTrails"); const { burgsByFeature } = this.sortBurgsByFeature(pack.burgs); const trails: Route[] = []; @@ -437,12 +449,12 @@ class RoutesModule { }); } - TIME && console.timeEnd("generateTrails"); + TIME && timeEnd("generateTrails"); return trails; } private generateSeaRoutes() { - TIME && console.time("generateSeaRoutes"); + TIME && timeStart("generateSeaRoutes"); const { portsByFeature } = this.sortBurgsByFeature(pack.burgs); const seaRoutes: Route[] = []; @@ -461,7 +473,7 @@ class RoutesModule { }); } - TIME && console.timeEnd("generateSeaRoutes"); + TIME && timeEnd("generateSeaRoutes"); return seaRoutes; } diff --git a/src/generators/states-generator.ts b/src/generators/states-generator.ts index 7487e516f3..ee27c28c4b 100644 --- a/src/generators/states-generator.ts +++ b/src/generators/states-generator.ts @@ -16,6 +16,8 @@ import { rand, rn, rw, + timeEnd, + timeStart, trimVowels } from "../utils"; import type { Label } from "./labels-generator"; @@ -269,7 +271,7 @@ class StatesModule { } generate() { - TIME && console.time("generateStates"); + TIME && timeStart("generateStates"); pack.states = this.createStates(); this.expandStates(); this.normalize(); @@ -279,11 +281,11 @@ class StatesModule { this.generateCampaigns(); this.generateDiplomacy(); - TIME && console.timeEnd("generateStates"); + TIME && timeEnd("generateStates"); } expandStates() { - TIME && console.time("expandStates"); + TIME && timeStart("expandStates"); const { cells, states, cultures, burgs } = pack; cells.state = cells.state || new Uint16Array(cells.i.length); @@ -349,11 +351,11 @@ class StatesModule { .forEach(b => { b.state = cells.state[b.cell]; // assign state to burgs }); - TIME && console.timeEnd("expandStates"); + TIME && timeEnd("expandStates"); } normalize() { - TIME && console.time("normalizeStates"); + TIME && timeStart("normalizeStates"); const { cells, burgs } = pack; for (const i of cells.i) { @@ -368,7 +370,7 @@ class StatesModule { if (adversaries.length <= buddies.length) continue; cells.state[i] = cells.state[adversaries[0]]; } - TIME && console.timeEnd("normalizeStates"); + TIME && timeEnd("normalizeStates"); } // calculate pole of inaccessibility for each state @@ -435,7 +437,7 @@ class StatesModule { // calculate states data like area, population etc. collectStatistics() { - TIME && console.time("collectStatistics"); + TIME && timeStart("collectStatistics"); const { cells, states } = pack; states.forEach(s => { @@ -457,7 +459,7 @@ class StatesModule { } } - TIME && console.timeEnd("collectStatistics"); + TIME && timeEnd("collectStatistics"); } generateCampaign(state: State): Campaign[] { @@ -492,7 +494,7 @@ class StatesModule { // generate Diplomatic Relationships generateDiplomacy() { - TIME && console.time("generateDiplomacy"); + TIME && timeStart("generateDiplomacy"); const { cells, states } = pack; states[0].diplomacy = []; // FIRST STATE IS ALWAYS NEUTRAL and contains the history of diplomacy @@ -675,12 +677,12 @@ class StatesModule { // TODO: record war in chronicle to keep state interface clean chronicle.push(war as any); // add a record to diplomatical history } - TIME && console.timeEnd("generateDiplomacy"); + TIME && timeEnd("generateDiplomacy"); } // select a forms for listed or all valid states defineStateForms(list: number[] | null = null) { - TIME && console.time("defineStateForms"); + TIME && timeStart("defineStateForms"); const states = pack.states.filter(s => s.i && !s.removed && !s.lock); if (states.length < 1) return; @@ -821,7 +823,7 @@ class StatesModule { s.pollTax = taxes.pollTax; } - TIME && console.timeEnd("defineStateForms"); + TIME && timeEnd("defineStateForms"); } defineTaxRates(state: State) { diff --git a/src/generators/zones-generator.ts b/src/generators/zones-generator.ts index 66b4a47a1c..c8e16893aa 100644 --- a/src/generators/zones-generator.ts +++ b/src/generators/zones-generator.ts @@ -1,5 +1,5 @@ import { max, mean } from "d3"; -import { gauss, getAdjective, P, ra, rand, rw } from "../utils"; +import { gauss, getAdjective, P, ra, rand, rw, timeEnd, timeStart } from "../utils"; declare global { var Zones: ZonesModule; @@ -45,7 +45,7 @@ class ZonesModule { } generate(globalModifier = 1) { - TIME && console.time("generateZones"); + TIME && timeStart("generateZones"); const usedCells = new Uint8Array(pack.cells.i.length); pack.zones = []; @@ -56,7 +56,7 @@ class ZonesModule { while (number--) type.generate(usedCells); }); - TIME && console.timeEnd("generateZones"); + TIME && timeEnd("generateZones"); } private addInvasion(usedCells: Uint8Array) { diff --git a/src/renderers/draw-biomes.ts b/src/renderers/draw-biomes.ts index f5c50e5747..a9b18cf567 100644 --- a/src/renderers/draw-biomes.ts +++ b/src/renderers/draw-biomes.ts @@ -1,11 +1,11 @@ -import { ensureEl, getIsolines } from "@/utils"; +import { ensureEl, getIsolines, timeEnd, timeStart } from "@/utils"; import { buildFillPaths } from "./isoline-fills"; export function drawBiomes(): void { - TIME && console.time("drawBiomes"); + TIME && timeStart("drawBiomes"); const isolines = getIsolines(pack, cellId => pack.cells.biome[cellId], { fill: true, waterGap: true }); ensureEl("biomes").innerHTML = buildFillPaths("biome", isolines, index => pack.biomes[index].color); - TIME && console.timeEnd("drawBiomes"); + TIME && timeEnd("drawBiomes"); } diff --git a/src/renderers/draw-borders.ts b/src/renderers/draw-borders.ts index efbe9c9266..54572a21d3 100644 --- a/src/renderers/draw-borders.ts +++ b/src/renderers/draw-borders.ts @@ -1,7 +1,8 @@ import { select } from "d3"; +import { timeEnd, timeStart } from "@/utils"; const bordersRenderer = () => { - TIME && console.time("drawBorders"); + TIME && timeStart("drawBorders"); const { cells, vertices } = pack; const statePath: string[] = []; @@ -157,7 +158,7 @@ const bordersRenderer = () => { return chain; } - TIME && console.timeEnd("drawBorders"); + TIME && timeEnd("drawBorders"); }; export { bordersRenderer as drawBorders }; diff --git a/src/renderers/draw-burg-icons.ts b/src/renderers/draw-burg-icons.ts index 25417dba79..28561b247e 100644 --- a/src/renderers/draw-burg-icons.ts +++ b/src/renderers/draw-burg-icons.ts @@ -1,7 +1,8 @@ import { select } from "d3"; +import { timeEnd, timeStart } from "@/utils"; export const drawBurgIcons = (): void => { - TIME && console.time("drawBurgIcons"); + TIME && timeStart("drawBurgIcons"); createIconGroups(); for (const { name } of options.burgs.groups) { @@ -27,7 +28,7 @@ export const drawBurgIcons = (): void => { .join(""); } - TIME && console.timeEnd("drawBurgIcons"); + TIME && timeEnd("drawBurgIcons"); }; /** drop the icons, keeping the burg groups: they carry the styles edited in the Style editor */ diff --git a/src/renderers/draw-cultures.ts b/src/renderers/draw-cultures.ts index 31b4e8d753..a19a19907a 100644 --- a/src/renderers/draw-cultures.ts +++ b/src/renderers/draw-cultures.ts @@ -1,12 +1,12 @@ -import { ensureEl, getIsolines } from "@/utils"; +import { ensureEl, getIsolines, timeEnd, timeStart } from "@/utils"; import { buildFillPaths } from "./isoline-fills"; export function drawCultures(): void { - TIME && console.time("drawCultures"); + TIME && timeStart("drawCultures"); const { cells, cultures } = pack; const isolines = getIsolines(pack, cellId => cells.culture[cellId], { fill: true, waterGap: true }); ensureEl("cults").innerHTML = buildFillPaths("culture", isolines, index => cultures[index].color!); - TIME && console.timeEnd("drawCultures"); + TIME && timeEnd("drawCultures"); } diff --git a/src/renderers/draw-emblems.ts b/src/renderers/draw-emblems.ts index 1c2c329f9d..574ea83263 100644 --- a/src/renderers/draw-emblems.ts +++ b/src/renderers/draw-emblems.ts @@ -4,7 +4,7 @@ import type { Province } from "@/generators/provinces-generator"; import { EmblemRenderer } from "@/renderers/emblems/renderer"; import { Scene, ViewportLayers, type ViewportRenderContext } from "@/renderers/viewport/viewport-renderer"; import type { Emblem } from "@/types/emblems"; -import { ensureEl, findEl, minmax, rn } from "@/utils"; +import { ensureEl, findEl, minmax, rn, timeEnd, timeStart } from "@/utils"; import type { Burg } from "../generators/burgs-generator"; import type { State } from "../generators/states-generator"; @@ -70,7 +70,7 @@ function getEmblemSize(type: EmblemType, count: number): number { } export function drawEmblems(): void { - TIME && console.time("drawEmblems"); + TIME && timeStart("drawEmblems"); const version = ++drawVersion; isDrawPending = true; needsFullRedraw = false; @@ -108,7 +108,7 @@ export function drawEmblems(): void { if (needsFullRedraw) { // the snapshot was taken before an edit landed: rebuild it from the current data needsFullRedraw = false; - TIME && console.timeEnd("drawEmblems"); + TIME && timeEnd("drawEmblems"); drawEmblems(); return; } @@ -127,7 +127,7 @@ export function drawEmblems(): void { scene.replace(next); } layer.render(); - TIME && console.timeEnd("drawEmblems"); + TIME && timeEnd("drawEmblems"); }); } diff --git a/src/renderers/draw-goods.ts b/src/renderers/draw-goods.ts index e087381821..97afe7e6a1 100644 --- a/src/renderers/draw-goods.ts +++ b/src/renderers/draw-goods.ts @@ -1,6 +1,6 @@ import { select } from "d3"; import type { Good } from "../generators/goods-generator"; -import { normalize, rn } from "../utils"; +import { normalize, rn, timeEnd, timeStart } from "../utils"; import { getPackPolygon } from "../utils/graphUtils"; const PLATE_ICON = 3; @@ -15,13 +15,13 @@ const PLATE_FILL = "#f5f5f5"; const DEFAULT_SIZE = 6; export function drawGoods() { - TIME && console.time("drawGoods"); + TIME && timeStart("drawGoods"); const visible = new Set(pack.goods.filter(good => good.visible).map(good => good.i)); select("#goods").select("#goodsCells").html(buildGoodsCellsContent(visible)); select("#goods").select("#goodsIcons").html(buildGoodsIconsContent(visible)); select("#goods").select("#goodsBurgs").html(buildGoodsBurgsContent(visible)); - TIME && console.timeEnd("drawGoods"); + TIME && timeEnd("drawGoods"); } function buildGoodsCellsContent(displayedGoods: Set): string { diff --git a/src/renderers/draw-heightmap.ts b/src/renderers/draw-heightmap.ts index 1262055ccb..3447ff3e42 100644 --- a/src/renderers/draw-heightmap.ts +++ b/src/renderers/draw-heightmap.ts @@ -23,7 +23,7 @@ import { select } from "d3"; import { tip } from "../components/tooltips"; -import { round } from "../utils"; +import { round, timeEnd, timeStart } from "../utils"; const CURVE_MAP: Record = { curveBasis, @@ -49,7 +49,7 @@ export const drawHeightmap = (): void => { if (customization === 1) return void tip("The Layer control is not available in the heightmap edit mode", false, "error"); - TIME && console.time("drawHeightmap"); + TIME && timeStart("drawHeightmap"); const ocean = select("#terrs").select("#oceanHeights"); const land = select("#terrs").select("#landHeights"); @@ -192,5 +192,5 @@ export const drawHeightmap = (): void => { return chain.filter((_d, i) => i % n === 0); } - TIME && console.timeEnd("drawHeightmap"); + TIME && timeEnd("drawHeightmap"); }; diff --git a/src/renderers/draw-ice.ts b/src/renderers/draw-ice.ts index 3fb5124832..7e57c5149c 100644 --- a/src/renderers/draw-ice.ts +++ b/src/renderers/draw-ice.ts @@ -1,8 +1,9 @@ import { select } from "d3"; import type { Ice } from "@/generators/ice-generator"; +import { timeEnd, timeStart } from "@/utils"; export const drawIce = (): void => { - TIME && console.time("drawIce"); + TIME && timeStart("drawIce"); select("#ice").selectAll("*").remove(); let html = ""; @@ -17,7 +18,7 @@ export const drawIce = (): void => { select("#ice").html(html); - TIME && console.timeEnd("drawIce"); + TIME && timeEnd("drawIce"); }; export const redrawIceberg = (id: number): void => { diff --git a/src/renderers/draw-landmass.ts b/src/renderers/draw-landmass.ts index 8feb5ec1c7..d45a792b85 100644 --- a/src/renderers/draw-landmass.ts +++ b/src/renderers/draw-landmass.ts @@ -1,13 +1,13 @@ import type { Layer } from "@/components/layers"; import { Coastline } from "@/generators/coastline-generator"; -import { ensureEl } from "@/utils"; +import { ensureEl, timeEnd, timeStart } from "@/utils"; /** * The landmass is a plain rect shown through the land mask. The layer also owns the shared feature * geometry in defs: the coastline and lakes layers reference it, so it is drawn before both of them */ export function drawLandmass(layer: Layer): void { - TIME && console.time("drawLandmass"); + TIME && timeStart("drawLandmass"); const paths: string[] = []; const landMask: string[] = []; @@ -34,5 +34,5 @@ export function drawLandmass(layer: Layer): void { layer.getEl().innerHTML = /* html */ ``; - TIME && console.timeEnd("drawLandmass"); + TIME && timeEnd("drawLandmass"); } diff --git a/src/renderers/draw-markers.ts b/src/renderers/draw-markers.ts index dd14e2bbe8..94760e4f5c 100644 --- a/src/renderers/draw-markers.ts +++ b/src/renderers/draw-markers.ts @@ -1,6 +1,6 @@ import { select } from "d3"; import type { Marker } from "@/generators/markers-generator"; -import { rn } from "../utils"; +import { rn, timeEnd, timeStart } from "../utils"; type PinShapeFunction = (fill: string, stroke: string) => string; type PinShapes = { [key: string]: PinShapeFunction }; @@ -61,7 +61,7 @@ export const setMarkersFilter = (ids: number[] | null): void => { }; export const drawMarkers = (): void => { - TIME && console.time("drawMarkers"); + TIME && timeStart("drawMarkers"); const rescale = +select("#markers").attr("rescale"); const pinned = +select("#markers").attr("pinned"); @@ -73,5 +73,5 @@ export const drawMarkers = (): void => { const html = markersData.map(marker => drawMarker(marker, rescale)); select("#markers").html(html.join("")); - TIME && console.timeEnd("drawMarkers"); + TIME && timeEnd("drawMarkers"); }; diff --git a/src/renderers/draw-markets.ts b/src/renderers/draw-markets.ts index 9cab98c508..1d76edd59f 100644 --- a/src/renderers/draw-markets.ts +++ b/src/renderers/draw-markets.ts @@ -1,12 +1,12 @@ import { color, curveBasisClosed, line, select } from "d3"; -import { rn } from "../utils"; +import { rn, timeEnd, timeStart } from "../utils"; import { getIsolines } from "../utils/pathUtils"; export function drawMarkets() { - TIME && console.time("drawMarkets"); + TIME && timeStart("drawMarkets"); select("#markets").html(buildMarketsContent()); highlightMarketsOnHover(); - TIME && console.timeEnd("drawMarkets"); + TIME && timeEnd("drawMarkets"); } const MARKET_RADIUS = 3; diff --git a/src/renderers/draw-military.ts b/src/renderers/draw-military.ts index af41982a83..d4652846a4 100644 --- a/src/renderers/draw-military.ts +++ b/src/renderers/draw-military.ts @@ -1,9 +1,9 @@ import { color, easeSinInOut, select, transition } from "d3"; import type { Regiment } from "../generators/military-generator"; -import { rn } from "../utils"; +import { rn, timeEnd, timeStart } from "../utils"; export const drawMilitary = (): void => { - TIME && console.time("drawMilitary"); + TIME && timeStart("drawMilitary"); select("#armies").selectAll("g").remove(); for (const state of pack.states) { @@ -11,7 +11,7 @@ export const drawMilitary = (): void => { drawRegimentsRenderer(state.military || [], state.i); } - TIME && console.timeEnd("drawMilitary"); + TIME && timeEnd("drawMilitary"); }; const drawRegimentsRenderer = (regiments: Regiment[], s: number): void => { diff --git a/src/renderers/draw-ocean.ts b/src/renderers/draw-ocean.ts index 350f8ac344..45f9980acb 100644 --- a/src/renderers/draw-ocean.ts +++ b/src/renderers/draw-ocean.ts @@ -1,6 +1,6 @@ import { curveBasisClosed, line } from "d3"; import { Ocean } from "@/generators/ocean-generator"; -import { ensureEl, rn, round } from "@/utils"; +import { ensureEl, rn, round, timeEnd, timeStart } from "@/utils"; /** the ocean outline rings, stacked from the coast outwards so the overlap deepens the shade */ export function drawOcean(): void { @@ -10,7 +10,7 @@ export function drawOcean(): void { const limits = Ocean.getLimits(oceanLayers.getAttribute("layers") ?? ""); if (!limits.length) return; - TIME && console.time("drawOcean"); + TIME && timeStart("drawOcean"); const opacity = rn(0.4 / limits.length, 2); const lineGen = line().curve(curveBasisClosed); @@ -21,7 +21,7 @@ export function drawOcean(): void { oceanLayers.insertAdjacentHTML("beforeend", paths.join("")); - TIME && console.timeEnd("drawOcean"); + TIME && timeEnd("drawOcean"); } /** drop the rings, keeping #oceanBase: the base rect is created once, at startup */ diff --git a/src/renderers/draw-precipitation.ts b/src/renderers/draw-precipitation.ts index de71e33fe2..bcfe33dad1 100644 --- a/src/renderers/draw-precipitation.ts +++ b/src/renderers/draw-precipitation.ts @@ -1,8 +1,8 @@ import { easeSinIn, select, transition } from "d3"; -import { ensureEl, rn } from "@/utils"; +import { ensureEl, rn, timeEnd, timeStart } from "@/utils"; export function drawPrecipitation(): void { - TIME && console.time("drawPrecipitation"); + TIME && timeStart("drawPrecipitation"); const { cells, points } = grid; const prec = select(ensureEl("prec")); @@ -26,7 +26,7 @@ export function drawPrecipitation(): void { .transition(show) .attr("r", cellId => getRadius(cells.prec[cellId])); - TIME && console.timeEnd("drawPrecipitation"); + TIME && timeEnd("drawPrecipitation"); } /** drop the circles, keeping #wind: the wind direction arrows are written once, at map generation */ diff --git a/src/renderers/draw-provinces.ts b/src/renderers/draw-provinces.ts index 2b92e11dbe..1efaeb3628 100644 --- a/src/renderers/draw-provinces.ts +++ b/src/renderers/draw-provinces.ts @@ -1,13 +1,13 @@ -import { ensureEl, getIsolines } from "@/utils"; +import { ensureEl, getIsolines, timeEnd, timeStart } from "@/utils"; import { buildFillPaths } from "./isoline-fills"; export function drawProvinces(): void { - TIME && console.time("drawProvinces"); + TIME && timeStart("drawProvinces"); const { cells, provinces } = pack; const isolines = getIsolines(pack, cellId => cells.province[cellId], { fill: true, waterGap: true }); const bodyPaths = buildFillPaths("province", isolines, index => provinces[index].color!); ensureEl("provs").innerHTML = /* html */ `${bodyPaths}`; - TIME && console.timeEnd("drawProvinces"); + TIME && timeEnd("drawProvinces"); } diff --git a/src/renderers/draw-relief-icons.ts b/src/renderers/draw-relief-icons.ts index 31bf4f1765..576735eb9c 100644 --- a/src/renderers/draw-relief-icons.ts +++ b/src/renderers/draw-relief-icons.ts @@ -1,6 +1,7 @@ import { Layers } from "@/components/layers"; import type { ReliefIcon } from "@/generators/relief-generator"; import { Scene, ViewportLayers, type ViewportRenderContext } from "@/renderers/viewport/viewport-renderer"; +import { timeEnd, timeStart } from "@/utils"; interface ReliefSceneIcon { id: string; @@ -12,11 +13,11 @@ const layer = ViewportLayers.register({ id: "relief", render: reconcileRelief }) let frameId: number | null = null; export const drawRelief = (): void => { - TIME && console.time("drawRelief"); + TIME && timeStart("drawRelief"); if (!pack.relief?.length) Relief.generate(); scene.replace(pack.relief.map((data, i) => ({ id: String(i), data }))); layer.render(); - TIME && console.timeEnd("drawRelief"); + TIME && timeEnd("drawRelief"); }; export const redrawRelief = (): void => { diff --git a/src/renderers/draw-religions.ts b/src/renderers/draw-religions.ts index f781be353d..bc7b7ce0fb 100644 --- a/src/renderers/draw-religions.ts +++ b/src/renderers/draw-religions.ts @@ -1,12 +1,12 @@ -import { ensureEl, getIsolines } from "@/utils"; +import { ensureEl, getIsolines, timeEnd, timeStart } from "@/utils"; import { buildFillPaths } from "./isoline-fills"; export function drawReligions(): void { - TIME && console.time("drawReligions"); + TIME && timeStart("drawReligions"); const { cells, religions } = pack; const isolines = getIsolines(pack, cellId => cells.religion[cellId], { fill: true, waterGap: true }); ensureEl("relig").innerHTML = buildFillPaths("religion", isolines, index => religions[index].color!); - TIME && console.timeEnd("drawReligions"); + TIME && timeEnd("drawReligions"); } diff --git a/src/renderers/draw-rivers.ts b/src/renderers/draw-rivers.ts index 4fed0561b2..678f02bc6d 100644 --- a/src/renderers/draw-rivers.ts +++ b/src/renderers/draw-rivers.ts @@ -1,7 +1,7 @@ -import { ensureEl } from "@/utils"; +import { ensureEl, timeEnd, timeStart } from "@/utils"; export function drawRivers(): void { - TIME && console.time("drawRivers"); + TIME && timeStart("drawRivers"); const riverPaths = pack.rivers.map(({ cells, points, i, widthFactor, sourceWidth }) => { if (!cells || cells.length < 2) return ""; @@ -18,5 +18,5 @@ export function drawRivers(): void { ensureEl("rivers").innerHTML = riverPaths.join(""); - TIME && console.timeEnd("drawRivers"); + TIME && timeEnd("drawRivers"); } diff --git a/src/renderers/draw-routes.ts b/src/renderers/draw-routes.ts index 7a79e99442..31e5253a48 100644 --- a/src/renderers/draw-routes.ts +++ b/src/renderers/draw-routes.ts @@ -1,9 +1,9 @@ import { select } from "d3"; import type { Route } from "@/generators/routes-generator"; -import { ensureEl } from "@/utils"; +import { ensureEl, timeEnd, timeStart } from "@/utils"; export function drawRoutes(): void { - TIME && console.time("drawRoutes"); + TIME && timeStart("drawRoutes"); const routePaths: Record = {}; for (const route of pack.routes) { @@ -19,7 +19,7 @@ export function drawRoutes(): void { routes.select(`#${group}`).html(routePaths[group].join("")); } - TIME && console.timeEnd("drawRoutes"); + TIME && timeEnd("drawRoutes"); } /** drop the paths, keeping the route groups: they are user data carrying the group styles */ diff --git a/src/renderers/draw-scalebar.ts b/src/renderers/draw-scalebar.ts index d49a994c37..c25ac78316 100644 --- a/src/renderers/draw-scalebar.ts +++ b/src/renderers/draw-scalebar.ts @@ -1,5 +1,5 @@ import { range, select } from "d3"; -import { ensureEl, rn } from "../utils"; +import { ensureEl, rn, timeEnd, timeStart } from "../utils"; export function drawScaleBar(parent?: SVGSVGElement, scaleLevel = scale, width = svgWidth, height = svgHeight): void { const parentEl = parent || ensureEl("map"); @@ -11,7 +11,7 @@ export function drawScaleBar(parent?: SVGSVGElement, scaleLevel = scale, width = const renderedContent = scaleBar.select("#scaleBarContent"); const isRendered = Boolean(renderedContent.size()); - TIME && !isRendered && console.time("drawScaleBar"); + TIME && !isRendered && timeStart("drawScaleBar"); const unit = distanceUnitInput.value; const size = +scaleBar.attr("data-bar-size"); @@ -93,7 +93,7 @@ export function drawScaleBar(parent?: SVGSVGElement, scaleLevel = scale, width = scaleBar.attr("transform", `translate(${x},${y})`); } - TIME && !isRendered && console.timeEnd("drawScaleBar"); + TIME && !isRendered && timeEnd("drawScaleBar"); function getLength(): number { const init = 100; diff --git a/src/renderers/draw-states.ts b/src/renderers/draw-states.ts index 8c10c6b3d3..ce5b00bfc3 100644 --- a/src/renderers/draw-states.ts +++ b/src/renderers/draw-states.ts @@ -1,9 +1,9 @@ import { color as d3Color } from "d3"; -import { ensureEl, getIsolines } from "@/utils"; +import { ensureEl, getIsolines, timeEnd, timeStart } from "@/utils"; import { buildFillPaths } from "./isoline-fills"; export function drawStates(): void { - TIME && console.time("drawStates"); + TIME && timeStart("drawStates"); const { cells, states } = pack; const renderHalo = ensureEl("shapeRendering").value === "geometricPrecision"; @@ -25,5 +25,5 @@ export function drawStates(): void { ensureEl("statePaths").innerHTML = clipPaths.join(""); ensureEl("statesHalo").innerHTML = haloPaths.join(""); - TIME && console.timeEnd("drawStates"); + TIME && timeEnd("drawStates"); } diff --git a/src/renderers/draw-temperature.ts b/src/renderers/draw-temperature.ts index 4870ae0d0a..c958fd2d4a 100644 --- a/src/renderers/draw-temperature.ts +++ b/src/renderers/draw-temperature.ts @@ -10,10 +10,10 @@ import { scaleSequential, select } from "d3"; -import { connectVertices, convertTemperature, ensureEl, round } from "../utils"; +import { connectVertices, convertTemperature, ensureEl, round, timeEnd, timeStart } from "../utils"; const temperatureRenderer = (): void => { - TIME && console.time("drawTemperature"); + TIME && timeStart("drawTemperature"); select("#temperature").selectAll("*").remove(); const lineGen = line<[number, number]>().curve(curveBasisClosed); @@ -128,7 +128,7 @@ const temperatureRenderer = (): void => { labels.push([x, y, t]); } - TIME && console.timeEnd("drawTemperature"); + TIME && timeEnd("drawTemperature"); }; export { temperatureRenderer as drawTemperature }; diff --git a/src/renderers/erosion-bake.ts b/src/renderers/erosion-bake.ts index eccebd4597..f899f09a64 100644 --- a/src/renderers/erosion-bake.ts +++ b/src/renderers/erosion-bake.ts @@ -2,6 +2,7 @@ import type * as THREEType from "three"; import { Coastline } from "@/generators/coastline-generator"; +import { timeEnd, timeStart } from "@/utils"; export type BakeParams = { strength: number; @@ -873,7 +874,7 @@ export async function bake(renderer: THREEType.WebGLRenderer, params: BakeParams if (cached && cached.key === key) return cached; try { - TIME && console.time("erosionBake"); + TIME && timeStart("erosionBake"); const [bakeW, bakeH] = getBakeSize(params.bakeResolution); const coast = buildCoastTexture(bakeW, bakeH); @@ -896,7 +897,7 @@ export async function bake(renderer: THREEType.WebGLRenderer, params: BakeParams const result: ErosionBakeResult = { key, heights, pixels, coast: coast.data, cols: bakeW, rows: bakeH }; if (params.riverDepth > 0) enforceDownhillCourses(result); cached = result; - TIME && console.timeEnd("erosionBake"); + TIME && timeEnd("erosionBake"); return cached; } catch (error) { console.error("3D erosion bake failed:", error); diff --git a/src/renderers/labels/labels-renderer.ts b/src/renderers/labels/labels-renderer.ts index 51582b0e3c..d5d451da9b 100644 --- a/src/renderers/labels/labels-renderer.ts +++ b/src/renderers/labels/labels-renderer.ts @@ -2,6 +2,7 @@ import { Layers } from "@/components/layers"; import type { LabelGroup, LabelType } from "@/generators/labels-generator"; import type { LabelData } from "@/renderers/labels/labels"; import { Scene, ViewportLayers, type ViewportRenderContext } from "@/renderers/viewport/viewport-renderer"; +import { timeEnd, timeStart } from "@/utils"; import { getLabelsData } from "./label-data"; import { renderLabelGroups } from "./label-groups"; import { createLabelElements } from "./label-markup"; @@ -13,13 +14,13 @@ const labelsByGroup = new Map(); export function drawLabels(): void { if (!Layers.isOn("labels")) return void removeLabels(); - TIME && console.time("drawLabels"); + TIME && timeStart("drawLabels"); renderLabelGroups(); document.getElementById("textPaths")?.replaceChildren(); scene.replace(getLabelsData()); indexLabelsByGroup(); layer.render(); - TIME && console.timeEnd("drawLabels"); + TIME && timeEnd("drawLabels"); } export function removeLabels(): void { diff --git a/src/services/io/export-json.ts b/src/services/io/export-json.ts index 3a97355fc3..f71036d991 100644 --- a/src/services/io/export-json.ts +++ b/src/services/io/export-json.ts @@ -1,7 +1,7 @@ import { closeDialogs } from "@/components/dialog/dialog-helpers"; import { tip } from "@/components/tooltips"; import { VERSION } from "@/services/versioning"; -import { getFileName } from "@/utils"; +import { getFileName, timeEnd, timeStart } from "@/utils"; type ExportJsonType = "Full" | "Minimal" | "PackCells" | "GridCells"; @@ -19,7 +19,7 @@ function exportToJson(type: ExportJsonType): void { } closeDialogs("#alert"); - TIME && console.time("exportToJson"); + TIME && timeStart("exportToJson"); const mapData = typeMap[type](); const blob = new Blob([mapData], { type: "application/json" }); const URL = window.URL.createObjectURL(blob); @@ -29,7 +29,7 @@ function exportToJson(type: ExportJsonType): void { link.click(); tip(`${link.download} is saved. Open "Downloads" screen (CTRL + J) to check`, true, "success", 7000); window.URL.revokeObjectURL(URL); - TIME && console.timeEnd("exportToJson"); + TIME && timeEnd("exportToJson"); } function getFullDataJson(): string { diff --git a/src/services/io/export.ts b/src/services/io/export.ts index e9ef31763e..4c123a64c5 100644 --- a/src/services/io/export.ts +++ b/src/services/io/export.ts @@ -18,6 +18,8 @@ import { getFriendlyHeight, getGridPolygon, rn, + timeEnd, + timeStart, unique } from "@/utils"; @@ -38,7 +40,7 @@ export interface GetMapURLOptions { } async function exportToSvg(): Promise { - TIME && console.time("exportToSvg"); + TIME && timeStart("exportToSvg"); try { const url = await getMapURL("svg", { fullMap: true }); const link = document.createElement("a"); @@ -52,12 +54,12 @@ async function exportToSvg(): Promise { ERROR && console.error(error); tip(`SVG export failed: ${(error as Error)?.message || "Unknown error"}`, true, "error", 5000); } finally { - TIME && console.timeEnd("exportToSvg"); + TIME && timeEnd("exportToSvg"); } } async function exportToPng(): Promise { - TIME && console.time("exportToPng"); + TIME && timeStart("exportToPng"); try { const url = await getMapURL("png"); const resolution = ensureEl("pngResolutionInput").valueAsNumber; @@ -94,12 +96,12 @@ async function exportToPng(): Promise { ERROR && console.error(error); tip(`PNG export failed: ${(error as Error)?.message || "Unknown error"}`, true, "error", 5000); } finally { - TIME && console.timeEnd("exportToPng"); + TIME && timeEnd("exportToPng"); } } async function exportToJpeg(): Promise { - TIME && console.time("exportToJpeg"); + TIME && timeStart("exportToJpeg"); try { const url = await getMapURL("png"); const resolution = ensureEl("pngResolutionInput").valueAsNumber; @@ -136,7 +138,7 @@ async function exportToJpeg(): Promise { ERROR && console.error(error); tip(`JPEG export failed: ${(error as Error)?.message || "Unknown error"}`, true, "error", 5000); } finally { - TIME && console.timeEnd("exportToJpeg"); + TIME && timeEnd("exportToJpeg"); } } diff --git a/src/utils/graphUtils.ts b/src/utils/graphUtils.ts index fbd869d8b4..42c0f4d7fe 100644 --- a/src/utils/graphUtils.ts +++ b/src/utils/graphUtils.ts @@ -6,6 +6,7 @@ import type { PackedGraph } from "../types/PackedGraph"; import { createTypedArray } from "./arrayUtils"; import { ensureEl } from "./nodeUtils"; import { rn } from "./numberUtils"; +import { timeEnd, timeStart } from "./perfEvents"; /** * Get boundary points on a regular square grid @@ -77,7 +78,7 @@ const placePoints = ( cellsX: number; cellsY: number; } => { - TIME && console.time("placePoints"); + TIME && timeStart("placePoints"); const cellsDesired = +(ensureEl("pointsInput").dataset.cells || 0); const spacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); // spacing between points before jittering @@ -85,7 +86,7 @@ const placePoints = ( const points = getJitteredGrid(graphWidth, graphHeight, spacing); // points of jittered square grid const cellCountX = Math.floor((graphWidth + 0.5 * spacing - 1e-10) / spacing); // number of cells in x direction const cellCountY = Math.floor((graphHeight + 0.5 * spacing - 1e-10) / spacing); // number of cells in y direction - TIME && console.timeEnd("placePoints"); + TIME && timeEnd("placePoints"); return { spacing, @@ -157,12 +158,12 @@ export const generateGrid = (seed: string, graphWidth: number, graphHeight: numb * @returns {Object} - An object containing Voronoi cells and vertices */ export const calculateVoronoi = (points: Point[], boundary: Point[]): { cells: Cells; vertices: Vertices } => { - TIME && console.time("calculateDelaunay"); + TIME && timeStart("calculateDelaunay"); const allPoints = points.concat(boundary); const delaunay = Delaunator.from(allPoints); - TIME && console.timeEnd("calculateDelaunay"); + TIME && timeEnd("calculateDelaunay"); - TIME && console.time("calculateVoronoi"); + TIME && timeStart("calculateVoronoi"); const voronoi = new Voronoi(delaunay, allPoints, points.length); const cells = voronoi.cells; @@ -171,7 +172,7 @@ export const calculateVoronoi = (points: Point[], boundary: Point[]): { cells: C length: points.length }).map((_, i) => i) as Uint32Array; // array of indexes const vertices = voronoi.vertices; - TIME && console.timeEnd("calculateVoronoi"); + TIME && timeEnd("calculateVoronoi"); return { cells, vertices }; }; diff --git a/src/utils/index.ts b/src/utils/index.ts index 812b303e8b..8627e101d8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -42,6 +42,7 @@ import { } from "./graphUtils"; import { applyOption, ensureEl, findEl, getComposedPath, getNextId, getPointer } from "./nodeUtils"; import { connectVertices, findPath, getIsolines, getPolesOfInaccessibility, getVertexPath } from "./pathUtils"; +import { timeEnd, timeStart } from "./perfEvents"; import { biased, each, gauss, generateSeed, getNumberInRange, P, Pint, ra, rand, rw } from "./probabilityUtils"; import { capitalize, isValidJSON, parseTransform, round, safeParseJSON, sanitizeId, splitInTwo } from "./stringUtils"; import { @@ -79,6 +80,9 @@ window.generateSeed = generateSeed; window.toHEX = toHEX; +window.timeStart = timeStart; +window.timeEnd = timeEnd; + window.ensureEl = ensureEl; window.findEl = findEl; window.applyOption = applyOption; @@ -237,6 +241,8 @@ export { splitInTwo, TYPED_ARRAY_MAX, throttle, + timeEnd, + timeStart, toHEX, trimVowels, unique, diff --git a/src/utils/perfEvents.ts b/src/utils/perfEvents.ts new file mode 100644 index 0000000000..c63d3565e0 --- /dev/null +++ b/src/utils/perfEvents.ts @@ -0,0 +1,30 @@ +export interface PerfStageEventDetail { + stage: string; + ms: number; +} + +const starts = new Map(); + +export function timeStart(label: string): void { + starts.set(label, performance.now()); + console.time(label); +} + +export function timeEnd(label: string): void { + const start = starts.get(label); + if (start === undefined) return; + starts.delete(label); + console.timeEnd(label); + + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent("perf:stage", { detail: { stage: label, ms: performance.now() - start } }) + ); +} + +declare global { + interface Window { + timeStart: typeof timeStart; + timeEnd: typeof timeEnd; + } +} diff --git a/tests/perf/ab.mjs b/tests/perf/ab.mjs new file mode 100644 index 0000000000..8daac23600 --- /dev/null +++ b/tests/perf/ab.mjs @@ -0,0 +1,274 @@ +#!/usr/bin/env node +import { execFileSync, spawn } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const parseArgs = argv => { + const args = { rounds: 3, threshold: 0.25, base: "origin/master", head: "HEAD" }; + for (let i = 0; i < argv.length; i++) { + const next = () => argv[++i]; + if (argv[i] === "--base") args.base = next(); + else if (argv[i] === "--head") args.head = next(); + else if (argv[i] === "--rounds") args.rounds = Number(next()); + else if (argv[i] === "--threshold") args.threshold = Number(next()); + else if (argv[i] === "--json-out") args.jsonOut = next(); + else if (argv[i] === "--markdown-out") args.markdownOut = next(); + } + return args; +}; + +const writeOutput = (filePath, contents) => { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, contents); +}; + +const run = (cmd, cmdArgs, cwd, env) => + execFileSync(cmd, cmdArgs, { cwd, env: { ...process.env, ...env }, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }); + +const repoRoot = run("git", ["rev-parse", "--show-toplevel"], process.cwd()).trim(); + +function prepareWorktree(ref, label) { + const dir = mkdtempSync(path.join(tmpdir(), `perf-${label}-`)); + rmSync(dir, { recursive: true, force: true }); + run("git", ["worktree", "add", "--detach", dir, ref], repoRoot); + + const modules = path.join(repoRoot, "node_modules"); + if (existsSync(modules)) run("ln", ["-s", modules, path.join(dir, "node_modules")], repoRoot); + return dir; +} + +function syncPerfSpecs(fromDir, toDir) { + const fromPerfDir = path.join(fromDir, "tests/perf"); + if (!existsSync(fromPerfDir)) return; + + const toPerfDir = path.join(toDir, "tests/perf"); + mkdirSync(toPerfDir, { recursive: true }); + for (const file of readdirSync(fromPerfDir)) { + if (file.endsWith(".spec.ts") || file === "playwright.config.ts") { + copyFileSync(path.join(fromPerfDir, file), path.join(toPerfDir, file)); + } + } +} + +async function waitForServer(url, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(url); + if (res.ok || res.status === 404) return; + } catch {} + await new Promise(resolve => setTimeout(resolve, 500)); + } + throw new Error(`Server at ${url} did not become ready within ${timeoutMs}ms`); +} + +async function buildAndServe(dir, port, label) { + console.error(`[${label}] building...`); + run("npm", ["run", "build"], dir); + + console.error(`[${label}] starting preview server on :${port}...`); + const child = spawn("npm", ["run", "preview", "--", "--port", String(port), "--strictPort"], { + cwd: dir, + stdio: ["ignore", "pipe", "pipe"], + detached: true + }); + child.stdout?.on("data", () => {}); + child.stderr?.on("data", () => {}); + child.unref(); + + await waitForServer(`http://localhost:${port}/`); + return child; +} + +function killServer(child) { + if (!child?.pid) return; + try { + process.kill(-child.pid, "SIGTERM"); + } catch {} +} + +function parsePerfResults(stdout) { + const metrics = new Map(); + const checksums = new Map(); + for (const line of stdout.split("\n")) { + const marker = "PERF_RESULT "; + const idx = line.indexOf(marker); + if (idx === -1) continue; + + let parsed; + try { + parsed = JSON.parse(line.slice(idx + marker.length)); + } catch { + continue; + } + + const { suite, case: caseName, metrics: caseMetrics, checksum } = parsed; + const caseKey = `${suite} > ${caseName}`; + for (const [metric, value] of Object.entries(caseMetrics)) { + metrics.set(`${caseKey} > ${metric}`, value); + } + if (checksum) checksums.set(caseKey, checksum.hash); + } + return { metrics, checksums }; +} + +function runPerfSuite(dir, port) { + try { + const out = execFileSync( + "npx", + ["playwright", "test", "--config=tests/perf/playwright.config.ts", "tests/perf"], + { + cwd: dir, + env: { ...process.env, PERF_BASE_URL: `http://localhost:${port}` }, + encoding: "utf8" + } + ); + return parsePerfResults(out); + } catch (error) { + return parsePerfResults(error.stdout ?? ""); + } +} + +const median = values => { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +}; + +function toMarkdown(rows, threshold, hasRegression, checksumIssues, checksumWarnings) { + const emoji = status => (status === "REGRESSION" ? "🔴" : status === "ok" ? "🟢" : "⚪"); + const header = "| Metric | Change (median) | Spread across rounds | |\n|---|---|---|---|"; + const body = rows.map(r => `| ${r.metric} | ${r.change} | ${r.spread} | ${emoji(r.status)} |`).join("\n"); + const summary = hasRegression + ? `⚠️ One or more metrics are more than ${(threshold * 100).toFixed(0)}% slower than \`master\` (median across alternating rounds, so runner noise is cancelled out rather than thresholded around).` + : "No performance regressions detected (base and head were run alternately on the same runner, so this isn't affected by machine-to-machine noise)."; + + const sections = [`### Real-map generation/interaction benchmark (A/B vs \`master\`)`]; + if (checksumIssues.length) { + sections.push(`🔴 **Checksum mismatch**\n\n${checksumIssues.map(issue => `- ${issue}`).join("\n")}`); + } + if (checksumWarnings.length) { + sections.push(`⚠️ **Determinism warning**\n\n${checksumWarnings.map(warning => `- ${warning}`).join("\n")}`); + } + sections.push(`${header}\n${body}\n\n${summary}`); + return `${sections.join("\n\n")}\n`; +} + +const { base, head, rounds, threshold, jsonOut, markdownOut } = parseArgs(process.argv.slice(2)); + +const BASE_PORT = 4300; +const HEAD_PORT = 4301; + +const baseDir = prepareWorktree(base, "base"); +const headDir = prepareWorktree(head, "head"); +syncPerfSpecs(headDir, baseDir); + +let baseServer; +let headServer; +const ratios = new Map(); +const baseChecksums = new Map(); +const headChecksums = new Map(); + +try { + baseServer = await buildAndServe(baseDir, BASE_PORT, "base"); + headServer = await buildAndServe(headDir, HEAD_PORT, "head"); + + for (let round = 1; round <= rounds; round++) { + const first = round % 2 ? ["base", "head"] : ["head", "base"]; + const results = new Map(); + for (const which of first) { + const [dir, port] = which === "base" ? [baseDir, BASE_PORT] : [headDir, HEAD_PORT]; + results.set(which, runPerfSuite(dir, port)); + } + + const baseResult = results.get("base"); + const headResult = results.get("head"); + for (const [name, baseValue] of baseResult.metrics) { + const headValue = headResult.metrics.get(name); + if (headValue === undefined || !baseValue) continue; + if (!ratios.has(name)) ratios.set(name, []); + ratios.get(name).push(headValue / baseValue); + } + for (const [caseKey, hash] of baseResult.checksums) { + if (!baseChecksums.has(caseKey)) baseChecksums.set(caseKey, []); + baseChecksums.get(caseKey).push(hash); + } + for (const [caseKey, hash] of headResult.checksums) { + if (!headChecksums.has(caseKey)) headChecksums.set(caseKey, []); + headChecksums.get(caseKey).push(hash); + } + console.error(`round ${round}/${rounds} done`); + } +} finally { + for (const server of [baseServer, headServer]) killServer(server); + for (const dir of [baseDir, headDir]) { + run("git", ["worktree", "remove", "--force", dir], repoRoot); + } +} + +const checksumIssues = []; +const checksumWarnings = []; +for (const [caseKey, baseHashes] of baseChecksums) { + const headHashes = headChecksums.get(caseKey) ?? []; + const baseSet = new Set(baseHashes); + const headSet = new Set(headHashes); + if (baseSet.size > 1 || headSet.size > 1) { + checksumWarnings.push( + `\`${caseKey}\`: same seed generated different maps within one side (base: ${[...baseSet].join(", ")}; head: ${[...headSet].join(", ")}) — a generation determinism bug, timings for this case are noisier than they look` + ); + } else if (baseSet.size && headSet.size && [...baseSet][0] !== [...headSet][0]) { + checksumIssues.push( + `\`${caseKey}\`: base and head deterministically generate different maps (${[...baseSet][0]} vs ${[...headSet][0]}) — head changes generation output, so timings for this case are not comparable` + ); + } +} + +const isGated = name => name.endsWith("> total") || name.endsWith("> gesture"); + +const rows = []; +let regressed = false; +for (const [name, samples] of ratios) { + const change = median(samples) - 1; + const spread = Math.max(...samples) - Math.min(...samples); + const gated = isGated(name); + const isRegression = gated && change > threshold; + if (isRegression) regressed = true; + rows.push({ + metric: name, + change: `${change >= 0 ? "+" : ""}${(change * 100).toFixed(1)}%`, + spread: `${(spread * 100).toFixed(1)}%`, + status: isRegression ? "REGRESSION" : gated ? "ok" : "info" + }); +} + +rows.sort((a, b) => Number.parseFloat(b.change) - Number.parseFloat(a.change)); + +if (rows.length === 0) { + console.error(`No comparable metrics between ${base} and ${head} (one of the refs predates this perf suite).`); + if (markdownOut) { + writeOutput( + markdownOut, + `### Real-map generation/interaction benchmark (A/B vs \`master\`)\n\nNo comparable metrics yet — \`${base}\` predates this perf suite.\n` + ); + } + process.exit(0); +} + +console.table(rows); +if (checksumWarnings.length) console.error(`\nDeterminism warning:\n${checksumWarnings.map(w => `- ${w}`).join("\n")}`); +if (checksumIssues.length) console.error(`\nChecksum mismatch:\n${checksumIssues.map(i => `- ${i}`).join("\n")}`); + +if (jsonOut) writeOutput(jsonOut, JSON.stringify(rows, null, 2)); +if (markdownOut) writeOutput(markdownOut, toMarkdown(rows, threshold, regressed, checksumIssues, checksumWarnings)); + +if (checksumIssues.length) { + console.error("\nFAILED: head generates a different map than base for the same seed."); + process.exit(1); +} +if (regressed) { + console.error(`\nRegression: a metric is more than ${(threshold * 100).toFixed(0)}% slower than ${base}.`); + process.exit(1); +} +console.error(`\nNo regression beyond ${(threshold * 100).toFixed(0)}% vs ${base}.`); +process.exit(0); diff --git a/tests/perf/generation.spec.ts b/tests/perf/generation.spec.ts new file mode 100644 index 0000000000..cf3f702e24 --- /dev/null +++ b/tests/perf/generation.spec.ts @@ -0,0 +1,116 @@ +import { test } from "@playwright/test"; + +const SEEDS = ["100000000", "200000000"]; + +interface MapGeneratedDetail { + totalMs: number; +} + +interface PerfStageDetail { + stage: string; + ms: number; +} + +interface PerfWindow { + __mapGenerated?: MapGeneratedDetail; + __perfStages: Record; +} + +const STAGE_TIME_RE = /^([\w.]+): ([\d.]+) ?ms$/; +const TOTAL_TIME_RE = /^TOTAL: ([\d.]+)s$/; + +interface GenerationChecksum { + hash: string; + counts: Record; +} + +function computeChecksum(): GenerationChecksum { + const { cells, burgs, states, cultures, religions, provinces, rivers, routes } = (window as any).pack; + + let h = 0x811c9dc5; + const add = (x: number) => { + h ^= x & 0xff; + h = Math.imul(h, 0x01000193) >>> 0; + h ^= (x >>> 8) & 0xff; + h = Math.imul(h, 0x01000193) >>> 0; + }; + const addArray = (a: ArrayLike) => { + for (let i = 0; i < a.length; i++) add(a[i]); + }; + + addArray(cells.h); + addArray(cells.biome); + addArray(cells.state); + addArray(cells.burg); + addArray(cells.culture); + for (const burg of burgs) { + if (!burg?.i) continue; + add(Math.round(burg.x * 100)); + add(Math.round(burg.y * 100)); + add(Math.round(burg.population * 100)); + } + + return { + hash: h.toString(16), + counts: { + cells: cells.i.length, + burgs: burgs.length - 1, + states: states.length - 1, + cultures: cultures.length - 1, + religions: religions.length - 1, + provinces: provinces.length - 1, + rivers: rivers.length, + routes: routes.length + } + }; +} + +for (const seed of SEEDS) { + test(`generate map for seed ${seed}`, async ({ page }) => { + const consoleStageMs: Record = {}; + let consoleTotalMs: number | undefined; + + page.on("console", msg => { + const text = msg.text(); + const stageMatch = text.match(STAGE_TIME_RE); + if (stageMatch) { + consoleStageMs[stageMatch[1]] = Number(stageMatch[2]); + return; + } + const totalMatch = text.match(TOTAL_TIME_RE); + if (totalMatch) consoleTotalMs = Number(totalMatch[1]) * 1000; + }); + + await page.addInitScript(() => { + const perfWindow = window as unknown as PerfWindow; + perfWindow.__perfStages = {}; + window.addEventListener("perf:stage", event => { + const { stage, ms } = (event as CustomEvent).detail; + perfWindow.__perfStages[stage] = ms; + }); + window.addEventListener("map:generated", event => { + perfWindow.__mapGenerated = (event as CustomEvent).detail; + }); + }); + + await page.goto(`/?seed=${seed}`); + await page.waitForFunction(() => (window as unknown as PerfWindow).__mapGenerated !== undefined, { + timeout: 120_000 + }); + + const { totalMs, stageMs } = await page.evaluate(() => { + const perfWindow = window as unknown as PerfWindow; + return { totalMs: perfWindow.__mapGenerated?.totalMs, stageMs: perfWindow.__perfStages }; + }); + + const resolvedTotalMs = totalMs ?? consoleTotalMs; + if (resolvedTotalMs === undefined) throw new Error(`generation for seed ${seed} never reported a total time`); + const resolvedStageMs = Object.keys(stageMs).length ? stageMs : consoleStageMs; + + const checksum = await page.evaluate(computeChecksum); + + console.log( + `PERF_RESULT ${JSON.stringify({ suite: "generation", case: `seed ${seed}`, metrics: { total: resolvedTotalMs, ...resolvedStageMs }, checksum })}` + ); + }); +} diff --git a/tests/perf/interaction.spec.ts b/tests/perf/interaction.spec.ts new file mode 100644 index 0000000000..0561766d55 --- /dev/null +++ b/tests/perf/interaction.spec.ts @@ -0,0 +1,65 @@ +import fs from "fs"; +import path from "path"; +import { test } from "@playwright/test"; + +function findLatestFixture(): string { + const fixturesDir = path.join(__dirname, "../fixtures"); + const versioned = fs + .readdirSync(fixturesDir) + .filter(name => /^\d+\.\d+\.\d+\.map$/.test(name)) + .sort((a, b) => { + const toParts = (name: string) => name.replace(/\.map$/, "").split(".").map(Number); + const [aParts, bParts] = [toParts(a), toParts(b)]; + for (let i = 0; i < 3; i++) { + if (aParts[i] !== bParts[i]) return aParts[i] - bParts[i]; + } + return 0; + }); + + const latest = versioned.at(-1); + if (!latest) throw new Error(`No versioned .map fixture found in ${fixturesDir}`); + return path.join(fixturesDir, latest); +} + +const FIXTURE_PATH = findLatestFixture(); + +test("zoom and pan gesture over a loaded map", async ({ page }) => { + await page.goto("/"); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + await page.waitForSelector("#mapToLoad", { state: "attached" }); + await page.locator("#mapToLoad").setInputFiles(FIXTURE_PATH); + await page.waitForFunction(() => (window as unknown as { mapId?: unknown }).mapId !== undefined, { + timeout: 120_000 + }); + await page.waitForTimeout(500); + + const map = page.locator("#map"); + const box = await map.boundingBox(); + if (!box) throw new Error("#map has no bounding box"); + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + + const start = Date.now(); + + await page.mouse.move(centerX, centerY); + for (let i = 0; i < 5; i++) { + await page.mouse.wheel(0, -120); + } + await page.mouse.move(centerX - 150, centerY - 100); + await page.mouse.down(); + await page.mouse.move(centerX + 150, centerY + 100, { steps: 20 }); + await page.mouse.up(); + for (let i = 0; i < 5; i++) { + await page.mouse.wheel(0, 120); + } + + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + + const durationMs = Date.now() - start; + + console.log(`PERF_RESULT ${JSON.stringify({ suite: "interaction", case: "zoom-pan gesture", metrics: { gesture: durationMs } })}`); +}); diff --git a/tests/perf/playwright.config.ts b/tests/perf/playwright.config.ts new file mode 100644 index 0000000000..b0a78a131a --- /dev/null +++ b/tests/perf/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = process.env.PERF_BASE_URL || "http://localhost:4173"; + +export default defineConfig({ + testDir: ".", + fullyParallel: false, + retries: 0, + workers: 1, + timeout: 180_000, + expect: { timeout: 180_000 }, + reporter: [["list"]], + use: { + baseURL, + viewport: { width: 1280, height: 720 } + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] } + } + ] +});