Возможно ли вычислить выражение Mapbox напрямую?

#javascript #expression #interpreter #mapbox-gl-js

#javascript #выражение #переводчик #картографический ящик-gl-js

Вопрос:

Я ищу синтаксис выражения JavaScript для указания действий в JSON. Выражения Mapbox — это именно то, что я ищу, но я не могу найти никакой документации о том, можно ли их использовать за пределами Mapbox. Возможно ли это? Если да, то как бы вы это сделали?

Ответ №1:

Это «просто» JSON-форма абстрактного синтаксического дерева, поэтому вы можете написать свой собственный исполнитель. В частности, кажется, что они следуют следующему соглашению, согласно их собственным документам:

  1. Массивы являются выражениями, в то время как все остальные типы JSON являются литералами (любопытно, что это подразумевает отсутствие литералов массива напрямую! Я разработаю исправление позже)
  2. Первый элемент массива — это выполняемая функция, в то время как остальные элементы являются параметрами этой функции.
  3. Корневой объект не обязательно имеет отношение к синтаксису выражения, просто там, где они его используют.
  4. Единственная «отслеживающая состояние» вещь — это let / var functions , которая позволяет вам создавать переменные, ограниченные заключающим let выражением, что предполагает, что у них есть какой-то способ передать контекст функциям.

Итак, давайте создадим его! Я попытаюсь просмотреть код построчно ниже, но вы также можете просто посмотреть источник фрагмента в конце вопроса, если вы предпочитаете форматирование там.


Позже мы определим все функции, доступные для языка выражений

 const OPERATIONS = {};
 

Теперь давайте настроим функцию оценщика. Очевидно, что он должен получить выражение, которое он будет вычислять, но также и контекст, который может быть изменен с помощью операций.

 const evaluate = (expression, context = {}) => {
 

Во-первых, мы имеем дело с литералами, оценивая их как самих себя

   if (!(expression instanceof Array)) {
    return expression;
  }
 

Хорошо, а теперь перейдем к настоящему делу:
давайте выясним, какую операцию запускать и ее параметры.

   const [operationKey, ...rawParameters] = expression;
  const operation = OPERATIONS[operationKey];
 

Мы справляемся с неизвестными операциями, паникуя! АААА!

   if (operation == null) {
    throw new Error(`Unknown operation ${operationKey}!`);
  }
 

О, здорово, мы знаем эту операцию! Теперь, как мы должны это назвать?
Очевидно, что ему необходимо получить его параметры, а также контекст,
на случай, если это одна из тех надоедливых операций с отслеживанием состояния. Кроме того, как мы
видели с Mapbox let , операции могут создавать новые контексты!

Я предлагаю следующую подпись, хотя вы можете изменить ее в соответствии с вашими конкретными предпочтениями и вариантами использования:

Первый параметр: Текущий контекст

Второй параметр: массив всех параметров операции. Это упрощает итерацию, если операция является переменной, и более простые вещи все равно могут просто использовать деконструкцию, чтобы иметь «фиксированную» подпись. Мы передадим параметры «сырыми», а не оцененными, чтобы операция могла делать с ними любые злые вещи, которые она захочет сделать.

Возвращаемое значение: все, что хочет оценить операция!

   return operation(context, rawParameters);
};
 

Верно, верно, мы настроили оценщик, но как мы на самом деле его используем?

Нам нужны некоторые операции, давайте начнем с самых простых, чтобы намочить ноги: помните, как я сказал выше, что массив параметров поставляется в необработанном виде? Нам нужно будет оценить их вручную внутри наших операционных функций.

 OPERATIONS["-"] = (context, [a, b]) => evaluate(a, context) - evaluate(b, context);
OPERATIONS[" "] = (context, [a, b]) => evaluate(a, context)   evaluate(b, context);
 

Хорошо, это было легко, но что, если мы хотим принять произвольное количество аргументов?

 OPERATIONS["*"] = (context, parameters) => parameters
  .map(p => evaluate(p, context))
  .reduce((accumulator, x) => accumulator * x);
 

Хорошо, теперь давайте реализуем те массивы, о которых мы говорили. Решение простое, есть операция, которая создает массив из его параметров!

 OPERATIONS["array"] = (context, parameters) => parameters
  .map(p => evaluate(p, context));
 

Круто, круто, но как насчет злобных порождений самого сатаны? let и var ?

Давайте начнем с меньшего из них: легко, мы просто читаем все, что было сохранено в контексте для этого имени переменной!

 OPERATIONS["var"] = (context, [variable]) => context[variable];
 

Теперь «хитрый» вариант, let , который одновременно является переменным и меняет контекст!

