diff --git a/package.json b/package.json index f4243315d..57061a64b 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "leaflet": "1.9.3", "localforage": "1.10.0", "lodash-es": "4.17.21", + "lru-cache": "^8.0.4", "mini-svg-data-uri": "1.4.4", "parse-domain": "7.0.1", "quickjs-emscripten": "0.21.1", diff --git a/src/core/mantle/evaluator/simple/index.ts b/src/core/mantle/evaluator/simple/index.ts index 5552129f2..7d91364eb 100644 --- a/src/core/mantle/evaluator/simple/index.ts +++ b/src/core/mantle/evaluator/simple/index.ts @@ -1,4 +1,5 @@ import { pick } from "lodash-es"; +import LRUCache from "lru-cache"; import type { EvalContext, EvalResult } from ".."; import { @@ -15,6 +16,9 @@ import { import { ConditionalExpression } from "./conditionalExpression"; import { clearExpressionCaches, Expression } from "./expression"; import { evalTimeInterval } from "./interval"; +import { getCacheableProperties } from "./utils"; + +const EVAL_EXPRESSION_CACHES = new LRUCache({ max: 10000 }); export async function evalSimpleLayer( layer: LayerSimple, @@ -23,6 +27,11 @@ export async function evalSimpleLayer( const features = layer.data ? await ctx.getAllFeatures(layer.data) : undefined; const appearances: Partial = pick(layer, appearanceKeys); const timeIntervals = evalTimeInterval(features, layer.data?.time); + + if (!features) { + return undefined; + } + return { layer: evalLayerAppearances(appearances, layer), features: features?.map((f, i) => evalSimpleLayerFeature(layer, f, timeIntervals?.[i])), @@ -111,11 +120,41 @@ function evalExpression( if (typeof styleExpression === "undefined") { return undefined; } else if (typeof styleExpression === "object" && styleExpression.conditions) { - return new ConditionalExpression(styleExpression, feature, layer.defines).evaluate(); + const cacheKey = JSON.stringify([ + styleExpression, + getCacheableProperties(styleExpression, feature), + layer.defines, + ]); + + if (EVAL_EXPRESSION_CACHES.has(cacheKey)) { + return EVAL_EXPRESSION_CACHES.get(cacheKey); + } + + const result = new ConditionalExpression( + styleExpression, + feature, + layer.defines, + ).evaluate(); + EVAL_EXPRESSION_CACHES.set(cacheKey, result); + + return result; } else if (typeof styleExpression === "boolean" || typeof styleExpression === "number") { - return new Expression(String(styleExpression), feature, layer.defines).evaluate(); + return styleExpression; } else if (typeof styleExpression === "string") { - return new Expression(styleExpression, feature, layer.defines).evaluate(); + const cacheKey = JSON.stringify([ + styleExpression, + getCacheableProperties(styleExpression, feature), + layer.defines, + ]); + + if (EVAL_EXPRESSION_CACHES.has(cacheKey)) { + return EVAL_EXPRESSION_CACHES.get(cacheKey); + } + + const result = new Expression(styleExpression, feature, layer.defines).evaluate(); + EVAL_EXPRESSION_CACHES.set(cacheKey, result); + + return result; } return styleExpression; } diff --git a/src/core/mantle/evaluator/simple/utils.test.ts b/src/core/mantle/evaluator/simple/utils.test.ts new file mode 100644 index 000000000..7e0f967ba --- /dev/null +++ b/src/core/mantle/evaluator/simple/utils.test.ts @@ -0,0 +1,71 @@ +import { expect, test, describe } from "vitest"; + +import { Feature, StyleExpression } from "../../types"; + +import { getReferences, getCacheableProperties, getCombinedReferences } from "./utils"; + +describe("getCacheableProperties", () => { + const feature: Feature = { + id: "test", + type: "feature", + properties: { + name: "Test Feature", + description: "This is a test feature", + test: "test_path", + }, + }; + + const styleExpression: StyleExpression = "color: ${test}"; + + test("should return cacheable properties", () => { + const properties = getCacheableProperties(styleExpression, feature); + expect(properties).toEqual({ test: "test_path" }); + }); + + const styleExpressionBeta: StyleExpression = "color: ${$.['test_var']}"; + + test("should return combined references", () => { + const references = getCacheableProperties(styleExpressionBeta, feature); + expect(references).toEqual({ + name: "Test Feature", + description: "This is a test feature", + test: "test_path", + }); + }); +}); + +describe("getCombinedReferences", () => { + const styleExpression: StyleExpression = { + conditions: [ + ["${test_var} === 1", "color: blue"], + ["${test_var} === 2", "color: red"], + ], + }; + + test("should return combined references", () => { + const references = getCombinedReferences(styleExpression); + expect(references).toEqual(["test_var", "test_var"]); + }); +}); + +describe("getReferences", () => { + test("should return references in a string expression", () => { + const references = getReferences("color: ${test_var}"); + expect(references).toEqual(["test_var"]); + }); + + test("should return references in a string expression with quotes", () => { + const references = getReferences('color: "${test_var}"'); + expect(references).toEqual(["test_var"]); + }); + + test("should return references in a string expression with single quotes", () => { + const references = getReferences("color: '${test_var}'"); + expect(references).toEqual(["test_var"]); + }); + + test("should return JSONPATH_IDENTIFIER for expressions with variable expression syntax", () => { + const references = getReferences("color: ${$.['test_var']}"); + expect(references).toEqual(["REEARTH_JSONPATH"]); + }); +}); diff --git a/src/core/mantle/evaluator/simple/utils.ts b/src/core/mantle/evaluator/simple/utils.ts new file mode 100644 index 000000000..c4766c788 --- /dev/null +++ b/src/core/mantle/evaluator/simple/utils.ts @@ -0,0 +1,77 @@ +import { pick } from "lodash-es"; +import LRU from "lru-cache"; + +import { Feature, StyleExpression } from "../../types"; + +const JSONPATH_IDENTIFIER = "REEARTH_JSONPATH"; +const ID_IDENTIFIER = "REEARTH_ID"; +const MAX_CACHE_SIZE = 1000; + +export function getCacheableProperties(styleExpression: StyleExpression, feature?: Feature) { + const ref = getCombinedReferences(styleExpression); + const keys = ref.includes(JSONPATH_IDENTIFIER) ? Object.keys(feature?.properties) : null; + const properties = ref.includes(ID_IDENTIFIER) + ? { id: feature?.id } + : pick(feature?.properties, keys || ref); + return properties; +} + +export function getCombinedReferences(expression: StyleExpression): string[] { + if (typeof expression === "string") { + return getReferences(expression); + } else { + const references: string[] = []; + for (const [condition, value] of expression.conditions) { + references.push(...getReferences(condition), ...getReferences(value)); + } + return references; + } +} + +const cache = new LRU({ max: MAX_CACHE_SIZE }); + +export function getReferences(expression: string): string[] { + const cachedResult = cache.get(expression); + if (cachedResult !== undefined) { + return cachedResult; + } + + const result: string[] = []; + let exp = expression; + let i = exp.indexOf("${"); + const varExpRegex = /^\$./; + + while (i >= 0) { + const openSingleQuote = exp.indexOf("'", i); + const openDoubleQuote = exp.indexOf('"', i); + + if (openSingleQuote >= 0 && openSingleQuote < i) { + const closeQuote = exp.indexOf("'", openSingleQuote + 1); + result.push(exp.substring(0, closeQuote + 1)); + exp = exp.substring(closeQuote + 1); + } else if (openDoubleQuote >= 0 && openDoubleQuote < i) { + const closeQuote = exp.indexOf('"', openDoubleQuote + 1); + result.push(exp.substring(0, closeQuote + 1)); + exp = exp.substring(closeQuote + 1); + } else { + const j = exp.indexOf("}", i); + if (j < 0) { + return result; + } + const varExp = exp.slice(i + 2, j); + if (varExp === "id") { + return [ID_IDENTIFIER]; + } + if (varExpRegex.test(varExp)) { + return [JSONPATH_IDENTIFIER]; + } else { + result.push(exp.substring(i + 2, j)); + } + exp = exp.substring(j + 1); + } + i = exp.indexOf("${"); + } + + cache.set(expression, result); + return result; +} diff --git a/yarn.lock b/yarn.lock index ae0a67ec7..c9a29a8e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12945,6 +12945,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lru-cache@^8.0.4: + version "8.0.4" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-8.0.4.tgz#49fbbc46c0b4cedc36258885247f93dba341e7ec" + integrity sha512-E9FF6+Oc/uFLqZCuZwRKUzgFt5Raih6LfxknOSAVTjNkrCZkBf7DQCwJxZQgd9l4eHjIJDGR+E+1QKD1RhThPw== + lz-string@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"