Ctrl K
ComunidadGuías…

web/js/num.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/* web/js/num.js — one place that decides how a number looks.
 *
 * There used to be seven. `chart.js` declared BOTH an `en-US` formatter and
 * an `fa-IR` one and used them side by side in the same component — the
 * "مجموع" tile in Latin digits next to the "تعداد نقاط" tile in Persian.
 * `llm-status.js` did the same thing inside one stat grid; `table.js`,
 * `turn.js`, `sessions.js` and `memory.js` each made the call again
 * locally. That is not one bug: it is the same decision taken seven times,
 * differently, and it is why the numbers looked unsettled.
 *
 * Two rules, and the second is the one that is easy to get wrong.
 *
 * ONE DIGIT SYSTEM FOR EVERYTHING A READER READS. The interface is
 * Persian, so counts, measures, percentages and durations are Persian
 * digits. Consistency is the whole point; a mix reads as an accident
 * because it is one.
 *
 * LATIN DIGITS SURVIVE ONLY WHERE THE DIGITS ARE NOT PROSE. Token counts
 * an operator pastes into a ticket, identifiers, anything inside a
 * `dir="ltr"` block, and SQL itself stay Latin — those are values that
 * travel to other systems, and converting them makes them wrong somewhere
 * else. `technical()` is that carve-out, and it is deliberate rather than
 * left over.
 *
 * Bidi: a number rendered inside a Persian sentence is isolated, because
 * an un-isolated numeral run next to Persian text lets the separators and
 * any adjacent punctuation reorder. `inText()` does that; the plain
 * formatters are for standalone cells and SVG, where the surrounding
 * direction is already fixed. */

"use strict";

const FA = new Intl.NumberFormat("fa-IR");
const LATIN = new Intl.NumberFormat("en-US");

/** The default: a number a person reads, in the interface's own digits. */
export function fmt(value) {
  const n = Number(value);
  return Number.isFinite(n) ? FA.format(n) : "—";
}

/** A value that travels to another system — tokens, ids, LTR content.
 *
 * Kept Latin on purpose. Converting a figure someone will paste into a
 * ticket or compare against a server log makes it wrong at the other end,
 * which is worse than a mixed-looking page. */
export function technical(value) {
  const n = Number(value);
  return Number.isFinite(n) ? LATIN.format(n) : "—";
}

/** A percentage, already in 0..100. */
export function pct(value, digits = 1) {
  const n = Number(value);
  if (!Number.isFinite(n)) return "—";
  return `${FA.format(Number(n.toFixed(digits)))}٪`;
}

/** A rate given as either 0..1 or 0..100, rendered as a percentage.
 *
 * The backend reports some rates one way and some the other; guessing
 * once here beats every call site guessing separately. */
export function rate(value, digits = 1) {
  const n = Number(value);
  if (!Number.isFinite(n)) return "—";
  return pct(n <= 1 ? n * 100 : n, digits);
}

/** Milliseconds, as a person would say them. */
export function ms(value) {
  const n = Number(value);
  if (!Number.isFinite(n)) return "—";
  if (n < 1000) return `${fmt(Math.round(n))} میلی‌ثانیه`;
  return `${fmt(Number((n / 1000).toFixed(1)))} ثانیه`;
}

/** Wrap a formatted number for safe placement inside Persian prose.
 *
 * U+2066 LEFT-TO-RIGHT ISOLATE … U+2069 POP DIRECTIONAL ISOLATE. Without
 * it, a numeral run adjacent to Persian text can drag neighbouring
 * punctuation to the wrong side — the subtle half of "the numbers look
 * wrong" that survives fixing the digit system.
 */
export function inText(formatted) {
  return `⁦${formatted}⁩`;
}

/* ── Is this number a label, not a quantity? ─────────────────────────────
 *
 * A numeric column whose NAME says it identifies something (`ID`, `RingID`,
 * `broker_id`, `ringId`, `Code`, `BrokerCode`, `کد`, `شناسه`) holds numbers
 * that are labels: adding them, ranking by them or drawing them as bars
 * means nothing. A result of (Name, ID) once became a bar chart of the ids.
 * The shape picker (`render/table.js`), the chart (`render/chart.js`) and the
 * recommender (`chart-engine/recommend.js`, which keeps its own copy of this
 * rule because it may not import) all ask this before they take a numeric
 * column as the measure.
 *
 * It is decided from the name alone, on word shape, because the server tells
 * the UI only `name` and `type`. The rule is deliberately narrow: a name that
 * merely ENDS in the letters is not an identifier unless a word boundary says
 * so, which is why `Paid`, `Valid`, `Width`, `Barcode` and `Postcode` stay
 * measures while `RingID`, `ringId` and `BrokerCode` do not.
 *
 *   - the whole name is `id` or `code`, in any letter case;
 *   - `id` or `code` is the last word after a space, `_`, `-` or `.`
 *     (`ring_id`, `Broker Code`), in any letter case;
 *   - an upper-case `ID` follows a lower-case letter or a digit (`RingID`),
 *     or a capitalised `Id` / `Code` follows a letter or a digit (`ringId`,
 *     `BrokerCode`, `ISOCode`). `VALID` and `PAID` in capitals are not
 *     recognised, nor is `BROKERID`; write `BROKER_ID` for that;
 *   - a Persian word `کد` or `شناسه` (`کد ملی`, `شناسهٔ سفارش`, `کد_کارگزار`).
 */
const ID_NAME_PATTERNS = [
  /^(?:id|code)$/i,
  /[\s_.-](?:id|code)$/i,
  /[a-z0-9]ID$/,
  /[A-Za-z0-9](?:Id|Code)$/,
];
const ID_PERSIAN_WORDS = new Set(["کد", "شناسه"]);

export function isIdentifierName(name) {
  if (typeof name !== "string") return false;
  const text = name.trim();
  if (!text) return false;
  if (ID_NAME_PATTERNS.some((pattern) => pattern.test(text))) return true;
  const persian = text
    .replace(/ك/g, "ک")
    .replace(/ي/g, "ی")
    .replace(/[ٕٔ]/g, "");
  return persian.split(/[\s_.‌-]+/).some((word) => ID_PERSIAN_WORDS.has(word));
}