Я вытащу свои брекеты здесь, потому что это будет немного больше, чем предыдущие красивые однолинейные операции!

 OPERATIONS["let"] = (context, [...definitions]) => {
 

Да, у нас есть контекст, но мы не хотим загрязнять его за пределами let квартала! Итак, давайте скопируем его в новый временный файл:

   const innerContext = { ...context };
 

Теперь нам нужно перебрать определения, помните, что каждое из них состоит из 2 элементов:
Имя переменной и выражение ее значения! Но сначала нам нужно выбрать последний аргумент, который является выражением, которое будет выполнено в результирующем контексте:

   const body = definitions.pop()
 

Давайте уберем с пути очевидные вещи: если у нас нечетное количество вещей в наших определениях, пользователь ошибается! Давайте бросим это на их уродливое лицо! Давайте используем загадочное сообщение об ошибке просто для того, чтобы быть злыми…

   if (definitions.length % 2 === 1) {
    throw new Error("Unmatched definitions!");
  }
 

Круто, теперь мы должны сделать классную вещь, которая заключается в создании этих переменных:

   for (let i = 0; i < definitions.length - 1; i  = 2) {
    const name = definitions[i];
    const value = definitions[i   1];
 

Здесь я сделал выбор, что переменные в одном и том же блоке могут зависеть
от предыдущих переменных, если это вам не нравится, используйте родительский
контекст вместо того, который мы изменяем в данный момент.

     innerContext[name] = evaluate(value, innerContext);
  }
 

С переменными покончено, теперь давайте оценим тело!

   return evaluate(body, innerContext);
};
 

И мы закончили! Это основа оценки синтаксического дерева!

Возможно, вы захотите продолжить и добавить свои собственные доменные операции прямо сейчас.

Я сделал этот фрагмент для демонстрации того, как это в конечном итоге работает, и использования комментариев к коду вместо грамотного кодирования, если это ваш стиль. HTML и CSS не имеют значения, просто немного помады, чтобы сделать его более презентабельным.

 // Here we will later define all functions available for the expression language
const OPERATIONS = {};

// Now, let's set up the evaluator function.
// It obviously must receive the expression it will evaluate,
// but also a context that can be modified by operations.
const evaluate = (expression, context = {}) => {
  // First, we deal with literals by evaluating them as themselves
  if (!(expression instanceof Array)) {
    return expression;
  }

  // Right, now to the real deal:
  // let's find out what operation to run and its parameters.
  const [operationKey, ...rawParameters] = expression;
  const operation = OPERATIONS[operationKey];

  // We handle unknown operations by panicking! AAAH!
  if (operation == null) {
    throw new Error(`Unknown operation ${operationKey}!`);
  }

  // Oh nice, we know this operation! Now, how should we call it?
  // It obviously needs to receive its parameters, as well as the context,
  // in case it is one of those pesky stateful operations. Plus, as we
  // have seen with Mapbox's `let`, operations can create new contexts!
  //
  // I propose the following signature, though you can change it for your
  // particular preference and use-cases:
  //
  // First parameter:
  //      Current context
  // Second parameter:
  //      Array of all of the operation's parameters. This makes for
  //      easy iteration if the operation is variadic, and simpler stuff
  //      can still just use deconstruction to have a "fixed" signature.
  //      We will pass the parameters "raw", not evaluated, so that the
  //      operation can do whatever evil things it wants to do to them.
  // Return value:
  //      Whatever the operation wants to evaluate to!
  return operation(context, rawParameters);
};

// Right, right, we have set up the evaluator, but how do we actually use it?
// We need some operations, let's start with the easy ones to wet our feet:
// Remember how I said above that the parameters array comes in raw?
// We'll need to evaluate them manually inside our operation functions.
OPERATIONS["-"] = (context, [a, b]) => evaluate(a, context) - evaluate(b, context);
OPERATIONS[" "] = (context, [a, b]) => evaluate(a, context)   evaluate(b, context);

// Okay, that was easy, but what if we want
// to accept an arbitrary amount of arguments?
OPERATIONS["*"] = (context, parameters) => parameters
  .map(p => evaluate(p, context))
  .reduce((accumulator, x) => accumulator * x);
  
// Right, now let's implement those arrays we spoke of.
// The solution is simple, have an operation that
// creates the array from its parameters!
OPERATIONS["array"] = (context, parameters) => parameters
  .map(p => evaluate(p, context));

// Cool, cool, but what about the evil spawns of Satan himself? Let and Var?

