@bluemarble/bi 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,350 +1,146 @@
1
- //#region src/star.ts
2
- /** `'tabela.coluna'` -> `{ table, column }` (nulo se não tiver esse formato). */
3
- function parseColumnRef(ref) {
4
- if (typeof ref !== "string") return null;
5
- const match = /^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(ref.trim());
6
- return match ? {
7
- table: match[1],
8
- column: match[2]
9
- } : null;
10
- }
11
- /** Relacionamentos válidos do modelo, como arestas N -> 1. */
12
- function modelEdges(model) {
13
- return (model.relationships ?? []).flatMap((relationship, index) => {
14
- const from = parseColumnRef(relationship?.from);
15
- const to = parseColumnRef(relationship?.to);
16
- return from && to ? [{
17
- from,
18
- to,
19
- index,
20
- inner: relationship.inner === true
21
- }] : [];
22
- });
23
- }
24
- const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
- /**
26
- * Tabelas citadas no SQL como `tabela.coluna`. O que está entre aspas simples é ignorado, para
27
- * `'a.b'` num literal não contar como referência.
28
- */
29
- function referencedTables(sql, tables) {
30
- const code = sql.replace(/'(?:[^'\\]|\\.)*'/g, "''");
31
- return tables.filter((name) => new RegExp(`(?<![\\w.\`])${escapeRegex(name)}\\.`).test(code));
32
- }
33
- /** Busca em largura a partir de `start`, seguindo os relacionamentos no sentido N -> 1. */
34
- function reachFrom(start, edges) {
35
- const paths = /* @__PURE__ */ new Map([[start, []]]);
36
- let frontier = [start];
37
- while (frontier.length) {
38
- const next = [];
39
- for (const table of frontier) for (const edge of edges) {
40
- if (edge.from.table !== table || paths.has(edge.to.table)) continue;
41
- paths.set(edge.to.table, [...paths.get(table) ?? [], {
42
- table: edge.to.table,
43
- edge
44
- }]);
45
- next.push(edge.to.table);
46
- }
47
- frontier = next;
48
- }
49
- const incoming = /* @__PURE__ */ new Map();
50
- for (const edge of edges) if (paths.has(edge.from.table)) incoming.set(edge.to.table, (incoming.get(edge.to.table) ?? 0) + 1);
51
- return {
52
- paths,
53
- ambiguous: [...incoming].filter(([table, count]) => table !== start && count > 1).map(([table]) => table)
54
- };
55
- }
56
- /**
57
- * Tabelas que precisam entrar na consulta, na ordem do JOIN, para que a partir de `home` se chegue
58
- * a todas as `targets`. Como cada tabela é alcançada por um caminho só (o modelo valida ambiguidade),
59
- * a união dos caminhos é uma árvore: cada tabela entra uma vez, ligada pela aresta de quem veio antes.
60
- */
61
- function joinPlan(home, targets, edges) {
62
- const { paths } = reachFrom(home, edges);
63
- const plan = [];
64
- const added = /* @__PURE__ */ new Set([home]);
65
- const include = (target) => {
66
- for (const step of paths.get(target) ?? []) {
67
- if (added.has(step.table)) continue;
68
- added.add(step.table);
69
- plan.push(step);
70
- }
71
- };
72
- for (const target of targets) include(target);
73
- for (let grew = true; grew;) {
74
- grew = false;
75
- for (const edge of edges) if (edge.inner && added.has(edge.from.table) && !added.has(edge.to.table)) {
76
- include(edge.to.table);
77
- grew = true;
78
- }
79
- }
80
- const depth = (table) => paths.get(table)?.length ?? 0;
81
- return plan.sort((a, b) => depth(a.table) - depth(b.table));
82
- }
83
- /**
84
- * Tabela de origem de uma medida `sum`: entre as tabelas que o SQL cita, a que alcança todas as
85
- * outras (ex.: `fPesagem.peso * dProduto.fator` agrega em `fPesagem`). Nulo se nenhuma alcança.
86
- */
87
- function measureHome(tables, edges) {
88
- if (!tables.length) return null;
89
- return tables.find((candidate) => {
90
- const { paths } = reachFrom(candidate, edges);
91
- return tables.every((table) => paths.has(table));
92
- }) ?? null;
93
- }
94
- //#endregion
95
- //#region src/model.ts
96
- const VALUE_FORMATS = [
97
- "number",
98
- "integer",
99
- "currency",
100
- "percent"
1
+ import { l as isAggregate, s as VALUE_FORMATS } from "./codegen-DBe4-XW_.js";
2
+ //#region src/formatRules.ts
3
+ const COLUMN_ALIGNS = [
4
+ "left",
5
+ "center",
6
+ "right"
101
7
  ];
102
- /**
103
- * Agregações sobre uma expressão SQL (`tabela.coluna`). Todas valem nos dois formatos; no estrela,
104
- * agregam no grão da tabela de origem, como a soma.
105
- */
106
- const AGGREGATIONS = [
107
- "sum",
108
- "count",
109
- "countDistinct",
110
- "avg",
111
- "min",
112
- "max"
8
+ const COLUMN_VERTICAL_ALIGNS = [
9
+ "top",
10
+ "middle",
11
+ "bottom"
12
+ ];
13
+ const RULE_OPERATORS = [
14
+ ">",
15
+ ">=",
16
+ "<",
17
+ "<=",
18
+ "=",
19
+ "!=",
20
+ "between",
21
+ "contains",
22
+ "empty",
23
+ "notEmpty"
113
24
  ];
114
- const AGGREGATION_LABEL = {
115
- sum: "Soma",
116
- count: "Contagem",
117
- countDistinct: "Contagem distinta",
118
- avg: "Média",
119
- min: "Mínimo",
120
- max: "Máximo"
25
+ const RULE_OPERATOR_LABEL = {
26
+ ">": "maior que",
27
+ ">=": "maior ou igual a",
28
+ "<": "menor que",
29
+ "<=": "menor ou igual a",
30
+ "=": "igual a",
31
+ "!=": "diferente de",
32
+ between: "entre",
33
+ contains: "contém",
34
+ empty: "vazio",
35
+ notEmpty: "não vazio"
121
36
  };
122
- const isAggregate = (measure) => AGGREGATIONS.includes(measure.type);
123
- /** O SQL agregado da medida: `SUM(x)`, `COUNT(DISTINCT x)`, `AVG(x)`... */
124
- function aggregateSql(measure) {
125
- if (measure.type === "countDistinct") return `COUNT(DISTINCT ${measure.sql})`;
126
- return `${{
127
- sum: "SUM",
128
- count: "COUNT",
129
- avg: "AVG",
130
- min: "MIN",
131
- max: "MAX"
132
- }[measure.type]}(${measure.sql})`;
133
- }
134
- /** Nomes citados como `{nome}` numa fórmula. */
135
- const formulaRefs = (expression) => [...new Set([...expression.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((match) => match[1]))];
136
- /** Medidas que uma medida usa diretamente (razão e fórmula). */
137
- function measureDeps(measure) {
138
- if (measure.type === "ratio") return [measure.numerator, measure.denominator];
139
- if (measure.type === "formula") return formulaRefs(measure.expression);
140
- return [];
141
- }
142
- const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
37
+ /** Operadores que comparam com número (`value`, e `to` no `between`). */
38
+ const NUMERIC_OPERATORS = [
39
+ ">",
40
+ ">=",
41
+ "<",
42
+ "<=",
43
+ "between"
44
+ ];
45
+ /** Operadores sem valor. */
46
+ const UNARY_OPERATORS = ["empty", "notEmpty"];
143
47
  const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
144
- const isText = (value) => typeof value === "string" && !!value.trim();
145
- function metaErrors(context, field) {
146
- const errors = [];
147
- for (const key of ["label", "description"]) if (field[key] !== void 0 && typeof field[key] !== "string") errors.push(`${context}: \`${key}\` precisa ser texto`);
148
- if (field.format !== void 0 && !VALUE_FORMATS.includes(field.format)) errors.push(`${context}: formato inválido "${field.format}"`);
149
- const { decimals } = field;
150
- if (decimals !== void 0 && (typeof decimals !== "number" || !Number.isInteger(decimals) || decimals < 0 || decimals > 10)) errors.push(`${context}: \`decimals\` precisa ser um inteiro entre 0 e 10`);
151
- return errors;
152
- }
153
- function modelMeta(model) {
154
- const pick = (field) => ({
155
- ...field.label !== void 0 && { label: field.label },
156
- ...field.description !== void 0 && { description: field.description },
157
- ...field.format !== void 0 && { format: field.format },
158
- ...field.decimals !== void 0 && { decimals: field.decimals }
159
- });
160
- return {
161
- name: model.name,
162
- dimensions: Object.fromEntries(Object.entries(model.dimensions).map(([name, dim]) => [name, {
163
- type: dim.type,
164
- ...pick(dim)
165
- }])),
166
- measures: Object.fromEntries(Object.entries(model.measures).map(([name, measure]) => [name, {
167
- type: measure.type,
168
- ...(measure.type === "count" || measure.type === "countDistinct") && { format: "integer" },
169
- ...pick(measure),
170
- ...measure.type === "ratio" && {
171
- numerator: measure.numerator,
172
- denominator: measure.denominator
173
- },
174
- ...measure.type === "formula" && { refs: formulaRefs(measure.expression) }
175
- }]))
176
- };
177
- }
178
- /**
179
- * Lista os problemas do modelo (vazio = válido). Confere também a forma do objeto, porque o rascunho
180
- * do devtools chega como JSON da request.
181
- */
182
- function validateModel(model) {
183
- if (!isRecord$1(model)) return ["modelo inválido"];
184
- if (!isRecord$1(model.dimensions) || !isRecord$1(model.measures)) return ["`dimensions` e `measures` são obrigatórios"];
185
- if ("base" in model || "joins" in model) return ["o formato `base` + `joins` não existe mais: use `tables` + `relationships`"];
186
- if (!isRecord$1(model.tables)) return ["`tables` é obrigatório"];
187
- if (model.relationships !== void 0 && !Array.isArray(model.relationships)) return ["`relationships` precisa ser uma lista"];
188
- if (model.diagram !== void 0 && !diagramValid(model.diagram)) return ["`diagram` precisa ser { nome: { x, y, width?, height? } } com números (tamanho > 0)"];
189
- const errors = [];
190
- const { tables } = model;
191
- const names = [...Object.keys(model.dimensions), ...Object.keys(model.measures)];
192
- for (const name of [...names, ...Object.keys(tables)]) if (!IDENTIFIER.test(name)) errors.push(`nome inválido: "${name}"`);
193
- else if (name.startsWith("__")) errors.push(`nome reservado: "${name}" (começa com "__")`);
194
- const duplicated = names.find((name, index) => names.indexOf(name) !== index);
195
- if (duplicated) errors.push(`nome repetido: "${duplicated}"`);
196
- for (const [name, table] of Object.entries(tables)) {
197
- if (!isRecord$1(table) || !isText(table.sql)) {
198
- errors.push(`tabela "${name}" sem SQL`);
199
- continue;
200
- }
201
- if (table.key !== void 0 && (typeof table.key !== "string" || !IDENTIFIER.test(table.key))) errors.push(`tabela "${name}": key inválida`);
202
- errors.push(...metaErrors(`tabela "${name}"`, table));
203
- }
204
- const tableNames = Object.keys(tables);
205
- errors.push(...relationshipErrors(model, tableNames));
206
- for (const [name, dimension] of Object.entries(model.dimensions)) if (!isRecord$1(dimension) || !isText(dimension.sql)) errors.push(`dimensão "${name}" sem SQL`);
207
- else if (dimension.type !== "number" && dimension.type !== "string") errors.push(`dimensão "${name}" com tipo inválido`);
208
- else {
209
- errors.push(...metaErrors(`dimensão "${name}"`, dimension));
210
- if (referencedTables(dimension.sql, tableNames).length !== 1) errors.push(`dimensão "${name}" precisa usar colunas de uma tabela só (\`tabela.coluna\`)`);
211
- }
212
- const edges = modelEdges(model);
213
- for (const [name, measure] of Object.entries(model.measures)) {
214
- if (!isRecord$1(measure)) errors.push(`medida "${name}" inválida`);
215
- else if (isAggregate(measure)) {
216
- if (!isText(measure.sql)) errors.push(`medida "${name}" sem SQL`);
217
- else {
218
- const used = referencedTables(measure.sql, tableNames);
219
- if (!used.length) errors.push(`medida "${name}" precisa usar colunas de uma tabela (\`tabela.coluna\`)`);
220
- else if (!measureHome(used, edges)) errors.push(`medida "${name}" usa ${used.join(", ")}, mas nenhuma delas chega às outras pelos relacionamentos`);
221
- }
222
- } else if (measure.type === "ratio" || measure.type === "formula") {
223
- if (measure.type === "formula" && !isText(measure.expression)) errors.push(`medida "${name}" sem expressão`);
224
- else if (measure.type === "formula" && !formulaRefs(measure.expression).length) errors.push(`medida "${name}": a fórmula precisa citar medidas como {nome}`);
225
- for (const ref of measureDeps(measure)) if (typeof ref !== "string" || !model.measures[ref]) errors.push(`medida "${name}" referencia "${ref}", que não existe`);
226
- } else errors.push(`medida "${name}" com tipo inválido`);
227
- if (isRecord$1(measure)) errors.push(...metaErrors(`medida "${name}"`, measure));
228
- }
229
- if (errors.length) return errors;
230
- const visit = (name, stack) => {
231
- if (stack.includes(name)) return [...stack, name].join(" -> ");
232
- for (const dep of measureDeps(model.measures[name])) {
233
- const cycle = visit(dep, [...stack, name]);
234
- if (cycle) return cycle;
235
- }
236
- return null;
237
- };
238
- for (const name of Object.keys(model.measures)) {
239
- const cycle = visit(name, []);
240
- if (cycle) {
241
- errors.push(`medida circular: ${cycle}`);
242
- break;
243
- }
48
+ const isColor = (value) => typeof value === "string" && /^#[0-9a-fA-F]{3,8}$/.test(value);
49
+ const RULE_KEYS = [
50
+ "field",
51
+ "operator",
52
+ "value",
53
+ "to",
54
+ "target",
55
+ "column",
56
+ "color",
57
+ "background",
58
+ "bold",
59
+ "align",
60
+ "verticalAlign"
61
+ ];
62
+ /** Problema de forma de uma regra (`null` = válida). */
63
+ function formatRuleError(rule) {
64
+ if (!isRecord$1(rule)) return "precisa ser um objeto";
65
+ const unknown = Object.keys(rule).find((key) => !RULE_KEYS.includes(key));
66
+ if (unknown) return `campo desconhecido \`${unknown}\``;
67
+ if (typeof rule.field !== "string" || !rule.field) return "`field` é obrigatório";
68
+ if (!RULE_OPERATORS.includes(rule.operator)) return `\`operator\` precisa ser ${RULE_OPERATORS.join(", ")}`;
69
+ const operator = rule.operator;
70
+ if (NUMERIC_OPERATORS.includes(operator)) {
71
+ if (typeof rule.value !== "number" || !Number.isFinite(rule.value)) return "`value` precisa ser um número";
72
+ if (operator === "between" && (typeof rule.to !== "number" || !Number.isFinite(rule.to))) return "`to` precisa ser um número (fim do intervalo)";
73
+ } else if (!UNARY_OPERATORS.includes(operator)) {
74
+ if (typeof rule.value !== "number" && typeof rule.value !== "string") return "`value` precisa ser texto ou número";
244
75
  }
245
- return errors;
76
+ if (operator !== "between" && rule.to !== void 0) return "`to` só vale com `between`";
77
+ if (rule.target !== void 0 && rule.target !== "cell" && rule.target !== "row") return "`target` precisa ser cell ou row";
78
+ if (rule.column !== void 0 && (typeof rule.column !== "string" || !rule.column)) return "`column` precisa ser texto";
79
+ if (rule.target === "row" && rule.column !== void 0) return "`column` não vale com row";
80
+ for (const key of ["color", "background"]) if (rule[key] !== void 0 && !isColor(rule[key])) return `\`${key}\` precisa ser uma cor (#rrggbb)`;
81
+ if (rule.bold !== void 0 && typeof rule.bold !== "boolean") return "`bold` precisa ser true/false";
82
+ if (rule.align !== void 0 && !COLUMN_ALIGNS.includes(rule.align)) return `\`align\` precisa ser ${COLUMN_ALIGNS.join(", ")}`;
83
+ if (rule.verticalAlign !== void 0 && !COLUMN_VERTICAL_ALIGNS.includes(rule.verticalAlign)) return `\`verticalAlign\` precisa ser ${COLUMN_VERTICAL_ALIGNS.join(", ")}`;
84
+ if (rule.color === void 0 && rule.background === void 0 && !rule.bold && rule.align === void 0 && rule.verticalAlign === void 0) return "precisa de `color`, `background`, `bold`, `align` ou `verticalAlign`";
85
+ return null;
246
86
  }
247
- const diagramValid = (diagram) => isRecord$1(diagram) && Object.values(diagram).every((point) => isRecord$1(point) && Number.isFinite(point.x) && Number.isFinite(point.y) && [point.width, point.height].every((size) => size === void 0 || Number.isFinite(size) && size > 0));
248
- function relationshipErrors(model, tableNames) {
249
- const errors = [];
250
- const seen = /* @__PURE__ */ new Set();
251
- for (const [index, relationship] of (model.relationships ?? []).entries()) {
252
- const context = `relacionamento ${index + 1}`;
253
- if (!isRecord$1(relationship)) {
254
- errors.push(`${context} inválido`);
255
- continue;
87
+ const isEmpty = (value) => value === null || value === void 0 || value === "";
88
+ /** A regra vale para esse valor? Valor vazio só casa com `empty` (e `!=`). */
89
+ function matchesRule(rule, value) {
90
+ if (rule.operator === "empty") return isEmpty(value);
91
+ if (rule.operator === "notEmpty") return !isEmpty(value);
92
+ if (isEmpty(value)) return rule.operator === "!=";
93
+ const number = typeof value === "number" ? value : Number(value);
94
+ const target = rule.value;
95
+ switch (rule.operator) {
96
+ case ">": return number > Number(target);
97
+ case ">=": return number >= Number(target);
98
+ case "<": return number < Number(target);
99
+ case "<=": return number <= Number(target);
100
+ case "between": {
101
+ const [low, high] = [Number(target), Number(rule.to)].sort((a, b) => a - b);
102
+ return number >= low && number <= high;
256
103
  }
257
- const from = parseColumnRef(relationship.from);
258
- const to = parseColumnRef(relationship.to);
259
- if (!from || !to) {
260
- errors.push(`${context}: use \`tabela.coluna\` em \`from\` e \`to\``);
261
- continue;
104
+ case "=":
105
+ case "!=": {
106
+ const equal = typeof target === "number" || Number.isFinite(number) && typeof value === "number" ? number === Number(target) : String(value).toLowerCase() === String(target).toLowerCase();
107
+ return rule.operator === "=" ? equal : !equal;
262
108
  }
263
- for (const ref of [from, to]) if (!tableNames.includes(ref.table)) errors.push(`${context}: tabela "${ref.table}" não existe`);
264
- if (from.table === to.table) errors.push(`${context}: liga a tabela "${from.table}" a ela mesma`);
265
- if (relationship.inner !== void 0 && typeof relationship.inner !== "boolean") errors.push(`${context}: \`inner\` precisa ser true/false`);
266
- const pair = `${from.table}->${to.table}`;
267
- if (seen.has(pair)) errors.push(`${context}: já existe um relacionamento de "${from.table}" para "${to.table}"`);
268
- seen.add(pair);
109
+ case "contains": return String(value).toLowerCase().includes(String(target ?? "").toLowerCase());
269
110
  }
270
- if (errors.length) return errors;
271
- const edges = modelEdges(model);
272
- for (const table of tableNames) {
273
- const { ambiguous, paths } = reachFrom(table, edges);
274
- if (ambiguous.length) {
275
- errors.push(`caminho ambíguo de "${table}" até "${ambiguous[0]}": há mais de um relacionamento levando lá`);
276
- break;
277
- }
278
- if (edges.some((edge) => edge.to.table === table && paths.has(edge.from.table))) {
279
- errors.push(`relacionamentos em ciclo passando por "${table}"`);
280
- break;
281
- }
282
- }
283
- return errors;
284
111
  }
285
- //#endregion
286
- //#region src/codegen.ts
287
112
  /**
288
- * Gera o código TS de um modelo (`defineModel({...})`). Usado pelo "Copiar TS" do devtools e pelo
289
- * "Salvar no arquivo" do servidor, que troca só a chamada do `defineModel` dentro do arquivo.
113
+ * Estilo de cada coluna de uma linha: as regras `row` valem para todas, as `cell` só para a coluna
114
+ * delas, por cima das de linha. Entre regras do mesmo alcance, a de baixo ganha.
290
115
  */
291
- const diagramCode = ({ x, y, width, height }) => `{ x: ${x}, y: ${y}${width === void 0 ? "" : `, width: ${width}`}${height === void 0 ? "" : `, height: ${height}`} }`;
292
- /** JSON com as chaves ordenadas: compara modelos sem depender da ordem das propriedades. */
293
- function canonicalJson(value) {
294
- return JSON.stringify(value, (_, item) => item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : item);
295
- }
296
- function literal(text) {
297
- if (text.includes("\n")) return `\`${text.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")}\``;
298
- return `'${text.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
299
- }
300
- function metaEntries(field) {
301
- const entries = [];
302
- if (field.label !== void 0) entries.push(`label: ${literal(field.label)}`);
303
- if (field.description !== void 0) entries.push(`description: ${literal(field.description)}`);
304
- if (field.format !== void 0) entries.push(`format: '${field.format}'`);
305
- if (field.decimals !== void 0) entries.push(`decimals: ${field.decimals}`);
306
- return entries;
307
- }
308
- function measureCode(measure) {
309
- const meta = metaEntries(measure);
310
- const args = isAggregate(measure) ? [literal(measure.sql)] : measure.type === "formula" ? [literal(measure.expression)] : [literal(measure.numerator), literal(measure.denominator)];
311
- if (meta.length) args.push(`{ ${meta.join(", ")} }`);
312
- return `${measure.type}(${args.join(", ")})`;
313
- }
314
- function tableCode(table) {
315
- return `{ ${[
316
- ...table.key !== void 0 ? [`key: ${literal(table.key)}`] : [],
317
- `sql: ${literal(table.sql)}`,
318
- ...metaEntries(table)
319
- ].join(", ")} }`;
320
- }
321
- const entries = (record, render) => Object.entries(record).map(([name, value]) => ` ${name}: ${render(value)}`).join(",\n");
322
- /** O objeto passado ao `defineModel`, começando em `{` (sem indentação na primeira linha). */
323
- function modelObjectCode(model) {
324
- const lines = ["{", ` name: ${literal(model.name)},`];
325
- lines.push(Object.keys(model.tables).length ? ` tables: {\n${entries(model.tables, tableCode)}\n },` : " tables: {},");
326
- if (model.relationships?.length) lines.push(` relationships: [\n${model.relationships.map((relationship) => ` { from: ${literal(relationship.from)}, to: ${literal(relationship.to)}${relationship.inner ? ", inner: true" : ""} }`).join(",\n")}\n ],`);
327
- lines.push(` dimensions: {\n${entries(model.dimensions, (dim) => [
328
- `{ sql: ${literal(dim.sql)}`,
329
- `type: '${dim.type}'`,
330
- ...metaEntries(dim)
331
- ].join(", ").concat(" }"))}\n },`, ` measures: {\n${entries(model.measures, measureCode)}\n }${model.diagram ? "," : ""}`);
332
- if (model.diagram) lines.push(` diagram: {\n${entries(model.diagram, diagramCode)}\n }`);
333
- lines.push("}");
334
- return lines.join("\n");
335
- }
336
- /** Helpers do `@bluemarble/bi/server` que o código do modelo usa. */
337
- function modelImports(model) {
338
- return ["defineModel", ...new Set(Object.values(model.measures).map((measure) => measure.type))].sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }));
116
+ function rowRuleStyles(rules, row, columns) {
117
+ const rowStyle = {};
118
+ const cells = {};
119
+ for (const rule of rules) {
120
+ if (!matchesRule(rule, row[rule.field])) continue;
121
+ const style = ruleStyle(rule);
122
+ if (rule.target === "row") Object.assign(rowStyle, style);
123
+ else {
124
+ const column = rule.column ?? rule.field;
125
+ cells[column] = {
126
+ ...cells[column],
127
+ ...style
128
+ };
129
+ }
130
+ }
131
+ return Object.fromEntries(columns.map((column) => [column, {
132
+ ...rowStyle,
133
+ ...cells[column]
134
+ }]));
339
135
  }
340
- /** Arquivo completo, pronto para colar em `src/dashboard/models/<nome>.ts`. */
341
- function toModelCode(model) {
342
- return [
343
- `import { ${modelImports(model).join(", ")} } from '@bluemarble/bi/server'`,
344
- "",
345
- `export const ${model.name} = defineModel(${modelObjectCode(model)})`,
346
- ""
347
- ].join("\n");
136
+ function ruleStyle(rule) {
137
+ return {
138
+ ...rule.color && { color: rule.color },
139
+ ...rule.background && { background: rule.background },
140
+ ...rule.bold && { bold: true },
141
+ ...rule.align && { align: rule.align },
142
+ ...rule.verticalAlign && { verticalAlign: rule.verticalAlign }
143
+ };
348
144
  }
349
145
  //#endregion
350
146
  //#region src/layout.ts
@@ -440,7 +236,7 @@ const percentDecimals = {
440
236
  max: 6,
441
237
  default: 1,
442
238
  label: "Casas do %",
443
- section: "Rótulos e formatos"
239
+ section: "Dados"
444
240
  };
445
241
  const CHART_FIELDS = {
446
242
  kpi: {
@@ -634,16 +430,24 @@ const CHART_FIELDS = {
634
430
  label: "Medidas (colunas)",
635
431
  section: "Dados"
636
432
  },
433
+ columnOrder: {
434
+ kind: "columnOrder",
435
+ label: "Ordem das colunas",
436
+ hint: "Dimensões, medidas e % do total em qualquer ordem; vazio = dimensões e depois medidas (cada % logo depois da medida)",
437
+ section: "Dados"
438
+ },
637
439
  percentOf: {
638
440
  kind: "measures",
441
+ percent: true,
639
442
  label: "% do total",
640
443
  hint: "Coluna extra com a participação de cada linha: valor da linha ÷ soma da coluna. Faz sentido com medidas somáveis (custo, volume), não com razões.",
641
444
  section: "Dados"
642
445
  },
643
446
  sortBy: {
644
447
  kind: "column",
448
+ outside: true,
645
449
  label: "Ordenar por",
646
- hint: "Ordem inicial; quem usa reordena clicando no cabeçalho",
450
+ hint: "Ordem inicial; quem usa reordena clicando no cabeçalho. Pode ser uma dimensão/medida fora da tabela (ex.: número do mês para o nome do mês) — uma dimensão de fora divide as linhas se tiver mais de um valor por linha.",
647
451
  section: "Dados"
648
452
  },
649
453
  sortDirection: {
@@ -667,9 +471,40 @@ const CHART_FIELDS = {
667
471
  ...clickToFilter,
668
472
  hint: "Clicar numa linha filtra pela primeira dimensão"
669
473
  },
474
+ cellBorders: {
475
+ kind: "boolean",
476
+ default: false,
477
+ label: "Bordas entre as células",
478
+ hint: "Grade em volta de cada célula; desligado = só a linha embaixo de cada linha",
479
+ section: "Aparência"
480
+ },
481
+ borderColor: {
482
+ kind: "color",
483
+ default: "#e0e0e0",
484
+ label: "Cor das bordas",
485
+ section: "Aparência"
486
+ },
670
487
  labels: LABELS_FIELD,
671
488
  formats: FORMATS_FIELD,
672
- percentDecimals
489
+ align: {
490
+ kind: "aligns",
491
+ label: "Alinhamento",
492
+ hint: "Alinhamento horizontal de cada coluna; vazio = dimensão à esquerda, número à direita",
493
+ section: "Rótulos e formatos"
494
+ },
495
+ verticalAlign: {
496
+ kind: "verticalAligns",
497
+ label: "Alinhamento vertical",
498
+ hint: "Alinhamento vertical de cada coluna (aparece com texto em mais de uma linha); vazio = no meio",
499
+ section: "Rótulos e formatos"
500
+ },
501
+ percentDecimals,
502
+ formatRules: {
503
+ kind: "formatRules",
504
+ label: "Regras",
505
+ hint: "Muda a cor do texto, o fundo ou o negrito da célula ou da linha conforme o valor de uma dimensão/medida (pode ser de fora da tabela). Regras de linha primeiro, de célula por cima; a de baixo ganha.",
506
+ section: "Formatação condicional"
507
+ }
673
508
  }
674
509
  };
675
510
  const BUILTIN_CHART_TYPES = Object.fromEntries(CHART_TYPES.map((type) => [type, {
@@ -905,11 +740,21 @@ function fieldError(spec, value) {
905
740
  case "column": return isName(value) ? null : spec.required ? "é obrigatório" : "precisa ser texto";
906
741
  case "dimensions":
907
742
  case "measures":
743
+ case "columnOrder":
908
744
  if (!Array.isArray(value) || !value.every(isName)) return "precisa ser uma lista de nomes";
909
745
  if (spec.required && !value.length) return "precisa de pelo menos um item";
910
746
  return new Set(value).size === value.length ? null : "tem item repetido";
911
747
  case "formatters": return isRecord(value) && Object.values(value).every(isName) ? null : "precisa ser { dimensão: formatter }";
912
748
  case "labels": return isRecord(value) && Object.values(value).every((label) => typeof label === "string") ? null : "precisa ser { coluna: rótulo }";
749
+ case "aligns": return isRecord(value) && Object.values(value).every((align) => COLUMN_ALIGNS.includes(align)) ? null : `precisa ser { coluna: ${COLUMN_ALIGNS.join(" | ")} }`;
750
+ case "verticalAligns": return isRecord(value) && Object.values(value).every((align) => COLUMN_VERTICAL_ALIGNS.includes(align)) ? null : `precisa ser { coluna: ${COLUMN_VERTICAL_ALIGNS.join(" | ")} }`;
751
+ case "formatRules":
752
+ if (!Array.isArray(value)) return "precisa ser uma lista de regras";
753
+ for (const [index, rule] of value.entries()) {
754
+ const error = formatRuleError(rule);
755
+ if (error) return `regra ${index + 1}: ${error}`;
756
+ }
757
+ return null;
913
758
  case "formats":
914
759
  if (!isRecord(value)) return "precisa ser { medida: formato }";
915
760
  for (const [measure, format] of Object.entries(value)) {
@@ -1033,10 +878,51 @@ function fieldReferences(fields, item) {
1033
878
  }
1034
879
  return refs;
1035
880
  }
1036
- /** Colunas que o gráfico mostra (para `sortBy`): as dimensões e medidas escolhidas nele. */
881
+ /**
882
+ * Colunas que o gráfico mostra (para `sortBy`, rótulos): as dimensões e medidas escolhidas nele e os
883
+ * percentuais (`%:medida`), na ordem do campo `columnOrder` (se o tipo tiver) — ver `tableColumns`.
884
+ */
1037
885
  function chartColumns(chart, types = BUILTIN_CHART_TYPES) {
1038
- const { dimensions, measures } = chartReferences(chart, types);
1039
- return [...dimensions, ...measures];
886
+ const values = chart;
887
+ const columns = [];
888
+ const percents = [];
889
+ for (const [key, spec] of Object.entries(types[chart.type]?.fields ?? {})) {
890
+ const value = values[key];
891
+ if (value === void 0 || value === "") continue;
892
+ if (spec.kind === "dimension" || spec.kind === "measure") columns.push(value);
893
+ if (spec.kind === "dimensions") columns.push(...value);
894
+ if (spec.kind === "measures") (spec.percent ? percents : columns).push(...value);
895
+ }
896
+ return tableColumns([...new Set(columns)], [...new Set(percents)], columnOrderOf(chart, types));
897
+ }
898
+ const percentColumn = (measure) => `%:${measure}`;
899
+ /**
900
+ * Ordem das colunas: as de `order` primeiro, o resto depois, na ordem em que veio (`orderedColumns`).
901
+ * O % de uma medida que não está em `order` vem logo depois da medida (ou no fim, se ela não é
902
+ * coluna) — layouts sem `%:` no `columnOrder` continuam como antes.
903
+ */
904
+ function tableColumns(columns, percentOf, order) {
905
+ const percents = percentOf.map(percentColumn);
906
+ const result = orderedColumns([...columns, ...percents.filter((column) => order?.includes(column))], order);
907
+ for (const measure of percentOf) {
908
+ const column = percentColumn(measure);
909
+ if (result.includes(column)) continue;
910
+ const at = result.indexOf(measure);
911
+ if (at === -1) result.push(column);
912
+ else result.splice(at + 1, 0, column);
913
+ }
914
+ return result;
915
+ }
916
+ /** Valor do campo `columnOrder` do gráfico (o tipo pode não ter). */
917
+ function columnOrderOf(chart, types = BUILTIN_CHART_TYPES) {
918
+ const values = chart;
919
+ for (const [key, spec] of Object.entries(types[chart.type]?.fields ?? {})) if (spec.kind === "columnOrder" && Array.isArray(values[key])) return values[key];
920
+ }
921
+ /** `columns` na ordem de `order`; os que não estão nela vão depois, na ordem em que vieram. */
922
+ function orderedColumns(columns, order) {
923
+ if (!order?.length) return columns;
924
+ const first = order.filter((name) => columns.includes(name));
925
+ return [...first, ...columns.filter((name) => !first.includes(name))];
1040
926
  }
1041
927
  /**
1042
928
  * Dimensões/medidas que o layout usa e o modelo não tem. Não impede salvar (o modelo pode estar
@@ -1052,15 +938,26 @@ function layoutModelWarnings(layout, model, types = BUILTIN_CHART_TYPES) {
1052
938
  const columns = chartColumns(chart, types);
1053
939
  for (const [key, spec] of Object.entries(types[chart.type]?.fields ?? {})) {
1054
940
  const value = chart[key];
1055
- if (spec.kind === "column" && isName(value) && !columns.includes(value)) warnings.push(`${name}: "${value}" (${key}) não é coluna do gráfico`);
1056
- if ((spec.kind === "labels" || spec.kind === "formats") && isRecord(value)) {
941
+ if (spec.kind === "column" && isName(value) && !columns.includes(value)) warnings.push(spec.outside ? isModelField(model, value) ? "" : `${name}: "${value}" (${key}) não existe no modelo` : `${name}: "${value}" (${key}) não é coluna do gráfico`);
942
+ if (spec.kind === "formatRules" && Array.isArray(value)) value.forEach((rule, index) => {
943
+ if (!isRecord(rule)) return;
944
+ const context = `${name}: regra ${index + 1} (${key})`;
945
+ if (isName(rule.field) && !columns.includes(rule.field) && !isModelField(model, rule.field)) warnings.push(`${context}: "${rule.field}" não existe no modelo`);
946
+ const painted = rule.target === "row" ? void 0 : rule.column ?? rule.field;
947
+ if (isName(painted) && !columns.includes(painted)) warnings.push(`${context}: "${painted}" não é coluna do gráfico (nada é pintado)`);
948
+ });
949
+ if ((spec.kind === "labels" || spec.kind === "formats" || spec.kind === "aligns" || spec.kind === "verticalAligns") && isRecord(value)) {
1057
950
  for (const column of Object.keys(value)) if (!columns.includes(column)) warnings.push(`${name}: "${column}" (${key}) não é coluna do gráfico`);
1058
951
  }
952
+ if (spec.kind === "columnOrder" && Array.isArray(value)) {
953
+ for (const column of value) if (isName(column) && !columns.includes(column)) warnings.push(`${name}: "${column}" (${key}) não é coluna do gráfico`);
954
+ }
1059
955
  }
1060
956
  }
1061
957
  for (const filter of layout.filters ?? []) if (!model.dimensions[filter.dimension]) warnings.push(`filtro "${filter.label || filter.dimension}": dimensão "${filter.dimension}" não existe`);
1062
- return warnings;
958
+ return warnings.filter(Boolean);
1063
959
  }
960
+ const isModelField = (model, name) => Object.hasOwn(model.dimensions, name) || Object.hasOwn(model.measures, name);
1064
961
  /**
1065
962
  * Gráfico novo de um tipo, reaproveitando o que der do anterior (troca de tipo no devtools) ou
1066
963
  * escolhendo dimensão/medidas do modelo.
@@ -1158,6 +1055,6 @@ function layoutFileContent(layout) {
1158
1055
  return `${JSON.stringify(layout, null, 2)}\n`;
1159
1056
  }
1160
1057
  //#endregion
1161
- export { modelEdges as A, VALUE_FORMATS as C, validateModel as D, modelMeta as E, reachFrom as M, referencedTables as N, joinPlan as O, AGGREGATION_LABEL as S, isAggregate as T, canonicalJson as _, FILTER_FIELDS as a, toModelCode as b, chartOfType as c, filterReferences as d, isBuiltinChart as f, validateLayout as g, resolveChartTypes as h, CARD_FIELDS as i, parseColumnRef as j, measureHome as k, chartReferences as l, layoutModelWarnings as m, CANVAS_PRESETS as n, chartCardStyle as o, layoutFileContent as p, CARDS_FIELDS as r, chartColumns as s, BUILTIN_CHART_TYPES as t, customChartProps as u, modelImports as v, aggregateSql as w, AGGREGATIONS as x, modelObjectCode as y };
1058
+ export { RULE_OPERATOR_LABEL as C, RULE_OPERATORS as S, rowRuleStyles as T, tableColumns as _, FILTER_FIELDS as a, COLUMN_VERTICAL_ALIGNS as b, chartOfType as c, filterReferences as d, isBuiltinChart as f, resolveChartTypes as g, percentColumn as h, CARD_FIELDS as i, chartReferences as l, layoutModelWarnings as m, CANVAS_PRESETS as n, chartCardStyle as o, layoutFileContent as p, CARDS_FIELDS as r, chartColumns as s, BUILTIN_CHART_TYPES as t, customChartProps as u, validateLayout as v, UNARY_OPERATORS as w, NUMERIC_OPERATORS as x, COLUMN_ALIGNS as y };
1162
1059
 
1163
- //# sourceMappingURL=layout-676sEvvb.js.map
1060
+ //# sourceMappingURL=layout-BS51SQcJ.js.map