@bluemarble/bi 0.5.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;
256
- }
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;
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;
262
103
  }
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);
269
- }
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;
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;
281
108
  }
109
+ case "contains": return String(value).toLowerCase().includes(String(target ?? "").toLowerCase());
282
110
  }
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
@@ -637,19 +433,21 @@ const CHART_FIELDS = {
637
433
  columnOrder: {
638
434
  kind: "columnOrder",
639
435
  label: "Ordem das colunas",
640
- hint: "Dimensões e medidas em qualquer ordem; vazio = dimensões e depois medidas",
436
+ hint: "Dimensões, medidas e % do total em qualquer ordem; vazio = dimensões e depois medidas (cada % logo depois da medida)",
641
437
  section: "Dados"
642
438
  },
643
439
  percentOf: {
644
440
  kind: "measures",
441
+ percent: true,
645
442
  label: "% do total",
646
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.",
647
444
  section: "Dados"
648
445
  },
649
446
  sortBy: {
650
447
  kind: "column",
448
+ outside: true,
651
449
  label: "Ordenar por",
652
- 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.",
653
451
  section: "Dados"
654
452
  },
655
453
  sortDirection: {
@@ -673,9 +471,40 @@ const CHART_FIELDS = {
673
471
  ...clickToFilter,
674
472
  hint: "Clicar numa linha filtra pela primeira dimensão"
675
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
+ },
676
487
  labels: LABELS_FIELD,
677
488
  formats: FORMATS_FIELD,
678
- 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
+ }
679
508
  }
680
509
  };
681
510
  const BUILTIN_CHART_TYPES = Object.fromEntries(CHART_TYPES.map((type) => [type, {
@@ -917,6 +746,15 @@ function fieldError(spec, value) {
917
746
  return new Set(value).size === value.length ? null : "tem item repetido";
918
747
  case "formatters": return isRecord(value) && Object.values(value).every(isName) ? null : "precisa ser { dimensão: formatter }";
919
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;
920
758
  case "formats":
921
759
  if (!isRecord(value)) return "precisa ser { medida: formato }";
922
760
  for (const [measure, format] of Object.entries(value)) {
@@ -1041,12 +879,39 @@ function fieldReferences(fields, item) {
1041
879
  return refs;
1042
880
  }
1043
881
  /**
1044
- * Colunas que o gráfico mostra (para `sortBy`, rótulos): as dimensões e medidas escolhidas nele, na
1045
- * ordem do campo `columnOrder` (se o tipo tiver) e o resto depois.
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`.
1046
884
  */
1047
885
  function chartColumns(chart, types = BUILTIN_CHART_TYPES) {
1048
- const { dimensions, measures } = chartReferences(chart, types);
1049
- return orderedColumns([.../* @__PURE__ */ new Set([...dimensions, ...measures])], columnOrderOf(chart, types));
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;
1050
915
  }
1051
916
  /** Valor do campo `columnOrder` do gráfico (o tipo pode não ter). */
1052
917
  function columnOrderOf(chart, types = BUILTIN_CHART_TYPES) {
@@ -1073,8 +938,15 @@ function layoutModelWarnings(layout, model, types = BUILTIN_CHART_TYPES) {
1073
938
  const columns = chartColumns(chart, types);
1074
939
  for (const [key, spec] of Object.entries(types[chart.type]?.fields ?? {})) {
1075
940
  const value = chart[key];
1076
- if (spec.kind === "column" && isName(value) && !columns.includes(value)) warnings.push(`${name}: "${value}" (${key}) não é coluna do gráfico`);
1077
- 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)) {
1078
950
  for (const column of Object.keys(value)) if (!columns.includes(column)) warnings.push(`${name}: "${column}" (${key}) não é coluna do gráfico`);
1079
951
  }
1080
952
  if (spec.kind === "columnOrder" && Array.isArray(value)) {
@@ -1083,8 +955,9 @@ function layoutModelWarnings(layout, model, types = BUILTIN_CHART_TYPES) {
1083
955
  }
1084
956
  }
1085
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`);
1086
- return warnings;
958
+ return warnings.filter(Boolean);
1087
959
  }
960
+ const isModelField = (model, name) => Object.hasOwn(model.dimensions, name) || Object.hasOwn(model.measures, name);
1088
961
  /**
1089
962
  * Gráfico novo de um tipo, reaproveitando o que der do anterior (troca de tipo no devtools) ou
1090
963
  * escolhendo dimensão/medidas do modelo.
@@ -1182,6 +1055,6 @@ function layoutFileContent(layout) {
1182
1055
  return `${JSON.stringify(layout, null, 2)}\n`;
1183
1056
  }
1184
1057
  //#endregion
1185
- export { measureHome as A, AGGREGATION_LABEL as C, modelMeta as D, isAggregate as E, parseColumnRef as M, reachFrom as N, validateModel as O, referencedTables as P, AGGREGATIONS as S, aggregateSql as T, validateLayout as _, FILTER_FIELDS as a, modelObjectCode as b, chartOfType as c, filterReferences as d, isBuiltinChart as f, resolveChartTypes as g, orderedColumns as h, CARD_FIELDS as i, modelEdges as j, joinPlan 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, canonicalJson as v, VALUE_FORMATS as w, toModelCode as x, modelImports 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 };
1186
1059
 
1187
- //# sourceMappingURL=layout-QuOYFmp1.js.map
1060
+ //# sourceMappingURL=layout-BS51SQcJ.js.map