// Let's start with the lesser of them:
// Easy, we just read whatever was stored in the context for that variable name!
OPERATIONS["var"] = (context, [variable]) => context[variable];

// Now, the "tricky" one, Let, which is both variadic AND changes the context!
// I'll pull out my braces here  because it's gonna be a bit bigger than the
// previous beautiful one-line operations!
OPERATIONS["let"] = (context, [...definitions]) => {
  // Right, we have A context, but we don't want to pollute it outside
  // the Let block! So let's copy it to a new temporary one:
  const innerContext = { ...context
  };

  // Now we need to loop the definitions, remember, they are 2 elements each:
  // A variable name, and its value expression! But first, we need to pick
  // out the last argument which is the expression to be executed in the
  // resulting context:
  const body = definitions.pop()

  // Let's get the obvious stuff out of the way, if we have an odd number of
  // things in our definitions, the user is wrong! Let's throw it on their
  // ugly face! Let's use a cryptic error message just to be evil...
  if (definitions.length % 2 === 1) {
    throw new Error("Unmatched definitions!");
  }

  // Cool, now we get to do the cool stuff which is create those variables:
  for (let i = 0; i < definitions.length - 1; i  = 2) {
    const name = definitions[i];
    const value = definitions[i   1];

    // Here I made the choice that variables in the same block can depend
    // on previous variables, if that's not to your liking, use the parent
    // context instead of the one we're modifying at the moment.
    innerContext[name] = evaluate(value, innerContext);
  }

  // Variables are DONE, now let's evaluate the body!
  return evaluate(body, innerContext);
};

// Bonus points for reading the snippet code:
// Remember that we are not limited to numeric values,
// anything that JSON accepts we accept too!
// So here's some simple string manipulation.
OPERATIONS["join"] = (context, [separator, things]) => evaluate(things, context)
  .flat()
  .join(separator);

// And we're done! That is the basic of evaluating a syntax tree!







// Not really relevant to the question itself, just a quick and dirty REPL

(() => {
  const input = document.getElementById("input");
  const output = document.getElementById("output");

  const runSnippet = () => {
    let expression;

    try {
      expression = JSON.parse(input.value);
    } catch (e) {
      // Let the user type at peace by not spamming errors on partial JSON
      return;
    }

    const result = evaluate(expression);
    output.innerText = JSON.stringify(result, null, 2);
  }

  input.addEventListener("input", runSnippet);

  runSnippet();
})(); 
 html {
  display: flex;
  align-items: stretch;
  justify-content: stretch;
  height: 100vh;
  background: beige;
}

body {
  flex: 1;
  display: grid;
  grid-template-rows: 1fr auto;
  grid-gap: 1em;
}

textarea {
  padding: 0.5em;
  border: none;
  background: rgba(0, 0, 0, 0.8);
  color: white;
  resize: none;
} 
 <textarea id="input">
[
  "let",
  "pi", 3.14159,
  "radius", 5,
  [
    "join",
    " ",
    [
      "array",
      "a circle with radius",
      ["var", "radius"],
      "has a perimeter of",
      [
        "*",
        2,
        ["var", "pi"],
        ["var", "radius"]
      ]
    ]
  ]
]

</textarea>
<pre id="output">
</pre> 

Комментарии:

1. Спасибо вам за этот фантастический, подробный ответ!

Ответ №2:

Я думаю, что ответ Кролтана несколько недооценивает оставшуюся работу по реализации семантики, особенно для ["interpolate"] .

Более плодотворный подход, вероятно, заключается в том, чтобы получить доступ к механизму вычисления выражений, который существует внутри Mapbox-GL, и предоставить его, как это делается в этом gist .

Эта проблема отслеживается здесь.

Если вы действительно просто ищете общий механизм оценки выражений, смутно напоминающий Mapbox GL, вы можете оказаться cheap-eval полезными.

Обновить

Я выпустил пакет NPM mapbox-expression для поддержки непосредственного вычисления выражений Mapbox GL.

Комментарии:

1. Правда, функции Mapbox обладают некоторой утонченностью, но я интерпретировал вопрос как «Я хочу запускать выражения, а подход Mapbox аккуратный», а не «Я хочу запускать выражения Mapbox».

2. С учетом сказанного, для поддержки выражений камеры, в которых есть операторы, которые действительны только внутри них, можно было бы расширить context значение реализации, чтобы иметь как способ поиска переменных, так и операций (в терминах TS, type Context = { operations: {[key:string]: Function}, variables: {[key:string]: any}} ). И, наконец, спасибо вам за то, что нашли время написать этот пакет! Кому-то это наверняка пригодится!

3. Конечно, это запутанный вопрос.