diff --git a/.changeset/lib-ol-feature-benches.md b/.changeset/lib-ol-feature-benches.md new file mode 100644 index 00000000..7ed78649 --- /dev/null +++ b/.changeset/lib-ol-feature-benches.md @@ -0,0 +1,4 @@ +--- +--- + +Add feature-property and refresh benches. No package version bump. diff --git a/packages/lib-ol/__tests__/feature-properties.bench.ts b/packages/lib-ol/__tests__/feature-properties.bench.ts new file mode 100644 index 00000000..d401b6e0 --- /dev/null +++ b/packages/lib-ol/__tests__/feature-properties.bench.ts @@ -0,0 +1,520 @@ +import Feature from "ol/Feature.js"; +import GeoJSON from "ol/format/GeoJSON.js"; + +import {createPropsFilter} from "../src/js/style/styleFunction.ts"; +import type {GeoJsonCollection} from "./feature-properties/datasets.ts"; +import {CACHE_DIR, loadUnionCollection} from "./feature-properties/datasets.ts"; +import LearnableFeature, { + DEFAULT_CORE_PROPERTY_KEYS, +} from "./feature-properties/learnable-feature.ts"; + +type AccessStrategy = + | "get-allowed-keys" + | "get-miss-description" + | "get-properties" + | "get-properties-internal" + | "modify-full" + | "modify-filtered" + | "style-hash-filter" + | "style-hash-internal" + | "style-hash-get-keys" + | "clone"; + +type IngestStrategy = + "filtered-ingest" | "full-copy" | "learnable-promote" | "learnable-warn"; + +type PreparedFeature = { + feature: Feature; + properties: Record; +}; + +const CORE_KEYS = [...DEFAULT_CORE_PROPERTY_KEYS]; +const STYLE_ALLOWED_PROPS = [ + "chargingPower", + "id", + "mapsightIconId", + "markerCaption", + "markerCaptionColor", + "name", + "occupancyTrendString", + "state", + "title", + "type", +] as const; +const REFRESH = process.env.BENCH_PROPERTIES_REFRESH === "1"; +const DATASET_FILTER = new Set( + (process.env.BENCH_PROPERTIES_DATASETS ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean), +); +const filterStyleProps = createPropsFilter([...STYLE_ALLOWED_PROPS]); +const geoJsonFormat = new GeoJSON(); + +function jsonBytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value ?? null)); +} + +function pickCoreProperties( + properties: Record, +): Record { + const picked: Record = {}; + for (const key of CORE_KEYS) { + if (Object.hasOwn(properties, key)) { + picked[key] = properties[key]; + } + } + return picked; +} + +function collectGc(): void { + if (typeof globalThis.gc === "function") { + globalThis.gc(); + } +} + +function readHeapUsed(): number { + return process.memoryUsage().heapUsed; +} + +function analyzeCollection(id: string, collection: GeoJsonCollection): void { + const features = collection.features ?? []; + const keyCount = new Map(); + const keyBytes = new Map(); + let propertyBytes = 0; + let geometryBytes = 0; + let corePropertyBytes = 0; + + for (const feature of features) { + const properties = feature.properties ?? {}; + propertyBytes += jsonBytes(properties); + geometryBytes += jsonBytes(feature.geometry); + corePropertyBytes += jsonBytes(pickCoreProperties(properties)); + + for (const [key, value] of Object.entries(properties)) { + keyCount.set(key, (keyCount.get(key) ?? 0) + 1); + keyBytes.set(key, (keyBytes.get(key) ?? 0) + jsonBytes(value)); + } + } + + const keys = [...keyBytes.entries()].sort( + (left, right) => right[1] - left[1], + ); + const descriptionShare = + propertyBytes === 0 + ? 0 + : ((keyBytes.get("description") ?? 0) / propertyBytes) * 100; + + console.log(`\n${id}`); + console.log( + ` features=${features.length} props=${(propertyBytes / 1024).toFixed(1)}KB geom=${(geometryBytes / 1024).toFixed(1)}KB core-subset=${(corePropertyBytes / 1024).toFixed(1)}KB description=${descriptionShare.toFixed(0)}%`, + ); + console.log( + ` keys: ${keys + .slice(0, 8) + .map( + ([key, bytes]) => + `${key}×${keyCount.get(key) ?? 0} ${(bytes / 1024).toFixed(1)}KB`, + ) + .join(" · ")}`, + ); +} + +function readOlFeatures(collection: GeoJsonCollection): Feature[] { + return geoJsonFormat.readFeatures(collection); +} + +function createPrepared( + olFeatures: Feature[], + ingest: IngestStrategy, +): PreparedFeature[] { + const missCounts = new Map(); + const onMiss = (key: string) => { + missCounts.set(key, (missCounts.get(key) ?? 0) + 1); + }; + + return olFeatures.map((source) => { + const properties = {...source.getProperties()}; + delete properties.geometry; + const geometry = source.getGeometry(); + const id = source.getId(); + + let feature: Feature; + if (ingest === "full-copy") { + feature = new Feature(); + if (geometry) { + feature.setGeometry(geometry); + } + feature.setProperties(properties, true); + } else if (ingest === "filtered-ingest") { + feature = new Feature(); + if (geometry) { + feature.setGeometry(geometry); + } + feature.setProperties(pickCoreProperties(properties), true); + } else { + feature = new LearnableFeature(undefined, { + coreKeys: CORE_KEYS, + onMiss, + promoteOnMiss: ingest === "learnable-promote", + }); + if (geometry) { + feature.setGeometry(geometry); + } + feature.setProperties(properties, true); + } + + if (id !== undefined) { + feature.setId(id); + } + + return {feature, properties}; + }); +} + +function captionForUpdate(properties: Record): string { + const caption = properties.markerCaption; + return typeof caption === "string" || typeof caption === "number" + ? String(caption) + : "x"; +} + +function getPropertiesInternal(feature: Feature) { + return feature.getPropertiesInternal(); +} + +function modifyFeature( + feature: Feature, + nextProperties: Record, +): void { + const previous = feature.getProperties(); + let changed = false; + + for (const key of Object.keys(nextProperties)) { + const nextValue = nextProperties[key]; + if (previous[key] !== nextValue) { + feature.set(key, nextValue, key !== "geometry"); + changed = true; + } + } + + if (changed) { + feature.changed(); + } +} + +function runAccess( + prepared: PreparedFeature[], + access: AccessStrategy, +): number { + let touched = 0; + + for (const {feature, properties} of prepared) { + switch (access) { + case "get-properties": { + touched += Object.keys(feature.getProperties()).length; + break; + } + case "get-properties-internal": { + const internal = getPropertiesInternal(feature); + touched += internal ? Object.keys(internal).length : 0; + break; + } + case "get-allowed-keys": { + for (const key of STYLE_ALLOWED_PROPS) { + if (feature.get(key) != null) { + touched += 1; + } + } + break; + } + case "get-miss-description": { + if (feature.get("description") != null) { + touched += 1; + } + break; + } + case "style-hash-filter": { + const filtered = filterStyleProps(feature.getProperties()); + touched += JSON.stringify(filtered).length; + break; + } + case "style-hash-internal": { + const filtered = filterStyleProps( + getPropertiesInternal(feature) ?? {}, + ); + touched += JSON.stringify(filtered).length; + break; + } + case "style-hash-get-keys": { + const picked: Record = {}; + for (const key of STYLE_ALLOWED_PROPS) { + const value = feature.get(key); + if (value != null) { + picked[key] = value; + } + } + touched += JSON.stringify(picked).length; + break; + } + case "modify-full": { + modifyFeature(feature, { + ...properties, + markerCaption: captionForUpdate(properties), + }); + touched += 1; + break; + } + case "modify-filtered": { + modifyFeature(feature, { + ...pickCoreProperties(properties), + markerCaption: captionForUpdate(properties), + }); + touched += 1; + break; + } + case "clone": { + touched += Object.keys(feature.clone().getProperties()).length; + break; + } + } + } + + return touched; +} + +function benchAccess( + label: string, + prepared: PreparedFeature[], + access: AccessStrategy, + rounds: number, +): {ms: number; perFeatureUs: number} { + runAccess(prepared, access); + + const start = performance.now(); + for (let round = 0; round < rounds; round += 1) { + runAccess(prepared, access); + } + const ms = performance.now() - start; + const perFeatureUs = (ms * 1000) / (rounds * prepared.length); + + console.log( + ` ${label.padEnd(42)} ${ms.toFixed(2).padStart(8)}ms ${perFeatureUs.toFixed(2).padStart(8)}µs/feature ×${rounds}`, + ); + + return {ms, perFeatureUs}; +} + +function benchHeap( + label: string, + factory: () => PreparedFeature[], +): PreparedFeature[] { + collectGc(); + const before = readHeapUsed(); + const prepared = factory(); + collectGc(); + const after = readHeapUsed(); + const bytesPerFeature = (after - before) / Math.max(prepared.length, 1); + + console.log( + ` ${label.padEnd(42)} ${((after - before) / 1024 / 1024).toFixed(2).padStart(8)}MB ${bytesPerFeature.toFixed(0).padStart(8)}B/feature n=${prepared.length}`, + ); + + return prepared; +} + +async function main(): Promise { + console.log( + "Braunschweig live property-storage bench\n" + + `(cache ${CACHE_DIR}${REFRESH ? ", refresh forced" : ""})`, + ); + if (typeof globalThis.gc !== "function") { + console.log( + "Note: restart with `node --expose-gc` for tighter heap deltas.\n", + ); + } + + const {loaded, union: allCollection} = await loadUnionCollection( + REFRESH, + DATASET_FILTER, + ); + for (const {id, collection} of loaded) { + analyzeCollection(id, collection); + } + analyzeCollection("all-live (union)", allCollection); + + const olFeatures = readOlFeatures(allCollection); + const ingestStrategies: IngestStrategy[] = [ + "full-copy", + "filtered-ingest", + "learnable-warn", + "learnable-promote", + ]; + + console.log("\nHeap after ingest (keep references)"); + const preparedByIngest = new Map(); + for (const ingest of ingestStrategies) { + preparedByIngest.set( + ingest, + benchHeap(ingest, () => createPrepared(olFeatures, ingest)), + ); + } + + const accessCases: Array<{ + access: AccessStrategy; + ingest: IngestStrategy; + label: string; + rounds: number; + }> = [ + { + access: "get-properties", + ingest: "full-copy", + label: "getProperties / full-copy", + rounds: 40, + }, + { + access: "get-properties-internal", + ingest: "full-copy", + label: "getPropertiesInternal / full-copy", + rounds: 40, + }, + { + access: "get-properties", + ingest: "filtered-ingest", + label: "getProperties / filtered-ingest", + rounds: 40, + }, + { + access: "get-properties", + ingest: "learnable-warn", + label: "getProperties / learnable-warn", + rounds: 40, + }, + { + access: "get-allowed-keys", + ingest: "full-copy", + label: "get(allowed) / full-copy", + rounds: 40, + }, + { + access: "get-allowed-keys", + ingest: "learnable-warn", + label: "get(allowed) / learnable-warn", + rounds: 40, + }, + { + access: "get-miss-description", + ingest: "full-copy", + label: "get(description) / full-copy", + rounds: 20, + }, + { + access: "get-miss-description", + ingest: "filtered-ingest", + label: "get(description) / filtered-ingest", + rounds: 20, + }, + { + access: "get-miss-description", + ingest: "learnable-warn", + label: "get(description) / learnable-warn", + rounds: 20, + }, + { + access: "get-miss-description", + ingest: "learnable-promote", + label: "get(description) / learnable-promote", + rounds: 20, + }, + { + access: "style-hash-filter", + ingest: "full-copy", + label: "style hash(getProperties) / full-copy", + rounds: 20, + }, + { + access: "style-hash-filter", + ingest: "filtered-ingest", + label: "style hash(getProperties) / filtered", + rounds: 20, + }, + { + access: "style-hash-filter", + ingest: "learnable-warn", + label: "style hash(getProperties) / learnable", + rounds: 20, + }, + { + access: "style-hash-internal", + ingest: "full-copy", + label: "style hash(internal) / full-copy", + rounds: 20, + }, + { + access: "style-hash-get-keys", + ingest: "learnable-warn", + label: "style hash(get keys) / learnable", + rounds: 20, + }, + { + access: "modify-full", + ingest: "full-copy", + label: "modifyFeature(all keys) / full-copy", + rounds: 8, + }, + { + access: "modify-filtered", + ingest: "full-copy", + label: "modifyFeature(core keys) / full-copy", + rounds: 8, + }, + { + access: "modify-filtered", + ingest: "learnable-warn", + label: "modifyFeature(core keys) / learnable", + rounds: 8, + }, + { + access: "clone", + ingest: "full-copy", + label: "clone / full-copy", + rounds: 6, + }, + { + access: "clone", + ingest: "filtered-ingest", + label: "clone / filtered-ingest", + rounds: 6, + }, + { + access: "clone", + ingest: "learnable-warn", + label: "clone / learnable-warn", + rounds: 6, + }, + ]; + + console.log("\nAccess / update (lower is better)"); + for (const {access, ingest, label, rounds} of accessCases) { + const prepared = preparedByIngest.get(ingest); + if (!prepared) { + continue; + } + benchAccess(label, prepared, access, rounds); + } + + console.log("\nLearnable miss log from the promote heap sample:"); + const promoteSample = + preparedByIngest.get("learnable-promote")?.[0]?.feature; + if (promoteSample instanceof LearnableFeature) { + console.log( + ` first feature local keys after promote-path benches: ${Object.keys( + promoteSample.getProperties(), + ) + .filter((key) => key !== "geometry") + .join(", ")}`, + ); + } +} + +await main(); diff --git a/packages/lib-ol/__tests__/feature-properties/datasets.ts b/packages/lib-ol/__tests__/feature-properties/datasets.ts new file mode 100644 index 00000000..5c07d698 --- /dev/null +++ b/packages/lib-ol/__tests__/feature-properties/datasets.ts @@ -0,0 +1,150 @@ +import {mkdir, readFile, stat, writeFile} from "node:fs/promises"; +import {dirname, join} from "node:path"; +import {fileURLToPath} from "node:url"; + +export type DatasetId = + | "charging-stations" + | "kultur" + | "oepnv-stop-groups" + | "parken" + | "parkhaeuser" + | "sehenswuerdigkeiten" + | "smart-city" + | "top-sights" + | "traffic" + | "traffic-messages"; + +export type DatasetSpec = { + id: DatasetId; + url: string; +}; + +export type GeoJsonFeature = { + geometry?: unknown; + id?: string | number; + properties?: Record | null; + type?: string; +}; + +export type GeoJsonCollection = { + features?: GeoJsonFeature[]; + type?: string; +}; + +const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +export const CACHE_DIR = join(PACKAGE_ROOT, "tmp", "braunschweig-geojson"); + +const USER_AGENT = "mapsight-property-bench/1.0"; + +export const DATASETS: DatasetSpec[] = [ + { + id: "charging-stations", + url: "https://www.braunschweig.de/mapsight/pulp/result/charging-stations.geojson", + }, + { + id: "oepnv-stop-groups", + url: "https://www.braunschweig.de/mapsight/pulp/result/oepnv-stop-groups.geojson", + }, + { + id: "sehenswuerdigkeiten", + url: "https://www.braunschweig.de/geojson/sehenswuerdigkeiten.geojson", + }, + { + id: "smart-city", + url: "https://www.braunschweig.de/mapsight/pulp/result/smart-city.geojson", + }, + { + id: "traffic", + url: "https://www.braunschweig.de/mapsight/pulp/result/traffic.geojson", + }, + { + id: "kultur", + url: "https://www.braunschweig.de/mapsight/pulp/result/kultur.geojson", + }, + { + id: "parken", + url: "https://www.braunschweig.de/mapsight/pulp/result/parken.geojson", + }, + { + id: "traffic-messages", + url: "https://www.braunschweig.de/mapsight/pulp/result/traffic-messages.geojson", + }, + { + id: "parkhaeuser", + url: "https://www.braunschweig.de/mapsight/pulp/result/parkhaeuser.geojson", + }, + { + id: "top-sights", + url: "https://www.braunschweig.de/geojson/top-sights.geojson", + }, +]; + +async function fileExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +export async function loadDataset( + spec: DatasetSpec, + refresh = false, +): Promise { + await mkdir(CACHE_DIR, {recursive: true}); + const cachePath = join(CACHE_DIR, `${spec.id}.geojson`); + + if (!refresh && (await fileExists(cachePath))) { + return JSON.parse( + await readFile(cachePath, "utf8"), + ) as GeoJsonCollection; + } + + const response = await fetch(spec.url, { + headers: { + Accept: "application/geo+json,application/json", + "User-Agent": USER_AGENT, + }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch ${spec.url}: HTTP ${response.status}`); + } + + const text = await response.text(); + JSON.parse(text); + await writeFile(cachePath, text); + return JSON.parse(text) as GeoJsonCollection; +} + +/** Union FeatureCollection of the given datasets (all by default). */ +export async function loadUnionCollection( + refresh = false, + filterIds?: ReadonlySet, +): Promise<{ + loaded: Array<{collection: GeoJsonCollection; id: DatasetId}>; + union: GeoJsonCollection; +}> { + const specs = filterIds?.size + ? DATASETS.filter((spec) => filterIds.has(spec.id)) + : DATASETS; + + const loaded: Array<{collection: GeoJsonCollection; id: DatasetId}> = []; + for (const spec of specs) { + loaded.push({ + collection: await loadDataset(spec, refresh), + id: spec.id, + }); + } + + return { + loaded, + union: { + features: loaded.flatMap( + ({collection}) => collection.features ?? [], + ), + type: "FeatureCollection", + }, + }; +} diff --git a/packages/lib-ol/__tests__/feature-properties/learnable-feature.test.ts b/packages/lib-ol/__tests__/feature-properties/learnable-feature.test.ts new file mode 100644 index 00000000..dbe613ed --- /dev/null +++ b/packages/lib-ol/__tests__/feature-properties/learnable-feature.test.ts @@ -0,0 +1,90 @@ +import Point from "ol/geom/Point.js"; + +import {describe, expect, it, vi} from "vitest"; + +import LearnableFeature, { + DEFAULT_CORE_PROPERTY_KEYS, + resetLearnableFeatureMissWarningsForTests, +} from "./learnable-feature.ts"; + +describe("LearnableFeature", () => { + it("keeps only core keys in the local OpenLayers bag", () => { + const backing = { + name: "Parkhaus Eiermarkt", + markerCaption: "207", + description: "
fat html
", + tagGroups: {place: ["innenstadt"]}, + }; + const feature = new LearnableFeature( + {geometry: new Point([0, 0]), ...backing}, + {coreKeys: DEFAULT_CORE_PROPERTY_KEYS}, + ); + + const local = feature.getProperties(); + expect(local.name).toBe("Parkhaus Eiermarkt"); + expect(local.markerCaption).toBe("207"); + expect(local).not.toHaveProperty("description"); + expect(local).not.toHaveProperty("tagGroups"); + expect(feature.getBacking()).toMatchObject(backing); + expect(feature.getBacking()).not.toHaveProperty("geometry"); + }); + + it("serves backing keys from get() and warns once per key", () => { + resetLearnableFeatureMissWarningsForTests(); + const onMiss = vi.fn(); + const feature = new LearnableFeature(new Point([1, 2]), { + backing: {description: "html", name: "Stop"}, + coreKeys: ["name"], + onMiss, + }); + + expect(feature.get("name")).toBe("Stop"); + expect(onMiss).not.toHaveBeenCalled(); + expect(feature.get("description")).toBe("html"); + expect(feature.get("description")).toBe("html"); + expect(onMiss).toHaveBeenCalledTimes(2); + expect(onMiss.mock.calls[0]?.[0]).toBe("description"); + expect(feature.getProperties()).not.toHaveProperty("description"); + }); + + it("keeps the GeoJSON properties object as the backing reference", () => { + const backing = {name: "Stop", description: "html"}; + const feature = new LearnableFeature(new Point([0, 0]), { + coreKeys: ["name"], + onMiss: () => undefined, + }); + feature.setProperties(backing, true); + + expect(feature.getBacking()).toBe(backing); + expect(feature.get("name")).toBe("Stop"); + expect(feature.getProperties()).not.toHaveProperty("description"); + }); + + it("promotes a missed key into the local bag when asked", () => { + const feature = new LearnableFeature(new Point([0, 0]), { + backing: {description: "html", mapsightIconId: "parkhaus"}, + coreKeys: ["mapsightIconId"], + onMiss: () => undefined, + promoteOnMiss: true, + }); + + expect(feature.get("description")).toBe("html"); + expect(feature.getProperties().description).toBe("html"); + expect(feature.coreKeys.has("description")).toBe(true); + }); + + it("clones local properties and keeps the same backing reference", () => { + const backing = {name: "A", description: "html"}; + const feature = new LearnableFeature( + {geometry: new Point([3, 4]), ...backing}, + {backing, coreKeys: ["name"], onMiss: () => undefined}, + ); + const clone = feature.clone(); + + expect(clone).toBeInstanceOf(LearnableFeature); + expect(clone.get("name")).toBe("A"); + expect(clone.getBacking()).toEqual(backing); + expect(clone.getGeometry()).not.toBe(feature.getGeometry()); + expect(clone.get("description")).toBe("html"); + }); +}); diff --git a/packages/lib-ol/__tests__/feature-properties/learnable-feature.ts b/packages/lib-ol/__tests__/feature-properties/learnable-feature.ts new file mode 100644 index 00000000..cdba67ac --- /dev/null +++ b/packages/lib-ol/__tests__/feature-properties/learnable-feature.ts @@ -0,0 +1,205 @@ +import Feature from "ol/Feature.js"; +import type {ObjectWithGeometry} from "ol/Feature.js"; +import type Geometry from "ol/geom/Geometry.js"; + +/** + * Style / identity keys that belong on the OpenLayers feature bag. + * Taken from simplestyle, traffic-style `attr()` / selectors, and live + * Braunschweig GeoJSON (mapsightIconId, markerCaption*, occupancyTrendString). + */ +export const DEFAULT_CORE_PROPERTY_KEYS = [ + "id", + "name", + "title", + "type", + "state", + "cluster", + "clusterSize", + "mapsightIconId", + "markerCaption", + "markerCaptionColor", + "markerCaptionHalo", + "marker-size", + "marker-color", + "marker-symbol", + "stroke", + "stroke-width", + "stroke-opacity", + "fill", + "fill-opacity", + "chargingPower", + "occupancyTrendString", +] as const; + +export type LearnableFeatureMissHandler = ( + key: string, + feature: LearnableFeature, +) => void; + +export type LearnableFeatureOptions = { + backing?: Record | null; + coreKeys?: Iterable; + onMiss?: LearnableFeatureMissHandler; + promoteOnMiss?: boolean; +}; + +const warnedMissKeys = new Set(); + +export function resetLearnableFeatureMissWarningsForTests(): void { + warnedMissKeys.clear(); +} + +function defaultOnMiss(key: string): void { + if (warnedMissKeys.has(key)) { + return; + } + + warnedMissKeys.add(key); + console.warn( + `[LearnableFeature] property "${key}" is not in the core OpenLayers bag; served from the backing store. Add it to coreKeys if styling or map code needs it.`, + ); +} + +function isGeometry(value: unknown): value is Geometry { + return ( + typeof value === "object" && + value !== null && + typeof (value as Geometry).getSimplifiedGeometry === "function" + ); +} + +/** + * OpenLayers Feature that keeps a small local property bag (core / promoted + * keys) and serves everything else from a backing object — typically the + * Redux / GeoJSON `properties` object, by reference. + * + * `getProperties()` stays local so the style hot path does not copy fat + * UI-only keys (`description`, `tagGroups`, …). `get(key)` is the failsafe: + * it warns (once per key) and can promote that key into the local bag. + */ +export default class LearnableFeature extends Feature { + readonly coreKeys: Set; + private backing: Record | null; + private readonly onMiss: LearnableFeatureMissHandler; + private readonly promoteOnMiss: boolean; + + constructor( + geometryOrProperties?: Geometry | ObjectWithGeometry, + options: LearnableFeatureOptions = {}, + ) { + super(); + + this.coreKeys = new Set(options.coreKeys ?? DEFAULT_CORE_PROPERTY_KEYS); + this.backing = options.backing ?? null; + this.onMiss = options.onMiss ?? defaultOnMiss; + this.promoteOnMiss = options.promoteOnMiss ?? false; + + if (!geometryOrProperties) { + this.applyCoreFromBacking(true); + return; + } + + if (isGeometry(geometryOrProperties)) { + this.setGeometry(geometryOrProperties); + this.applyCoreFromBacking(true); + return; + } + + this.setProperties(geometryOrProperties, true); + } + + setBacking(backing: Record | null): void { + this.backing = backing; + this.applyCoreFromBacking(true); + } + + getBacking(): Record | null { + return this.backing; + } + + override get(key: string): unknown { + const local: unknown = super.get(key); + if (local !== undefined || key === this.getGeometryName()) { + return local; + } + + if (!this.backing || !Object.hasOwn(this.backing, key)) { + return local; + } + + const value = this.backing[key]; + if (this.coreKeys.has(key)) { + return value; + } + + this.onMiss(key, this); + + if (this.promoteOnMiss) { + this.coreKeys.add(key); + super.set(key, value, true); + } + + return value; + } + + override setProperties( + values: Record, + silent?: boolean, + ): void { + const geometryName = this.getGeometryName(); + if (Object.hasOwn(values, geometryName)) { + super.set(geometryName, values[geometryName], silent); + const properties = {...values}; + delete properties[geometryName]; + this.backing = properties; + } else { + this.backing = values; + } + + this.applyCoreFromBacking(silent); + } + + override clone(): LearnableFeature { + const clone = new LearnableFeature(undefined, { + backing: this.backing, + coreKeys: this.coreKeys, + onMiss: this.onMiss, + promoteOnMiss: this.promoteOnMiss, + }); + clone.setGeometryName(this.getGeometryName()); + + const local = this.getPropertiesInternal(); + if (local) { + const geometry = this.getGeometry(); + for (const key of Object.keys(local)) { + if (key === this.getGeometryName() && geometry) { + clone.set(key, geometry.clone()); + } else { + clone.set(key, local[key], true); + } + } + } + + const style = this.getStyle(); + if (style) { + clone.setStyle(style); + } + + return clone; + } + + private applyCoreFromBacking(silent?: boolean): void { + if (!this.backing) { + return; + } + + const geometryName = this.getGeometryName(); + for (const key of this.coreKeys) { + if (key === geometryName || !Object.hasOwn(this.backing, key)) { + continue; + } + + super.set(key, this.backing[key], silent); + } + } +} diff --git a/packages/lib-ol/__tests__/feature-refresh.bench.ts b/packages/lib-ol/__tests__/feature-refresh.bench.ts new file mode 100644 index 00000000..62c760c5 --- /dev/null +++ b/packages/lib-ol/__tests__/feature-refresh.bench.ts @@ -0,0 +1,326 @@ +/** + * Benchmarks the polling refresh pipeline (xhrJsonRefreshing sources) on live + * Braunschweig data: JSON.parse → GeoJSON.readFeatures → update-in-source, + * with an identical payload — the common case for 60s polls. + * + * The update step mirrors @mapsight/core's `updateFeaturesInSource` / + * `modifyFeature` (core depends on lib-ol, so it cannot be imported here). + * Strategies: + * + * - baseline core behavior today; geometry is diffed by reference, + * and readFeatures always creates new Geometry objects + * - geometry-aware skip the geometry set when flat coordinates are equal + * - core-keys geometry-aware + diff only core/style keys + * - text-skip compare the raw response text and skip everything + */ +import type Feature from "ol/Feature.js"; +import GeoJSON from "ol/format/GeoJSON.js"; +import GeometryCollection from "ol/geom/GeometryCollection.js"; +import SimpleGeometry from "ol/geom/SimpleGeometry.js"; +import VectorSource from "ol/source/Vector.js"; + +import {CACHE_DIR, loadUnionCollection} from "./feature-properties/datasets.ts"; +import {DEFAULT_CORE_PROPERTY_KEYS} from "./feature-properties/learnable-feature.ts"; + +type UpdateStrategy = "baseline" | "core-keys" | "geometry-aware"; + +type RefreshCounters = { + changedFeatures: number; + geometryReplacements: number; + sourceChangeFeatureEvents: number; +}; + +type PhaseTotals = { + parseMs: number; + readMs: number; + updateMs: number; +}; + +const ROUNDS = Number(process.env.BENCH_REFRESH_ROUNDS || 5); +const REFRESH = process.env.BENCH_PROPERTIES_REFRESH === "1"; +const CORE_KEYS = new Set(DEFAULT_CORE_PROPERTY_KEYS); +const geoJsonFormat = new GeoJSON(); + +function geometriesEqual(left: unknown, right: unknown): boolean { + if (left === right) { + return true; + } + + if ( + left instanceof GeometryCollection && + right instanceof GeometryCollection + ) { + const leftParts = left.getGeometriesArray(); + const rightParts = right.getGeometriesArray(); + return ( + leftParts.length === rightParts.length && + leftParts.every((part, index) => + geometriesEqual(part, rightParts[index]), + ) + ); + } + + if ( + !(left instanceof SimpleGeometry) || + !(right instanceof SimpleGeometry) + ) { + return false; + } + + if ( + left.getType() !== right.getType() || + left.getLayout() !== right.getLayout() + ) { + return false; + } + + // Coordinates + type + layout is a sufficient equality proxy for polling + // payloads (ring/part splits do not change while coordinates stay equal). + const leftCoordinates = left.getFlatCoordinates(); + const rightCoordinates = right.getFlatCoordinates(); + if (leftCoordinates.length !== rightCoordinates.length) { + return false; + } + + for (let i = 0; i < leftCoordinates.length; i += 1) { + if (leftCoordinates[i] !== rightCoordinates[i]) { + return false; + } + } + + return true; +} + +/** Mirrors core's modifyFeature with optional geometry/key-subset awareness. */ +function modifyFeature( + baseFeature: Feature, + newProps: Record, + strategy: UpdateStrategy, + counters: RefreshCounters, +): boolean { + const oldProps = baseFeature.getProperties(); + + let featureChanged = false; + for (const key of Object.keys(newProps)) { + if ( + strategy === "core-keys" && + key !== "geometry" && + !CORE_KEYS.has(key) + ) { + continue; + } + + const newValue = newProps[key]; + if (oldProps[key] === newValue) { + continue; + } + + if (key === "geometry") { + if ( + strategy !== "baseline" && + geometriesEqual(oldProps[key], newValue) + ) { + continue; + } + counters.geometryReplacements += 1; + } + + featureChanged = true; + baseFeature.set(key, newValue, key !== "geometry"); + } + + if (featureChanged) { + counters.changedFeatures += 1; + baseFeature.changed(); + } + return featureChanged; +} + +/** Mirrors core's updateFeaturesInSource (public addFeature instead of internal). */ +function updateFeaturesInSource( + source: VectorSource, + nextFeatures: Feature[], + strategy: UpdateStrategy, + counters: RefreshCounters, +): void { + const ids = new Set(); + for (const feature of source.getFeatures()) { + const id = feature.getId(); + if (id !== undefined) { + ids.add(String(id)); + } else { + source.removeFeature(feature); + } + } + + let hasChanged = false; + for (const nextFeature of nextFeatures) { + const rawId = nextFeature.getId(); + const newId = rawId === undefined ? undefined : String(rawId); + if (newId !== undefined && ids.has(newId)) { + ids.delete(newId); + const prevFeature = source.getFeatureById(newId); + if (prevFeature) { + if ( + modifyFeature( + prevFeature, + nextFeature.getProperties(), + strategy, + counters, + ) + ) { + hasChanged = true; + } + continue; + } + } + + hasChanged = true; + source.addFeature(nextFeature); + } + + for (const id of ids) { + const oldFeature = source.getFeatureById(id); + if (oldFeature) { + source.removeFeature(oldFeature); + hasChanged = true; + } + } + + if (hasChanged) { + source.changed(); + } +} + +function createPopulatedSource(text: string): { + counters: RefreshCounters; + source: VectorSource; +} { + const source = new VectorSource(); + source.addFeatures(geoJsonFormat.readFeatures(JSON.parse(text))); + + const counters: RefreshCounters = { + changedFeatures: 0, + geometryReplacements: 0, + sourceChangeFeatureEvents: 0, + }; + source.on("changefeature", () => { + counters.sourceChangeFeatureEvents += 1; + }); + + return {counters, source}; +} + +/** Force a distinct string object so === measures a real O(n) compare. */ +function detachString(text: string): string { + return (" " + text).slice(1); +} + +function runUpdateStrategy( + label: string, + text: string, + featureCount: number, + strategy: UpdateStrategy, +): void { + const {counters, source} = createPopulatedSource(text); + const totals: PhaseTotals = {parseMs: 0, readMs: 0, updateMs: 0}; + + for (let round = 0; round < ROUNDS; round += 1) { + const parseStart = performance.now(); + const parsed = JSON.parse(detachString(text)) as object; + const readStart = performance.now(); + const nextFeatures = geoJsonFormat.readFeatures(parsed); + const updateStart = performance.now(); + updateFeaturesInSource(source, nextFeatures, strategy, counters); + const end = performance.now(); + + totals.parseMs += readStart - parseStart; + totals.readMs += updateStart - readStart; + totals.updateMs += end - updateStart; + } + + const perRound = (value: number) => (value / ROUNDS).toFixed(2); + console.log( + ` ${label.padEnd(16)} parse ${perRound(totals.parseMs).padStart(7)}ms read ${perRound(totals.readMs).padStart(7)}ms update ${perRound(totals.updateMs).padStart(7)}ms | per refresh: changed=${counters.changedFeatures / ROUNDS}/${featureCount} geomSets=${counters.geometryReplacements / ROUNDS} sourceEvents=${counters.sourceChangeFeatureEvents / ROUNDS}`, + ); +} + +function runTextSkip(text: string): void { + const detached = detachString(text); + let skipped = 0; + + const start = performance.now(); + for (let round = 0; round < ROUNDS; round += 1) { + if (detachString(text) === detached) { + skipped += 1; + } + } + const ms = (performance.now() - start) / ROUNDS; + + console.log( + ` ${"text-skip".padEnd(16)} compare ${ms.toFixed(2).padStart(5)}ms/refresh (${(text.length / 1024 / 1024).toFixed(1)}MB text) skipped=${skipped}/${ROUNDS} | changed=0 geomSets=0 sourceEvents=0`, + ); +} + +function benchCopyStrategies(bags: Array>): void { + const cases: Array<{ + fn: (bag: Record) => unknown; + label: string; + }> = [ + {fn: (bag) => ({...bag}), label: "{...props} shallow spread"}, + {fn: (bag) => structuredClone(bag), label: "structuredClone (deep)"}, + { + fn: (bag) => JSON.parse(JSON.stringify(bag)) as unknown, + label: "JSON roundtrip (deep)", + }, + ]; + + for (const {fn, label} of cases) { + for (const bag of bags) { + fn(bag); + } + + const start = performance.now(); + for (let round = 0; round < 5; round += 1) { + for (const bag of bags) { + fn(bag); + } + } + const ms = (performance.now() - start) / 5; + const perFeatureUs = (ms * 1000) / bags.length; + + console.log( + ` ${label.padEnd(28)} ${ms.toFixed(2).padStart(8)}ms/pass ${perFeatureUs.toFixed(2).padStart(7)}µs/feature`, + ); + } +} + +async function main(): Promise { + const {union} = await loadUnionCollection(REFRESH); + const text = JSON.stringify(union); + const featureCount = union.features?.length ?? 0; + + console.log( + `Braunschweig refresh-pipeline bench (identical payload, ${featureCount} features, ${(text.length / 1024 / 1024).toFixed(1)}MB, ${ROUNDS} rounds, cache ${CACHE_DIR})\n`, + ); + + console.log( + "Identical-payload poll: per-refresh cost and change churn (lower is better)", + ); + runUpdateStrategy("baseline", text, featureCount, "baseline"); + runUpdateStrategy("geometry-aware", text, featureCount, "geometry-aware"); + runUpdateStrategy("core-keys", text, featureCount, "core-keys"); + runTextSkip(text); + + const bags = (union.features ?? []) + .map((feature) => feature.properties) + .filter( + (properties): properties is Record => + properties != null, + ); + + console.log("\nProperty-bag copy strategies (whole union per pass)"); + benchCopyStrategies(bags); +} + +await main(); diff --git a/packages/lib-ol/package.json b/packages/lib-ol/package.json index 898807ae..e11607c0 100644 --- a/packages/lib-ol/package.json +++ b/packages/lib-ol/package.json @@ -31,6 +31,8 @@ "url": "https://github.com/open-mapsight/mapsight" }, "scripts": { + "bench:properties": "node --expose-gc __tests__/feature-properties.bench.ts", + "bench:refresh": "node __tests__/feature-refresh.bench.ts", "build": "tsc && tsc-alias", "clean": "rimraf dist/*", "clean-build": "run-s clean build",